1
0
forked from baron/baron-sso

권한부여 및 정합성 검사 추가

This commit is contained in:
2026-05-14 08:45:48 +09:00
parent f6f8e88342
commit 9ca73e8774
36 changed files with 1772 additions and 105 deletions

View File

@@ -22,6 +22,12 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/projections/users"); expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
}); });
it("registers the super-admin data integrity management route", () => {
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => { it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/"); const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0]; const protectedShellRoute = rootRoute?.children?.[0];

View File

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

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type * as React from "react"; import type * as React from "react";
import { fetchMe } from "../../lib/adminApi"; import { fetchMe } from "../../lib/adminApi";
import { normalizeAdminRole } from "../../lib/roles";
interface RoleGuardProps { interface RoleGuardProps {
children: React.ReactNode; children: React.ReactNode;
@@ -29,8 +30,10 @@ export function RoleGuard({
if (isLoading) return null; if (isLoading) return null;
const userRole = profile?.role || "user"; const userRole = normalizeAdminRole(profile?.role);
const hasAccess = roles.includes(userRole); const hasAccess = roles
.map((role) => normalizeAdminRole(role))
.includes(userRole);
if (!hasAccess) { if (!hasAccess) {
return <>{fallback}</>; return <>{fallback}</>;

View File

@@ -10,6 +10,7 @@ import {
Moon, Moon,
Network, Network,
NotebookTabs, NotebookTabs,
ShieldCheck,
ShieldHalf, ShieldHalf,
Sun, Sun,
User as UserIcon, User as UserIcon,
@@ -32,6 +33,7 @@ import {
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi"; import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { import {
shouldAttemptSlidingSessionRenew, shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew, shouldAttemptUnlimitedSessionRenew,
@@ -150,7 +152,7 @@ function AppLayout() {
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role; const effectiveRole = mockRoleOverride || profile?.role;
const isSuperAdmin = isTest || effectiveRole === "super_admin"; const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const isTenantAdmin = effectiveRole === "tenant_admin"; const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0; const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl( const orgfrontUrl = buildAuthenticatedOrgChartUrl(
@@ -180,6 +182,11 @@ function AppLayout() {
to: "/system/projections/users", to: "/system/projections/users",
icon: Database, icon: Database,
}); });
filteredItems.splice(5, 0, {
label: "ui.admin.nav.data_integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else if (isTenantAdmin || manageableCount > 0) { } else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {

View File

@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
import DataIntegrityPage from "./DataIntegrityPage";
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchDataIntegrityReport: vi.fn(async () => ({
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description:
"active tenant slug의 대소문자 무시 중복을 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<DataIntegrityPage />
</QueryClientProvider>,
);
}
describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
});
it("renders integrity report for super_admin", async () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,209 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "정상";
case "warning":
return "주의";
case "fail":
return "실패";
default:
return status;
}
}
function statusBadgeVariant(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "success";
case "warning":
return "warning";
default:
return "warning";
}
}
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 CheckIcon({ check }: { check: DataIntegrityCheck }) {
if (check.status === "pass") {
return <CheckCircle2 className="text-emerald-600" size={18} />;
}
if (check.status === "warning") {
return <AlertTriangle className="text-amber-600" size={18} />;
}
return <ShieldAlert className="text-destructive" size={18} />;
}
function DataIntegrityContent() {
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
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">
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<Database size={16} />
</Button>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<ShieldAlert size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Read model integrity</h3>
<p className="text-sm text-muted-foreground">
Ory SoT를 backend DB read model의
.
</p>
</div>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</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 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"></dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<h3 className="text-base font-semibold">{section.label}</h3>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">{check.label}</div>
<p className="mt-1 text-sm text-muted-foreground">
{check.description}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
</main>
);
}
export default function DataIntegrityPage() {
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>
}
>
<DataIntegrityContent />
</RoleGuard>
);
}

View File

@@ -3,14 +3,20 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminRPUsageDaily } from "../../lib/adminApi"; import {
fetchAdminRPUsageDaily,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage"; import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage"; import GlobalOverviewPage from "./GlobalOverviewPage";
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })), fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchAdminOverviewStats: vi.fn(async () => ({ fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10, totalTenants: 10,
totalUsers: 152,
oidcClients: 3, oidcClients: 3,
auditEvents24h: 18, auditEvents24h: 18,
})), })),
@@ -93,6 +99,30 @@ vi.mock("../../lib/adminApi", () => ({
}, },
], ],
})), })),
fetchDataIntegrityReport: vi.fn(async () => ({
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 5,
passed: 4,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "pass",
checks: [],
},
{
key: "user_integrity",
label: "사용자 정합성",
status: "fail",
checks: [],
},
],
})),
})); }));
function renderWithProviders(ui: React.ReactElement) { function renderWithProviders(ui: React.ReactElement) {
@@ -112,6 +142,7 @@ function renderWithProviders(ui: React.ReactElement) {
describe("admin overview and auth guard pages", () => { describe("admin overview and auth guard pages", () => {
beforeEach(() => { beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -132,15 +163,18 @@ describe("admin overview and auth guard pages", () => {
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument(); expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
}); });
it("renders overview summary metrics from the admin stats API", async () => { it("renders overview tenant count from the fully fetched tenant list", async () => {
renderWithProviders(<GlobalOverviewPage />); renderWithProviders(<GlobalOverviewPage />);
expect( expect(
(await screen.findByText("전체 테넌트 수")).parentElement, (await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10"); ).toHaveTextContent("3");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent( expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3", "3",
); );
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
"152",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent( expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18", "18",
); );
@@ -172,6 +206,26 @@ describe("admin overview and auth guard pages", () => {
expect(await screen.findAllByText("05월")).not.toHaveLength(0); expect(await screen.findAllByText("05월")).not.toHaveLength(0);
}); });
it("shows the latest integrity summary at the bottom for super admins only", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
expect(screen.getByText("실패 1건")).toBeInTheDocument();
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("does not fetch or show the integrity summary for non-super admins", async () => {
currentRole = "tenant_admin";
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => { it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />); renderWithProviders(<AuthPage />);

View File

@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Activity, Activity,
AlertTriangle,
BarChart3, BarChart3,
CheckCircle2,
Database, Database,
ShieldCheck, ShieldCheck,
Users, Users,
@@ -9,12 +11,14 @@ import {
import { type ReactNode, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard"; import { RoleGuard } from "../../components/auth/RoleGuard";
import { import {
type DataIntegrityStatus,
type RPUsageDailyMetric, type RPUsageDailyMetric,
type RPUsagePeriod, type RPUsagePeriod,
type TenantSummary, type TenantSummary,
fetchAdminOverviewStats, fetchAdminOverviewStats,
fetchAdminRPUsageDaily, fetchAdminRPUsageDaily,
fetchAllTenants, fetchAllTenants,
fetchDataIntegrityReport,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -151,6 +155,102 @@ function OverviewMetric({
); );
} }
function formatOverviewDateTime(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: "short",
}).format(date);
}
function integrityStatusText(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "정상";
case "warning":
return "주의";
default:
return "실패";
}
}
function integrityStatusClass(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "text-emerald-700 dark:text-emerald-300";
case "warning":
return "text-amber-700 dark:text-amber-300";
default:
return "text-destructive";
}
}
function IntegrityOverviewSummary() {
const { data, isError } = useQuery({
queryKey: ["admin-overview-integrity"],
queryFn: fetchDataIntegrityReport,
retry: false,
});
if (isError) {
return (
<section className="border-t border-border/60 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle size={16} />
<span> .</span>
</div>
</section>
);
}
if (!data) {
return null;
}
return (
<section className="border-t border-border/60 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
{data.status === "pass" ? (
<CheckCircle2 size={18} className="text-emerald-600" />
) : (
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-base font-semibold"> </h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span
className={`font-semibold ${integrityStatusClass(data.status)}`}
>
{integrityStatusText(data.status)}
</span>
<span className="tabular-nums"> {data.summary.failures}</span>
<span className="text-muted-foreground">
{formatOverviewDateTime(data.checkedAt)}
</span>
</div>
</div>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
{data.sections.map((section) => (
<div
key={section.key}
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
>
<span>{section.label}</span>
<span
className={`font-medium ${integrityStatusClass(section.status)}`}
>
{integrityStatusText(section.status)}
</span>
</div>
))}
</div>
</section>
);
}
function RPUsageMixedChart({ function RPUsageMixedChart({
rows, rows,
filters, filters,
@@ -371,6 +471,7 @@ function GlobalOverviewPage() {
retry: false, retry: false,
}); });
const stats = statsQuery.data; const stats = statsQuery.data;
const visibleTenantCount = tenantsQuery.data?.items.length;
const usageRows = usageQuery.data?.items ?? []; const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) => const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString(); value === undefined ? "-" : value.toLocaleString();
@@ -444,7 +545,7 @@ function GlobalOverviewPage() {
"ui.admin.overview.summary.total_tenants", "ui.admin.overview.summary.total_tenants",
"전체 테넌트 수", "전체 테넌트 수",
)} )}
value={metric(stats?.totalTenants)} value={metric(visibleTenantCount ?? stats?.totalTenants)}
/> />
<OverviewMetric <OverviewMetric
icon={<ShieldCheck size={14} />} icon={<ShieldCheck size={14} />}
@@ -454,6 +555,11 @@ function GlobalOverviewPage() {
)} )}
value={metric(stats?.oidcClients)} value={metric(stats?.oidcClients)}
/> />
<OverviewMetric
icon={<Users size={14} />}
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
value={metric(stats?.totalUsers)}
/>
</RoleGuard> </RoleGuard>
<OverviewMetric <OverviewMetric
icon={<Activity size={14} />} icon={<Activity size={14} />}
@@ -491,6 +597,10 @@ function GlobalOverviewPage() {
period={period} period={period}
/> />
)} )}
<RoleGuard roles={["super_admin"]}>
<IntegrityOverviewSummary />
</RoleGuard>
</div> </div>
); );
} }

View File

@@ -5,6 +5,7 @@ import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export function canShowWorksmobileEntry(tenant?: { export function canShowWorksmobileEntry(tenant?: {
id?: string; id?: string;
@@ -30,8 +31,9 @@ function TenantDetailPage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data); const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions"); const isPermissionsTab = location.pathname.includes("/permissions");

View File

@@ -74,6 +74,7 @@ import {
importTenantsCSV, importTenantsCSV,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { import {
filterNonHanmacFamilyTenants, filterNonHanmacFamilyTenants,
@@ -262,10 +263,11 @@ function TenantListPage() {
queryKey: ["me"], queryKey: ["me"],
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list // Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
React.useEffect(() => { React.useEffect(() => {
if (profile?.role === "tenant_admin") { if (profile && profileRole === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0; const manageableCount = profile.manageableTenants?.length ?? 0;
if ( if (
(manageableCount === 1 || manageableCount === 0) && (manageableCount === 1 || manageableCount === 0) &&
@@ -274,7 +276,7 @@ function TenantListPage() {
navigate(`/tenants/${profile.tenantId}`, { replace: true }); navigate(`/tenants/${profile.tenantId}`, { replace: true });
} }
} }
}, [profile, navigate]); }, [profile, profileRole, navigate]);
const query = useInfiniteQuery({ const query = useInfiniteQuery({
queryKey: ["tenants", "lazy"], queryKey: ["tenants", "lazy"],
@@ -289,9 +291,9 @@ function TenantListPage() {
getNextPageParam: (lastPage) => getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined, lastPage.nextCursor || lastPage.next_cursor || undefined,
enabled: enabled:
profile?.role === "super_admin" || profileRole === "super_admin" ||
(profile?.role === "tenant_admin" && (profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1), (profile?.manageableTenants?.length ?? 0) > 1),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -356,8 +358,8 @@ function TenantListPage() {
if ( if (
profile && profile &&
profile.role !== "super_admin" && profileRole !== "super_admin" &&
profile.role !== "tenant_admin" profileRole !== "tenant_admin"
) { ) {
return ( return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4"> <div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
@@ -372,7 +374,8 @@ function TenantListPage() {
} }
if ( if (
profile?.role === "tenant_admin" && profile &&
profileRole === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1 (profile.manageableTenants?.length ?? 0) <= 1
) { ) {
return null; return null;
@@ -396,7 +399,7 @@ function TenantListPage() {
return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id; return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id;
}, [rawTenants]); }, [rawTenants]);
const allTenants = React.useMemo(() => { const allTenants = React.useMemo(() => {
if (profile?.role === "super_admin") { if (profileRole === "super_admin") {
return rawTenants; return rawTenants;
} }
if ( if (
@@ -406,7 +409,7 @@ function TenantListPage() {
return rawTenants; return rawTenants;
} }
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
}, [hanmacFamilyTenantId, profile, rawTenants]); }, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
const importParentOptionGroups = const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants); buildTenantImportParentOptionGroups(allTenants);
const tenantSortResolvers = React.useMemo< const tenantSortResolvers = React.useMemo<

View File

@@ -16,6 +16,7 @@ import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export type SchemaFieldType = export type SchemaFieldType =
| "text" | "text"
@@ -101,8 +102,9 @@ export function TenantSchemaPage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const tenantQuery = useQuery({ const tenantQuery = useQuery({
queryKey: ["tenant", tenantId], queryKey: ["tenant", tenantId],

View File

@@ -48,6 +48,7 @@ import {
fetchTenant, fetchTenant,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { import {
type OrgChartTenantSelection, type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
@@ -642,7 +643,7 @@ function UserCreatePage() {
id="role" id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")} {...register("role")}
disabled={profile?.role !== "super_admin"} disabled={!isSuperAdminRole(profile?.role)}
> >
<option value="super_admin"> <option value="super_admin">
{t("ui.admin.role.super_admin", "시스템 관리자")} {t("ui.admin.role.super_admin", "시스템 관리자")}

View File

@@ -67,6 +67,7 @@ import {
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi"; import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils"; import { generateSecurePassword } from "../../lib/utils";
import { import {
type OrgChartTenantSelection, type OrgChartTenantSelection,
@@ -387,8 +388,9 @@ function UserDetailPage() {
}, },
}); });
const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const watchedStatus = watch("status"); const watchedStatus = watch("status");
@@ -1070,7 +1072,7 @@ function UserDetailPage() {
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50" className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("role")} {...register("role")}
disabled={ disabled={
profile?.role !== "super_admin" || !isSuperAdminRole(profile?.role) ||
profile?.id === user?.id profile?.id === user?.id
} }
> >

View File

@@ -70,7 +70,13 @@ import {
updateUser, updateUser,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import {
type UserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
type UserSchemaField = { type UserSchemaField = {
key: string; key: string;
@@ -80,6 +86,29 @@ type UserSchemaField = {
type UserSortKey = string; type UserSortKey = string;
const bulkPermissionOptions = [
{
value: "super_admin",
labelKey: "ui.admin.role.super_admin",
fallback: "시스템 관리자",
},
{
value: "tenant_admin",
labelKey: "ui.admin.role.tenant_admin",
fallback: "테넌트 관리자",
},
{
value: "rp_admin",
labelKey: "ui.admin.role.rp_admin",
fallback: "서비스 관리자",
},
{
value: "user",
labelKey: "ui.admin.role.user",
fallback: "일반 사용자",
},
] as const;
function UserListPage() { function UserListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
@@ -90,6 +119,11 @@ function UserListPage() {
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]); const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | ""
>("");
const [selectedBulkPermission, setSelectedBulkPermission] =
React.useState("");
const [sortConfig, setSortConfig] = const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null); React.useState<SortConfig<UserSortKey> | null>(null);
@@ -270,7 +304,7 @@ function UserListPage() {
const total = query.data?.total ?? 0; const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = profile?.role === "super_admin"; const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) { if (selectedUserIds.length === items.length) {
@@ -306,6 +340,8 @@ function UserListPage() {
onSuccess: () => { onSuccess: () => {
query.refetch(); query.refetch();
setSelectedUserIds([]); setSelectedUserIds([]);
setSelectedBulkStatus("");
setSelectedBulkPermission("");
toast.success( toast.success(
t( t(
"msg.admin.users.bulk.update_success", "msg.admin.users.bulk.update_success",
@@ -315,22 +351,20 @@ function UserListPage() {
}, },
}); });
const handleBulkStatusChange = (status: string) => { const handleApplyBulkStatus = () => {
if (selectedUserIds.length === 0) return; if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
};
const handlePromoteSuperAdmin = () => {
if (selectedUserIds.length === 0) return;
bulkUpdateMutation.mutate({ bulkUpdateMutation.mutate({
userIds: selectedUserIds, userIds: selectedUserIds,
role: "super_admin", status: selectedBulkStatus,
}); });
}; };
const handleBulkRoleChange = (role: string) => { const handleApplyBulkPermission = () => {
if (selectedUserIds.length === 0) return; if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
bulkUpdateMutation.mutate({ userIds: selectedUserIds, role }); bulkUpdateMutation.mutate({
userIds: selectedUserIds,
role: selectedBulkPermission,
});
}; };
const handleBulkDelete = () => { const handleBulkDelete = () => {
@@ -746,7 +780,7 @@ function UserListPage() {
} }
disabled={ disabled={
bulkUpdateMutation.isPending || bulkUpdateMutation.isPending ||
profile?.role !== "super_admin" || !isSuperAdminRole(profile?.role) ||
user.id === profile?.id user.id === profile?.id
} }
> >
@@ -754,7 +788,7 @@ function UserListPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{profile?.role === "super_admin" && ( {isSuperAdminRole(profile?.role) && (
<SelectItem value="super_admin"> <SelectItem value="super_admin">
{t( {t(
"ui.admin.role.super_admin", "ui.admin.role.super_admin",
@@ -819,63 +853,80 @@ function UserListPage() {
})} })}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Select
variant="ghost" value={selectedBulkStatus}
size="sm" onValueChange={(value) =>
className="text-background hover:bg-background/10 h-8" setSelectedBulkStatus(value as UserStatusValue)
onClick={() => handleBulkStatusChange("active")} }
data-testid="bulk-active-btn"
> >
{t("ui.common.status.active", "활성화")} <SelectTrigger
</Button> className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
<Button data-testid="bulk-status-select"
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")}
data-testid="bulk-inactive-btn"
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
{canPromoteSuperAdmin && (
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8 gap-1.5"
onClick={handlePromoteSuperAdmin}
data-testid="bulk-promote-super-admin-btn"
> >
<ShieldCheck size={14} /> <SelectValue
{t("ui.admin.users.bulk.promote_admin", "Admin으로 만들기")} placeholder={t(
</Button> "ui.admin.users.bulk.status_placeholder",
)} "상태 선택",
)}
/>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={handleApplyBulkStatus}
disabled={!selectedBulkStatus || bulkUpdateMutation.isPending}
data-testid="bulk-apply-status-btn"
>
{t("ui.common.apply", "적용")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" /> <div className="w-px h-4 bg-background/20 mx-1" />
{profile?.role === "super_admin" && ( {canPromoteSuperAdmin && (
<> <>
<Select onValueChange={handleBulkRoleChange}> <Select
<SelectTrigger className="h-8 w-[140px] bg-transparent border-background/20 text-background text-xs"> value={selectedBulkPermission}
onValueChange={setSelectedBulkPermission}
>
<SelectTrigger
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue <SelectValue
placeholder={t( placeholder={t(
"ui.admin.users.list.table.role", "ui.admin.users.bulk.permission_placeholder",
"ROLE", "권한 선택",
)} )}
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="super_admin"> {bulkPermissionOptions.map((option) => (
{t("ui.admin.role.super_admin", "시스템 관리자")} <SelectItem key={option.value} value={option.value}>
</SelectItem> {t(option.labelKey, option.fallback)}
<SelectItem value="tenant_admin"> </SelectItem>
{t("ui.admin.role.tenant_admin", "테넌트 관리자")} ))}
</SelectItem>
<SelectItem value="rp_admin">
{t("ui.admin.role.rp_admin", "서비스 관리자")}
</SelectItem>
<SelectItem value="user">
{t("ui.admin.role.user", "일반 사용자")}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8 gap-1.5"
onClick={handleApplyBulkPermission}
disabled={
!selectedBulkPermission || bulkUpdateMutation.isPending
}
data-testid="bulk-apply-permission-btn"
>
<ShieldCheck size={14} />
{t("ui.common.apply", "적용")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" /> <div className="w-px h-4 bg-background/20 mx-1" />
</> </>
)} )}

View File

@@ -129,6 +129,7 @@ export type RPUsageDailyResponse = {
export type AdminOverviewStats = { export type AdminOverviewStats = {
totalTenants: number; totalTenants: number;
totalUsers: number;
oidcClients: number; oidcClients: number;
auditEvents24h: number; auditEvents24h: number;
}; };
@@ -149,6 +150,36 @@ export type UserProjectionActionResult = {
updatedAt: string; updatedAt: string;
}; };
export type DataIntegrityStatus = "pass" | "warning" | "fail";
export type DataIntegrityCheck = {
key: string;
label: string;
description: string;
status: DataIntegrityStatus;
severity: "info" | "warning" | "error" | string;
count: number;
};
export type DataIntegritySection = {
key: string;
label: string;
status: DataIntegrityStatus;
checks: DataIntegrityCheck[];
};
export type DataIntegrityReport = {
status: DataIntegrityStatus;
checkedAt: string;
summary: {
totalChecks: number;
passed: number;
warnings: number;
failures: number;
};
sections: DataIntegritySection[];
};
export async function fetchAuditLogs(limit = 50, cursor?: string) { export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", { const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor }, params: { limit, cursor },
@@ -161,6 +192,13 @@ export async function fetchAdminOverviewStats() {
return data; return data;
} }
export async function fetchDataIntegrityReport() {
const { data } = await apiClient.get<DataIntegrityReport>(
"/v1/admin/integrity",
);
return data;
}
export async function fetchUserProjectionStatus() { export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>( const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users", "/v1/admin/projections/users",

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
ROLE_RP_ADMIN,
ROLE_SUPER_ADMIN,
ROLE_TENANT_ADMIN,
ROLE_USER,
isSuperAdminRole,
normalizeAdminRole,
} from "./roles";
describe("admin role helpers", () => {
it.each([
["super_admin", ROLE_SUPER_ADMIN],
["superadmin", ROLE_SUPER_ADMIN],
["super-admin", ROLE_SUPER_ADMIN],
[" SUPER-ADMIN ", ROLE_SUPER_ADMIN],
["tenant_admin", ROLE_TENANT_ADMIN],
["tenantadmin", ROLE_TENANT_ADMIN],
["tenant-admin", ROLE_TENANT_ADMIN],
["admin", ROLE_TENANT_ADMIN],
["rp_admin", ROLE_RP_ADMIN],
["rpadmin", ROLE_RP_ADMIN],
["rp-admin", ROLE_RP_ADMIN],
["tenant_member", ROLE_USER],
["member", ROLE_USER],
["custom", ROLE_USER],
["", ROLE_USER],
])("normalizes %s to %s", (input, expected) => {
expect(normalizeAdminRole(input)).toBe(expected);
});
it("detects super admin aliases", () => {
expect(isSuperAdminRole("super-admin")).toBe(true);
expect(isSuperAdminRole("admin")).toBe(false);
expect(isSuperAdminRole(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
export const ROLE_SUPER_ADMIN = "super_admin";
export const ROLE_TENANT_ADMIN = "tenant_admin";
export const ROLE_RP_ADMIN = "rp_admin";
export const ROLE_USER = "user";
export type AdminRole =
| typeof ROLE_SUPER_ADMIN
| typeof ROLE_TENANT_ADMIN
| typeof ROLE_RP_ADMIN
| typeof ROLE_USER;
export function normalizeAdminRole(role?: string | null): AdminRole {
const normalized = role?.trim().toLowerCase() ?? "";
switch (normalized) {
case ROLE_SUPER_ADMIN:
case "superadmin":
case "super-admin":
return ROLE_SUPER_ADMIN;
case ROLE_TENANT_ADMIN:
case "tenantadmin":
case "tenant-admin":
case "admin":
return ROLE_TENANT_ADMIN;
case ROLE_RP_ADMIN:
case "rpadmin":
case "rp-admin":
return ROLE_RP_ADMIN;
case ROLE_USER:
case "tenant_member":
case "member":
return ROLE_USER;
default:
return ROLE_USER;
}
}
export function isSuperAdminRole(role?: string | null) {
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
}

View File

@@ -842,6 +842,7 @@ org_chart = "Org Chart"
api_keys = "API Keys" api_keys = "API Keys"
audit_logs = "Audit Logs" audit_logs = "Audit Logs"
auth_guard = "Auth Guard" auth_guard = "Auth Guard"
data_integrity = "Data Integrity"
logout = "Logout" logout = "Logout"
overview = "Overview" overview = "Overview"
relying_parties = "Apps (RP)" relying_parties = "Apps (RP)"
@@ -876,6 +877,7 @@ audit_events_24h = "24h Events"
oidc_clients = "OIDC Clients" oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate" policy_gate = "Policy Gate"
total_tenants = "Total Tenants" total_tenants = "Total Tenants"
total_users = "Total Users"
[ui.admin.profile] [ui.admin.profile]
manageable_tenants = "Manageable Tenants" manageable_tenants = "Manageable Tenants"

View File

@@ -844,6 +844,7 @@ org_chart = "조직도"
api_keys = "API 키" api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
auth_guard = "인증 가드" auth_guard = "인증 가드"
data_integrity = "데이터 정합성"
logout = "로그아웃" logout = "로그아웃"
overview = "개요" overview = "개요"
relying_parties = "애플리케이션(RP)" relying_parties = "애플리케이션(RP)"
@@ -878,6 +879,7 @@ audit_events_24h = "24시간 이벤트"
oidc_clients = "OIDC 클라이언트" oidc_clients = "OIDC 클라이언트"
policy_gate = "정책 게이트" policy_gate = "정책 게이트"
total_tenants = "전체 테넌트 수" total_tenants = "전체 테넌트 수"
total_users = "전체 사용자 수"
[ui.admin.profile] [ui.admin.profile]
manageable_tenants = "관리 가능한 테넌트" manageable_tenants = "관리 가능한 테넌트"

View File

@@ -189,6 +189,7 @@ audit_events_24h = ""
oidc_clients = "" oidc_clients = ""
policy_gate = "" policy_gate = ""
total_tenants = "" total_tenants = ""
total_users = ""
[msg.admin.tenants] [msg.admin.tenants]
approve_confirm = "" approve_confirm = ""
@@ -856,6 +857,7 @@ org_chart = ""
api_keys = "" api_keys = ""
audit_logs = "" audit_logs = ""
auth_guard = "" auth_guard = ""
data_integrity = ""
logout = "" logout = ""
overview = "" overview = ""
relying_parties = "" relying_parties = ""

View File

@@ -143,9 +143,10 @@ test.describe("Bulk Actions and Tree Search", () => {
const selectionBar = page.getByTestId("bulk-action-bar"); const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 }); await expect(selectionBar).toBeVisible({ timeout: 15000 });
// 활성화 버튼 확인 await expect(page.getByTestId("bulk-status-select")).toBeVisible({
const activeBtn = page.getByTestId("bulk-active-btn"); timeout: 10000,
await expect(activeBtn).toBeVisible({ timeout: 10000 }); });
await expect(page.getByTestId("bulk-apply-status-btn")).toBeVisible();
// 전체 선택 // 전체 선택
await page.locator('table input[type="checkbox"]').first().click(); await page.locator('table input[type="checkbox"]').first().click();
@@ -158,7 +159,7 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 }); await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
}); });
test("should let super admins promote selected users to super admin", async ({ test("should apply selected bulk status to selected users", async ({
page, page,
}) => { }) => {
let capturedPayload: unknown = null; let capturedPayload: unknown = null;
@@ -182,7 +183,48 @@ test.describe("Bulk Actions and Tree Search", () => {
const selectionBar = page.getByTestId("bulk-action-bar"); const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 }); await expect(selectionBar).toBeVisible({ timeout: 15000 });
await page.getByTestId("bulk-promote-super-admin-btn").click(); await page.getByTestId("bulk-status-select").click();
await page.getByRole("option", { name: /정지|Suspended/i }).click();
await page.getByTestId("bulk-apply-status-btn").click();
await expect
.poll(() => capturedPayload)
.toEqual({
userIds: ["u-1"],
status: "suspended",
});
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should let super admins apply selected admin permission to selected users", async ({
page,
}) => {
let capturedPayload: unknown = null;
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "PUT") {
capturedPayload = route.request().postDataJSON();
return route.fulfill({
json: { results: [{ id: "u-1", success: true }] },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fallback();
});
await page.goto("/users");
await expect(page.locator("table")).toContainText("User One", {
timeout: 20000,
});
await page.locator('table input[type="checkbox"]').nth(1).click();
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
await page.getByTestId("bulk-permission-select").click();
await page
.getByRole("option", { name: /시스템 관리자|Super Admin/i })
.click();
await page.getByTestId("bulk-apply-permission-btn").click();
await expect await expect
.poll(() => capturedPayload) .poll(() => capturedPayload)
@@ -193,6 +235,65 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 }); await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
}); });
test("should let canonical super admin aliases promote selected users", async ({
page,
}) => {
await page.unroute("**/api/v1/**");
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin",
role: "super-admin",
name: "Admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/users")) {
return route.fulfill({
json: {
items: [
{
id: "u-1",
name: "User One",
email: "u1@test.com",
status: "active",
role: "user",
createdAt: new Date().toISOString(),
},
],
total: 1,
},
headers,
});
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: { items: [], total: 0 },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.goto("/users");
await expect(page.locator("table")).toContainText("User One", {
timeout: 20000,
});
await page.locator('table input[type="checkbox"]').nth(1).click();
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
await expect(page.getByTestId("bulk-permission-select")).toBeVisible();
await expect(page.getByTestId("bulk-apply-permission-btn")).toBeVisible();
});
test("should filter and highlight nodes in organization tree", async ({ test("should filter and highlight nodes in organization tree", async ({
page, page,
}) => { }) => {

View File

@@ -0,0 +1,151 @@
import { expect, test } from "@playwright/test";
test.describe("Data integrity management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const clientId = "adminfront";
const key = `oidc.user:${authority}:${clientId}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({
json: {
id: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
manageableTenants: [],
},
});
});
await page.route("**/api/v1/admin/integrity", async (route) => {
await route.fulfill({
json: {
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description:
"삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
},
});
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {
const url = route.request().url();
if (url.includes("/api/v1/user/me")) {
await route.fulfill({
json: {
id: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
manageableTenants: [],
},
});
return;
}
if (url.includes("/api/v1/admin/integrity")) {
await route.fulfill({
json: {
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description:
"삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
},
});
return;
}
if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [], total: 0 } });
} else {
await route.fulfill({ status: 200, json: {} });
}
});
});
test("shows the super-admin integrity report", async ({ page }) => {
await page.goto("/system/data-integrity");
await expect(
page.getByRole("heading", { name: "데이터 정합성 검증" }),
).toBeVisible();
await expect(page.getByText("테넌트 정합성")).toBeVisible();
await expect(page.getByText("중복 테넌트 slug")).toBeVisible();
await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible();
});
test("shows the latest integrity summary on the overview page", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByText("정합성 최종 검증")).toBeVisible();
await expect(page.getByText("실패 1건")).toBeVisible();
await expect(page.getByText("테넌트 정합성")).toBeVisible();
});
});

View File

@@ -373,6 +373,7 @@ func main() {
adminHandler.AuditRepo = auditRepo adminHandler.AuditRepo = auditRepo
adminHandler.UserProjectionRepo = userProjectionRepo adminHandler.UserProjectionRepo = userProjectionRepo
adminHandler.UserProjectionSyncer = userProjectionSyncer adminHandler.UserProjectionSyncer = userProjectionSyncer
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo devHandler.AuditRepo = auditRepo
@@ -714,6 +715,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus) admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection) admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection) admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)

View File

@@ -0,0 +1,41 @@
package domain
import "time"
type DataIntegrityStatus string
const (
DataIntegrityStatusPass DataIntegrityStatus = "pass"
DataIntegrityStatusWarning DataIntegrityStatus = "warning"
DataIntegrityStatusFail DataIntegrityStatus = "fail"
)
type DataIntegrityReport struct {
Status DataIntegrityStatus `json:"status"`
CheckedAt time.Time `json:"checkedAt"`
Summary DataIntegritySummary `json:"summary"`
Sections []DataIntegritySection `json:"sections"`
}
type DataIntegritySummary struct {
TotalChecks int `json:"totalChecks"`
Passed int `json:"passed"`
Warnings int `json:"warnings"`
Failures int64 `json:"failures"`
}
type DataIntegritySection struct {
Key string `json:"key"`
Label string `json:"label"`
Status DataIntegrityStatus `json:"status"`
Checks []DataIntegrityCheck `json:"checks"`
}
type DataIntegrityCheck struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description"`
Status DataIntegrityStatus `json:"status"`
Severity string `json:"severity"`
Count int64 `json:"count"`
}

View File

@@ -12,6 +12,8 @@ func TestNormalizeRole(t *testing.T) {
{name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin}, {name: "tenant admin unchanged", in: "tenant_admin", want: RoleTenantAdmin},
{name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin}, {name: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin},
{name: "user unchanged", in: "user", want: RoleUser}, {name: "user unchanged", in: "user", want: RoleUser},
{name: "super admin hyphen alias", in: "super-admin", want: RoleSuperAdmin},
{name: "super admin compact alias", in: "superadmin", want: RoleSuperAdmin},
{name: "legacy admin", in: "admin", want: RoleTenantAdmin}, {name: "legacy admin", in: "admin", want: RoleTenantAdmin},
{name: "legacy tenant member", in: "tenant_member", want: RoleUser}, {name: "legacy tenant member", in: "tenant_member", want: RoleUser},
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin}, {name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},

View File

@@ -26,6 +26,7 @@ type AdminHandler struct {
AuditRepo domain.AuditRepository AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository UserProjectionRepo repository.UserProjectionRepository
UserProjectionSyncer service.UserProjectionReconciler UserProjectionSyncer service.UserProjectionReconciler
IntegrityChecker repository.DataIntegrityChecker
} }
func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler {
@@ -109,17 +110,18 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
} }
func requireSuperAdminProfile(c *fiber.Ctx) error { func requireSuperAdminProfile(c *fiber.Ctx) bool {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin { if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"}) _ = c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"})
return false
} }
return nil return true
} }
func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error { func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
if err := requireSuperAdminProfile(c); err != nil { if !requireSuperAdminProfile(c) {
return err return nil
} }
if h == nil || h.UserProjectionRepo == nil { if h == nil || h.UserProjectionRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
@@ -132,8 +134,8 @@ func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error {
} }
func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error { func (h *AdminHandler) ReconcileUserProjection(c *fiber.Ctx) error {
if err := requireSuperAdminProfile(c); err != nil { if !requireSuperAdminProfile(c) {
return err return nil
} }
if h == nil || h.UserProjectionSyncer == nil { if h == nil || h.UserProjectionSyncer == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection sync service unavailable"})
@@ -153,6 +155,20 @@ func (h *AdminHandler) ResetUserProjection(c *fiber.Ctx) error {
return h.ReconcileUserProjection(c) return h.ReconcileUserProjection(c)
} }
func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.IntegrityChecker == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"})
}
report, err := h.IntegrityChecker.CheckDataIntegrity(c.Context())
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(report)
}
// GetSystemStats returns runtime statistics for monitoring // GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats var m runtime.MemStats
@@ -161,6 +177,7 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
stats := fiber.Map{ stats := fiber.Map{
"totalTenants": h.countTenants(ctx), "totalTenants": h.countTenants(ctx),
"totalUsers": h.countUsers(ctx),
"oidcClients": h.countOIDCClients(ctx), "oidcClients": h.countOIDCClients(ctx),
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)), "auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
"goroutines": runtime.NumGoroutine(), "goroutines": runtime.NumGoroutine(),
@@ -188,6 +205,17 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
return total return total
} }
func (h *AdminHandler) countUsers(ctx context.Context) int64 {
if h == nil || h.UserProjectionRepo == nil {
return 0
}
status, err := h.UserProjectionRepo.GetStatus(ctx)
if err != nil {
return 0
}
return status.ProjectedUsers
}
func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 { func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 {
if h == nil || h.Hydra == nil { if h == nil || h.Hydra == nil {
return 0 return 0

View File

@@ -263,7 +263,17 @@ func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) { func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
auditRepo := &fakeOverviewAuditRepo{count: 22} auditRepo := &fakeOverviewAuditRepo{count: 22}
h := &AdminHandler{AuditRepo: auditRepo} h := &AdminHandler{
AuditRepo: auditRepo,
UserProjectionRepo: &fakeAdminUserProjectionRepo{
status: domain.UserProjectionStatus{
Name: domain.UserProjectionNameKratos,
Status: domain.UserProjectionStatusReady,
Ready: true,
ProjectedUsers: 152,
},
},
}
app := fiber.New() app := fiber.New()
app.Get("/api/v1/admin/stats", h.GetSystemStats) app.Get("/api/v1/admin/stats", h.GetSystemStats)
@@ -275,8 +285,10 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
var body map[string]any var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body, "totalTenants") require.Contains(t, body, "totalTenants")
require.Contains(t, body, "totalUsers")
require.Contains(t, body, "oidcClients") require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h") require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(152), body["totalUsers"])
require.Equal(t, float64(22), body["auditEvents24h"]) require.Equal(t, float64(22), body["auditEvents24h"])
require.Equal(t, time.UTC, auditRepo.since.Location()) require.Equal(t, time.UTC, auditRepo.since.Location())
} }

View File

@@ -0,0 +1,92 @@
package handler
import (
"baron-sso-backend/internal/domain"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/require"
)
type fakeDataIntegrityChecker struct {
calls int
report domain.DataIntegrityReport
err error
}
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
f.calls++
return f.report, f.err
}
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
checker := &fakeDataIntegrityChecker{}
h := &AdminHandler{IntegrityChecker: checker}
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/integrity", h.GetDataIntegrity)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Equal(t, 0, checker.calls)
}
func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
checkedAt := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC)
checker := &fakeDataIntegrityChecker{
report: domain.DataIntegrityReport{
Status: domain.DataIntegrityStatusFail,
CheckedAt: checkedAt,
Summary: domain.DataIntegritySummary{
TotalChecks: 1,
Failures: 1,
},
Sections: []domain.DataIntegritySection{
{
Key: "tenant_integrity",
Label: "테넌트 정합성",
Status: domain.DataIntegrityStatusFail,
Checks: []domain.DataIntegrityCheck{
{
Key: "duplicate_tenant_slugs",
Label: "중복 테넌트 slug",
Status: domain.DataIntegrityStatusFail,
Count: 1,
Severity: "error",
},
},
},
},
},
}
h := &AdminHandler{IntegrityChecker: checker}
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/integrity", h.GetDataIntegrity)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, checker.calls)
var body domain.DataIntegrityReport
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Equal(t, domain.DataIntegrityStatusFail, body.Status)
require.Equal(t, int64(1), body.Summary.Failures)
require.Len(t, body.Sections, 1)
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
}

View File

@@ -0,0 +1,226 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"time"
"gorm.io/gorm"
)
type DataIntegrityChecker interface {
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
}
type dataIntegrityChecker struct {
db *gorm.DB
}
func NewDataIntegrityChecker(db *gorm.DB) DataIntegrityChecker {
return &dataIntegrityChecker{db: db}
}
func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
return CheckDataIntegrity(ctx, c.db)
}
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
tenantChecks := []domain.DataIntegrityCheck{
{
Key: "duplicate_tenant_slugs",
Label: "중복 테넌트 slug",
Description: "삭제되지 않은 tenant의 slug를 대소문자 무시 기준으로 검사합니다.",
Severity: "error",
Count: 0,
},
{
Key: "orphan_tenant_parents",
Label: "유령 상위 테넌트 참조",
Description: "tenant.parent_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
Severity: "error",
Count: 0,
},
}
userChecks := []domain.DataIntegrityCheck{
{
Key: "orphan_user_tenant_memberships",
Label: "유령 테넌트 사용자 소속",
Description: "users.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
Severity: "error",
Count: 0,
},
{
Key: "orphan_user_login_id_tenants",
Label: "유령 테넌트 로그인 ID",
Description: "user_login_ids.tenant_id가 없거나 삭제된 tenant를 참조하는지 검사합니다.",
Severity: "error",
Count: 0,
},
{
Key: "orphan_user_login_id_users",
Label: "유령 사용자 로그인 ID",
Description: "user_login_ids.user_id가 없거나 삭제된 user를 참조하는지 검사합니다.",
Severity: "error",
Count: 0,
},
}
counts := []struct {
target *int64
query string
}{
{
target: &tenantChecks[0].Count,
query: `
SELECT COUNT(*)
FROM (
SELECT LOWER(TRIM(slug)) AS normalized_slug
FROM tenants
WHERE deleted_at IS NULL
AND status <> 'deleted'
AND TRIM(slug) <> ''
GROUP BY LOWER(TRIM(slug))
HAVING COUNT(*) > 1
) AS duplicate_slugs
`,
},
{
target: &tenantChecks[1].Count,
query: `
SELECT COUNT(*)
FROM tenants AS child
WHERE child.deleted_at IS NULL
AND child.parent_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS parent
WHERE parent.id = child.parent_id
AND parent.deleted_at IS NULL
)
`,
},
{
target: &userChecks[0].Count,
query: `
SELECT COUNT(*)
FROM users AS u
WHERE u.deleted_at IS NULL
AND u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
`,
},
{
target: &userChecks[1].Count,
query: `
SELECT COUNT(*)
FROM user_login_ids AS uli
WHERE NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = uli.tenant_id
AND t.deleted_at IS NULL
)
`,
},
{
target: &userChecks[2].Count,
query: `
SELECT COUNT(*)
FROM user_login_ids AS uli
WHERE NOT EXISTS (
SELECT 1
FROM users AS u
WHERE u.id = uli.user_id
AND u.deleted_at IS NULL
)
`,
},
}
for _, item := range counts {
if err := db.WithContext(ctx).Raw(item.query).Scan(item.target).Error; err != nil {
return domain.DataIntegrityReport{}, err
}
}
tenantChecks = applyIntegrityStatuses(tenantChecks)
userChecks = applyIntegrityStatuses(userChecks)
sections := []domain.DataIntegritySection{
{
Key: "tenant_integrity",
Label: "테넌트 정합성",
Status: summarizeIntegrityStatus(tenantChecks),
Checks: tenantChecks,
},
{
Key: "user_integrity",
Label: "사용자 정합성",
Status: summarizeIntegrityStatus(userChecks),
Checks: userChecks,
},
}
summary := domain.DataIntegritySummary{}
for _, section := range sections {
for _, check := range section.Checks {
summary.TotalChecks++
switch check.Status {
case domain.DataIntegrityStatusFail:
summary.Failures += check.Count
case domain.DataIntegrityStatusWarning:
summary.Warnings++
default:
summary.Passed++
}
}
}
return domain.DataIntegrityReport{
Status: summarizeSectionStatus(sections),
CheckedAt: time.Now().UTC(),
Summary: summary,
Sections: sections,
}, nil
}
func applyIntegrityStatuses(checks []domain.DataIntegrityCheck) []domain.DataIntegrityCheck {
for i := range checks {
if checks[i].Count > 0 {
checks[i].Status = domain.DataIntegrityStatusFail
} else {
checks[i].Status = domain.DataIntegrityStatusPass
}
}
return checks
}
func summarizeIntegrityStatus(checks []domain.DataIntegrityCheck) domain.DataIntegrityStatus {
status := domain.DataIntegrityStatusPass
for _, check := range checks {
if check.Status == domain.DataIntegrityStatusFail {
return domain.DataIntegrityStatusFail
}
if check.Status == domain.DataIntegrityStatusWarning {
status = domain.DataIntegrityStatusWarning
}
}
return status
}
func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataIntegrityStatus {
status := domain.DataIntegrityStatusPass
for _, section := range sections {
if section.Status == domain.DataIntegrityStatusFail {
return domain.DataIntegrityStatusFail
}
if section.Status == domain.DataIntegrityStatusWarning {
status = domain.DataIntegrityStatusWarning
}
}
return status
}

View File

@@ -0,0 +1,106 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
ctx := context.Background()
suffix := uuid.NewString()
parent := domain.Tenant{
ID: uuid.NewString(),
Name: "Deleted Parent " + suffix,
Slug: "deleted-parent-" + suffix,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
child := domain.Tenant{
ID: uuid.NewString(),
Name: "Orphan Child " + suffix,
Slug: "orphan-child-" + suffix,
Type: domain.TenantTypeOrganization,
ParentID: &parent.ID,
Status: domain.TenantStatusActive,
}
dupA := domain.Tenant{
ID: uuid.NewString(),
Name: "Duplicate A " + suffix,
Slug: "Dup-" + suffix,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
dupB := domain.Tenant{
ID: uuid.NewString(),
Name: "Duplicate B " + suffix,
Slug: "dup-" + suffix,
Type: domain.TenantTypeCompany,
Status: domain.TenantStatusActive,
}
require.NoError(t, testDB.Create(&parent).Error)
require.NoError(t, testDB.Create(&child).Error)
require.NoError(t, testDB.Create(&dupA).Error)
require.NoError(t, testDB.Create(&dupB).Error)
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", parent.ID).Error)
orphanUser := domain.User{
ID: uuid.NewString(),
Email: "orphan-" + suffix + "@example.com",
Name: "Orphan User",
Role: domain.RoleUser,
TenantID: &parent.ID,
Status: domain.UserStatusActive,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
require.NoError(t, testDB.Create(&orphanUser).Error)
require.NoError(t, testDB.Create(&domain.UserLoginID{
ID: uuid.NewString(),
UserID: orphanUser.ID,
TenantID: parent.ID,
FieldKey: "emp_id",
LoginID: "EMP-" + suffix,
}).Error)
require.NoError(t, testDB.Create(&domain.UserLoginID{
ID: uuid.NewString(),
UserID: uuid.NewString(),
TenantID: child.ID,
FieldKey: "emp_id",
LoginID: "MISSING-" + suffix,
}).Error)
report, err := CheckDataIntegrity(ctx, testDB)
require.NoError(t, err)
require.Equal(t, domain.DataIntegrityStatusFail, report.Status)
require.Equal(t, int64(5), report.Summary.Failures)
requireIntegrityCheck(t, report, "tenant_integrity", "duplicate_tenant_slugs", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "tenant_integrity", "orphan_tenant_parents", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_tenant_memberships", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
}
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
t.Helper()
for _, section := range report.Sections {
if section.Key != sectionKey {
continue
}
for _, check := range section.Checks {
if check.Key == checkKey {
require.Equal(t, status, check.Status)
require.Equal(t, count, check.Count)
return
}
}
}
t.Fatalf("integrity check %s/%s not found", sectionKey, checkKey)
}

View File

@@ -32,6 +32,7 @@ type WorksmobileDirectoryClient interface {
CreateUser(ctx context.Context, payload WorksmobileUserPayload) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error DeleteUser(ctx context.Context, userID string) error
SetUserActive(ctx context.Context, userID string, active bool) error
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error)
ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error) ListGroups(ctx context.Context) ([]WorksmobileRemoteGroup, error)
} }
@@ -330,6 +331,33 @@ func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) e
return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil) return c.sendJSON(ctx, http.MethodDelete, "/scim/v2/Users/"+url.PathEscape(remote.ID), nil)
} }
func (c *WorksmobileHTTPClient) SetUserActive(ctx context.Context, userID string, active bool) error {
userID = strings.TrimSpace(userID)
if userID == "" {
return fmt.Errorf("worksmobile user id is required")
}
if strings.TrimSpace(c.SCIMToken) == "" {
return fmt.Errorf("worksmobile scim token is not configured")
}
remote, err := c.findSCIMUser(ctx, userID)
if err != nil {
return err
}
if remote == nil {
return nil
}
return c.sendJSON(ctx, http.MethodPatch, "/scim/v2/Users/"+url.PathEscape(remote.ID), map[string]any{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]any{
{
"op": "replace",
"path": "active",
"value": active,
},
},
})
}
func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) { func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
users, err := c.ListUsers(ctx) users, err := c.ListUsers(ctx)
if err != nil { if err != nil {
@@ -344,6 +372,21 @@ func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string)
return nil, nil return nil, nil
} }
func (c *WorksmobileHTTPClient) findSCIMUser(ctx context.Context, identifier string) (*WorksmobileRemoteUser, error) {
identifier = strings.TrimSpace(identifier)
var matched *WorksmobileRemoteUser
err := c.listSCIM(ctx, "/scim/v2/Users", func(resource map[string]any) {
if matched != nil {
return
}
user := parseWorksmobileRemoteUser(resource)
if strings.EqualFold(user.UserName, identifier) || user.ExternalID == identifier || strings.EqualFold(user.Email, identifier) {
matched = &user
}
})
return matched, err
}
func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { func (c *WorksmobileHTTPClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 { if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
users, err := c.listDirectoryUsers(ctx, c.DomainIDs) users, err := c.listDirectoryUsers(ctx, c.DomainIDs)

View File

@@ -235,6 +235,42 @@ func TestWorksmobileHTTPClientListUsersFallsBackToSCIMWhenDirectoryFails(t *test
require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path) require.Equal(t, "/scim/v2/Users", transport.requests[1].URL.Path)
} }
func TestWorksmobileHTTPClientSetUserActivePatchesSCIMActiveFlag(t *testing.T) {
transport := &captureRoundTripper{
responses: []captureResponse{
{statusCode: http.StatusOK, body: `{"totalResults":1,"Resources":[{"id":"scim-user-1","externalId":"user-1","userName":"tester@samaneng.com","active":true,"emails":[{"value":"tester@samaneng.com","primary":true}]}]}`},
{statusCode: http.StatusOK, body: `{}`},
},
}
client := &WorksmobileHTTPClient{
BaseURL: "https://works.example.test",
SCIMToken: "scim-token-1",
HTTPClient: &http.Client{Transport: transport},
}
err := client.SetUserActive(context.Background(), "tester@samaneng.com", false)
require.NoError(t, err)
require.Len(t, transport.requests, 2)
require.Equal(t, http.MethodGet, transport.requests[0].Method)
require.Equal(t, "/scim/v2/Users", transport.requests[0].URL.Path)
require.Equal(t, http.MethodPatch, transport.requests[1].Method)
require.Equal(t, "/scim/v2/Users/scim-user-1", transport.requests[1].URL.Path)
require.Equal(t, "Bearer scim-token-1", transport.requests[1].Header.Get("Authorization"))
var patchPayload map[string]any
require.Len(t, transport.requestBodies, 1)
require.NoError(t, json.Unmarshal(transport.requestBodies[0], &patchPayload))
operations, ok := patchPayload["Operations"].([]any)
require.True(t, ok)
require.Len(t, operations, 1)
operation, ok := operations[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "replace", operation["op"])
require.Equal(t, "active", operation["path"])
require.Equal(t, false, operation["value"])
}
func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) { func TestWorksmobileHTTPClientListGroupsUsesDirectoryAPIFirst(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955") t.Setenv("SAMAN_DOMAIN_ID", "300285955")
transport := &captureRoundTripper{ transport := &captureRoundTripper{
@@ -373,6 +409,60 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email) require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
} }
func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionSuspend,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
}),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processingIDs)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, []string{"tester@samaneng.com"}, client.suspendedUsers)
}
func TestWorksmobileRelayWorkerProcessesActiveUserUpsertAndReactivates(t *testing.T) {
repo := &fakeWorksmobileOutboxRepo{
ready: []domain.WorksmobileOutbox{
{
ID: "job-1",
ResourceType: domain.WorksmobileResourceUser,
ResourceID: "user-1",
Action: domain.WorksmobileActionUpsert,
Status: domain.WorksmobileOutboxStatusPending,
Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{
Email: "tester@samaneng.com",
UserExternalKey: "user-1",
}, domain.UserStatusActive),
},
},
}
client := &fakeWorksmobileDirectoryClient{}
worker := NewWorksmobileRelayWorker(repo, client)
err := worker.ProcessOnce(context.Background())
require.NoError(t, err)
require.Equal(t, []string{"job-1"}, repo.processedIDs)
require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email)
require.Equal(t, []string{"tester@samaneng.com"}, client.activeUsers)
}
func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) { func TestRedactWorksmobileOutboxPayloadsRemovesInitialPasswordFromOverview(t *testing.T) {
jobs := []domain.WorksmobileOutbox{ jobs := []domain.WorksmobileOutbox{
{ {
@@ -714,6 +804,8 @@ type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload createdUsers []WorksmobileUserPayload
deletedUsers []string deletedUsers []string
activeUsers []string
suspendedUsers []string
orgUnitMatchKeys []string orgUnitMatchKeys []string
} }
@@ -803,6 +895,15 @@ func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID
return nil return nil
} }
func (f *fakeWorksmobileDirectoryClient) SetUserActive(ctx context.Context, userID string, active bool) error {
if active {
f.activeUsers = append(f.activeUsers, userID)
} else {
f.suspendedUsers = append(f.suspendedUsers, userID)
}
return nil
}
func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) { func (f *fakeWorksmobileDirectoryClient) ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) {
return nil, nil return nil, nil
} }

View File

@@ -97,13 +97,17 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil {
return err return err
} }
return w.client.UpsertUser(ctx, payload) if err := w.client.UpsertUser(ctx, payload); err != nil {
case domain.WorksmobileActionDelete: return err
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
} }
return w.client.DeleteUser(ctx, userID) if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive {
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true)
}
return nil
case domain.WorksmobileActionDelete:
return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job))
case domain.WorksmobileActionSuspend:
return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false)
default: default:
return nil return nil
} }
@@ -112,6 +116,14 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm
} }
} }
func worksmobileOutboxUserIdentifier(job domain.WorksmobileOutbox) string {
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
}
return userID
}
func decodeWorksmobileRequest(payload domain.JSONMap, target any) error { func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
raw := payload["request"] raw := payload["request"]
if raw == nil { if raw == nil {

View File

@@ -315,7 +315,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
ResourceID: user.ID, ResourceID: user.ID,
Action: action, Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload), Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
} }
if err := s.outboxRepo.Create(ctx, item); err != nil { if err := s.outboxRepo.Create(ctx, item); err != nil {
return nil, err return nil, err
@@ -459,7 +459,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
ResourceID: user.ID, ResourceID: user.ID,
Action: action, Action: action,
DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID,
Payload: worksmobileUserOutboxPayload(root.ID, payload), Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status),
}) })
} }
@@ -649,13 +649,19 @@ func normalizeWorksmobileOrgUnitParent(payload WorksmobileOrgUnitPayload, tenant
return payload return payload
} }
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap { func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
return domain.JSONMap{ outboxPayload := domain.JSONMap{
"request": payload, "request": payload,
"tenantRootId": rootID, "tenantRootId": rootID,
"loginEmail": payload.Email, "loginEmail": payload.Email,
"initialPassword": payload.PasswordConfig.Password, "initialPassword": payload.PasswordConfig.Password,
} }
if len(statuses) > 0 {
if status := strings.TrimSpace(statuses[0]); status != "" {
outboxPayload["baronStatus"] = status
}
}
return outboxPayload
} }
func stringValue(value any) string { func stringValue(value any) string {

View File

@@ -56,6 +56,51 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
require.Empty(t, outboxRepo.created) require.Empty(t, outboxRepo.created)
} }
func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "root-tenant"
tenantID := "saman-tenant"
root := domain.Tenant{
ID: rootID,
Slug: HanmacFamilyTenantSlug,
Name: "Hanmac Family",
}
tenant := domain.Tenant{
ID: tenantID,
Slug: "saman",
Name: "Saman",
Type: domain.TenantTypeCompany,
ParentID: &rootID,
Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
}
target := domain.User{
ID: "target-user",
Email: "target@samaneng.com",
Name: "Target",
Status: domain.UserStatusSuspended,
TenantID: &tenantID,
}
outboxRepo := &fakeWorksmobileOutboxRepo{}
service := NewWorksmobileSyncService(
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}},
&fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}},
outboxRepo,
nil,
)
item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID)
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
require.Equal(t, domain.WorksmobileActionSuspend, outboxRepo.created[0].Action)
require.Equal(t, domain.UserStatusSuspended, outboxRepo.created[0].Payload["baronStatus"])
request, ok := outboxRepo.created[0].Payload["request"].(WorksmobileUserPayload)
require.True(t, ok)
require.NotEmpty(t, request.Organizations)
require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"])
}
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) { func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1") t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
root := domain.Tenant{ root := domain.Tenant{

View File

@@ -0,0 +1,45 @@
# 데이터 정합성 검증 관리
## 개요
adminfront의 `데이터 정합성` 메뉴는 Baron SSO backend DB read model의 이상 징후를 `super_admin` 전용으로 확인하는 관리 화면입니다.
Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이 기능은 SoT를 직접 수정하지 않고, backend PostgreSQL read model에서 운영자가 확인해야 할 불일치만 리포트합니다.
## API
- Method: `GET`
- Path: `/api/v1/admin/integrity`
- 권한: `super_admin`
응답은 전체 상태, 검사 시각, 요약, 섹션별 검사 결과를 포함합니다.
## 검사 항목
### 테넌트 정합성
- `duplicate_tenant_slugs`: 삭제되지 않은 tenant의 `LOWER(TRIM(slug))` 기준 중복을 검사합니다.
- `orphan_tenant_parents`: `tenants.parent_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
### 사용자 정합성
- `orphan_user_tenant_memberships`: `users.tenant_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
- `orphan_user_login_id_tenants`: `user_login_ids.tenant_id`가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.
- `orphan_user_login_id_users`: `user_login_ids.user_id`가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다.
## adminfront 동작
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
- 개요 화면은 `super_admin`에게 전체 사용자 수(`totalUsers`)도 표시합니다. 이 값은 Kratos user projection 상태의 `projectedUsers` 기준입니다.
## 운영 주의
현재 기능은 read-only 검증입니다. 자동 정리 작업은 별도 이슈와 승인된 maintenance action으로 분리해야 합니다.
이미 존재하는 orphan 사용자 소속 정리 경로는 다음과 같습니다.
- CLI: `backend/cmd/adminctl clear-orphan-user-tenant-memberships`
- SQL: `scripts/clear_orphan_user_tenant_memberships.sql`