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

@@ -24,6 +24,8 @@ type AdminHandler struct {
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

@@ -23,6 +23,7 @@ type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
@@ -47,11 +48,12 @@ 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,
UserProjectionRepo: userProjectionRepo,
Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos,
@@ -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_ListTenants(t *testing.T) {
func TestTenantHandler_ListTenantsUsesReadyUserProjectionCountsWithoutKratos(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()
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)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
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

@@ -34,6 +34,7 @@ type UserHandler struct {
KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
UserProjectionRepo repository.UserProjectionRepository
UserGroupRepo repository.UserGroupRepository
AuditRepo domain.AuditRepository
Worksmobile service.WorksmobileSyncer
@@ -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,10 +212,15 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
return fmt.Errorf("user group not found: %w", err)
}
var tenant *domain.Tenant
if s.tenantRepo != nil {
tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID)
}
var updatedIdentity *KratosIdentity
// [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 {
if s.kratos != nil && tenant != nil {
// Fetch Kratos Identity
identity, err := s.kratos.GetIdentity(ctx, userID)
if err == nil && identity != nil {
@@ -226,24 +233,35 @@ func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string
traits["department"] = group.Name
// Update Kratos
_, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State)
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 {
if s.userRepo != nil && tenant != nil {
localUser, err := s.userRepo.FindByID(ctx, userID)
if err == nil && localUser != nil {
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
_ = s.userRepo.Update(ctx, localUser)
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."