1
0
forked from baron/baron-sso

adminfront 조직 통계오류 보정. Kratos Projection용 통계테이블 구조 추가

This commit is contained in:
2026-05-11 13:01:55 +09:00
parent 9a64a16cb9
commit 843b4100ad
36 changed files with 2022 additions and 169 deletions

View File

@@ -15,4 +15,10 @@ describe("admin routes", () => {
expect(callbackPath).toBe("/auth/callback"); expect(callbackPath).toBe("/auth/callback");
expect(matches?.at(-1)?.route.path).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");
});
}); });

View File

@@ -8,6 +8,7 @@ import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage"; import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
@@ -60,6 +61,7 @@ export const adminRoutes: RouteObject[] = [
}, },
{ path: "api-keys", element: <ApiKeyListPage /> }, { path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> }, { path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
], ],
}, },
]; ];

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { import {
Building2, Building2,
ChevronDown, ChevronDown,
Database,
Key, Key,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
@@ -137,6 +138,11 @@ function AppLayout() {
icon: Network, icon: Network,
isExternal: true, isExternal: true,
}); });
filteredItems.splice(4, 0, {
label: "ui.admin.nav.user_projection",
to: "/system/projections/users",
icon: Database,
});
} else if (isTenantAdmin || manageableCount > 0) { } else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {

View File

@@ -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();
});
});

View 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>
);
}

View File

@@ -128,6 +128,22 @@ export type AdminOverviewStats = {
auditEvents24h: number; 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) { export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", { const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor }, params: { limit, cursor },
@@ -140,6 +156,27 @@ export async function fetchAdminOverviewStats() {
return data; 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({ export async function fetchAdminRPUsageDaily({
days = 14, days = 14,
period = "day", period = "day",

View File

@@ -851,6 +851,7 @@ relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard" tenant_dashboard = "Tenant Dashboard"
user_groups = "User Groups" user_groups = "User Groups"
tenants = "Tenants" tenants = "Tenants"
user_projection = "User Projection"
users = "Users" users = "Users"
[ui.admin.org] [ui.admin.org]

View File

@@ -853,6 +853,7 @@ relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드" tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹" user_groups = "유저 그룹"
tenants = "테넌트" tenants = "테넌트"
user_projection = "사용자 Projection"
users = "사용자" users = "사용자"
[ui.admin.org] [ui.admin.org]

View File

@@ -865,6 +865,7 @@ relying_parties = ""
tenant_dashboard = "" tenant_dashboard = ""
user_groups = "" user_groups = ""
tenants = "" tenants = ""
user_projection = ""
users = "" users = ""
[ui.admin.org] [ui.admin.org]

View File

@@ -300,6 +300,7 @@ func main() {
tenantRepo := repository.NewTenantRepository(db) tenantRepo := repository.NewTenantRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db) userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
userProjectionRepo := repository.NewUserProjectionRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db) rpUsageOutboxRepo := repository.NewRPUsageOutboxRepository(db)
worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db) worksmobileOutboxRepo := repository.NewWorksmobileOutboxRepository(db)
@@ -307,6 +308,13 @@ func main() {
kratosAdminService := service.NewKratosAdminService() kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider() 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) tenantService := service.NewTenantService(tenantRepo, userRepo, userGroupRepo, ketoOutboxRepo)
worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "") worksmobilePrivateKey, err := getEnvFileOrValue("WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE", "WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY", "")
if err != nil { if err != nil {
@@ -354,6 +362,7 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo) auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
authHandler.HeadlessJWKS = headlessJWKSCache authHandler.HeadlessJWKS = headlessJWKSCache
authHandler.UserProjectionRepo = userProjectionRepo
authHandler.RPUserMetadataRepo = rpUserMetadataRepo authHandler.RPUserMetadataRepo = rpUserMetadataRepo
authHandler.RPUsageSink = rpUsageEmitter authHandler.RPUsageSink = rpUsageEmitter
adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo) adminHandler := handler.NewAdminHandler(ketoService, ketoOutboxRepo)
@@ -361,14 +370,17 @@ func main() {
adminHandler.TenantRepo = tenantRepo adminHandler.TenantRepo = tenantRepo
adminHandler.Hydra = hydraService adminHandler.Hydra = hydraService
adminHandler.AuditRepo = auditRepo adminHandler.AuditRepo = auditRepo
adminHandler.UserProjectionRepo = userProjectionRepo
adminHandler.UserProjectionSyncer = userProjectionSyncer
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo devHandler.AuditRepo = auditRepo
devHandler.RPUserMetadataRepo = rpUserMetadataRepo 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) userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo, userGroupRepo, auditRepo)
userHandler.UserProjectionRepo = userProjectionRepo
tenantHandler.SetWorksmobileSyncer(worksmobileService) tenantHandler.SetWorksmobileSyncer(worksmobileService)
userHandler.SetWorksmobileSyncer(worksmobileService) userHandler.SetWorksmobileSyncer(worksmobileService)
worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService) worksmobileHandler := handler.NewWorksmobileHandler(worksmobileService)
@@ -692,6 +704,9 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) 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) admin.Get("/rp-usage/daily", requireAdmin, adminHandler.GetRPUsageDaily)
// Tenant Management (Mixed roles, handler filters results) // Tenant Management (Mixed roles, handler filters results)

View File

@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.TenantDomain{}, &domain.TenantDomain{},
&domain.User{}, &domain.User{},
&domain.UserLoginID{}, &domain.UserLoginID{},
&domain.UserProjectionState{},
&domain.UserGroup{}, &domain.UserGroup{},
&domain.ApiKey{}, &domain.ApiKey{},
&domain.IdentityProviderConfig{}, &domain.IdentityProviderConfig{},

View 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"`
}

View File

@@ -18,12 +18,14 @@ type adminHydraClientLister interface {
} }
type AdminHandler struct { type AdminHandler struct {
Keto service.KetoService Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository TenantRepo repository.TenantRepository
Hydra adminHydraClientLister Hydra adminHydraClientLister
AuditRepo domain.AuditRepository AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository
UserProjectionSyncer service.UserProjectionReconciler
} }
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { 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"}) 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 // GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats var m runtime.MemStats

View File

@@ -5,6 +5,7 @@ import (
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -65,6 +66,41 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
return f.count, nil 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) { func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
repo := &fakeRPUsageQueryRepo{ repo := &fakeRPUsageQueryRepo{
items: []domain.RPUsageDailyMetric{ items: []domain.RPUsageDailyMetric{
@@ -111,6 +147,96 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
require.Equal(t, uint64(12), body.Items[0].LoginRequests) 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) { func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
repo := &fakeRPUsageQueryRepo{} repo := &fakeRPUsageQueryRepo{}
keto := &fakeAdminKeto{allowed: true} keto := &fakeAdminKeto{allowed: true}

View File

@@ -100,6 +100,7 @@ type AuthHandler struct {
KetoService service.KetoService KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink RPUsageSink domain.RPUsageEventSink
@@ -856,6 +857,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err := h.UserRepo.Update(ctx, u); err != nil { 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) slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
} else { } else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) 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 { 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) 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 // [Keto] Sync user-tenant relationship via Outbox
@@ -7242,6 +7245,115 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile 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 { func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse {
if profile == nil { if profile == nil {
return 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 // [New] Local DB Sync - Sync synchronously to ensure immediate consistency
if h.UserRepo != nil { if h.UserRepo != nil {
ctx := context.Background() ctx := context.Background()
// Also update local User record (read-model) if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
// We can fetch updated identity or just map current traits slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
// Since mapKratosIdentityToProfile is for UI, let's just use UpdateUserLoginIDs first markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil { if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err) slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
} }

View File

@@ -3,6 +3,7 @@ package handler
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -12,6 +13,23 @@ import (
"github.com/stretchr/testify/require" "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) { func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
token := "token-abc" token := "token-abc"
identityID := "user-1" identityID := "user-1"
@@ -107,3 +125,91 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2)) require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
require.Equal(t, "New Dept", profile2["department"]) 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)
}

View File

@@ -20,14 +20,15 @@ import (
) )
type TenantHandler struct { type TenantHandler struct {
DB *gorm.DB DB *gorm.DB
Service service.TenantService Service service.TenantService
UserRepo repository.UserRepository UserRepo repository.UserRepository
Keto service.KetoService UserProjectionRepo repository.UserProjectionRepository
KetoOutbox repository.KetoOutboxRepository Keto service.KetoService
KratosAdmin service.KratosAdminService KetoOutbox repository.KetoOutboxRepository
SharedLink service.SharedLinkService KratosAdmin service.KratosAdminService
Worksmobile service.WorksmobileSyncer SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
} }
func seedTenantDeleteError(c *fiber.Ctx) error { func seedTenantDeleteError(c *fiber.Ctx) error {
@@ -47,15 +48,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result 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{ return &TenantHandler{
DB: db, DB: db,
Service: svc, Service: svc,
UserRepo: userRepo, UserRepo: userRepo,
Keto: keto, UserProjectionRepo: userProjectionRepo,
KetoOutbox: outbox, Keto: keto,
KratosAdmin: kratos, KetoOutbox: outbox,
SharedLink: sharedLink, 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 memberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
tenantIDs := make([]string, 0, len(tenants)) if err != nil {
slugs := make([]string, 0, len(tenants)) return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
for _, t := range tenants {
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
} }
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs)
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
items := make([]tenantSummary, 0, len(tenants)) items := make([]tenantSummary, 0, len(tenants))
for _, t := range tenants { for _, t := range tenants {
summary := mapTenantSummary(t) summary := mapTenantSummary(t)
summary.MemberCount = memberCounts[t.ID]
// 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
}
items = append(items, summary) items = append(items, summary)
} }
@@ -939,19 +925,13 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return errorJSON(c, fiber.StatusInternalServerError, err.Error())
} }
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID}) memberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug}) if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
idCount := idCounts[tenant.ID]
slugCount := slugCounts[strings.ToLower(tenant.Slug)]
count := idCount
if slugCount > idCount {
count = slugCount
} }
summary := mapTenantSummary(tenant) summary := mapTenantSummary(tenant)
summary.MemberCount = count summary.MemberCount = memberCounts[tenant.ID]
return c.JSON(summary) 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 { func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
if value == "" { if value == "" {

View File

@@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -16,6 +17,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -101,11 +103,15 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
type MockUserRepoForHandler struct { type MockUserRepoForHandler struct {
mock.Mock mock.Mock
deletedIDs []string
} }
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil } 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) 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) { func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil return nil, nil
} }
@@ -174,6 +180,38 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
return "", nil 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) { func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
@@ -202,14 +240,84 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"]) 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) { func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler) mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{ h := &TenantHandler{
Service: mockSvc, Service: mockSvc,
UserRepo: mockUserRepo, UserProjectionRepo: mockProjection,
} }
app.Use(func(c *fiber.Ctx) error { 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 // 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() mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything). mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything).
Return(map[string]int64{}, nil).Maybe()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req) 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) { func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

View File

@@ -28,15 +28,16 @@ type OryProviderAPI interface {
} }
type UserHandler struct { type UserHandler struct {
KratosAdmin service.KratosAdminService KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI OryProvider OryProviderAPI
TenantService service.TenantService TenantService service.TenantService
KetoService service.KetoService KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository UserProjectionRepo repository.UserProjectionRepository
AuditRepo domain.AuditRepository UserGroupRepo repository.UserGroupRepository
Worksmobile service.WorksmobileSyncer 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 { 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()) identities, err := h.KratosAdmin.ListIdentities(c.Context())
if err == nil { if err == nil {
filtered := make([]service.KratosIdentity, 0, len(identities)) 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}) 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 for user list", "error", err)
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
// 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,
})
} }
func (h *UserHandler) GetUser(c *fiber.Ctx) error { 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) // Sync to local DB (Synchronous for immediate consistency)
if err := h.UserRepo.Update(c.Context(), localUser); err != nil { 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) 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 h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != 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 { 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) 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) // [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 { if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) 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 h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != 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 { 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) 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 { 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 ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { 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) 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 h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != 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 // Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil { 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) 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 // [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 // 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) return c.SendStatus(fiber.StatusNoContent)
} }

View File

@@ -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) { func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
app := fiber.New() app := fiber.New()
mockKratos := new(MockKratosAdmin) 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) { func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
app := fiber.New() app := fiber.New()
mockKratos := new(MockKratosAdmin) mockKratos := new(MockKratosAdmin)

View 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)
}
}

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
} }
// Auto-migrate // 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 { if err != nil {
log.Fatalf("failed to migrate database: %s", err) log.Fatalf("failed to migrate database: %s", err)
} }

View 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
}

View File

@@ -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)
}

View File

@@ -164,9 +164,9 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
query := ` query := `
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
FROM ( 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 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 ) as combined
WHERE LOWER(comp_code) = ANY($1) WHERE LOWER(comp_code) = ANY($1)
GROUP BY LOWER(comp_code) GROUP BY LOWER(comp_code)

View File

@@ -95,6 +95,25 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, int64(0), counts["tenant-c"]) 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) { t.Run("Multi-Identifier Support", func(t *testing.T) {
_ = testDB.AutoMigrate(&domain.UserLoginID{}) _ = testDB.AutoMigrate(&domain.UserLoginID{})
testDB.Exec("DELETE FROM user_login_ids") testDB.Exec("DELETE FROM user_login_ids")

View File

@@ -6,8 +6,10 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
) )
type UserGroupService interface { 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) return fmt.Errorf("user group not found: %w", err)
} }
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization var tenant *domain.Tenant
if s.kratos != nil && s.tenantRepo != nil { if s.tenantRepo != nil {
tenant, err := s.tenantRepo.FindByID(ctx, group.TenantID) tenant, _ = 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
// Update Kratos var updatedIdentity *KratosIdentity
_, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
if updateErr != nil { // [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr) 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 // Sync local user repo
if s.userRepo != nil && s.tenantRepo != nil { if s.userRepo != nil && tenant != nil {
tenant, _ := s.tenantRepo.FindByID(ctx, group.TenantID) localUser, err := s.userRepo.FindByID(ctx, userID)
if tenant != nil { if err != nil || localUser == nil {
localUser, err := s.userRepo.FindByID(ctx, userID) if updatedIdentity != nil {
if err == nil && localUser != nil { localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity)
localUser.CompanyCode = tenant.Slug } else {
localUser.TenantID = &tenant.ID slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
localUser.Department = group.Name localUser = nil
_ = s.userRepo.Update(ctx, localUser) }
}
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 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 { func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
// Validate group exists // Validate group exists
if _, err := s.repo.FindByID(ctx, groupID); err != nil { if _, err := s.repo.FindByID(ctx, groupID); err != nil {

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"gorm.io/gorm"
) )
// --- Mocks for Repositories --- // --- Mocks for Repositories ---
@@ -45,10 +46,15 @@ func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID s
type MockUserRepository struct { type MockUserRepository struct {
mock.Mock mock.Mock
updatedUsers []domain.User
} }
func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil } 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 { func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0) return m.Called(ctx, id).Error(0)
} }
@@ -270,6 +276,62 @@ func TestUserGroupService_AddMember(t *testing.T) {
// mockUserRepo.AssertExpectations(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) { func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared) mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository) mockUserGroupRepo := new(MockUserGroupRepository)

View 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
}
}

View File

@@ -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)
}

View File

@@ -55,21 +55,6 @@
"authorizer": { "handler": "remote_json" }, "authorizer": { "handler": "remote_json" },
"mutators": [{ "handler": "noop" }] "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", "id": "hydra-public",
"description": "Hydra Public API를 /hydra로 노출", "description": "Hydra Public API를 /hydra로 노출",

View File

@@ -5,7 +5,7 @@
- Gateway(Oathkeeper/Nginx) 경유 구조에서 발생하는 Public URL과 Internal URL의 의도된 차이를 정책적으로 허용하되, 매핑의 유효성은 엄격히 검증합니다. - Gateway(Oathkeeper/Nginx) 경유 구조에서 발생하는 Public URL과 Internal URL의 의도된 차이를 정책적으로 허용하되, 매핑의 유효성은 엄격히 검증합니다.
## 적용 범위 ## 적용 범위
- UserFront, AdminFront, DevFront의 로그인/콜백 경로 - UserFront, AdminFront, DevFront, OrgFront의 로그인/콜백 경로
- Ory Stack(Hydra/Kratos/Oathkeeper) 설정 - Ory Stack(Hydra/Kratos/Oathkeeper) 설정
- `compose.ory.yaml`, `docker/compose.ory.yaml`, `docker/staging_pull_compose.template.yaml` - `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` - `gateway/nginx.conf`, `deploy/templates/gateway/nginx.conf`, `docker/ory/oathkeeper/rules*.json`
@@ -40,15 +40,15 @@
## 검증 항목 ## 검증 항목
1. 정적 검증 (`make validate-auth-config`) 1. 정적 검증 (`make validate-auth-config`)
- `USERFRONT_URL`, `OATHKEEPER_PUBLIC_URL`, `HYDRA_PUBLIC_URL`, `KRATOS_BROWSER_URL` 정합성 - `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` 라우팅 규칙 존재 여부 - Gateway `/oidc`, `/auth` 라우팅 규칙 존재 여부
- Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부 - Oathkeeper `rules*.json`의 Hydra/Kratos 매핑 규칙 존재 여부
- staging pull/deploy template의 Oathkeeper entrypoint 사용 여부 - staging pull/deploy template의 Oathkeeper entrypoint 사용 여부
- `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부 - `KRATOS_ALLOWED_RETURN_URLS_JSON`에 공개 도메인, locale path, callback/return path가 포함되는지 여부
2. 런타임 검증 (`make verify-oidc-config`) 2. 런타임 검증 (`make verify-auth-config`)
- OIDC Discovery endpoint 조회 가능 여부 - OIDC Discovery endpoint 조회 가능 여부
- Hydra 등록 client(`adminfront`, `devfront`)의 `redirect_uris` 확인 - Hydra 등록 client(`adminfront`, `devfront`, `orgfront`)의 `redirect_uris` 확인
- 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인 - 필요 시 Gateway 경유 endpoint probe로 매핑 체인 확인
## 경로 규약 ## 경로 규약

View File

@@ -20,8 +20,10 @@ HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}"
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}" KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}" ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.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}" ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}"
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_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:-}" KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
declare -a WARNINGS=() declare -a WARNINGS=()
@@ -185,6 +187,7 @@ to_json_array() {
collect_values() { collect_values() {
declare -ga ADMIN_CALLBACKS=() declare -ga ADMIN_CALLBACKS=()
declare -ga DEV_CALLBACKS=() declare -ga DEV_CALLBACKS=()
declare -ga ORG_CALLBACKS=()
declare -ga EXTRA_ALLOWED_RETURNS=() declare -ga EXTRA_ALLOWED_RETURNS=()
while IFS= read -r item; do while IFS= read -r item; do
@@ -195,6 +198,10 @@ collect_values() {
DEV_CALLBACKS+=("$item") DEV_CALLBACKS+=("$item")
done < <(csv_to_lines "$DEVFRONT_CALLBACK_URLS") 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 while IFS= read -r item; do
EXTRA_ALLOWED_RETURNS+=("$item") EXTRA_ALLOWED_RETURNS+=("$item")
done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA") done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA")
@@ -309,6 +316,9 @@ build_allowed_return_urls() {
for url in "${DEV_CALLBACKS[@]}"; do for url in "${DEV_CALLBACKS[@]}"; do
add_allowed_url "$url" add_allowed_url "$url"
done done
for url in "${ORG_CALLBACKS[@]}"; do
add_allowed_url "$url"
done
for url in "${EXTRA_ALLOWED_RETURNS[@]}"; do for url in "${EXTRA_ALLOWED_RETURNS[@]}"; do
add_allowed_url "$url" add_allowed_url "$url"
done done
@@ -320,9 +330,10 @@ build_allowed_return_urls() {
write_output() { write_output() {
mkdir -p "$OUTPUT_DIR" 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)" admin_csv="$(join_csv ADMIN_CALLBACKS)"
dev_csv="$(join_csv DEV_CALLBACKS)" dev_csv="$(join_csv DEV_CALLBACKS)"
org_csv="$(join_csv ORG_CALLBACKS)"
returns_json="$(to_json_array KRATOS_ALLOWED_RETURN_URLS)" returns_json="$(to_json_array KRATOS_ALLOWED_RETURN_URLS)"
cat >"$OUTPUT_FILE" <<EOF cat >"$OUTPUT_FILE" <<EOF
@@ -330,6 +341,7 @@ write_output() {
# Do not edit manually. # Do not edit manually.
ADMINFRONT_CALLBACK_URLS=$admin_csv ADMINFRONT_CALLBACK_URLS=$admin_csv
DEVFRONT_CALLBACK_URLS=$dev_csv DEVFRONT_CALLBACK_URLS=$dev_csv
ORGFRONT_CALLBACK_URLS=$org_csv
KRATOS_ALLOWED_RETURN_URLS_JSON=$returns_json KRATOS_ALLOWED_RETURN_URLS_JSON=$returns_json
OIDC_HYDRA_URL_MATCH_MODE=$OIDC_HYDRA_URL_MATCH_MODE OIDC_HYDRA_URL_MATCH_MODE=$OIDC_HYDRA_URL_MATCH_MODE
EOF EOF
@@ -342,6 +354,8 @@ validate_compose_wiring() {
|| fail "compose.ory.yaml is not wired to ADMINFRONT_CALLBACK_URLS" || fail "compose.ory.yaml is not wired to ADMINFRONT_CALLBACK_URLS"
grep -Eq 'DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \ grep -Eq 'DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|| fail "compose.ory.yaml is not wired to DEVFRONT_CALLBACK_URLS" || 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() { verify_runtime_hydra_clients() {
@@ -355,13 +369,16 @@ verify_runtime_hydra_clients() {
return return
fi 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 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" fail "failed to read hydra client 'adminfront' from running container"
fi fi
if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then 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" fail "failed to read hydra client 'devfront' from running container"
fi 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 for callback in "${ADMIN_CALLBACKS[@]}"; do
if ! grep -Fq "$callback" <<<"$admin_info"; then if ! grep -Fq "$callback" <<<"$admin_info"; then
@@ -373,6 +390,11 @@ verify_runtime_hydra_clients() {
fail "devfront hydra client does not include callback: $callback" fail "devfront hydra client does not include callback: $callback"
fi fi
done 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() { run_validation() {
@@ -385,8 +407,10 @@ run_validation() {
validate_dotenv_line_safety "KRATOS_UI_URL" validate_dotenv_line_safety "KRATOS_UI_URL"
validate_dotenv_line_safety "ADMINFRONT_URL" validate_dotenv_line_safety "ADMINFRONT_URL"
validate_dotenv_line_safety "DEVFRONT_URL" validate_dotenv_line_safety "DEVFRONT_URL"
validate_dotenv_line_safety "ORGFRONT_URL"
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS" validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS" validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
validate_dotenv_line_safety "ORGFRONT_CALLBACK_URLS"
if [[ -n "$ADMINFRONT_URL" ]]; then if [[ -n "$ADMINFRONT_URL" ]]; then
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL" validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
@@ -394,10 +418,14 @@ run_validation() {
if [[ -n "$DEVFRONT_URL" ]]; then if [[ -n "$DEVFRONT_URL" ]]; then
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL" validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
fi fi
if [[ -n "$ORGFRONT_URL" ]]; then
validate_urls "ORGFRONT_URL" "$ORGFRONT_URL"
fi
collect_values collect_values
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}" validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}" validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
validate_callback_group "ORGFRONT_CALLBACK_URLS" "/auth/callback" "${ORG_CALLBACKS[@]}"
validate_gateway_mapping validate_gateway_mapping
build_allowed_return_urls 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] hydra_url_match_mode: $OIDC_HYDRA_URL_MATCH_MODE"
echo "[auth-config] admin_callbacks: $(join_csv ADMIN_CALLBACKS)" echo "[auth-config] admin_callbacks: $(join_csv ADMIN_CALLBACKS)"
echo "[auth-config] dev_callbacks: $(join_csv DEV_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[@]}" echo "[auth-config] kratos_allowed_return_urls_count: ${#KRATOS_ALLOWED_RETURN_URLS[@]}"
if [[ ${#WARNINGS[@]} -gt 0 ]]; then if [[ ${#WARNINGS[@]} -gt 0 ]]; then

View File

@@ -14,7 +14,8 @@ assert_mode() {
for script in \ for script in \
"./adminfront/scripts/runtime-mode.sh" \ "./adminfront/scripts/runtime-mode.sh" \
"./devfront/scripts/runtime-mode.sh" "./devfront/scripts/runtime-mode.sh" \
"./orgfront/scripts/runtime-mode.sh"
do do
assert_mode "$script" "production" "production" assert_mode "$script" "production" "production"
assert_mode "$script" "prod" "production" assert_mode "$script" "prod" "production"

View 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"

View 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."