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");
});
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", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];

View File

@@ -8,6 +8,7 @@ import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
@@ -66,6 +67,7 @@ export const adminRoutes: RouteObject[] = [
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ 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 type * as React from "react";
import { fetchMe } from "../../lib/adminApi";
import { normalizeAdminRole } from "../../lib/roles";
interface RoleGuardProps {
children: React.ReactNode;
@@ -29,8 +30,10 @@ export function RoleGuard({
if (isLoading) return null;
const userRole = profile?.role || "user";
const hasAccess = roles.includes(userRole);
const userRole = normalizeAdminRole(profile?.role);
const hasAccess = roles
.map((role) => normalizeAdminRole(role))
.includes(userRole);
if (!hasAccess) {
return <>{fallback}</>;

View File

@@ -10,6 +10,7 @@ import {
Moon,
Network,
NotebookTabs,
ShieldCheck,
ShieldHalf,
Sun,
User as UserIcon,
@@ -32,6 +33,7 @@ import {
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
@@ -150,7 +152,7 @@ function AppLayout() {
._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role;
const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
@@ -180,6 +182,11 @@ function AppLayout() {
to: "/system/projections/users",
icon: Database,
});
filteredItems.splice(5, 0, {
label: "ui.admin.nav.data_integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
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 { MemoryRouter } from "react-router-dom";
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 GlobalOverviewPage from "./GlobalOverviewPage";
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
totalUsers: 152,
oidcClients: 3,
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) {
@@ -112,6 +142,7 @@ function renderWithProviders(ui: React.ReactElement) {
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
});
@@ -132,15 +163,18 @@ describe("admin overview and auth guard pages", () => {
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 />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
).toHaveTextContent("3");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
"152",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
@@ -172,6 +206,26 @@ describe("admin overview and auth guard pages", () => {
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", () => {
renderWithProviders(<AuthPage />);

View File

@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Database,
ShieldCheck,
Users,
@@ -9,12 +11,14 @@ import {
import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
type DataIntegrityStatus,
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchAllTenants,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
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({
rows,
filters,
@@ -371,6 +471,7 @@ function GlobalOverviewPage() {
retry: false,
});
const stats = statsQuery.data;
const visibleTenantCount = tenantsQuery.data?.items.length;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
@@ -444,7 +545,7 @@ function GlobalOverviewPage() {
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(stats?.totalTenants)}
value={metric(visibleTenantCount ?? stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
@@ -454,6 +555,11 @@ function GlobalOverviewPage() {
)}
value={metric(stats?.oidcClients)}
/>
<OverviewMetric
icon={<Users size={14} />}
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
value={metric(stats?.totalUsers)}
/>
</RoleGuard>
<OverviewMetric
icon={<Activity size={14} />}
@@ -491,6 +597,10 @@ function GlobalOverviewPage() {
period={period}
/>
)}
<RoleGuard roles={["super_admin"]}>
<IntegrityOverviewSummary />
</RoleGuard>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -48,6 +48,7 @@ import {
fetchTenant,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
@@ -642,7 +643,7 @@ function UserCreatePage() {
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"
{...register("role")}
disabled={profile?.role !== "super_admin"}
disabled={!isSuperAdminRole(profile?.role)}
>
<option value="super_admin">
{t("ui.admin.role.super_admin", "시스템 관리자")}

View File

@@ -67,6 +67,7 @@ import {
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
type OrgChartTenantSelection,
@@ -387,8 +388,9 @@ function UserDetailPage() {
},
});
const profileRole = normalizeAdminRole(profile?.role);
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 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"
{...register("role")}
disabled={
profile?.role !== "super_admin" ||
!isSuperAdminRole(profile?.role) ||
profile?.id === user?.id
}
>

View File

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

View File

@@ -129,6 +129,7 @@ export type RPUsageDailyResponse = {
export type AdminOverviewStats = {
totalTenants: number;
totalUsers: number;
oidcClients: number;
auditEvents24h: number;
};
@@ -149,6 +150,36 @@ export type UserProjectionActionResult = {
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) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -161,6 +192,13 @@ export async function fetchAdminOverviewStats() {
return data;
}
export async function fetchDataIntegrityReport() {
const { data } = await apiClient.get<DataIntegrityReport>(
"/v1/admin/integrity",
);
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/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"
audit_logs = "Audit Logs"
auth_guard = "Auth Guard"
data_integrity = "Data Integrity"
logout = "Logout"
overview = "Overview"
relying_parties = "Apps (RP)"
@@ -876,6 +877,7 @@ audit_events_24h = "24h Events"
oidc_clients = "OIDC Clients"
policy_gate = "Policy Gate"
total_tenants = "Total Tenants"
total_users = "Total Users"
[ui.admin.profile]
manageable_tenants = "Manageable Tenants"

View File

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

View File

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

View File

@@ -143,9 +143,10 @@ test.describe("Bulk Actions and Tree Search", () => {
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
// 활성화 버튼 확인
const activeBtn = page.getByTestId("bulk-active-btn");
await expect(activeBtn).toBeVisible({ timeout: 10000 });
await expect(page.getByTestId("bulk-status-select")).toBeVisible({
timeout: 10000,
});
await expect(page.getByTestId("bulk-apply-status-btn")).toBeVisible();
// 전체 선택
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 });
});
test("should let super admins promote selected users to super admin", async ({
test("should apply selected bulk status to selected users", async ({
page,
}) => {
let capturedPayload: unknown = null;
@@ -182,7 +183,48 @@ test.describe("Bulk Actions and Tree Search", () => {
const selectionBar = page.getByTestId("bulk-action-bar");
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
.poll(() => capturedPayload)
@@ -193,6 +235,65 @@ test.describe("Bulk Actions and Tree Search", () => {
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 ({
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.UserProjectionRepo = userProjectionRepo
adminHandler.UserProjectionSyncer = userProjectionSyncer
adminHandler.IntegrityChecker = repository.NewDataIntegrityChecker(db)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, developerService, authHandler)
devHandler.HeadlessJWKS = headlessJWKSCache
devHandler.AuditRepo = auditRepo
@@ -714,6 +715,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
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: "rp admin unchanged", in: "rp_admin", want: RoleRPAdmin},
{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 tenant member", in: "tenant_member", want: RoleUser},
{name: "trim and lower", in: " ADMIN ", want: RoleTenantAdmin},

View File

@@ -26,6 +26,7 @@ type AdminHandler struct {
AuditRepo domain.AuditRepository
UserProjectionRepo repository.UserProjectionRepository
UserProjectionSyncer service.UserProjectionReconciler
IntegrityChecker repository.DataIntegrityChecker
}
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"})
}
func requireSuperAdminProfile(c *fiber.Ctx) error {
func requireSuperAdminProfile(c *fiber.Ctx) bool {
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"})
_ = 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 {
if err := requireSuperAdminProfile(c); err != nil {
return err
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.UserProjectionRepo == nil {
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 {
if err := requireSuperAdminProfile(c); err != nil {
return err
if !requireSuperAdminProfile(c) {
return nil
}
if h == nil || h.UserProjectionSyncer == nil {
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)
}
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
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats
@@ -161,6 +177,7 @@ func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
stats := fiber.Map{
"totalTenants": h.countTenants(ctx),
"totalUsers": h.countUsers(ctx),
"oidcClients": h.countOIDCClients(ctx),
"auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)),
"goroutines": runtime.NumGoroutine(),
@@ -188,6 +205,17 @@ func (h *AdminHandler) countTenants(ctx context.Context) int64 {
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 {
if h == nil || h.Hydra == nil {
return 0

View File

@@ -263,7 +263,17 @@ func TestAdminHandler_GetRPUsageDailyChecksTenantPermission(t *testing.T) {
func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
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.Get("/api/v1/admin/stats", h.GetSystemStats)
@@ -275,8 +285,10 @@ func TestAdminHandler_GetSystemStatsIncludesOverviewMetrics(t *testing.T) {
var body map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Contains(t, body, "totalTenants")
require.Contains(t, body, "totalUsers")
require.Contains(t, body, "oidcClients")
require.Contains(t, body, "auditEvents24h")
require.Equal(t, float64(152), body["totalUsers"])
require.Equal(t, float64(22), body["auditEvents24h"])
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
UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error
DeleteUser(ctx context.Context, userID string) error
SetUserActive(ctx context.Context, userID string, active bool) error
ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, 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)
}
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) {
users, err := c.ListUsers(ctx)
if err != nil {
@@ -344,6 +372,21 @@ func (c *WorksmobileHTTPClient) FindUser(ctx context.Context, identifier string)
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) {
if c.directoryAuthConfigured() && len(c.DomainIDs) > 0 {
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)
}
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) {
t.Setenv("SAMAN_DOMAIN_ID", "300285955")
transport := &captureRoundTripper{
@@ -373,6 +409,60 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T
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) {
jobs := []domain.WorksmobileOutbox{
{
@@ -714,6 +804,8 @@ type fakeWorksmobileDirectoryClient struct {
createdOrgUnits []WorksmobileOrgUnitPayload
createdUsers []WorksmobileUserPayload
deletedUsers []string
activeUsers []string
suspendedUsers []string
orgUnitMatchKeys []string
}
@@ -803,6 +895,15 @@ func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID
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) {
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 {
return err
}
return w.client.UpsertUser(ctx, payload)
case domain.WorksmobileActionDelete:
userID := stringValue(job.Payload["loginEmail"])
if userID == "" {
userID = stringValue(job.Payload["userExternalKey"])
if err := w.client.UpsertUser(ctx, payload); err != nil {
return err
}
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:
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 {
raw := payload["request"]
if raw == nil {

View File

@@ -315,7 +315,7 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID,
ResourceID: user.ID,
Action: action,
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 {
return nil, err
@@ -459,7 +459,7 @@ func (s *worksmobileSyncService) EnqueueUserUpsertIfInScope(ctx context.Context,
ResourceID: user.ID,
Action: action,
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
}
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload) domain.JSONMap {
return domain.JSONMap{
func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, statuses ...string) domain.JSONMap {
outboxPayload := domain.JSONMap{
"request": payload,
"tenantRootId": rootID,
"loginEmail": payload.Email,
"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 {

View File

@@ -56,6 +56,51 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
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) {
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
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`