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

View File

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

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;
};
export type UserProjectionStatus = {
name: string;
status: "ready" | "failed" | "syncing" | string;
ready: boolean;
lastSyncedAt?: string;
lastError?: string;
updatedAt?: string;
projectedUsers: number;
};
export type UserProjectionActionResult = {
status: string;
syncedUsers: number;
updatedAt: string;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -140,6 +156,27 @@ export async function fetchAdminOverviewStats() {
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",
);
return data;
}
export async function reconcileUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reconcile",
);
return data;
}
export async function resetUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reset",
);
return data;
}
export async function fetchAdminRPUsageDaily({
days = 14,
period = "day",

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.TenantDomain{},
&domain.User{},
&domain.UserLoginID{},
&domain.UserProjectionState{},
&domain.UserGroup{},
&domain.ApiKey{},
&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 {
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
RPUsageQueries domain.RPUsageQueryRepository
TenantRepo repository.TenantRepository
Hydra adminHydraClientLister
AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository
UserProjectionSyncer service.UserProjectionReconciler
}
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
@@ -107,6 +109,50 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
func requireSuperAdminProfile(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"})
}
return nil
}
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
if err := requireSuperAdminProfile(c); err != nil {
return err
}
if h == nil || h.UserProjectionRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
}
status, err := h.UserProjectionRepo.GetStatus(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(status)
}
func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error {
if err := requireSuperAdminProfile(c); err != nil {
return err
}
if h == nil || h.UserProjectionSyncer == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"})
}
count, err := h.UserProjectionSyncer.Reconcile(c.Context())
if err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"status": "success",
"syncedUsers": count,
"updatedAt": time.Now().UTC().Format(time.RFC3339),
})
}
func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error {
return h.ReconcileUserProjection(c)
}
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats

View File

@@ -5,6 +5,7 @@ import (
"baron-sso-backend/internal/service"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -65,6 +66,41 @@ func (f *fakeOverviewAuditRepo) CountEventsSince(ctx context.Context, since time
return f.count, nil
}
type fakeAdminUserProjectionRepo struct {
status domain.UserProjectionStatus
}
func (f *fakeAdminUserProjectionRepo) IsReady(ctx context.Context) (bool, error) {
return f.status.Ready, nil
}
func (f *fakeAdminUserProjectionRepo) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
return nil, nil
}
func (f *fakeAdminUserProjectionRepo) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) MarkFailed(ctx context.Context, syncErr error) error {
return nil
}
func (f *fakeAdminUserProjectionRepo) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
return f.status, nil
}
type fakeAdminUserProjectionSyncer struct {
count int
err error
calls int
}
func (f *fakeAdminUserProjectionSyncer) Reconcile(ctx context.Context) (int, error) {
f.calls++
return f.count, f.err
}
func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
repo := &fakeRPUsageQueryRepo{
items: []domain.RPUsageDailyMetric{
@@ -111,6 +147,96 @@ func TestAdminHandler_GetRPUsageDaily(t *testing.T) {
require.Equal(t, uint64(12), body.Items[0].LoginRequests)
}
func TestAdminHandler_UserProjectionStatusRequiresSuperAdmin(t *testing.T) {
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{Name: domain.UserProjectionNameKratos, Status: domain.UserProjectionStatusReady, Ready: true},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "tenant-admin", Role: domain.RoleTenantAdmin})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
}
func TestAdminHandler_UserProjectionStatusReturnsProjectionStateForSuperAdmin(t *testing.T) {
syncedAt := time.Date(2026, 5, 11, 3, 0, 0, 0, time.UTC)
h := &AdminHandler{
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
LastSyncedAt: &syncedAt,
ProjectedUsers: 152,
},
},
}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Get("/api/v1/admin/projections/users", h.GetUserProjectionStatus)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/projections/users", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var body domain.UserProjectionStatus
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, domain.UserProjectionNameKratos, body.Name)
require.Equal(t, domain.UserProjectionStatusReady, body.Status)
require.True(t, body.Ready)
require.Equal(t, int64(152), body.ProjectedUsers)
}
func TestAdminHandler_ReconcileUserProjectionRequiresSuperAdminAndRunsSyncer(t *testing.T) {
syncer := &fakeAdminUserProjectionSyncer{count: 4}
h := &AdminHandler{UserProjectionSyncer: syncer}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, syncer.calls)
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, "success", body["status"])
require.Equal(t, float64(4), body["syncedUsers"])
}
func TestAdminHandler_ReconcileUserProjectionReturnsServiceUnavailableOnSyncFailure(t *testing.T) {
syncer := &fakeAdminUserProjectionSyncer{err: errors.New("kratos down")}
h := &AdminHandler{UserProjectionSyncer: syncer}
app := fiber.New()
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
})
app.Post("/api/v1/admin/projections/users/reconcile", h.ReconcileUserProjection)
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/projections/users/reconcile", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
repo := &fakeRPUsageQueryRepo{}
keto := &fakeAdminKeto{allowed: true}

View File

@@ -100,6 +100,7 @@ type AuthHandler struct {
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
ConsentRepo repository.ClientConsentRepository
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageSink domain.RPUsageEventSink
@@ -856,6 +857,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
} else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
@@ -865,6 +867,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, u.ID, ids); err != nil {
slog.Error("[Signup] Failed to update user login IDs", "userID", u.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto] Sync user-tenant relationship via Outbox
@@ -7242,6 +7245,115 @@ func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[s
return profile
}
func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[string]interface{}, existing *domain.User) *domain.User {
now := time.Now()
localUser := &domain.User{
ID: identityID,
Status: domain.UserStatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: make(domain.JSONMap),
}
if existing != nil {
copied := *existing
localUser = &copied
localUser.UpdatedAt = now
if localUser.Metadata == nil {
localUser.Metadata = make(domain.JSONMap)
}
}
if email := extractTraitString(traits, "email"); email != "" {
localUser.Email = email
}
if name := extractTraitString(traits, "name"); name != "" {
localUser.Name = name
}
if phone := extractTraitString(traits, "phone_number"); phone != "" {
localUser.Phone = phone
}
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}
if jobTitle := extractTraitString(traits, "jobTitle"); jobTitle != "" {
localUser.JobTitle = jobTitle
}
if affType := extractTraitString(traits, "affiliationType"); affType != "" {
localUser.AffiliationType = affType
}
companyCode := extractTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = extractTraitString(traits, "company_code")
}
if companyCode != "" {
localUser.CompanyCode = companyCode
}
if companyCodes := extractTraitStringArray(traits, "companyCodes"); len(companyCodes) > 0 {
localUser.CompanyCodes = pq.StringArray(companyCodes)
}
if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
localUser.TenantID = &tenantID
}
if relyingPartyID := extractTraitString(traits, "relying_party_id"); relyingPartyID != "" {
localUser.RelyingPartyID = &relyingPartyID
}
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
localUser.Role = role
if localUser.Status == "" {
localUser.Status = domain.UserStatusActive
}
if localUser.CreatedAt.IsZero() {
localUser.CreatedAt = now
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "company_code": true,
"companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true,
"tenant_id": true, "relying_party_id": true,
"custom_login_ids": true, "id": true,
}
metadata := make(domain.JSONMap)
for k, v := range traits {
if !coreTraits[k] {
metadata[k] = v
}
}
localUser.Metadata = metadata
return localUser
}
func (h *AuthHandler) syncUpdatedKratosUserReadModel(ctx context.Context, identityID string, traits map[string]interface{}) error {
if h == nil || h.UserRepo == nil {
return nil
}
var existing *domain.User
if current, err := h.UserRepo.FindByID(ctx, identityID); err == nil {
existing = current
} else {
slog.Warn("[UpdateMe] Failed to load existing local user before read-model sync", "userID", identityID, "error", err)
}
localUser := h.mapKratosTraitsToLocalUser(identityID, traits, existing)
return h.UserRepo.Update(ctx, localUser)
}
func (h *AuthHandler) applySessionInfoFromWhoami(profile *domain.UserProfileResponse, authenticatedAt, usedIdentifier string) *domain.UserProfileResponse {
if profile == nil {
return nil
@@ -7374,9 +7486,10 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency
if h.UserRepo != nil {
ctx := context.Background()
// Also update local User record (read-model)
// We can fetch updated identity or just map current traits
// Since mapKratosIdentityToProfile is for UI, let's just use UpdateUserLoginIDs first
if err := h.syncUpdatedKratosUserReadModel(ctx, identityID, traits); err != nil {
slog.Error("[UpdateMe] Failed to sync local user read-model", "userID", identityID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if err := h.UserRepo.UpdateUserLoginIDs(ctx, identityID, loginIDRecords); err != nil {
slog.Error("[UpdateMe] Failed to update user login IDs", "userID", identityID, "error", err)
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,6 +13,23 @@ import (
"github.com/stretchr/testify/require"
)
type recordingUpdateMeUserRepo struct {
MockUserRepoForHandler
updated *domain.User
loginIDs []domain.UserLoginID
}
func (r *recordingUpdateMeUserRepo) Update(ctx context.Context, user *domain.User) error {
copied := *user
r.updated = &copied
return nil
}
func (r *recordingUpdateMeUserRepo) UpdateUserLoginIDs(ctx context.Context, userID string, loginIDs []domain.UserLoginID) error {
r.loginIDs = append([]domain.UserLoginID(nil), loginIDs...)
return nil
}
func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
token := "token-abc"
identityID := "user-1"
@@ -107,3 +125,91 @@ func TestUpdateMe_InvalidatesProfileCacheForTokenSession(t *testing.T) {
require.NoError(t, json.NewDecoder(getResp2.Body).Decode(&profile2))
require.Equal(t, "New Dept", profile2["department"])
}
func TestUpdateMe_SyncsLocalReadModelFields(t *testing.T) {
token := "token-sync"
identityID := "user-sync"
traits := map[string]interface{}{
"email": "sync@example.com",
"name": "Old Name",
"phone_number": "+821012345678",
"department": "Old Dept",
"affiliationType": "employee",
"companyCode": "saman",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"role": domain.RoleUser,
}
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.URL.Host == "kratos.test" &&
r.URL.Path == "/sessions/whoami" &&
r.Method == http.MethodGet:
if r.Header.Get("X-Session-Token") != token {
return httpResponse(r, http.StatusUnauthorized, `{"error":"invalid token"}`), nil
}
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": identityID,
"traits": traits,
},
}), nil
case r.URL.Host == "kratos.test" &&
r.URL.Path == "/admin/identities/"+identityID &&
r.Method == http.MethodPut:
var payload struct {
Traits map[string]interface{} `json:"traits"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return httpResponse(r, http.StatusBadRequest, `{"error":"invalid body"}`), nil
}
for k, v := range payload.Traits {
traits[k] = v
}
return httpResponse(r, http.StatusOK, `{"ok":true}`), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
setDefaultHTTPClientForTest(t, transport)
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
redis := &mockRedisRepo{data: map[string]string{
"verify_update_phone:" + identityID + ":+821087654321": "verified",
}}
userRepo := &recordingUpdateMeUserRepo{}
h := &AuthHandler{
RedisService: redis,
UserRepo: userRepo,
}
app := fiber.New()
app.Put("/api/v1/user/me", h.UpdateMe)
updateBody, _ := json.Marshal(map[string]interface{}{
"name": "New Name",
"phone": "01087654321",
"department": "New Dept",
})
updateReq := httptest.NewRequest(
http.MethodPut,
"/api/v1/user/me",
bytes.NewReader(updateBody),
)
updateReq.Header.Set("Content-Type", "application/json")
updateReq.Header.Set("Authorization", "Bearer "+token)
updateResp, err := app.Test(updateReq, -1)
require.NoError(t, err)
require.Equal(t, http.StatusOK, updateResp.StatusCode)
require.NotNil(t, userRepo.updated)
require.Equal(t, identityID, userRepo.updated.ID)
require.Equal(t, "sync@example.com", userRepo.updated.Email)
require.Equal(t, "New Name", userRepo.updated.Name)
require.Equal(t, "+821087654321", userRepo.updated.Phone)
require.Equal(t, "New Dept", userRepo.updated.Department)
require.Equal(t, "saman", userRepo.updated.CompanyCode)
require.NotNil(t, userRepo.updated.TenantID)
require.Equal(t, "11111111-1111-1111-1111-111111111111", *userRepo.updated.TenantID)
}

View File

@@ -20,14 +20,15 @@ import (
)
type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
SharedLink service.SharedLinkService
Worksmobile service.WorksmobileSyncer
}
func seedTenantDeleteError(c *fiber.Ctx) error {
@@ -47,15 +48,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, userProjectionRepo repository.UserProjectionRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService) *TenantHandler {
return &TenantHandler{
DB: db,
Service: svc,
UserRepo: userRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
DB: db,
Service: svc,
UserRepo: userRepo,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
SharedLink: sharedLink,
}
}
@@ -251,31 +253,15 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
// Fetch member counts for all tenants in one query using IDs
tenantIDs := make([]string, 0, len(tenants))
slugs := make([]string, 0, len(tenants))
for _, t := range tenants {
tenantIDs = append(tenantIDs, t.ID)
slugs = append(slugs, t.Slug)
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), tenants)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs)
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
items := make([]tenantSummary, 0, len(tenants))
for _, t := range tenants {
summary := mapTenantSummary(t)
// Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other)
idCount := idCounts[t.ID]
slugCount := slugCounts[strings.ToLower(t.Slug)]
if idCount > slugCount {
summary.MemberCount = idCount
} else {
summary.MemberCount = slugCount
}
summary.MemberCount = memberCounts[t.ID]
items = append(items, summary)
}
@@ -939,19 +925,13 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID})
slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
idCount := idCounts[tenant.ID]
slugCount := slugCounts[strings.ToLower(tenant.Slug)]
count := idCount
if slugCount > idCount {
count = slugCount
memberCounts, err := h.countTenantMembersFromProjection(c.Context(), []domain.Tenant{tenant})
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
summary := mapTenantSummary(tenant)
summary.MemberCount = count
summary.MemberCount = memberCounts[tenant.ID]
return c.JSON(summary)
}
@@ -1595,6 +1575,27 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
}
}
func (h *TenantHandler) countTenantMembersFromProjection(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
}
if len(tenants) == 0 {
return counts, nil
}
if h.UserProjectionRepo == nil {
return nil, errors.New("user projection is not configured")
}
ready, err := h.UserProjectionRepo.IsReady(ctx)
if err != nil {
return nil, fmt.Errorf("user projection status unavailable: %w", err)
}
if !ready {
return nil, errors.New("user projection is not ready")
}
return h.UserProjectionRepo.CountTenantMembers(ctx, tenants)
}
func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {

View File

@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
@@ -16,6 +17,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
@@ -101,11 +103,15 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
type MockUserRepoForHandler struct {
mock.Mock
deletedIDs []string
}
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil }
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error {
m.deletedIDs = append(m.deletedIDs, id)
return nil
}
func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil
}
@@ -174,6 +180,38 @@ func (m *MockUserRepoForHandler) FindTenantIDByLoginID(ctx context.Context, logi
return "", nil
}
type MockUserProjectionRepoForHandler struct {
mock.Mock
}
func (m *MockUserProjectionRepoForHandler) IsReady(ctx context.Context) (bool, error) {
args := m.Called(ctx)
return args.Bool(0), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) GetStatus(ctx context.Context) (domain.UserProjectionStatus, error) {
args := m.Called(ctx)
return args.Get(0).(domain.UserProjectionStatus), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) CountTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, error) {
args := m.Called(ctx, tenants)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserProjectionRepoForHandler) ReplaceAllFromKratos(ctx context.Context, users []domain.User) error {
args := m.Called(ctx, users)
return args.Error(0)
}
func (m *MockUserProjectionRepoForHandler) MarkFailed(ctx context.Context, syncErr error) error {
args := m.Called(ctx, syncErr)
return args.Error(0)
}
func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -202,14 +240,84 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
json.NewDecoder(resp.Body).Decode(&res)
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsRejectsStatsWhenUserProjectionIsNotReady(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertNotCalled(t, "CountTenantMembers", mock.Anything, mock.Anything)
}
func TestTenantHandler_ListTenants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
@@ -226,11 +334,9 @@ func TestTenantHandler_ListTenants(t *testing.T) {
// Mocking for the new allTenants check in ListTenants
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything).
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe()
mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything).
Return(map[string]int64{}, nil).Maybe()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -253,6 +359,84 @@ func TestTenantHandler_ListTenants(t *testing.T) {
}
}
func TestTenantHandler_ListTenantsReturnsServiceUnavailableWhenProjectionStatusFails(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(false, errors.New("projection state query failed")).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ListTenantsUsesProjectionCountsWhenAvailable(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUserRepo := new(MockUserRepoForHandler)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserRepo: mockUserRepo,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Saman", Slug: "saman"},
}
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(1), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"saman"}).
Return(map[string]int64{"saman": 152}, nil).Maybe()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Maybe()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
mockProjection.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -28,15 +28,16 @@ type OryProviderAPI interface {
}
type UserHandler struct {
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
Worksmobile service.WorksmobileSyncer
KratosAdmin service.KratosAdminService
OryProvider OryProviderAPI
TenantService service.TenantService
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
Worksmobile service.WorksmobileSyncer
}
func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler {
@@ -265,7 +266,10 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
}
}
// 1. Try Kratos First
if h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available")
}
identities, err := h.KratosAdmin.ListIdentities(c.Context())
if err == nil {
filtered := make([]service.KratosIdentity, 0, len(identities))
@@ -363,46 +367,8 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
}
// 2. Fallback to Local DB if Kratos is down
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// If requester is not Super Admin, we should technically filter by manageable slugs in DB too.
// For simplicity in fallback, if tenantSlug is empty we default to their primary company code.
if (requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin) && tenantSlug == "" {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil && profile.CompanyCode != "" {
tenantSlug = profile.CompanyCode
}
}
// Fetch from UserRepo
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
}
items := make([]userSummary, 0, len(users))
for _, u := range users {
items = append(items, userSummary{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Phone: u.Phone,
Role: u.Role,
Status: u.Status,
CompanyCode: u.CompanyCode,
Department: u.Department,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
})
}
return c.JSON(userListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
})
slog.Warn("Kratos unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable")
}
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
@@ -632,6 +598,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
// Sync to local DB (Synchronous for immediate consistency)
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
@@ -645,6 +612,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
@@ -938,6 +906,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil {
@@ -951,6 +920,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil {
slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err)
markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err)
}
if h.KetoOutboxRepo != nil {
@@ -1769,6 +1739,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
if h.Worksmobile != nil {
if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil {
@@ -1779,6 +1750,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// Update User Login IDs in local DB
if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil {
slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err)
markUserProjectionFailed(ctx, h.UserProjectionRepo, err)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count
@@ -1927,6 +1899,13 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
}
if h.UserRepo != nil {
if err := h.UserRepo.Delete(context.Background(), userID); err != nil {
slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err)
markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err)
}
}
return c.SendStatus(fiber.StatusNoContent)
}

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) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -672,6 +701,27 @@ func TestUserHandler_BulkDeleteUsers(t *testing.T) {
})
}
func TestUserHandler_DeleteUserDeletesLocalReadModel(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
userRepo := new(MockUserRepoForHandler)
h := &UserHandler{KratosAdmin: mockKratos, UserRepo: userRepo}
app.Delete("/users/:id", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
return h.DeleteUser(c)
})
mockKratos.On("DeleteIdentity", mock.Anything, "u-1").Return(nil).Once()
req := httptest.NewRequest(http.MethodDelete, "/users/u-1", nil)
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
assert.Equal(t, []string{"u-1"}, userRepo.deletedIDs)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)

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
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{}, &domain.UserLoginID{}, &domain.UserProjectionState{}, &domain.ClientConsent{}, &domain.RPUserMetadata{}, &domain.RPUsageEvent{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}

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 := `
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
FROM (
SELECT id, company_code as comp_code FROM users WHERE LOWER(company_code) = ANY($1)
SELECT id, company_code as comp_code FROM users WHERE deleted_at IS NULL AND LOWER(company_code) = ANY($1)
UNION ALL
SELECT id, unnest(company_codes) as comp_code FROM users WHERE company_codes && $1
SELECT id, unnest(company_codes) as comp_code FROM users WHERE deleted_at IS NULL AND company_codes IS NOT NULL
) as combined
WHERE LOWER(comp_code) = ANY($1)
GROUP BY LOWER(comp_code)

View File

@@ -95,6 +95,25 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, int64(0), counts["tenant-c"])
})
t.Run("CountByCompanyCodes excludes soft deleted cache rows", func(t *testing.T) {
testDB.Exec("DELETE FROM users")
active := &domain.User{Email: "active@a.com", Name: "Active", CompanyCode: "tenant-a"}
deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", CompanyCode: "tenant-a"}
arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array Deleted", CompanyCodes: []string{"tenant-a"}}
assert.NoError(t, repo.Create(ctx, active))
assert.NoError(t, repo.Create(ctx, deleted))
assert.NoError(t, repo.Create(ctx, arrayDeleted))
assert.NoError(t, repo.Delete(ctx, deleted.ID))
assert.NoError(t, repo.Delete(ctx, arrayDeleted.ID))
counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a"})
assert.NoError(t, err)
assert.Equal(t, int64(1), counts["tenant-a"])
})
t.Run("Multi-Identifier Support", func(t *testing.T) {
_ = testDB.AutoMigrate(&domain.UserLoginID{})
testDB.Exec("DELETE FROM user_login_ids")

View File

@@ -6,8 +6,10 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/lib/pq"
)
type UserGroupService interface {
@@ -210,40 +212,56 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
return fmt.Errorf("user group not found: %w", err)
}
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
if s.kratos != nil && s.tenantRepo != nil {
tenant, err := s.tenantRepo.FindByID(ctx, group.TenantID)
if err == nil && tenant != nil {
// Fetch Kratos Identity
identity, err := s.kratos.GetIdentity(ctx, userID)
if err == nil && identity != nil {
traits := identity.Traits
if traits == nil {
traits = make(map[string]interface{})
}
traits["companyCode"] = tenant.Slug
traits["tenant_id"] = tenant.ID
traits["department"] = group.Name
var tenant *domain.Tenant
if s.tenantRepo != nil {
tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID)
}
// Update Kratos
_, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
if updateErr != nil {
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr)
}
var updatedIdentity *KratosIdentity
// [Fix] Sync Kratos Traits & Local DB when a user is added to an organization
if s.kratos != nil && tenant != nil {
// Fetch Kratos Identity
identity, err := s.kratos.GetIdentity(ctx, userID)
if err == nil && identity != nil {
traits := identity.Traits
if traits == nil {
traits = make(map[string]interface{})
}
traits["companyCode"] = tenant.Slug
traits["tenant_id"] = tenant.ID
traits["department"] = group.Name
// Update Kratos
updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
if updateErr != nil {
slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr)
} else if updated != nil {
updatedIdentity = updated
} else {
identity.Traits = traits
updatedIdentity = identity
}
}
}
// Sync local user repo
if s.userRepo != nil && s.tenantRepo != nil {
tenant, _ := s.tenantRepo.FindByID(ctx, group.TenantID)
if tenant != nil {
localUser, err := s.userRepo.FindByID(ctx, userID)
if err == nil && localUser != nil {
localUser.CompanyCode = tenant.Slug
localUser.TenantID = &tenant.ID
localUser.Department = group.Name
_ = s.userRepo.Update(ctx, localUser)
if s.userRepo != nil && tenant != nil {
localUser, err := s.userRepo.FindByID(ctx, userID)
if err != nil || localUser == nil {
if updatedIdentity != nil {
localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity)
} else {
slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err)
localUser = nil
}
}
if localUser != nil {
localUser.CompanyCode = tenant.Slug
localUser.TenantID = &tenant.ID
localUser.Department = group.Name
if err := s.userRepo.Update(ctx, localUser); err != nil {
slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err)
}
}
}
@@ -271,6 +289,116 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
return nil
}
func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User {
traits := identity.Traits
now := time.Now()
createdAt := identity.CreatedAt
if createdAt.IsZero() {
createdAt = now
}
updatedAt := identity.UpdatedAt
if updatedAt.IsZero() {
updatedAt = now
}
role := userGroupTraitString(traits, "grade")
if role == "" {
role = userGroupTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
companyCode := userGroupTraitString(traits, "companyCode")
if companyCode == "" {
companyCode = userGroupTraitString(traits, "company_code")
}
user := &domain.User{
ID: identity.ID,
Email: userGroupTraitString(traits, "email"),
Name: userGroupTraitString(traits, "name"),
Phone: userGroupTraitString(traits, "phone_number"),
Role: role,
Status: userGroupIdentityStatus(identity.State),
CompanyCode: companyCode,
Department: userGroupTraitString(traits, "department"),
Position: userGroupTraitString(traits, "position"),
JobTitle: userGroupTraitString(traits, "jobTitle"),
AffiliationType: userGroupTraitString(traits, "affiliationType"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Metadata: make(domain.JSONMap),
}
if tenantID := userGroupTraitString(traits, "tenant_id"); tenantID != "" {
user.TenantID = &tenantID
}
if relyingPartyID := userGroupTraitString(traits, "relying_party_id"); relyingPartyID != "" {
user.RelyingPartyID = &relyingPartyID
}
user.CompanyCodes = pq.StringArray(userGroupTraitStringArray(traits, "companyCodes"))
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "role": true, "companyCode": true, "company_code": true,
"companyCodes": true, "tenant_id": true, "department": true,
"position": true, "jobTitle": true, "affiliationType": true,
"relying_party_id": true, "custom_login_ids": true, "id": true,
}
for key, value := range traits {
if !coreTraits[key] {
user.Metadata[key] = value
}
}
return user
}
func userGroupTraitString(traits map[string]interface{}, key string) string {
if traits == nil {
return ""
}
value, ok := traits[key]
if !ok || value == nil {
return ""
}
if str, ok := value.(string); ok {
return str
}
return fmt.Sprint(value)
}
func userGroupTraitStringArray(traits map[string]interface{}, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []interface{}:
items := make([]string, 0, len(value))
for _, item := range value {
if str, ok := item.(string); ok && str != "" {
items = append(items, str)
}
}
return items
default:
return nil
}
}
func userGroupIdentityStatus(state string) string {
switch state {
case "", "active":
return domain.UserStatusActive
case "inactive":
return domain.UserStatusInactive
default:
return state
}
}
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
// Validate group exists
if _, err := s.repo.FindByID(ctx, groupID); err != nil {

View File

@@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// --- Mocks for Repositories ---
@@ -45,10 +46,15 @@ func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID s
type MockUserRepository struct {
mock.Mock
updatedUsers []domain.User
}
func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error {
copied := *user
m.updatedUsers = append(m.updatedUsers, copied)
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
@@ -270,6 +276,62 @@ func TestUserGroupService_AddMember(t *testing.T) {
// mockUserRepo.AssertExpectations(t)
}
func TestUserGroupService_AddMemberUpsertsLocalReadModelWhenMissing(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
mockUserRepo := new(MockUserRepository)
mockTenantRepo := new(MockTenantRepository)
mockKratos := new(MockKratosAdminServiceShared)
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, mockTenantRepo, nil, mockOutbox, mockKratos)
groupID := "group-1"
userID := "user-1"
tenantID := "tenant-1"
tenantSlug := "tenant-slug"
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, TenantID: tenantID, Name: "Sales"}, nil)
mockUserRepo.On("FindByID", mock.Anything, userID).Return(nil, gorm.ErrRecordNotFound)
mockTenantRepo.On("FindByID", mock.Anything, tenantID).Return(&domain.Tenant{ID: tenantID, Slug: tenantSlug}, nil)
mockKratos.On("GetIdentity", mock.Anything, userID).Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "User Test",
},
State: "active",
}, nil)
mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == tenantSlug && traits["tenant_id"] == tenantID && traits["department"] == "Sales"
}), "active").Return(&KratosIdentity{
ID: userID,
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "User Test",
"companyCode": tenantSlug,
"tenant_id": tenantID,
"department": "Sales",
},
State: "active",
}, nil)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil).Once()
err := svc.AddMember(context.Background(), groupID, userID)
assert.NoError(t, err)
assert.Len(t, mockUserRepo.updatedUsers, 1)
assert.Equal(t, userID, mockUserRepo.updatedUsers[0].ID)
assert.Equal(t, tenantSlug, mockUserRepo.updatedUsers[0].CompanyCode)
assert.NotNil(t, mockUserRepo.updatedUsers[0].TenantID)
assert.Equal(t, tenantID, *mockUserRepo.updatedUsers[0].TenantID)
assert.Equal(t, "Sales", mockUserRepo.updatedUsers[0].Department)
mockOutbox.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)

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" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "kratos-public",
"description": "Kratos Public API를 /kratos로 노출",
"match": {
"url": "http://<.*>/kratos/<.*>",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
},
"upstream": {
"url": "http://kratos:4433",
"strip_path": "/kratos"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-public",
"description": "Hydra Public API를 /hydra로 노출",

View File

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

View File

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

View File

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

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