forked from baron/baron-sso
권한부여 및 정합성 검사 추가
This commit is contained in:
@@ -373,6 +373,7 @@ func main() {
|
||||
adminHandler.AuditRepo = auditRepo
|
||||
adminHandler.UserProjectionRepo = userProjectionRepo
|
||||
adminHandler.UserProjectionSyncer = userProjectionSyncer
|
||||
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
@@ -714,6 +715,7 @@ func main() {
|
||||
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
|
||||
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
|
||||
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
|
||||
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
|
||||
|
||||
41
backend/internal/domain/data_integrity.go
Normal file
41
backend/internal/domain/data_integrity.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type DataIntegrityStatus string
|
||||
|
||||
const (
|
||||
DataIntegrityStatusPass DataIntegrityStatus = "pass"
|
||||
DataIntegrityStatusWarning DataIntegrityStatus = "warning"
|
||||
DataIntegrityStatusFail DataIntegrityStatus = "fail"
|
||||
)
|
||||
|
||||
type DataIntegrityReport struct {
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
Summary DataIntegritySummary `json:"summary"`
|
||||
Sections []DataIntegritySection `json:"sections"`
|
||||
}
|
||||
|
||||
type DataIntegritySummary struct {
|
||||
TotalChecks int `json:"totalChecks"`
|
||||
Passed int `json:"passed"`
|
||||
Warnings int `json:"warnings"`
|
||||
Failures int64 `json:"failures"`
|
||||
}
|
||||
|
||||
type DataIntegritySection struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Checks []DataIntegrityCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type DataIntegrityCheck struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Status DataIntegrityStatus `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
@@ -12,6 +12,8 @@ func TestNormalizeRole(t *testing.T) {
|
||||
{name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin},
|
||||
{name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin},
|
||||
{name: "user unchanged", in: "user", want: RoleUser},
|
||||
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
|
||||
{name: "super admin compact alias", in: "superadmin", want: RoleSuperAdmin},
|
||||
{name: "legacy admin", in: "admin", want: RoleTenantAdmin},
|
||||
{name: "legacy tenant member", in: "tenant_member", want: RoleUser},
|
||||
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},
|
||||
|
||||
@@ -26,6 +26,7 @@ type AdminHandler struct {
|
||||
AuditRepo domain.AuditRepository
|
||||
UserProjectionRepo repository.UserProjectionRepository
|
||||
UserProjectionSyncer service.UserProjectionReconciler
|
||||
IntegrityChecker repository.DataIntegrityChecker
|
||||
}
|
||||
|
||||
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
|
||||
@@ -109,17 +110,18 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||
}
|
||||
|
||||
func requireSuperAdminProfile(c *fiber.Ctx) error {
|
||||
func requireSuperAdminProfile(c *fiber.Ctx) bool {
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"})
|
||||
_ = c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"})
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
if err := requireSuperAdminProfile(c); err != nil {
|
||||
return err
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
||||
@@ -132,8 +134,8 @@ func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error {
|
||||
if err := requireSuperAdminProfile(c); err != nil {
|
||||
return err
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.UserProjectionSyncer == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"})
|
||||
@@ -153,6 +155,20 @@ func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error {
|
||||
return h.ReconcileUserProjection(c)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
|
||||
if !requireSuperAdminProfile(c) {
|
||||
return nil
|
||||
}
|
||||
if h == nil || h.IntegrityChecker == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
|
||||
}
|
||||
report, err := h.IntegrityChecker.CheckDataIntegrity(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(report)
|
||||
}
|
||||
|
||||
// GetSystemStats returns runtime statistics for monitoring
|
||||
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
var m runtime.MemStats
|
||||
@@ -161,6 +177,7 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
|
||||
|
||||
stats := fiber.Map{
|
||||
"totalTenants": h.countTenants(ctx),
|
||||
"totalUsers": h.countUsers(ctx),
|
||||
"oidcClients": h.countOIDCClients(ctx),
|
||||
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
@@ -188,6 +205,17 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
|
||||
return total
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
|
||||
if h == nil || h.UserProjectionRepo == nil {
|
||||
return 0
|
||||
}
|
||||
status, err := h.UserProjectionRepo.GetStatus(ctx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return status.ProjectedUsers
|
||||
}
|
||||
|
||||
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
|
||||
if h == nil || h.Hydra == nil {
|
||||
return 0
|
||||
|
||||
@@ -263,7 +263,17 @@ func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
|
||||
|
||||
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
||||
auditRepo := &fakeOverviewAuditRepo{count: 22}
|
||||
h := &AdminHandler{AuditRepo: auditRepo}
|
||||
h := &AdminHandler{
|
||||
AuditRepo: auditRepo,
|
||||
UserProjectionRepo: &fakeAdminUserProjectionRepo{
|
||||
status: domain.UserProjectionStatus{
|
||||
Name: domain.UserProjectionNameKratos,
|
||||
Status: domain.UserProjectionStatusReady,
|
||||
Ready: true,
|
||||
ProjectedUsers: 152,
|
||||
},
|
||||
},
|
||||
}
|
||||
app := fiber.New()
|
||||
app.Get("/api/v1/admin/stats", h.GetSystemStats)
|
||||
|
||||
@@ -275,8 +285,10 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Contains(t, body, "totalTenants")
|
||||
require.Contains(t, body, "totalUsers")
|
||||
require.Contains(t, body, "oidcClients")
|
||||
require.Contains(t, body, "auditEvents24h")
|
||||
require.Equal(t, float64(152), body["totalUsers"])
|
||||
require.Equal(t, float64(22), body["auditEvents24h"])
|
||||
require.Equal(t, time.UTC, auditRepo.since.Location())
|
||||
}
|
||||
|
||||
92
backend/internal/handler/admin_integrity_test.go
Normal file
92
backend/internal/handler/admin_integrity_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeDataIntegrityChecker struct {
|
||||
calls int
|
||||
report domain.DataIntegrityReport
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||
f.calls++
|
||||
return f.report, f.err
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
|
||||
checker := &fakeDataIntegrityChecker{}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
require.Equal(t, 0, checker.calls)
|
||||
}
|
||||
|
||||
func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
|
||||
checkedAt := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC)
|
||||
checker := &fakeDataIntegrityChecker{
|
||||
report: domain.DataIntegrityReport{
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
CheckedAt: checkedAt,
|
||||
Summary: domain.DataIntegritySummary{
|
||||
TotalChecks: 1,
|
||||
Failures: 1,
|
||||
},
|
||||
Sections: []domain.DataIntegritySection{
|
||||
{
|
||||
Key: "tenant_integrity",
|
||||
Label: "테넌트 정합성",
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
Checks: []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "duplicate_tenant_slugs",
|
||||
Label: "중복 테넌트 slug",
|
||||
Status: domain.DataIntegrityStatusFail,
|
||||
Count: 1,
|
||||
Severity: "error",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
h := &AdminHandler{IntegrityChecker: checker}
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Get("/api/v1/admin/integrity", h.GetDataIntegrity)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
|
||||
resp, err := app.Test(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, 1, checker.calls)
|
||||
|
||||
var body domain.DataIntegrityReport
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
require.Equal(t, domain.DataIntegrityStatusFail, body.Status)
|
||||
require.Equal(t, int64(1), body.Summary.Failures)
|
||||
require.Len(t, body.Sections, 1)
|
||||
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
|
||||
}
|
||||
226
backend/internal/repository/data_integrity_repository.go
Normal file
226
backend/internal/repository/data_integrity_repository.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DataIntegrityChecker interface {
|
||||
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
|
||||
}
|
||||
|
||||
type dataIntegrityChecker struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDataIntegrityChecker(db *gorm.DB) DataIntegrityChecker {
|
||||
return &dataIntegrityChecker{db: db}
|
||||
}
|
||||
|
||||
func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
|
||||
return CheckDataIntegrity(ctx, c.db)
|
||||
}
|
||||
|
||||
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
|
||||
tenantChecks := []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "duplicate_tenant_slugs",
|
||||
Label: "중복 테넌트 slug",
|
||||
Description: "삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_tenant_parents",
|
||||
Label: "유령 상위 테넌트 참조",
|
||||
Description: "tenant.parent_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
}
|
||||
userChecks := []domain.DataIntegrityCheck{
|
||||
{
|
||||
Key: "orphan_user_tenant_memberships",
|
||||
Label: "유령 테넌트 사용자 소속",
|
||||
Description: "users.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_user_login_id_tenants",
|
||||
Label: "유령 테넌트 로그인 ID",
|
||||
Description: "user_login_ids.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
{
|
||||
Key: "orphan_user_login_id_users",
|
||||
Label: "유령 사용자 로그인 ID",
|
||||
Description: "user_login_ids.user_id가 없거나 삭제된 user를 참조하는지 검사합니다.",
|
||||
Severity: "error",
|
||||
Count: 0,
|
||||
},
|
||||
}
|
||||
|
||||
counts := []struct {
|
||||
target *int64
|
||||
query string
|
||||
}{
|
||||
{
|
||||
target: &tenantChecks[0].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM (
|
||||
SELECT LOWER(TRIM(slug)) AS normalized_slug
|
||||
FROM tenants
|
||||
WHERE deleted_at IS NULL
|
||||
AND status <> 'deleted'
|
||||
AND TRIM(slug) <> ''
|
||||
GROUP BY LOWER(TRIM(slug))
|
||||
HAVING COUNT(*) > 1
|
||||
) AS duplicate_slugs
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &tenantChecks[1].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM tenants AS child
|
||||
WHERE child.deleted_at IS NULL
|
||||
AND child.parent_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS parent
|
||||
WHERE parent.id = child.parent_id
|
||||
AND parent.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[0].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[1].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM user_login_ids AS uli
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = uli.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
{
|
||||
target: &userChecks[2].Count,
|
||||
query: `
|
||||
SELECT COUNT(*)
|
||||
FROM user_login_ids AS uli
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users AS u
|
||||
WHERE u.id = uli.user_id
|
||||
AND u.deleted_at IS NULL
|
||||
)
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, item := range counts {
|
||||
if err := db.WithContext(ctx).Raw(item.query).Scan(item.target).Error; err != nil {
|
||||
return domain.DataIntegrityReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tenantChecks = applyIntegrityStatuses(tenantChecks)
|
||||
userChecks = applyIntegrityStatuses(userChecks)
|
||||
sections := []domain.DataIntegritySection{
|
||||
{
|
||||
Key: "tenant_integrity",
|
||||
Label: "테넌트 정합성",
|
||||
Status: summarizeIntegrityStatus(tenantChecks),
|
||||
Checks: tenantChecks,
|
||||
},
|
||||
{
|
||||
Key: "user_integrity",
|
||||
Label: "사용자 정합성",
|
||||
Status: summarizeIntegrityStatus(userChecks),
|
||||
Checks: userChecks,
|
||||
},
|
||||
}
|
||||
|
||||
summary := domain.DataIntegritySummary{}
|
||||
for _, section := range sections {
|
||||
for _, check := range section.Checks {
|
||||
summary.TotalChecks++
|
||||
switch check.Status {
|
||||
case domain.DataIntegrityStatusFail:
|
||||
summary.Failures += check.Count
|
||||
case domain.DataIntegrityStatusWarning:
|
||||
summary.Warnings++
|
||||
default:
|
||||
summary.Passed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return domain.DataIntegrityReport{
|
||||
Status: summarizeSectionStatus(sections),
|
||||
CheckedAt: time.Now().UTC(),
|
||||
Summary: summary,
|
||||
Sections: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func applyIntegrityStatuses(checks []domain.DataIntegrityCheck) []domain.DataIntegrityCheck {
|
||||
for i := range checks {
|
||||
if checks[i].Count > 0 {
|
||||
checks[i].Status = domain.DataIntegrityStatusFail
|
||||
} else {
|
||||
checks[i].Status = domain.DataIntegrityStatusPass
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func summarizeIntegrityStatus(checks []domain.DataIntegrityCheck) domain.DataIntegrityStatus {
|
||||
status := domain.DataIntegrityStatusPass
|
||||
for _, check := range checks {
|
||||
if check.Status == domain.DataIntegrityStatusFail {
|
||||
return domain.DataIntegrityStatusFail
|
||||
}
|
||||
if check.Status == domain.DataIntegrityStatusWarning {
|
||||
status = domain.DataIntegrityStatusWarning
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataIntegrityStatus {
|
||||
status := domain.DataIntegrityStatusPass
|
||||
for _, section := range sections {
|
||||
if section.Status == domain.DataIntegrityStatusFail {
|
||||
return domain.DataIntegrityStatusFail
|
||||
}
|
||||
if section.Status == domain.DataIntegrityStatusWarning {
|
||||
status = domain.DataIntegrityStatusWarning
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
106
backend/internal/repository/data_integrity_repository_test.go
Normal file
106
backend/internal/repository/data_integrity_repository_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := uuid.NewString()
|
||||
|
||||
parent := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Deleted Parent " + suffix,
|
||||
Slug: "deleted-parent-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
child := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Orphan Child " + suffix,
|
||||
Slug: "orphan-child-" + suffix,
|
||||
Type: domain.TenantTypeOrganization,
|
||||
ParentID: &parent.ID,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
dupA := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Duplicate A " + suffix,
|
||||
Slug: "Dup-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
dupB := domain.Tenant{
|
||||
ID: uuid.NewString(),
|
||||
Name: "Duplicate B " + suffix,
|
||||
Slug: "dup-" + suffix,
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
|
||||
require.NoError(t, testDB.Create(&parent).Error)
|
||||
require.NoError(t, testDB.Create(&child).Error)
|
||||
require.NoError(t, testDB.Create(&dupA).Error)
|
||||
require.NoError(t, testDB.Create(&dupB).Error)
|
||||
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", parent.ID).Error)
|
||||
|
||||
orphanUser := domain.User{
|
||||
ID: uuid.NewString(),
|
||||
Email: "orphan-" + suffix + "@example.com",
|
||||
Name: "Orphan User",
|
||||
Role: domain.RoleUser,
|
||||
TenantID: &parent.ID,
|
||||
Status: domain.UserStatusActive,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
require.NoError(t, testDB.Create(&orphanUser).Error)
|
||||
require.NoError(t, testDB.Create(&domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: orphanUser.ID,
|
||||
TenantID: parent.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "EMP-" + suffix,
|
||||
}).Error)
|
||||
require.NoError(t, testDB.Create(&domain.UserLoginID{
|
||||
ID: uuid.NewString(),
|
||||
UserID: uuid.NewString(),
|
||||
TenantID: child.ID,
|
||||
FieldKey: "emp_id",
|
||||
LoginID: "MISSING-" + suffix,
|
||||
}).Error)
|
||||
|
||||
report, err := CheckDataIntegrity(ctx, testDB)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, domain.DataIntegrityStatusFail, report.Status)
|
||||
require.Equal(t, int64(5), report.Summary.Failures)
|
||||
|
||||
requireIntegrityCheck(t, report, "tenant_integrity", "duplicate_tenant_slugs", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "tenant_integrity", "orphan_tenant_parents", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_tenant_memberships", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, 1)
|
||||
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
|
||||
}
|
||||
|
||||
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
|
||||
t.Helper()
|
||||
for _, section := range report.Sections {
|
||||
if section.Key != sectionKey {
|
||||
continue
|
||||
}
|
||||
for _, check := range section.Checks {
|
||||
if check.Key == checkKey {
|
||||
require.Equal(t, status, check.Status)
|
||||
require.Equal(t, count, check.Count)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("integrity check %s/%s not found", sectionKey, checkKey)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type WorksmobileDirectoryClient interface {
|
||||
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
|
||||
DeleteUser(ctx context.Context, userID string) error
|
||||
SetUserActive(ctx context.Context, userID string, active bool) error
|
||||
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
|
||||
ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error)
|
||||
}
|
||||
@@ -330,6 +331,33 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e
|
||||
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("worksmobile user id is required")
|
||||
}
|
||||
if strings.TrimSpace(c.SCIMToken) == "" {
|
||||
return fmt.Errorf("worksmobile scim token is not configured")
|
||||
}
|
||||
remote, err := c.findSCIMUser(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if remote == nil {
|
||||
return nil
|
||||
}
|
||||
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{
|
||||
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
|
||||
"Operations": []map[string]any{
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "active",
|
||||
"value": active,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
|
||||
users, err := c.ListUsers(ctx)
|
||||
if err != nil {
|
||||
@@ -344,6 +372,21 @@ func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) findSCIMUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
var matched *WorksmobileRemoteUser
|
||||
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
|
||||
if matched != nil {
|
||||
return
|
||||
}
|
||||
user := parseWorksmobileRemoteUser(resource)
|
||||
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
|
||||
matched = &user
|
||||
}
|
||||
})
|
||||
return matched, err
|
||||
}
|
||||
|
||||
func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
|
||||
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
|
||||
users, err := c.listDirectoryUsers(ctx, c.DomainIDs)
|
||||
|
||||
@@ -235,6 +235,42 @@ func TestWorksmobileHTTPClientListUsersFallsBackToSCIMWhenDirectoryFails(t *test
|
||||
require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path)
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientSetUserActivePatchesSCIMActiveFlag(t *testing.T) {
|
||||
transport := &captureRoundTripper{
|
||||
responses: []captureResponse{
|
||||
{statusCode: http.StatusOK, body: `{"totalResults":1,"Resources":[{"id":"scim-user-1","externalId":"user-1","userName":"tester@samaneng.com","active":true,"emails":[{"value":"tester@samaneng.com","primary":true}]}]}`},
|
||||
{statusCode: http.StatusOK, body: `{}`},
|
||||
},
|
||||
}
|
||||
client := &WorksmobileHTTPClient{
|
||||
BaseURL: "https://works.example.test",
|
||||
SCIMToken: "scim-token-1",
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
}
|
||||
|
||||
err := client.SetUserActive(context.Background(), "tester@samaneng.com", false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, transport.requests, 2)
|
||||
require.Equal(t, http.MethodGet, transport.requests[0].Method)
|
||||
require.Equal(t, "/scim/v2/Users", transport.requests[0].URL.Path)
|
||||
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
|
||||
require.Equal(t, "/scim/v2/Users/scim-user-1", transport.requests[1].URL.Path)
|
||||
require.Equal(t, "Bearer scim-token-1", transport.requests[1].Header.Get("Authorization"))
|
||||
|
||||
var patchPayload map[string]any
|
||||
require.Len(t, transport.requestBodies, 1)
|
||||
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
|
||||
operations, ok := patchPayload["Operations"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, operations, 1)
|
||||
operation, ok := operations[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "replace", operation["op"])
|
||||
require.Equal(t, "active", operation["path"])
|
||||
require.Equal(t, false, operation["value"])
|
||||
}
|
||||
|
||||
func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
|
||||
transport := &captureRoundTripper{
|
||||
@@ -373,6 +409,60 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionSuspend,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
|
||||
Email: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processingIDs)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, []string{"tester@samaneng.com"}, client.suspendedUsers)
|
||||
}
|
||||
|
||||
func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testing.T) {
|
||||
repo := &fakeWorksmobileOutboxRepo{
|
||||
ready: []domain.WorksmobileOutbox{
|
||||
{
|
||||
ID: "job-1",
|
||||
ResourceType: domain.WorksmobileResourceUser,
|
||||
ResourceID: "user-1",
|
||||
Action: domain.WorksmobileActionUpsert,
|
||||
Status: domain.WorksmobileOutboxStatusPending,
|
||||
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
|
||||
Email: "tester@samaneng.com",
|
||||
UserExternalKey: "user-1",
|
||||
}, domain.UserStatusActive),
|
||||
},
|
||||
},
|
||||
}
|
||||
client := &fakeWorksmobileDirectoryClient{}
|
||||
worker := NewWorksmobileRelayWorker(repo, client)
|
||||
|
||||
err := worker.ProcessOnce(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"job-1"}, repo.processedIDs)
|
||||
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
|
||||
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
|
||||
}
|
||||
|
||||
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
|
||||
jobs := []domain.WorksmobileOutbox{
|
||||
{
|
||||
@@ -714,6 +804,8 @@ type fakeWorksmobileDirectoryClient struct {
|
||||
createdOrgUnits []WorksmobileOrgUnitPayload
|
||||
createdUsers []WorksmobileUserPayload
|
||||
deletedUsers []string
|
||||
activeUsers []string
|
||||
suspendedUsers []string
|
||||
orgUnitMatchKeys []string
|
||||
}
|
||||
|
||||
@@ -803,6 +895,15 @@ func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, userID string, active bool) error {
|
||||
if active {
|
||||
f.activeUsers = append(f.activeUsers, userID)
|
||||
} else {
|
||||
f.suspendedUsers = append(f.suspendedUsers, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -97,13 +97,17 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.client.UpsertUser(ctx, payload)
|
||||
case domain.WorksmobileActionDelete:
|
||||
userID := stringValue(job.Payload["loginEmail"])
|
||||
if userID == "" {
|
||||
userID = stringValue(job.Payload["userExternalKey"])
|
||||
if err := w.client.UpsertUser(ctx, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.client.DeleteUser(ctx, userID)
|
||||
if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
|
||||
}
|
||||
return nil
|
||||
case domain.WorksmobileActionDelete:
|
||||
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
|
||||
case domain.WorksmobileActionSuspend:
|
||||
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -112,6 +116,14 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
|
||||
}
|
||||
}
|
||||
|
||||
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
|
||||
userID := stringValue(job.Payload["loginEmail"])
|
||||
if userID == "" {
|
||||
userID = stringValue(job.Payload["userExternalKey"])
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
|
||||
raw := payload["request"]
|
||||
if raw == nil {
|
||||
|
||||
@@ -315,7 +315,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
}
|
||||
if err := s.outboxRepo.Create(ctx, item); err != nil {
|
||||
return nil, err
|
||||
@@ -459,7 +459,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
|
||||
ResourceID: user.ID,
|
||||
Action: action,
|
||||
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload),
|
||||
Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -649,13 +649,19 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
|
||||
return payload
|
||||
}
|
||||
|
||||
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap {
|
||||
return domain.JSONMap{
|
||||
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
|
||||
outboxPayload := domain.JSONMap{
|
||||
"request": payload,
|
||||
"tenantRootId": rootID,
|
||||
"loginEmail": payload.Email,
|
||||
"initialPassword": payload.PasswordConfig.Password,
|
||||
}
|
||||
if len(statuses) > 0 {
|
||||
if status := strings.TrimSpace(statuses[0]); status != "" {
|
||||
outboxPayload["baronStatus"] = status
|
||||
}
|
||||
}
|
||||
return outboxPayload
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
|
||||
@@ -56,6 +56,51 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
require.Empty(t, outboxRepo.created)
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *testing.T) {
|
||||
t.Setenv("SAMAN_DOMAIN_ID", "1001")
|
||||
rootID := "root-tenant"
|
||||
tenantID := "saman-tenant"
|
||||
root := domain.Tenant{
|
||||
ID: rootID,
|
||||
Slug: HanmacFamilyTenantSlug,
|
||||
Name: "Hanmac Family",
|
||||
}
|
||||
tenant := domain.Tenant{
|
||||
ID: tenantID,
|
||||
Slug: "saman",
|
||||
Name: "Saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentID: &rootID,
|
||||
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
|
||||
}
|
||||
target := domain.User{
|
||||
ID: "target-user",
|
||||
Email: "target@samaneng.com",
|
||||
Name: "Target",
|
||||
Status: domain.UserStatusSuspended,
|
||||
TenantID: &tenantID,
|
||||
}
|
||||
outboxRepo := &fakeWorksmobileOutboxRepo{}
|
||||
service := NewWorksmobileSyncService(
|
||||
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
|
||||
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
|
||||
outboxRepo,
|
||||
nil,
|
||||
)
|
||||
|
||||
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, item)
|
||||
require.Len(t, outboxRepo.created, 1)
|
||||
require.Equal(t, domain.WorksmobileActionSuspend, outboxRepo.created[0].Action)
|
||||
require.Equal(t, domain.UserStatusSuspended, outboxRepo.created[0].Payload["baronStatus"])
|
||||
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, request.Organizations)
|
||||
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
|
||||
}
|
||||
|
||||
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
|
||||
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
|
||||
root := domain.Tenant{
|
||||
|
||||
Reference in New Issue
Block a user