forked from baron/baron-sso
adminfront 조직 통계오류 보정. Kratos Projection용 통계테이블 구조 추가
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/projections/users", element: <UserProjectionPage /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserProjectionPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
206
adminfront/src/features/projections/UserProjectionPage.tsx
Normal file
206
adminfront/src/features/projections/UserProjectionPage.tsx
Normal file
@@ -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 <Badge variant="success">ready</Badge>;
|
||||
}
|
||||
if (status === "failed") {
|
||||
return <Badge variant="warning">failed</Badge>;
|
||||
}
|
||||
return <Badge variant="secondary">{status || "not ready"}</Badge>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="space-y-6 p-6 md:p-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">System</p>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
사용자 Projection 관리
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => reconcileMutation.mutate()}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
재동기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleReset}
|
||||
disabled={isWorking}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
초기화 후 재구축
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(error as Error)?.message ||
|
||||
"projection 상태를 불러오지 못했습니다."}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionResult ? (
|
||||
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
|
||||
{actionResult.syncedUsers}명 기준으로 projection을 갱신했습니다.
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<div className="flex items-center gap-3 border-b border-border pb-4">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||
<Database size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Kratos users projection</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backend DB 통계가 참조하는 사용자 read model 상태입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-sm text-muted-foreground">불러오는 중</div>
|
||||
) : (
|
||||
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">상태</dt>
|
||||
<dd className="mt-1">
|
||||
<ProjectionStatusBadge
|
||||
ready={data?.ready ?? false}
|
||||
status={data?.status ?? "unknown"}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">
|
||||
Projection 사용자
|
||||
</dt>
|
||||
<dd className="mt-1 text-xl font-semibold tabular-nums">
|
||||
{data?.projectedUsers ?? 0}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">마지막 동기화</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.lastSyncedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">상태 갱신</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{formatDateTime(data?.updatedAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{data?.lastError ? (
|
||||
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
|
||||
<span>{data.lastError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserProjectionPage() {
|
||||
return (
|
||||
<RoleGuard
|
||||
roles={["super_admin"]}
|
||||
fallback={
|
||||
<main className="p-6 md:p-8">
|
||||
<section className="rounded-lg border border-border bg-card p-5">
|
||||
<h2 className="text-lg font-semibold">접근 권한이 없습니다</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
이 화면은 super_admin 권한으로만 접근할 수 있습니다.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<UserProjectionContent />
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
@@ -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<AuditLogListResponse>("/v1/audit", {
|
||||
params: { limit, cursor },
|
||||
@@ -140,6 +156,27 @@ export async function fetchAdminOverviewStats() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchUserProjectionStatus() {
|
||||
const { data } = await apiClient.get<UserProjectionStatus>(
|
||||
"/v1/admin/projections/users",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function reconcileUserProjection() {
|
||||
const { data } = await apiClient.post<UserProjectionActionResult>(
|
||||
"/v1/admin/projections/users/reconcile",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function resetUserProjection() {
|
||||
const { data } = await apiClient.post<UserProjectionActionResult>(
|
||||
"/v1/admin/projections/users/reset",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchAdminRPUsageDaily({
|
||||
days = 14,
|
||||
period = "day",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -853,6 +853,7 @@ relying_parties = "애플리케이션(RP)"
|
||||
tenant_dashboard = "테넌트 대시보드"
|
||||
user_groups = "유저 그룹"
|
||||
tenants = "테넌트"
|
||||
user_projection = "사용자 Projection"
|
||||
users = "사용자"
|
||||
|
||||
[ui.admin.org]
|
||||
|
||||
@@ -865,6 +865,7 @@ relying_parties = ""
|
||||
tenant_dashboard = ""
|
||||
user_groups = ""
|
||||
tenants = ""
|
||||
user_projection = ""
|
||||
users = ""
|
||||
|
||||
[ui.admin.org]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.TenantDomain{},
|
||||
&domain.User{},
|
||||
&domain.UserLoginID{},
|
||||
&domain.UserProjectionState{},
|
||||
&domain.UserGroup{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
|
||||
29
backend/internal/domain/user_projection.go
Normal file
29
backend/internal/domain/user_projection.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
backend/internal/handler/user_projection_failure.go
Normal file
16
backend/internal/handler/user_projection_failure.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
176
backend/internal/repository/user_projection_repository.go
Normal file
176
backend/internal/repository/user_projection_repository.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
163
backend/internal/service/user_projection_sync_service.go
Normal file
163
backend/internal/service/user_projection_sync_service.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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로 노출",
|
||||
|
||||
@@ -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로 매핑 체인 확인
|
||||
|
||||
## 경로 규약
|
||||
|
||||
@@ -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" <<EOF
|
||||
@@ -330,6 +341,7 @@ write_output() {
|
||||
# Do not edit manually.
|
||||
ADMINFRONT_CALLBACK_URLS=$admin_csv
|
||||
DEVFRONT_CALLBACK_URLS=$dev_csv
|
||||
ORGFRONT_CALLBACK_URLS=$org_csv
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=$returns_json
|
||||
OIDC_HYDRA_URL_MATCH_MODE=$OIDC_HYDRA_URL_MATCH_MODE
|
||||
EOF
|
||||
@@ -342,6 +354,8 @@ validate_compose_wiring() {
|
||||
|| fail "compose.ory.yaml is not wired to ADMINFRONT_CALLBACK_URLS"
|
||||
grep -Eq 'DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to DEVFRONT_CALLBACK_URLS"
|
||||
grep -Eq 'ORGFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to ORGFRONT_CALLBACK_URLS"
|
||||
}
|
||||
|
||||
verify_runtime_hydra_clients() {
|
||||
@@ -355,13 +369,16 @@ verify_runtime_hydra_clients() {
|
||||
return
|
||||
fi
|
||||
|
||||
local admin_info dev_info
|
||||
local admin_info dev_info org_info
|
||||
if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" adminfront 2>/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
|
||||
|
||||
@@ -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"
|
||||
|
||||
27
test/auth_config_orgfront_callback_test.sh
Executable file
27
test/auth_config_orgfront_callback_test.sh
Executable file
@@ -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"
|
||||
53
test/oathkeeper_kratos_public_exposure_policy_test.sh
Normal file
53
test/oathkeeper_kratos_public_exposure_policy_test.sh
Normal file
@@ -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."
|
||||
Reference in New Issue
Block a user