From 843b4100adeedc6d81587a408327e7791342a1a7 Mon Sep 17 00:00:00 2001 From: Lectom Date: Mon, 11 May 2026 13:01:55 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20=EC=A1=B0=EC=A7=81=20=ED=86=B5?= =?UTF-8?q?=EA=B3=84=EC=98=A4=EB=A5=98=20=EB=B3=B4=EC=A0=95.=20Kratos=20Pr?= =?UTF-8?q?ojection=EC=9A=A9=20=ED=86=B5=EA=B3=84=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.test.tsx | 6 + adminfront/src/app/routes.tsx | 2 + .../src/components/layout/AppLayout.tsx | 6 + .../projections/UserProjectionPage.test.tsx | 99 +++++++++ .../projections/UserProjectionPage.tsx | 206 ++++++++++++++++++ adminfront/src/lib/adminApi.ts | 37 ++++ adminfront/src/locales/en.toml | 1 + adminfront/src/locales/ko.toml | 1 + adminfront/src/locales/template.toml | 1 + backend/cmd/server/main.go | 17 +- backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/domain/user_projection.go | 29 +++ backend/internal/handler/admin_handler.go | 58 ++++- .../internal/handler/admin_handler_test.go | 126 +++++++++++ backend/internal/handler/auth_handler.go | 119 +++++++++- .../auth_handler_profile_cache_test.go | 106 +++++++++ backend/internal/handler/tenant_handler.go | 93 ++++---- .../internal/handler/tenant_handler_test.go | 202 ++++++++++++++++- backend/internal/handler/user_handler.go | 79 +++---- backend/internal/handler/user_handler_test.go | 50 +++++ .../handler/user_projection_failure.go | 16 ++ backend/internal/repository/main_test.go | 2 +- .../repository/user_projection_repository.go | 176 +++++++++++++++ .../user_projection_repository_test.go | 87 ++++++++ .../internal/repository/user_repository.go | 4 +- .../repository/user_repository_test.go | 19 ++ .../internal/service/user_group_service.go | 184 +++++++++++++--- .../service/user_group_service_test.go | 64 +++++- .../service/user_projection_sync_service.go | 163 ++++++++++++++ .../user_projection_sync_service_test.go | 98 +++++++++ docker/ory/oathkeeper/rules.draft.json | 15 -- ...oidc_redirect_mapping_validation_policy.md | 8 +- scripts/auth_config.sh | 33 ++- scripts/test_frontend_runtime_mode.sh | 3 +- test/auth_config_orgfront_callback_test.sh | 27 +++ ...eper_kratos_public_exposure_policy_test.sh | 53 +++++ 36 files changed, 2022 insertions(+), 169 deletions(-) create mode 100644 adminfront/src/features/projections/UserProjectionPage.test.tsx create mode 100644 adminfront/src/features/projections/UserProjectionPage.tsx create mode 100644 backend/internal/domain/user_projection.go create mode 100644 backend/internal/handler/user_projection_failure.go create mode 100644 backend/internal/repository/user_projection_repository.go create mode 100644 backend/internal/repository/user_projection_repository_test.go create mode 100644 backend/internal/service/user_projection_sync_service.go create mode 100644 backend/internal/service/user_projection_sync_service_test.go create mode 100755 test/auth_config_orgfront_callback_test.sh create mode 100644 test/oathkeeper_kratos_public_exposure_policy_test.sh diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx index b68dcbc3..86782fde 100644 --- a/adminfront/src/app/routes.test.tsx +++ b/adminfront/src/app/routes.test.tsx @@ -15,4 +15,10 @@ describe("admin routes", () => { expect(callbackPath).toBe("/auth/callback"); expect(matches?.at(-1)?.route.path).toBe("/auth/callback"); }); + + it("registers the super-admin user projection management route", () => { + const matches = matchRoutes(adminRoutes, "/system/projections/users"); + + expect(matches?.at(-1)?.route.path).toBe("system/projections/users"); + }); }); diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 22594dad..9e1289a5 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -8,6 +8,7 @@ import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; +import UserProjectionPage from "../features/projections/UserProjectionPage"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; @@ -60,6 +61,7 @@ export const adminRoutes: RouteObject[] = [ }, { path: "api-keys", element: }, { path: "api-keys/new", element: }, + { path: "system/projections/users", element: }, ], }, ]; diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index fa05a182..320361b7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { Building2, ChevronDown, + Database, Key, KeyRound, LayoutDashboard, @@ -137,6 +138,11 @@ function AppLayout() { icon: Network, isExternal: true, }); + filteredItems.splice(4, 0, { + label: "ui.admin.nav.user_projection", + to: "/system/projections/users", + icon: Database, + }); } else if (isTenantAdmin || manageableCount > 0) { if (manageableCount <= 1 && profile?.tenantId) { filteredItems.splice(1, 0, { diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx new file mode 100644 index 00000000..8b5c9d7f --- /dev/null +++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx @@ -0,0 +1,99 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchUserProjectionStatus, + reconcileUserProjection, + resetUserProjection, +} from "../../lib/adminApi"; +import UserProjectionPage from "./UserProjectionPage"; + +let currentRole = "super_admin"; + +vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ role: currentRole })), + fetchUserProjectionStatus: vi.fn(async () => ({ + name: "kratos_users", + status: "ready", + ready: true, + lastSyncedAt: "2026-05-11T03:00:00Z", + updatedAt: "2026-05-11T03:00:10Z", + projectedUsers: 152, + })), + reconcileUserProjection: vi.fn(async () => ({ + status: "success", + syncedUsers: 152, + updatedAt: "2026-05-11T03:01:00Z", + })), + resetUserProjection: vi.fn(async () => ({ + status: "success", + syncedUsers: 152, + updatedAt: "2026-05-11T03:02:00Z", + })), +})); + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + return render( + + + , + ); +} + +describe("UserProjectionPage", () => { + beforeEach(() => { + currentRole = "super_admin"; + vi.clearAllMocks(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("renders projection status for super_admin", async () => { + renderPage(); + + expect( + await screen.findByText("사용자 Projection 관리"), + ).toBeInTheDocument(); + expect( + await screen.findByText("Kratos users projection"), + ).toBeInTheDocument(); + expect(screen.getByText("ready")).toBeInTheDocument(); + expect(screen.getByText("152")).toBeInTheDocument(); + expect(fetchUserProjectionStatus).toHaveBeenCalled(); + }); + + it("runs reconcile and reset actions for super_admin", async () => { + renderPage(); + + await screen.findByText("사용자 Projection 관리"); + fireEvent.click(screen.getByRole("button", { name: /재동기화/ })); + + await waitFor(() => { + expect(reconcileUserProjection).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ })); + + await waitFor(() => { + expect(resetUserProjection).toHaveBeenCalledTimes(1); + }); + }); + + it("blocks non-super admins", async () => { + currentRole = "tenant_admin"; + + renderPage(); + + expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument(); + expect( + screen.queryByText("사용자 Projection 관리"), + ).not.toBeInTheDocument(); + expect(fetchUserProjectionStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx new file mode 100644 index 00000000..c6b4ed04 --- /dev/null +++ b/adminfront/src/features/projections/UserProjectionPage.tsx @@ -0,0 +1,206 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react"; +import { RoleGuard } from "../../components/auth/RoleGuard"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + fetchUserProjectionStatus, + reconcileUserProjection, + resetUserProjection, +} from "../../lib/adminApi"; + +function formatDateTime(value?: string) { + if (!value) { + return "-"; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "medium", + }).format(date); +} + +function ProjectionStatusBadge({ + ready, + status, +}: { + ready: boolean; + status: string; +}) { + if (ready) { + return ready; + } + if (status === "failed") { + return failed; + } + return {status || "not ready"}; +} + +function UserProjectionContent() { + const queryClient = useQueryClient(); + const { data, isLoading, isError, error } = useQuery({ + queryKey: ["user-projection-status"], + queryFn: fetchUserProjectionStatus, + }); + + const invalidate = async () => { + await queryClient.invalidateQueries({ + queryKey: ["user-projection-status"], + }); + }; + + const reconcileMutation = useMutation({ + mutationFn: reconcileUserProjection, + onSuccess: invalidate, + }); + + const resetMutation = useMutation({ + mutationFn: resetUserProjection, + onSuccess: invalidate, + }); + + const handleReset = () => { + const confirmed = window.confirm( + "사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?", + ); + if (confirmed) { + resetMutation.mutate(); + } + }; + + const isWorking = reconcileMutation.isPending || resetMutation.isPending; + const actionResult = reconcileMutation.data ?? resetMutation.data; + const actionError = reconcileMutation.error ?? resetMutation.error; + + return ( +
+
+
+

System

+

+ 사용자 Projection 관리 +

+
+
+ + +
+
+ + {isError ? ( +
+ {(error as Error)?.message || + "projection 상태를 불러오지 못했습니다."} +
+ ) : null} + + {actionResult ? ( +
+ {actionResult.syncedUsers}명 기준으로 projection을 갱신했습니다. +
+ ) : null} + + {actionError ? ( +
+ {(actionError as Error)?.message || "projection 작업에 실패했습니다."} +
+ ) : null} + +
+
+
+ +
+
+

Kratos users projection

+

+ Backend DB 통계가 참조하는 사용자 read model 상태입니다. +

+
+
+ + {isLoading ? ( +
불러오는 중
+ ) : ( +
+
+
상태
+
+ +
+
+
+
+ Projection 사용자 +
+
+ {data?.projectedUsers ?? 0} +
+
+
+
마지막 동기화
+
+ {formatDateTime(data?.lastSyncedAt)} +
+
+
+
상태 갱신
+
+ {formatDateTime(data?.updatedAt)} +
+
+
+ )} + + {data?.lastError ? ( +
+ + {data.lastError} +
+ ) : null} +
+
+ ); +} + +export default function UserProjectionPage() { + return ( + +
+

접근 권한이 없습니다

+

+ 이 화면은 super_admin 권한으로만 접근할 수 있습니다. +

+
+ + } + > + +
+ ); +} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 67709852..22dc14d6 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -128,6 +128,22 @@ export type AdminOverviewStats = { auditEvents24h: number; }; +export type UserProjectionStatus = { + name: string; + status: "ready" | "failed" | "syncing" | string; + ready: boolean; + lastSyncedAt?: string; + lastError?: string; + updatedAt?: string; + projectedUsers: number; +}; + +export type UserProjectionActionResult = { + status: string; + syncedUsers: number; + updatedAt: string; +}; + export async function fetchAuditLogs(limit = 50, cursor?: string) { const { data } = await apiClient.get("/v1/audit", { params: { limit, cursor }, @@ -140,6 +156,27 @@ export async function fetchAdminOverviewStats() { return data; } +export async function fetchUserProjectionStatus() { + const { data } = await apiClient.get( + "/v1/admin/projections/users", + ); + return data; +} + +export async function reconcileUserProjection() { + const { data } = await apiClient.post( + "/v1/admin/projections/users/reconcile", + ); + return data; +} + +export async function resetUserProjection() { + const { data } = await apiClient.post( + "/v1/admin/projections/users/reset", + ); + return data; +} + export async function fetchAdminRPUsageDaily({ days = 14, period = "day", diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 75a05063..6f8d3d9e 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -851,6 +851,7 @@ relying_parties = "Apps (RP)" tenant_dashboard = "Tenant Dashboard" user_groups = "User Groups" tenants = "Tenants" +user_projection = "User Projection" users = "Users" [ui.admin.org] diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index cbe336e0..7ce00741 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -853,6 +853,7 @@ relying_parties = "애플리케이션(RP)" tenant_dashboard = "테넌트 대시보드" user_groups = "유저 그룹" tenants = "테넌트" +user_projection = "사용자 Projection" users = "사용자" [ui.admin.org] diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index f02882e6..0fa99c9c 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -865,6 +865,7 @@ relying_parties = "" tenant_dashboard = "" user_groups = "" tenants = "" +user_projection = "" users = "" [ui.admin.org] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3fb5f052..45aece73 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -300,6 +300,7 @@ func main() { tenantRepo := repository.NewTenantRepository(db) userGroupRepo := repository.NewUserGroupRepository(db) userRepo := repository.NewUserRepository(db) + userProjectionRepo := repository.NewUserProjectionRepository(db) ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) @@ -307,6 +308,13 @@ func main() { kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() + userProjectionSyncer := service.NewUserProjectionSyncService(kratosAdminService, userProjectionRepo) + if synced, err := userProjectionSyncer.Reconcile(context.Background()); err != nil { + slog.Error("❌ Kratos user projection sync failed", "error", err) + } else { + slog.Info("✅ Kratos user projection synced", "users", synced) + } + tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo) worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") if err != nil { @@ -354,6 +362,7 @@ func main() { auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache + authHandler.UserProjectionRepo = userProjectionRepo authHandler.RPUserMetadataRepo = rpUserMetadataRepo authHandler.RPUsageSink = rpUsageEmitter adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) @@ -361,14 +370,17 @@ func main() { adminHandler.TenantRepo = tenantRepo adminHandler.Hydra = hydraService adminHandler.AuditRepo = auditRepo + adminHandler.UserProjectionRepo = userProjectionRepo + adminHandler.UserProjectionSyncer = userProjectionSyncer devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo devHandler.RPUserMetadataRepo = rpUserMetadataRepo - tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) + tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, userProjectionRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) + userHandler.UserProjectionRepo = userProjectionRepo tenantHandler.SetWorksmobileSyncer(worksmobileService) userHandler.SetWorksmobileSyncer(worksmobileService) worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService) @@ -692,6 +704,9 @@ func main() { admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) + admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus) + admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection) + admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection) admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily) // Tenant Management (Mixed roles, handler filters results) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 2a38fb4c..05485e28 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, + &domain.UserProjectionState{}, &domain.UserGroup{}, &domain.ApiKey{}, &domain.IdentityProviderConfig{}, diff --git a/backend/internal/domain/user_projection.go b/backend/internal/domain/user_projection.go new file mode 100644 index 00000000..48de0cba --- /dev/null +++ b/backend/internal/domain/user_projection.go @@ -0,0 +1,29 @@ +package domain + +import "time" + +const ( + UserProjectionNameKratos = "kratos_users" + + UserProjectionStatusSyncing = "syncing" + UserProjectionStatusReady = "ready" + UserProjectionStatusFailed = "failed" +) + +type UserProjectionState struct { + Name string `gorm:"primaryKey;column:name" json:"name"` + Status string `gorm:"column:status;not null" json:"status"` + LastSyncedAt *time.Time `gorm:"column:last_synced_at" json:"lastSyncedAt,omitempty"` + LastError string `gorm:"column:last_error;type:text" json:"lastError,omitempty"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` +} + +type UserProjectionStatus struct { + Name string `json:"name"` + Status string `json:"status"` + Ready bool `json:"ready"` + LastSyncedAt *time.Time `json:"lastSyncedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + ProjectedUsers int64 `json:"projectedUsers"` +} diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 84d1ce0b..aa28a73d 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -18,12 +18,14 @@ type adminHydraClientLister interface { } type AdminHandler struct { - Keto service.KetoService - KetoOutbox repository.KetoOutboxRepository - RPUsageQueries domain.RPUsageQueryRepository - TenantRepo repository.TenantRepository - Hydra adminHydraClientLister - AuditRepo domain.AuditRepository + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository + RPUsageQueries domain.RPUsageQueryRepository + TenantRepo repository.TenantRepository + Hydra adminHydraClientLister + AuditRepo domain.AuditRepository + UserProjectionRepo repository.UserProjectionRepository + UserProjectionSyncer service.UserProjectionReconciler } func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { @@ -107,6 +109,50 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } +func requireSuperAdminProfile(c *fiber.Ctx) error { + 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"}) + } + return nil +} + +func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error { + if err := requireSuperAdminProfile(c); err != nil { + return err + } + if h == nil || h.UserProjectionRepo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"}) + } + status, err := h.UserProjectionRepo.GetStatus(c.Context()) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(status) +} + +func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error { + if err := requireSuperAdminProfile(c); err != nil { + return err + } + if h == nil || h.UserProjectionSyncer == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"}) + } + count, err := h.UserProjectionSyncer.Reconcile(c.Context()) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{ + "status": "success", + "syncedUsers": count, + "updatedAt": time.Now().UTC().Format(time.RFC3339), + }) +} + +func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error { + return h.ReconcileUserProjection(c) +} + // GetSystemStats returns runtime statistics for monitoring func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats diff --git a/backend/internal/handler/admin_handler_test.go b/backend/internal/handler/admin_handler_test.go index b392787e..73f5dfaf 100644 --- a/backend/internal/handler/admin_handler_test.go +++ b/backend/internal/handler/admin_handler_test.go @@ -5,6 +5,7 @@ import ( "baron-sso-backend/internal/service" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -65,6 +66,41 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time return f.count, nil } +type fakeAdminUserProjectionRepo struct { + status domain.UserProjectionStatus +} + +func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) { + return f.status.Ready, nil +} + +func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { + return nil, nil +} + +func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { + return nil +} + +func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error { + return nil +} + +func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { + return f.status, nil +} + +type fakeAdminUserProjectionSyncer struct { + count int + err error + calls int +} + +func (f *fakeAdminUserProjectionSyncer) Reconcile(ctx context.Context) (int, error) { + f.calls++ + return f.count, f.err +} + func TestAdminHandler_GetRPUsageDaily(t *testing.T) { repo := &fakeRPUsageQueryRepo{ items: []domain.RPUsageDailyMetric{ @@ -111,6 +147,96 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) { require.Equal(t, uint64(12), body.Items[0].LoginRequests) } +func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) { + h := &AdminHandler{ + UserProjectionRepo: &fakeAdminUserProjectionRepo{ + status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true}, + }, + } + 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/projections/users", h.GetUserProjectionStatus) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) +} + +func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) { + syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC) + h := &AdminHandler{ + UserProjectionRepo: &fakeAdminUserProjectionRepo{ + status: domain.UserProjectionStatus{ + Name: domain.UserProjectionNameKratos, + Status: domain.UserProjectionStatusReady, + Ready: true, + LastSyncedAt: &syncedAt, + ProjectedUsers: 152, + }, + }, + } + 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/projections/users", h.GetUserProjectionStatus) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body domain.UserProjectionStatus + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, domain.UserProjectionNameKratos, body.Name) + require.Equal(t, domain.UserProjectionStatusReady, body.Status) + require.True(t, body.Ready) + require.Equal(t, int64(152), body.ProjectedUsers) +} + +func TestAdminHandler_ReconcileUserProjectionRequiresSuperAdminAndRunsSyncer(t *testing.T) { + syncer := &fakeAdminUserProjectionSyncer{count: 4} + h := &AdminHandler{UserProjectionSyncer: syncer} + 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.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 1, syncer.calls) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + require.Equal(t, "success", body["status"]) + require.Equal(t, float64(4), body["syncedUsers"]) +} + +func TestAdminHandler_ReconcileUserProjectionReturnsServiceUnavailableOnSyncFailure(t *testing.T) { + syncer := &fakeAdminUserProjectionSyncer{err: errors.New("kratos down")} + h := &AdminHandler{UserProjectionSyncer: syncer} + 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.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil) + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) { repo := &fakeRPUsageQueryRepo{} keto := &fakeAdminKeto{allowed: true} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 807ab5b1..d8623d15 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -100,6 +100,7 @@ type AuthHandler struct { KetoService service.KetoService KetoOutboxRepo repository.KetoOutboxRepository UserRepo repository.UserRepository + UserProjectionRepo repository.UserProjectionRepository ConsentRepo repository.ClientConsentRepository RPUserMetadataRepo repository.RPUserMetadataRepository RPUsageSink domain.RPUsageEventSink @@ -856,6 +857,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { if err := h.UserRepo.Update(ctx, u); err != nil { slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err) + markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } else { slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) @@ -865,6 +867,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil { slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err) + markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } // [Keto] Sync user-tenant relationship via Outbox @@ -7242,6 +7245,115 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s return profile } +func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]interface{}, existing *domain.User) *domain.User { + now := time.Now() + localUser := &domain.User{ + ID: identityID, + Status: domain.UserStatusActive, + CreatedAt: now, + UpdatedAt: now, + Metadata: make(domain.JSONMap), + } + if existing != nil { + copied := *existing + localUser = &copied + localUser.UpdatedAt = now + if localUser.Metadata == nil { + localUser.Metadata = make(domain.JSONMap) + } + } + + if email := extractTraitString(traits, "email"); email != "" { + localUser.Email = email + } + if name := extractTraitString(traits, "name"); name != "" { + localUser.Name = name + } + if phone := extractTraitString(traits, "phone_number"); phone != "" { + localUser.Phone = phone + } + if department := extractTraitString(traits, "department"); department != "" { + localUser.Department = department + } + if position := extractTraitString(traits, "position"); position != "" { + localUser.Position = position + } + if jobTitle := extractTraitString(traits, "jobTitle"); jobTitle != "" { + localUser.JobTitle = jobTitle + } + if affType := extractTraitString(traits, "affiliationType"); affType != "" { + localUser.AffiliationType = affType + } + + companyCode := extractTraitString(traits, "companyCode") + if companyCode == "" { + companyCode = extractTraitString(traits, "company_code") + } + if companyCode != "" { + localUser.CompanyCode = companyCode + } + if companyCodes := extractTraitStringArray(traits, "companyCodes"); len(companyCodes) > 0 { + localUser.CompanyCodes = pq.StringArray(companyCodes) + } + if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" { + localUser.TenantID = &tenantID + } + if relyingPartyID := extractTraitString(traits, "relying_party_id"); relyingPartyID != "" { + localUser.RelyingPartyID = &relyingPartyID + } + + role := extractTraitString(traits, "grade") + if role == "" { + role = extractTraitString(traits, "role") + } + role = domain.NormalizeRole(role) + if role == "" { + role = domain.RoleUser + } + localUser.Role = role + if localUser.Status == "" { + localUser.Status = domain.UserStatusActive + } + if localUser.CreatedAt.IsZero() { + localUser.CreatedAt = now + } + + coreTraits := map[string]bool{ + "email": true, "name": true, "phone_number": true, + "grade": true, "companyCode": true, "company_code": true, + "companyCodes": true, "department": true, + "position": true, "jobTitle": true, + "affiliationType": true, "role": true, + "tenant_id": true, "relying_party_id": true, + "custom_login_ids": true, "id": true, + } + metadata := make(domain.JSONMap) + for k, v := range traits { + if !coreTraits[k] { + metadata[k] = v + } + } + localUser.Metadata = metadata + + return localUser +} + +func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]interface{}) error { + if h == nil || h.UserRepo == nil { + return nil + } + + var existing *domain.User + if current, err := h.UserRepo.FindByID(ctx, identityID); err == nil { + existing = current + } else { + slog.Warn("[UpdateMe] Failed to load existing local user before read-model sync", "userID", identityID, "error", err) + } + + localUser := h.mapKratosTraitsToLocalUser(identityID, traits, existing) + return h.UserRepo.Update(ctx, localUser) +} + func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse { if profile == nil { return nil @@ -7374,9 +7486,10 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // [New] Local DB Sync - Sync synchronously to ensure immediate consistency if h.UserRepo != nil { ctx := context.Background() - // Also update local User record (read-model) - // We can fetch updated identity or just map current traits - // Since mapKratosIdentityToProfile is for UI, let's just use UpdateUserLoginIDs first + if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil { + slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err) + markUserProjectionFailed(ctx, h.UserProjectionRepo, err) + } if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil { slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err) } diff --git a/backend/internal/handler/auth_handler_profile_cache_test.go b/backend/internal/handler/auth_handler_profile_cache_test.go index aa66ac14..c33b07b4 100644 --- a/backend/internal/handler/auth_handler_profile_cache_test.go +++ b/backend/internal/handler/auth_handler_profile_cache_test.go @@ -3,6 +3,7 @@ package handler import ( "baron-sso-backend/internal/domain" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -12,6 +13,23 @@ import ( "github.com/stretchr/testify/require" ) +type recordingUpdateMeUserRepo struct { + MockUserRepoForHandler + updated *domain.User + loginIDs []domain.UserLoginID +} + +func (r *recordingUpdateMeUserRepo) Update(ctx context.Context, user *domain.User) error { + copied := *user + r.updated = &copied + return nil +} + +func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error { + r.loginIDs = append([]domain.UserLoginID(nil), loginIDs...) + return nil +} + func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) { token := "token-abc" identityID := "user-1" @@ -107,3 +125,91 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) { require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2)) require.Equal(t, "New Dept", profile2["department"]) } + +func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) { + token := "token-sync" + identityID := "user-sync" + traits := map[string]interface{}{ + "email": "sync@example.com", + "name": "Old Name", + "phone_number": "+821012345678", + "department": "Old Dept", + "affiliationType": "employee", + "companyCode": "saman", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "role": domain.RoleUser, + } + + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.URL.Host == "kratos.test" && + r.URL.Path == "/sessions/whoami" && + r.Method == http.MethodGet: + if r.Header.Get("X-Session-Token") != token { + return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil + } + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "identity": map[string]interface{}{ + "id": identityID, + "traits": traits, + }, + }), nil + + case r.URL.Host == "kratos.test" && + r.URL.Path == "/admin/identities/"+identityID && + r.Method == http.MethodPut: + var payload struct { + Traits map[string]interface{} `json:"traits"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil + } + for k, v := range payload.Traits { + traits[k] = v + } + return httpResponse(r, http.StatusOK, `{"ok":true}`), nil + } + + return httpResponse(r, http.StatusNotFound, "not found"), nil + }) + setDefaultHTTPClientForTest(t, transport) + t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") + t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test") + + redis := &mockRedisRepo{data: map[string]string{ + "verify_update_phone:" + identityID + ":+821087654321": "verified", + }} + userRepo := &recordingUpdateMeUserRepo{} + h := &AuthHandler{ + RedisService: redis, + UserRepo: userRepo, + } + app := fiber.New() + app.Put("/api/v1/user/me", h.UpdateMe) + + updateBody, _ := json.Marshal(map[string]interface{}{ + "name": "New Name", + "phone": "01087654321", + "department": "New Dept", + }) + updateReq := httptest.NewRequest( + http.MethodPut, + "/api/v1/user/me", + bytes.NewReader(updateBody), + ) + updateReq.Header.Set("Content-Type", "application/json") + updateReq.Header.Set("Authorization", "Bearer "+token) + updateResp, err := app.Test(updateReq, -1) + require.NoError(t, err) + require.Equal(t, http.StatusOK, updateResp.StatusCode) + + require.NotNil(t, userRepo.updated) + require.Equal(t, identityID, userRepo.updated.ID) + require.Equal(t, "sync@example.com", userRepo.updated.Email) + require.Equal(t, "New Name", userRepo.updated.Name) + require.Equal(t, "+821087654321", userRepo.updated.Phone) + require.Equal(t, "New Dept", userRepo.updated.Department) + require.Equal(t, "saman", userRepo.updated.CompanyCode) + require.NotNil(t, userRepo.updated.TenantID) + require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID) +} diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 71de9bf4..38fe4d83 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -20,14 +20,15 @@ import ( ) type TenantHandler struct { - DB *gorm.DB - Service service.TenantService - UserRepo repository.UserRepository - Keto service.KetoService - KetoOutbox repository.KetoOutboxRepository - KratosAdmin service.KratosAdminService - SharedLink service.SharedLinkService - Worksmobile service.WorksmobileSyncer + DB *gorm.DB + Service service.TenantService + UserRepo repository.UserRepository + UserProjectionRepo repository.UserProjectionRepository + Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository + KratosAdmin service.KratosAdminService + SharedLink service.SharedLinkService + Worksmobile service.WorksmobileSyncer } func seedTenantDeleteError(c *fiber.Ctx) error { @@ -47,15 +48,16 @@ func seedTenantSlugsForDeleteGuard() []string { return result } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler { +func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler { return &TenantHandler{ - DB: db, - Service: svc, - UserRepo: userRepo, - Keto: keto, - KetoOutbox: outbox, - KratosAdmin: kratos, - SharedLink: sharedLink, + DB: db, + Service: svc, + UserRepo: userRepo, + UserProjectionRepo: userProjectionRepo, + Keto: keto, + KetoOutbox: outbox, + KratosAdmin: kratos, + SharedLink: sharedLink, } } @@ -251,31 +253,15 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { } } - // Fetch member counts for all tenants in one query using IDs - tenantIDs := make([]string, 0, len(tenants)) - slugs := make([]string, 0, len(tenants)) - for _, t := range tenants { - tenantIDs = append(tenantIDs, t.ID) - slugs = append(slugs, t.Slug) + memberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } - idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs) - slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs) - items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { summary := mapTenantSummary(t) - - // Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other) - idCount := idCounts[t.ID] - slugCount := slugCounts[strings.ToLower(t.Slug)] - - if idCount > slugCount { - summary.MemberCount = idCount - } else { - summary.MemberCount = slugCount - } - + summary.MemberCount = memberCounts[t.ID] items = append(items, summary) } @@ -939,19 +925,13 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID}) - slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug}) - - idCount := idCounts[tenant.ID] - slugCount := slugCounts[strings.ToLower(tenant.Slug)] - - count := idCount - if slugCount > idCount { - count = slugCount + memberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant}) + if err != nil { + return errorJSON(c, fiber.StatusServiceUnavailable, err.Error()) } summary := mapTenantSummary(tenant) - summary.MemberCount = count + summary.MemberCount = memberCounts[tenant.ID] return c.JSON(summary) } @@ -1595,6 +1575,27 @@ func mapTenantSummary(t domain.Tenant) tenantSummary { } } +func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { + counts := make(map[string]int64, len(tenants)) + for _, tenant := range tenants { + counts[tenant.ID] = 0 + } + if len(tenants) == 0 { + return counts, nil + } + if h.UserProjectionRepo == nil { + return nil, errors.New("user projection is not configured") + } + ready, err := h.UserProjectionRepo.IsReady(ctx) + if err != nil { + return nil, fmt.Errorf("user projection status unavailable: %w", err) + } + if !ready { + return nil, errors.New("user projection is not ready") + } + return h.UserProjectionRepo.CountTenantMembers(ctx, tenants) +} + func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index be6725e0..9bb2aad8 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "mime/multipart" "net/http" @@ -16,6 +17,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "gorm.io/gorm" ) @@ -101,11 +103,15 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN type MockUserRepoForHandler struct { mock.Mock + deletedIDs []string } func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil } -func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil } +func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { + m.deletedIDs = append(m.deletedIDs, id) + return nil +} func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) { return nil, nil } @@ -174,6 +180,38 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi return "", nil } +type MockUserProjectionRepoForHandler struct { + mock.Mock +} + +func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) { + args := m.Called(ctx) + return args.Bool(0), args.Error(1) +} + +func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { + args := m.Called(ctx) + return args.Get(0).(domain.UserProjectionStatus), args.Error(1) +} + +func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { + args := m.Called(ctx, tenants) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(map[string]int64), args.Error(1) +} + +func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { + args := m.Called(ctx, users) + return args.Error(0) +} + +func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error { + args := m.Called(ctx, syncErr) + return args.Error(0) +} + func TestTenantHandler_CreateTenant(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) @@ -202,14 +240,84 @@ func TestTenantHandler_CreateTenant(t *testing.T) { assert.Equal(t, "t1", got["id"]) } +func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockProjection := new(MockUserProjectionRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserProjectionRepo: mockProjection, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() + }) + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() + mockProjection.On("CountTenantMembers", mock.Anything, tenants). + Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + require.Equal(t, http.StatusOK, resp.StatusCode) + + var res tenantListResponse + json.NewDecoder(resp.Body).Decode(&res) + + require.Len(t, res.Items, 1) + assert.Equal(t, int64(2), res.Items[0].MemberCount) + mockProjection.AssertExpectations(t) +} + +func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockProjection := new(MockUserProjectionRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserProjectionRepo: mockProjection, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() + }) + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once() + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything) +} + func TestTenantHandler_ListTenants(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) - mockUserRepo := new(MockUserRepoForHandler) + mockProjection := new(MockUserProjectionRepoForHandler) h := &TenantHandler{ - Service: mockSvc, - UserRepo: mockUserRepo, + Service: mockSvc, + UserProjectionRepo: mockProjection, } app.Use(func(c *fiber.Ctx) error { @@ -226,11 +334,9 @@ func TestTenantHandler_ListTenants(t *testing.T) { // Mocking for the new allTenants check in ListTenants mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() - - mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything). - Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() - mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything). - Return(map[string]int64{}, nil).Maybe() + mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() + mockProjection.On("CountTenantMembers", mock.Anything, tenants). + Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) @@ -253,6 +359,84 @@ func TestTenantHandler_ListTenants(t *testing.T) { } } +func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockProjection := new(MockUserProjectionRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserProjectionRepo: mockProjection, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() + }) + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + } + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once() + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + mockProjection.AssertExpectations(t) +} + +func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) { + app := fiber.New() + mockSvc := new(MockTenantService) + mockUserRepo := new(MockUserRepoForHandler) + mockProjection := new(MockUserProjectionRepoForHandler) + + h := &TenantHandler{ + Service: mockSvc, + UserRepo: mockUserRepo, + UserProjectionRepo: mockProjection, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() + }) + app.Get("/tenants", h.ListTenants) + + tenants := []domain.Tenant{ + {ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"}, + } + + mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once() + mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once() + mockProjection.On("CountTenantMembers", mock.Anything, tenants). + Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once() + + mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}). + Return(map[string]int64{"saman": 152}, nil).Maybe() + mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}). + Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe() + + req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) + resp, _ := app.Test(req) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res tenantListResponse + json.NewDecoder(resp.Body).Decode(&res) + + assert.Len(t, res.Items, 1) + assert.Equal(t, int64(2), res.Items[0].MemberCount) + mockProjection.AssertExpectations(t) +} + func TestTenantHandler_ExportTenantsCSV(t *testing.T) { app := fiber.New() mockSvc := new(MockTenantService) diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index d921c33e..11ec8f55 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -28,15 +28,16 @@ type OryProviderAPI interface { } type UserHandler struct { - KratosAdmin service.KratosAdminService - OryProvider OryProviderAPI - TenantService service.TenantService - KetoService service.KetoService - KetoOutboxRepo repository.KetoOutboxRepository - UserRepo repository.UserRepository - UserGroupRepo repository.UserGroupRepository - AuditRepo domain.AuditRepository - Worksmobile service.WorksmobileSyncer + KratosAdmin service.KratosAdminService + OryProvider OryProviderAPI + TenantService service.TenantService + KetoService service.KetoService + KetoOutboxRepo repository.KetoOutboxRepository + UserRepo repository.UserRepository + UserProjectionRepo repository.UserProjectionRepository + UserGroupRepo repository.UserGroupRepository + AuditRepo domain.AuditRepository + Worksmobile service.WorksmobileSyncer } func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler { @@ -265,7 +266,10 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { } } - // 1. Try Kratos First + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") + } + identities, err := h.KratosAdmin.ListIdentities(c.Context()) if err == nil { filtered := make([]service.KratosIdentity, 0, len(identities)) @@ -363,46 +367,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } - // 2. Fallback to Local DB if Kratos is down - slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) - - // If requester is not Super Admin, we should technically filter by manageable slugs in DB too. - // For simplicity in fallback, if tenantSlug is empty we default to their primary company code. - if (requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin) && tenantSlug == "" { - profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) - if profile != nil && profile.CompanyCode != "" { - tenantSlug = profile.CompanyCode - } - } - - // Fetch from UserRepo - users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db") - } - - items := make([]userSummary, 0, len(users)) - for _, u := range users { - items = append(items, userSummary{ - ID: u.ID, - Email: u.Email, - Name: u.Name, - Phone: u.Phone, - Role: u.Role, - Status: u.Status, - CompanyCode: u.CompanyCode, - Department: u.Department, - CreatedAt: u.CreatedAt.Format(time.RFC3339), - UpdatedAt: u.UpdatedAt.Format(time.RFC3339), - }) - } - - return c.JSON(userListResponse{ - Items: items, - Total: total, - Limit: limit, - Offset: offset, - }) + slog.Warn("Kratos unavailable for user list", "error", err) + return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable") } func (h *UserHandler) GetUser(c *fiber.Ctx) error { @@ -632,6 +598,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { // Sync to local DB (Synchronous for immediate consistency) if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err) + markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { @@ -645,6 +612,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil { slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err) + markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } // [Keto] Sync relations via Outbox (Synchronous for accurate counting) @@ -938,6 +906,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) + markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { @@ -951,6 +920,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil { slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err) + markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.KetoOutboxRepo != nil { @@ -1769,6 +1739,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { ctx := context.Background() // Use request context if appropriate, but sync must finish if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) + markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil { @@ -1779,6 +1750,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { // Update User Login IDs in local DB if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil { slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err) + markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } // [Keto Sync] asynchronously as it's less critical for immediate UI count @@ -1927,6 +1899,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { // Additional cleanup for tenants could be added here if we keep track of user's current tenants } + if h.UserRepo != nil { + if err := h.UserRepo.Delete(context.Background(), userID); err != nil { + slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err) + markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err) + } + } + return c.SendStatus(fiber.StatusNoContent) } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index e8a08967..a20047bf 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -430,6 +430,35 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) { }) } +func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockRepo := new(MockUserRepoForHandler) + + h := &UserHandler{ + KratosAdmin: mockKratos, + UserRepo: mockRepo, + } + + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: domain.RoleSuperAdmin, + }) + return c.Next() + }) + app.Get("/users", h.ListUsers) + + mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{}, errors.New("kratos down")).Once() + + req := httptest.NewRequest("GET", "/users?limit=10&offset=0", nil) + resp, err := app.Test(req) + + assert.NoError(t, err) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockKratos.AssertExpectations(t) +} + func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) @@ -672,6 +701,27 @@ func TestUserHandler_BulkDeleteUsers(t *testing.T) { }) } +func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + userRepo := new(MockUserRepoForHandler) + h := &UserHandler{KratosAdmin: mockKratos, UserRepo: userRepo} + + app.Delete("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin}) + return h.DeleteUser(c) + }) + + mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once() + + req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs) + mockKratos.AssertExpectations(t) +} + func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) diff --git a/backend/internal/handler/user_projection_failure.go b/backend/internal/handler/user_projection_failure.go new file mode 100644 index 00000000..8fadca50 --- /dev/null +++ b/backend/internal/handler/user_projection_failure.go @@ -0,0 +1,16 @@ +package handler + +import ( + "baron-sso-backend/internal/repository" + "context" + "log/slog" +) + +func markUserProjectionFailed(ctx context.Context, repo repository.UserProjectionRepository, syncErr error) { + if repo == nil || syncErr == nil { + return + } + if err := repo.MarkFailed(ctx, syncErr); err != nil { + slog.Error("Failed to mark user projection as failed", "syncError", syncErr, "error", err) + } +} diff --git a/backend/internal/repository/main_test.go b/backend/internal/repository/main_test.go index e8de32c8..4d1aa43e 100644 --- a/backend/internal/repository/main_test.go +++ b/backend/internal/repository/main_test.go @@ -63,7 +63,7 @@ func TestMain(m *testing.M) { } // Auto-migrate - err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}) + err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{}) if err != nil { log.Fatalf("failed to migrate database: %s", err) } diff --git a/backend/internal/repository/user_projection_repository.go b/backend/internal/repository/user_projection_repository.go new file mode 100644 index 00000000..f14b9879 --- /dev/null +++ b/backend/internal/repository/user_projection_repository.go @@ -0,0 +1,176 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type UserProjectionRepository interface { + IsReady(ctx context.Context) (bool, error) + GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) + CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) + ReplaceAllFromKratos(ctx context.Context, users []domain.User) error + MarkFailed(ctx context.Context, syncErr error) error +} + +type userProjectionRepository struct { + db *gorm.DB +} + +func NewUserProjectionRepository(db *gorm.DB) UserProjectionRepository { + return &userProjectionRepository{db: db} +} + +func (r *userProjectionRepository) IsReady(ctx context.Context) (bool, error) { + status, err := r.GetStatus(ctx) + if err != nil { + return false, err + } + return status.Ready, nil +} + +func (r *userProjectionRepository) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { + var projectedUsers int64 + if err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&projectedUsers).Error; err != nil { + return domain.UserProjectionStatus{}, err + } + + var state domain.UserProjectionState + err := r.db.WithContext(ctx).First(&state, "name = ?", domain.UserProjectionNameKratos).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return domain.UserProjectionStatus{ + Name: domain.UserProjectionNameKratos, + Status: domain.UserProjectionStatusFailed, + Ready: false, + ProjectedUsers: projectedUsers, + }, nil + } + if err != nil { + return domain.UserProjectionStatus{}, err + } + return domain.UserProjectionStatus{ + Name: state.Name, + Status: state.Status, + Ready: state.Status == domain.UserProjectionStatusReady && state.LastSyncedAt != nil, + LastSyncedAt: state.LastSyncedAt, + LastError: state.LastError, + UpdatedAt: &state.UpdatedAt, + ProjectedUsers: projectedUsers, + }, nil +} + +func (r *userProjectionRepository) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { + counts := make(map[string]int64, len(tenants)) + for _, tenant := range tenants { + counts[tenant.ID] = 0 + } + if len(tenants) == 0 { + return counts, nil + } + + valuePlaceholders := make([]string, 0, len(tenants)) + args := make([]interface{}, 0, len(tenants)*2) + for _, tenant := range tenants { + valuePlaceholders = append(valuePlaceholders, "(?, ?)") + args = append(args, strings.TrimSpace(tenant.ID), strings.TrimSpace(tenant.Slug)) + } + + query := fmt.Sprintf(` + WITH requested(tenant_id, slug) AS ( + VALUES %s + ) + SELECT requested.tenant_id, COUNT(DISTINCT users.id) AS count + FROM requested + LEFT JOIN users ON users.deleted_at IS NULL AND ( + users.tenant_id::text = requested.tenant_id + OR LOWER(users.company_code) = LOWER(requested.slug) + OR EXISTS ( + SELECT 1 FROM unnest(users.company_codes) AS company_code + WHERE LOWER(company_code) = LOWER(requested.slug) + ) + ) + GROUP BY requested.tenant_id + `, strings.Join(valuePlaceholders, ",")) + + type result struct { + TenantID string + Count int64 + } + var rows []result + if err := r.db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + counts[row.TenantID] = row.Count + } + return counts, nil +} + +func (r *userProjectionRepository) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { + now := time.Now() + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + ids := make([]string, 0, len(users)) + for i := range users { + users[i].DeletedAt = gorm.DeletedAt{} + if users[i].CreatedAt.IsZero() { + users[i].CreatedAt = now + } + if users[i].UpdatedAt.IsZero() { + users[i].UpdatedAt = now + } + ids = append(ids, users[i].ID) + } + + if len(users) > 0 { + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(&users).Error; err != nil { + return err + } + if err := tx.Where("id NOT IN ?", ids).Delete(&domain.User{}).Error; err != nil { + return err + } + } else if err := tx.Where("1 = 1").Delete(&domain.User{}).Error; err != nil { + return err + } + + return upsertUserProjectionState(tx, domain.UserProjectionStatusReady, &now, "") + }) +} + +func (r *userProjectionRepository) MarkFailed(ctx context.Context, syncErr error) error { + message := "" + if syncErr != nil { + message = syncErr.Error() + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return upsertUserProjectionState(tx, domain.UserProjectionStatusFailed, nil, message) + }) +} + +func upsertUserProjectionState(tx *gorm.DB, status string, syncedAt *time.Time, lastError string) error { + state := domain.UserProjectionState{ + Name: domain.UserProjectionNameKratos, + Status: status, + LastSyncedAt: syncedAt, + LastError: lastError, + UpdatedAt: time.Now(), + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "name"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "status", + "last_synced_at", + "last_error", + "updated_at", + }), + }).Create(&state).Error +} diff --git a/backend/internal/repository/user_projection_repository_test.go b/backend/internal/repository/user_projection_repository_test.go new file mode 100644 index 00000000..c0f92041 --- /dev/null +++ b/backend/internal/repository/user_projection_repository_test.go @@ -0,0 +1,87 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleUsers(t *testing.T) { + ctx := context.Background() + repo := NewUserProjectionRepository(testDB) + + require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error) + require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error) + require.NoError(t, testDB.Exec("DELETE FROM users").Error) + + tenantID := "10000000-0000-0000-0000-000000000001" + tenantSlug := "projection-saman" + require.NoError(t, testDB.Create(&domain.Tenant{ + ID: tenantID, + Name: "Projection Saman", + Slug: tenantSlug, + Type: domain.TenantTypeCompany, + Status: domain.TenantStatusActive, + }).Error) + stale := &domain.User{ + ID: "00000000-0000-0000-0000-000000000099", + Email: "stale@example.com", + Name: "Stale", + CompanyCode: tenantSlug, + } + require.NoError(t, NewUserRepository(testDB).Create(ctx, stale)) + + users := []domain.User{ + { + ID: "00000000-0000-0000-0000-000000000101", + Email: "one@example.com", + Name: "One", + CompanyCode: tenantSlug, + TenantID: &tenantID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + { + ID: "00000000-0000-0000-0000-000000000102", + Email: "two@example.com", + Name: "Two", + CompanyCodes: []string{tenantSlug}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + } + + require.NoError(t, repo.ReplaceAllFromKratos(ctx, users)) + + ready, err := repo.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready) + + counts, err := repo.CountTenantMembers(ctx, []domain.Tenant{ + {ID: tenantID, Slug: tenantSlug}, + }) + require.NoError(t, err) + assert.Equal(t, int64(2), counts[tenantID]) + + var activeCount int64 + require.NoError(t, testDB.Model(&domain.User{}).Count(&activeCount).Error) + assert.Equal(t, int64(2), activeCount) +} + +func TestUserProjectionRepository_MarkFailedMakesProjectionNotReady(t *testing.T) { + ctx := context.Background() + repo := NewUserProjectionRepository(testDB) + + require.NoError(t, testDB.Exec("DELETE FROM user_projection_states").Error) + + require.NoError(t, repo.MarkFailed(ctx, errors.New("kratos down"))) + + ready, err := repo.IsReady(ctx) + require.NoError(t, err) + assert.False(t, ready) +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 6e0af6ea..ca8105c1 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -164,9 +164,9 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string query := ` SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count FROM ( - SELECT id, company_code as comp_code FROM users WHERE LOWER(company_code) = ANY($1) + SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1) UNION ALL - SELECT id, unnest(company_codes) as comp_code FROM users WHERE company_codes && $1 + SELECT id, unnest(company_codes) as comp_code FROM users WHERE deleted_at IS NULL AND company_codes IS NOT NULL ) as combined WHERE LOWER(comp_code) = ANY($1) GROUP BY LOWER(comp_code) diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 4a156178..0313c94d 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -95,6 +95,25 @@ func TestUserRepository(t *testing.T) { assert.Equal(t, int64(0), counts["tenant-c"]) }) + t.Run("CountByCompanyCodes excludes soft deleted cache rows", func(t *testing.T) { + testDB.Exec("DELETE FROM users") + + active := &domain.User{Email: "active@a.com", Name: "Active", CompanyCode: "tenant-a"} + deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", CompanyCode: "tenant-a"} + arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array Deleted", CompanyCodes: []string{"tenant-a"}} + + assert.NoError(t, repo.Create(ctx, active)) + assert.NoError(t, repo.Create(ctx, deleted)) + assert.NoError(t, repo.Create(ctx, arrayDeleted)) + assert.NoError(t, repo.Delete(ctx, deleted.ID)) + assert.NoError(t, repo.Delete(ctx, arrayDeleted.ID)) + + counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a"}) + + assert.NoError(t, err) + assert.Equal(t, int64(1), counts["tenant-a"]) + }) + t.Run("Multi-Identifier Support", func(t *testing.T) { _ = testDB.AutoMigrate(&domain.UserLoginID{}) testDB.Exec("DELETE FROM user_login_ids") diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 563e1247..5122979e 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -6,8 +6,10 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/google/uuid" + "github.com/lib/pq" ) type UserGroupService interface { @@ -210,40 +212,56 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string return fmt.Errorf("user group not found: %w", err) } - // [Fix] Sync Kratos Traits & Local DB when a user is added to an organization - if s.kratos != nil && s.tenantRepo != nil { - tenant, err := s.tenantRepo.FindByID(ctx, group.TenantID) - if err == nil && tenant != nil { - // Fetch Kratos Identity - identity, err := s.kratos.GetIdentity(ctx, userID) - if err == nil && identity != nil { - traits := identity.Traits - if traits == nil { - traits = make(map[string]interface{}) - } - traits["companyCode"] = tenant.Slug - traits["tenant_id"] = tenant.ID - traits["department"] = group.Name + var tenant *domain.Tenant + if s.tenantRepo != nil { + tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID) + } - // Update Kratos - _, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State) - if updateErr != nil { - slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr) - } + var updatedIdentity *KratosIdentity + + // [Fix] Sync Kratos Traits & Local DB when a user is added to an organization + if s.kratos != nil && tenant != nil { + // Fetch Kratos Identity + identity, err := s.kratos.GetIdentity(ctx, userID) + if err == nil && identity != nil { + traits := identity.Traits + if traits == nil { + traits = make(map[string]interface{}) + } + traits["companyCode"] = tenant.Slug + traits["tenant_id"] = tenant.ID + traits["department"] = group.Name + + // Update Kratos + updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State) + if updateErr != nil { + slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr) + } else if updated != nil { + updatedIdentity = updated + } else { + identity.Traits = traits + updatedIdentity = identity } } } // Sync local user repo - if s.userRepo != nil && s.tenantRepo != nil { - tenant, _ := s.tenantRepo.FindByID(ctx, group.TenantID) - if tenant != nil { - localUser, err := s.userRepo.FindByID(ctx, userID) - if err == nil && localUser != nil { - localUser.CompanyCode = tenant.Slug - localUser.TenantID = &tenant.ID - localUser.Department = group.Name - _ = s.userRepo.Update(ctx, localUser) + if s.userRepo != nil && tenant != nil { + localUser, err := s.userRepo.FindByID(ctx, userID) + if err != nil || localUser == nil { + if updatedIdentity != nil { + localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity) + } else { + slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err) + localUser = nil + } + } + if localUser != nil { + localUser.CompanyCode = tenant.Slug + localUser.TenantID = &tenant.ID + localUser.Department = group.Name + if err := s.userRepo.Update(ctx, localUser); err != nil { + slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err) } } } @@ -271,6 +289,116 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string return nil } +func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User { + traits := identity.Traits + now := time.Now() + createdAt := identity.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := identity.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + + role := userGroupTraitString(traits, "grade") + if role == "" { + role = userGroupTraitString(traits, "role") + } + role = domain.NormalizeRole(role) + if role == "" { + role = domain.RoleUser + } + + companyCode := userGroupTraitString(traits, "companyCode") + if companyCode == "" { + companyCode = userGroupTraitString(traits, "company_code") + } + + user := &domain.User{ + ID: identity.ID, + Email: userGroupTraitString(traits, "email"), + Name: userGroupTraitString(traits, "name"), + Phone: userGroupTraitString(traits, "phone_number"), + Role: role, + Status: userGroupIdentityStatus(identity.State), + CompanyCode: companyCode, + Department: userGroupTraitString(traits, "department"), + Position: userGroupTraitString(traits, "position"), + JobTitle: userGroupTraitString(traits, "jobTitle"), + AffiliationType: userGroupTraitString(traits, "affiliationType"), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Metadata: make(domain.JSONMap), + } + if tenantID := userGroupTraitString(traits, "tenant_id"); tenantID != "" { + user.TenantID = &tenantID + } + if relyingPartyID := userGroupTraitString(traits, "relying_party_id"); relyingPartyID != "" { + user.RelyingPartyID = &relyingPartyID + } + user.CompanyCodes = pq.StringArray(userGroupTraitStringArray(traits, "companyCodes")) + + coreTraits := map[string]bool{ + "email": true, "name": true, "phone_number": true, + "grade": true, "role": true, "companyCode": true, "company_code": true, + "companyCodes": true, "tenant_id": true, "department": true, + "position": true, "jobTitle": true, "affiliationType": true, + "relying_party_id": true, "custom_login_ids": true, "id": true, + } + for key, value := range traits { + if !coreTraits[key] { + user.Metadata[key] = value + } + } + return user +} + +func userGroupTraitString(traits map[string]interface{}, key string) string { + if traits == nil { + return "" + } + value, ok := traits[key] + if !ok || value == nil { + return "" + } + if str, ok := value.(string); ok { + return str + } + return fmt.Sprint(value) +} + +func userGroupTraitStringArray(traits map[string]interface{}, key string) []string { + if traits == nil { + return nil + } + switch value := traits[key].(type) { + case []string: + return value + case []interface{}: + items := make([]string, 0, len(value)) + for _, item := range value { + if str, ok := item.(string); ok && str != "" { + items = append(items, str) + } + } + return items + default: + return nil + } +} + +func userGroupIdentityStatus(state string) string { + switch state { + case "", "active": + return domain.UserStatusActive + case "inactive": + return domain.UserStatusInactive + default: + return state + } +} + func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error { // Validate group exists if _, err := s.repo.FindByID(ctx, groupID); err != nil { diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 1f6e4f05..8bb9907a 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "gorm.io/gorm" ) // --- Mocks for Repositories --- @@ -45,10 +46,15 @@ func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID s type MockUserRepository struct { mock.Mock + updatedUsers []domain.User } func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil } -func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil } +func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { + copied := *user + m.updatedUsers = append(m.updatedUsers, copied) + return nil +} func (m *MockUserRepository) Delete(ctx context.Context, id string) error { return m.Called(ctx, id).Error(0) } @@ -270,6 +276,62 @@ func TestUserGroupService_AddMember(t *testing.T) { // mockUserRepo.AssertExpectations(t) } +func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T) { + mockOutbox := new(MockKetoOutboxRepositoryShared) + mockUserGroupRepo := new(MockUserGroupRepository) + mockUserRepo := new(MockUserRepository) + mockTenantRepo := new(MockTenantRepository) + mockKratos := new(MockKratosAdminServiceShared) + svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos) + + groupID := "group-1" + userID := "user-1" + tenantID := "tenant-1" + tenantSlug := "tenant-slug" + + mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil) + mockUserRepo.On("FindByID", mock.Anything, userID).Return(nil, gorm.ErrRecordNotFound) + mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil) + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "user@test.com", + "name": "User Test", + }, + State: "active", + }, nil) + mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { + return traits["companyCode"] == tenantSlug && traits["tenant_id"] == tenantID && traits["department"] == "Sales" + }), "active").Return(&KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "user@test.com", + "name": "User Test", + "companyCode": tenantSlug, + "tenant_id": tenantID, + "department": "Sales", + }, + State: "active", + }, nil) + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID + })).Return(nil).Once() + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID + })).Return(nil).Once() + + err := svc.AddMember(context.Background(), groupID, userID) + assert.NoError(t, err) + assert.Len(t, mockUserRepo.updatedUsers, 1) + assert.Equal(t, userID, mockUserRepo.updatedUsers[0].ID) + assert.Equal(t, tenantSlug, mockUserRepo.updatedUsers[0].CompanyCode) + assert.NotNil(t, mockUserRepo.updatedUsers[0].TenantID) + assert.Equal(t, tenantID, *mockUserRepo.updatedUsers[0].TenantID) + assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department) + mockOutbox.AssertExpectations(t) + mockKratos.AssertExpectations(t) +} + func TestUserGroupService_AssignRoleToTenant(t *testing.T) { mockOutbox := new(MockKetoOutboxRepositoryShared) mockUserGroupRepo := new(MockUserGroupRepository) diff --git a/backend/internal/service/user_projection_sync_service.go b/backend/internal/service/user_projection_sync_service.go new file mode 100644 index 00000000..a3e4612f --- /dev/null +++ b/backend/internal/service/user_projection_sync_service.go @@ -0,0 +1,163 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "fmt" + "strings" + "time" + + "github.com/lib/pq" +) + +type UserProjectionSyncService struct { + kratos KratosAdminService + repo repository.UserProjectionRepository +} + +type UserProjectionReconciler interface { + Reconcile(ctx context.Context) (int, error) +} + +func NewUserProjectionSyncService(kratos KratosAdminService, repo repository.UserProjectionRepository) *UserProjectionSyncService { + return &UserProjectionSyncService{ + kratos: kratos, + repo: repo, + } +} + +func (s *UserProjectionSyncService) Reconcile(ctx context.Context) (int, error) { + if s == nil || s.kratos == nil || s.repo == nil { + return 0, fmt.Errorf("user projection sync dependencies are not configured") + } + + identities, err := s.kratos.ListIdentities(ctx) + if err != nil { + _ = s.repo.MarkFailed(ctx, err) + return 0, err + } + + users := make([]domain.User, 0, len(identities)) + for _, identity := range identities { + users = append(users, MapKratosIdentityToLocalUser(identity)) + } + if err := s.repo.ReplaceAllFromKratos(ctx, users); err != nil { + _ = s.repo.MarkFailed(ctx, err) + return 0, err + } + return len(users), nil +} + +func MapKratosIdentityToLocalUser(identity KratosIdentity) domain.User { + traits := identity.Traits + now := time.Now() + createdAt := identity.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := identity.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + + role := kratosProjectionTraitString(traits, "grade") + if role == "" { + role = kratosProjectionTraitString(traits, "role") + } + role = domain.NormalizeRole(role) + if role == "" { + role = domain.RoleUser + } + + companyCode := kratosProjectionTraitString(traits, "companyCode") + if companyCode == "" { + companyCode = kratosProjectionTraitString(traits, "company_code") + } + + user := domain.User{ + ID: identity.ID, + Email: kratosProjectionTraitString(traits, "email"), + Name: kratosProjectionTraitString(traits, "name"), + Phone: kratosProjectionTraitString(traits, "phone_number"), + Role: role, + Status: normalizeProjectionStatus(identity.State), + CompanyCode: companyCode, + CompanyCodes: pq.StringArray(kratosProjectionTraitStringArray(traits, "companyCodes")), + Department: kratosProjectionTraitString(traits, "department"), + Position: kratosProjectionTraitString(traits, "position"), + JobTitle: kratosProjectionTraitString(traits, "jobTitle"), + AffiliationType: kratosProjectionTraitString(traits, "affiliationType"), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Metadata: make(domain.JSONMap), + } + if tenantID := kratosProjectionTraitString(traits, "tenant_id"); tenantID != "" { + user.TenantID = &tenantID + } + if relyingPartyID := kratosProjectionTraitString(traits, "relying_party_id"); relyingPartyID != "" { + user.RelyingPartyID = &relyingPartyID + } + + coreTraits := map[string]bool{ + "email": true, "name": true, "phone_number": true, + "grade": true, "role": true, + "companyCode": true, "company_code": true, "companyCodes": true, + "tenant_id": true, "department": true, + "position": true, "jobTitle": true, "affiliationType": true, + "relying_party_id": true, "custom_login_ids": true, "id": true, + } + for key, value := range traits { + if !coreTraits[key] { + user.Metadata[key] = value + } + } + return user +} + +func kratosProjectionTraitString(traits map[string]interface{}, key string) string { + if traits == nil { + return "" + } + value, ok := traits[key] + if !ok || value == nil { + return "" + } + if str, ok := value.(string); ok { + return str + } + return fmt.Sprint(value) +} + +func kratosProjectionTraitStringArray(traits map[string]interface{}, key string) []string { + if traits == nil { + return nil + } + switch value := traits[key].(type) { + case []string: + return value + case []interface{}: + items := make([]string, 0, len(value)) + for _, item := range value { + if str, ok := item.(string); ok && strings.TrimSpace(str) != "" { + items = append(items, str) + } + } + return items + default: + return nil + } +} + +func normalizeProjectionStatus(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "blocked", domain.UserStatusInactive: + return domain.UserStatusInactive + case domain.UserStatusSuspended: + return domain.UserStatusSuspended + case domain.UserStatusLeaveOfAbsence: + return domain.UserStatusLeaveOfAbsence + default: + return domain.UserStatusActive + } +} diff --git a/backend/internal/service/user_projection_sync_service_test.go b/backend/internal/service/user_projection_sync_service_test.go new file mode 100644 index 00000000..4b4e9ee8 --- /dev/null +++ b/backend/internal/service/user_projection_sync_service_test.go @@ -0,0 +1,98 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeUserProjectionRepo struct { + replacedUsers []domain.User + failedErr error + replaceErr error +} + +func (f *fakeUserProjectionRepo) IsReady(ctx context.Context) (bool, error) { + return false, nil +} + +func (f *fakeUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) { + return domain.UserProjectionStatus{}, nil +} + +func (f *fakeUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) { + return nil, nil +} + +func (f *fakeUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error { + f.replacedUsers = append([]domain.User(nil), users...) + return f.replaceErr +} + +func (f *fakeUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error { + f.failedErr = syncErr + return nil +} + +func TestUserProjectionSyncService_ReconcileReplacesProjectionFromKratos(t *testing.T) { + ctx := context.Background() + kratos := new(MockKratosAdminServiceShared) + repo := &fakeUserProjectionRepo{} + svc := NewUserProjectionSyncService(kratos, repo) + + tenantID := "00000000-0000-0000-0000-000000000001" + kratos.On("ListIdentities", ctx).Return([]KratosIdentity{ + { + ID: "00000000-0000-0000-0000-000000000101", + Traits: map[string]interface{}{ + "email": "one@example.com", + "name": "One", + "phone_number": "+821012345678", + "companyCode": "saman", + "companyCodes": []interface{}{"saman", "group-a"}, + "tenant_id": tenantID, + "department": "DX", + "customAttr": "kept", + }, + State: "active", + }, + }, nil).Once() + + count, err := svc.Reconcile(ctx) + + require.NoError(t, err) + assert.Equal(t, 1, count) + require.Len(t, repo.replacedUsers, 1) + assert.Equal(t, "one@example.com", repo.replacedUsers[0].Email) + assert.Equal(t, "One", repo.replacedUsers[0].Name) + assert.Equal(t, "+821012345678", repo.replacedUsers[0].Phone) + assert.Equal(t, "saman", repo.replacedUsers[0].CompanyCode) + assert.Equal(t, []string{"saman", "group-a"}, []string(repo.replacedUsers[0].CompanyCodes)) + require.NotNil(t, repo.replacedUsers[0].TenantID) + assert.Equal(t, tenantID, *repo.replacedUsers[0].TenantID) + assert.Equal(t, "kept", repo.replacedUsers[0].Metadata["customAttr"]) + assert.NoError(t, repo.failedErr) + kratos.AssertExpectations(t) +} + +func TestUserProjectionSyncService_ReconcileMarksFailedWhenKratosFails(t *testing.T) { + ctx := context.Background() + kratos := new(MockKratosAdminServiceShared) + repo := &fakeUserProjectionRepo{} + svc := NewUserProjectionSyncService(kratos, repo) + + expectedErr := errors.New("kratos down") + kratos.On("ListIdentities", ctx).Return([]KratosIdentity{}, expectedErr).Once() + + count, err := svc.Reconcile(ctx) + + assert.Equal(t, 0, count) + assert.ErrorIs(t, err, expectedErr) + assert.ErrorIs(t, repo.failedErr, expectedErr) + assert.Empty(t, repo.replacedUsers) + kratos.AssertExpectations(t) +} diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json index 3201389b..1f825b2f 100755 --- a/docker/ory/oathkeeper/rules.draft.json +++ b/docker/ory/oathkeeper/rules.draft.json @@ -55,21 +55,6 @@ "authorizer": { "handler": "remote_json" }, "mutators": [{ "handler": "noop" }] }, - { - "id": "kratos-public", - "description": "Kratos Public API를 /kratos로 노출", - "match": { - "url": "http://<.*>/kratos/<.*>", - "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] - }, - "upstream": { - "url": "http://kratos:4433", - "strip_path": "/kratos" - }, - "authenticators": [{ "handler": "noop" }], - "authorizer": { "handler": "allow" }, - "mutators": [{ "handler": "noop" }] - }, { "id": "hydra-public", "description": "Hydra Public API를 /hydra로 노출", diff --git a/docs/oidc_redirect_mapping_validation_policy.md b/docs/oidc_redirect_mapping_validation_policy.md index d46fdfc9..c31a73d6 100644 --- a/docs/oidc_redirect_mapping_validation_policy.md +++ b/docs/oidc_redirect_mapping_validation_policy.md @@ -5,7 +5,7 @@ - Gateway(Oathkeeper/Nginx) 경유 구조에서 발생하는 Public URL과 Internal URL의 의도된 차이를 정책적으로 허용하되, 매핑의 유효성은 엄격히 검증합니다. ## 적용 범위 -- UserFront, AdminFront, DevFront의 로그인/콜백 경로 +- UserFront, AdminFront, DevFront, OrgFront의 로그인/콜백 경로 - Ory Stack(Hydra/Kratos/Oathkeeper) 설정 - `compose.ory.yaml`, `docker/compose.ory.yaml`, `docker/staging_pull_compose.template.yaml` - `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json` @@ -40,15 +40,15 @@ ## 검증 항목 1. 정적 검증 (`make validate-auth-config`) - `USERFRONT_URL`, `OATHKEEPER_PUBLIC_URL`, `HYDRA_PUBLIC_URL`, `KRATOS_BROWSER_URL` 정합성 -- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS` URL 유효성/중복/경로 규약 +- `ADMINFRONT_CALLBACK_URLS`, `DEVFRONT_CALLBACK_URLS`, `ORGFRONT_CALLBACK_URLS` URL 유효성/중복/경로 규약 - Gateway `/oidc`, `/auth` 라우팅 규칙 존재 여부 - Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부 - staging pull/deploy template의 Oathkeeper entrypoint 사용 여부 - `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부 -2. 런타임 검증 (`make verify-oidc-config`) +2. 런타임 검증 (`make verify-auth-config`) - OIDC Discovery endpoint 조회 가능 여부 -- Hydra 등록 client(`adminfront`, `devfront`)의 `redirect_uris` 확인 +- Hydra 등록 client(`adminfront`, `devfront`, `orgfront`)의 `redirect_uris` 확인 - 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인 ## 경로 규약 diff --git a/scripts/auth_config.sh b/scripts/auth_config.sh index dfe3f60e..21ce936c 100755 --- a/scripts/auth_config.sh +++ b/scripts/auth_config.sh @@ -20,8 +20,10 @@ HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}" DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}" +ORGFRONT_URL="${ORGFRONT_URL:-https://sorg.hmac.kr}" ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}" DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_URL%/}/auth/callback}" +ORGFRONT_CALLBACK_URLS="${ORGFRONT_CALLBACK_URLS:-${ORGFRONT_URL%/}/auth/callback}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" declare -a WARNINGS=() @@ -185,6 +187,7 @@ to_json_array() { collect_values() { declare -ga ADMIN_CALLBACKS=() declare -ga DEV_CALLBACKS=() + declare -ga ORG_CALLBACKS=() declare -ga EXTRA_ALLOWED_RETURNS=() while IFS= read -r item; do @@ -195,6 +198,10 @@ collect_values() { DEV_CALLBACKS+=("$item") done < <(csv_to_lines "$DEVFRONT_CALLBACK_URLS") + while IFS= read -r item; do + ORG_CALLBACKS+=("$item") + done < <(csv_to_lines "$ORGFRONT_CALLBACK_URLS") + while IFS= read -r item; do EXTRA_ALLOWED_RETURNS+=("$item") done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA") @@ -309,6 +316,9 @@ build_allowed_return_urls() { for url in "${DEV_CALLBACKS[@]}"; do add_allowed_url "$url" done + for url in "${ORG_CALLBACKS[@]}"; do + add_allowed_url "$url" + done for url in "${EXTRA_ALLOWED_RETURNS[@]}"; do add_allowed_url "$url" done @@ -320,9 +330,10 @@ build_allowed_return_urls() { write_output() { mkdir -p "$OUTPUT_DIR" - local admin_csv dev_csv returns_json + local admin_csv dev_csv org_csv returns_json admin_csv="$(join_csv ADMIN_CALLBACKS)" dev_csv="$(join_csv DEV_CALLBACKS)" + org_csv="$(join_csv ORG_CALLBACKS)" returns_json="$(to_json_array KRATOS_ALLOWED_RETURN_URLS)" cat >"$OUTPUT_FILE" </dev/null)"; then fail "failed to read hydra client 'adminfront' from running container" fi if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then fail "failed to read hydra client 'devfront' from running container" fi + if ! org_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" orgfront 2>/dev/null)"; then + fail "failed to read hydra client 'orgfront' from running container" + fi for callback in "${ADMIN_CALLBACKS[@]}"; do if ! grep -Fq "$callback" <<<"$admin_info"; then @@ -373,6 +390,11 @@ verify_runtime_hydra_clients() { fail "devfront hydra client does not include callback: $callback" fi done + for callback in "${ORG_CALLBACKS[@]}"; do + if ! grep -Fq "$callback" <<<"$org_info"; then + fail "orgfront hydra client does not include callback: $callback" + fi + done } run_validation() { @@ -385,8 +407,10 @@ run_validation() { validate_dotenv_line_safety "KRATOS_UI_URL" validate_dotenv_line_safety "ADMINFRONT_URL" validate_dotenv_line_safety "DEVFRONT_URL" + validate_dotenv_line_safety "ORGFRONT_URL" validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" + validate_dotenv_line_safety "ORGFRONT_CALLBACK_URLS" if [[ -n "$ADMINFRONT_URL" ]]; then validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL" @@ -394,10 +418,14 @@ run_validation() { if [[ -n "$DEVFRONT_URL" ]]; then validate_urls "DEVFRONT_URL" "$DEVFRONT_URL" fi + if [[ -n "$ORGFRONT_URL" ]]; then + validate_urls "ORGFRONT_URL" "$ORGFRONT_URL" + fi collect_values validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}" + validate_callback_group "ORGFRONT_CALLBACK_URLS" "/auth/callback" "${ORG_CALLBACKS[@]}" validate_gateway_mapping build_allowed_return_urls } @@ -407,6 +435,7 @@ print_summary() { echo "[auth-config] hydra_url_match_mode: $OIDC_HYDRA_URL_MATCH_MODE" echo "[auth-config] admin_callbacks: $(join_csv ADMIN_CALLBACKS)" echo "[auth-config] dev_callbacks: $(join_csv DEV_CALLBACKS)" + echo "[auth-config] org_callbacks: $(join_csv ORG_CALLBACKS)" echo "[auth-config] kratos_allowed_return_urls_count: ${#KRATOS_ALLOWED_RETURN_URLS[@]}" if [[ ${#WARNINGS[@]} -gt 0 ]]; then diff --git a/scripts/test_frontend_runtime_mode.sh b/scripts/test_frontend_runtime_mode.sh index 00d099da..c7fc5404 100644 --- a/scripts/test_frontend_runtime_mode.sh +++ b/scripts/test_frontend_runtime_mode.sh @@ -14,7 +14,8 @@ assert_mode() { for script in \ "./adminfront/scripts/runtime-mode.sh" \ - "./devfront/scripts/runtime-mode.sh" + "./devfront/scripts/runtime-mode.sh" \ + "./orgfront/scripts/runtime-mode.sh" do assert_mode "$script" "production" "production" assert_mode "$script" "prod" "production" diff --git a/test/auth_config_orgfront_callback_test.sh b/test/auth_config_orgfront_callback_test.sh new file mode 100755 index 00000000..f725fd18 --- /dev/null +++ b/test/auth_config_orgfront_callback_test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUTPUT_FILE="$ROOT_DIR/config/.generated/auth-config.env" + +bash "$ROOT_DIR/scripts/auth_config.sh" build >/tmp/baron-auth-config-orgfront-test.log + +orgfront_callbacks="$(grep -E '^ORGFRONT_CALLBACK_URLS=' "$OUTPUT_FILE" | cut -d= -f2- || true)" +if [[ -z "$orgfront_callbacks" ]]; then + echo "ERROR: generated auth config must include ORGFRONT_CALLBACK_URLS." >&2 + exit 1 +fi + +first_orgfront_callback="${orgfront_callbacks%%,*}" +if [[ -z "$first_orgfront_callback" ]]; then + echo "ERROR: generated ORGFRONT_CALLBACK_URLS must not be empty." >&2 + exit 1 +fi + +allowed_returns="$(grep -E '^KRATOS_ALLOWED_RETURN_URLS_JSON=' "$OUTPUT_FILE" | cut -d= -f2- || true)" +if ! grep -Fq "$first_orgfront_callback" <<<"$allowed_returns"; then + echo "ERROR: KRATOS_ALLOWED_RETURN_URLS_JSON must include orgfront callback: $first_orgfront_callback" >&2 + exit 1 +fi + +echo "OK: auth config includes OrgFront callback URLs" diff --git a/test/oathkeeper_kratos_public_exposure_policy_test.sh b/test/oathkeeper_kratos_public_exposure_policy_test.sh new file mode 100644 index 00000000..bf9e3a9f --- /dev/null +++ b/test/oathkeeper_kratos_public_exposure_policy_test.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +failures=0 + +rule_files=() +while IFS= read -r file; do + rule_files+=("$file") +done < <(find \ + "$repo_root/docker/ory/oathkeeper" \ + "$repo_root/config/.generated/ory/oathkeeper" \ + -maxdepth 1 -name 'rules*.json' -print | sort) + +for file in "${rule_files[@]}"; do + if grep -Eq '"id"[[:space:]]*:[[:space:]]*"kratos-public"' "$file"; then + echo "ERROR: $file must not define a public Kratos proxy rule." >&2 + failures=$((failures + 1)) + fi + if grep -Eq '"url"[[:space:]]*:[[:space:]]*"[^"]*/kratos/<\.\*>"' "$file"; then + echo "ERROR: $file must not expose Kratos under /kratos." >&2 + failures=$((failures + 1)) + fi + if grep -Eq '"url"[[:space:]]*:[[:space:]]*"http://kratos:4433"' "$file"; then + echo "ERROR: $file must not proxy public requests directly to kratos:4433." >&2 + failures=$((failures + 1)) + fi +done + +for compose_file in \ + "$repo_root/compose.ory.yaml" \ + "$repo_root/docker/compose.ory.yaml" \ + "$repo_root/docker/staging_pull_compose.template.yaml" \ + "$repo_root/deploy/templates/docker-compose.yaml" +do + kratos_block="$( + awk ' + /^[[:space:]]+kratos:/ { in_block=1; print; next } + in_block && /^[[:space:]]+[A-Za-z0-9_-]+:/ { exit } + in_block { print } + ' "$compose_file" + )" + if grep -Eq '^[[:space:]]+ports:' <<<"$kratos_block"; then + echo "ERROR: $compose_file must not publish Kratos ports directly." >&2 + failures=$((failures + 1)) + fi +done + +if [[ "$failures" -gt 0 ]]; then + exit 1 +fi + +echo "OK: Kratos public API is not exposed through Oathkeeper rules or compose ports."