diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
index 0b9fd597..19f93442 100644
--- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
+++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
@@ -1,7 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
+import {
+ deleteOrphanUserLoginIDs,
+ fetchDataIntegrityReport,
+ fetchMe,
+ fetchOrphanUserLoginIDs,
+} from "../../lib/adminApi";
import DataIntegrityPage from "./DataIntegrityPage";
let currentRole = "super_admin";
@@ -36,6 +41,35 @@ vi.mock("../../lib/adminApi", () => ({
},
],
})),
+ fetchOrphanUserLoginIDs: vi.fn(async () => ({
+ items: [
+ {
+ id: "login-id-1",
+ userId: "user-1",
+ userEmail: "missing@example.com",
+ tenantId: "tenant-1",
+ tenantSlug: "deleted-tenant",
+ fieldKey: "emp_id",
+ loginId: "EMP001",
+ reasons: ["deleted_tenant"],
+ },
+ ],
+ total: 1,
+ })),
+ deleteOrphanUserLoginIDs: vi.fn(async () => ({
+ deletedCount: 1,
+ deleted: [
+ {
+ id: "login-id-1",
+ userId: "user-1",
+ tenantId: "tenant-1",
+ fieldKey: "emp_id",
+ loginId: "EMP001",
+ reasons: ["deleted_tenant"],
+ },
+ ],
+ skippedIds: [],
+ })),
}));
function renderPage() {
@@ -69,6 +103,26 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
+ it("shows orphan login ID targets and deletes selected rows", async () => {
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ renderPage();
+
+ expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
+ expect(await screen.findByText("EMP001")).toBeInTheDocument();
+ expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
+ expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
+ fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
+
+ await waitFor(() => {
+ expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
+ });
+ expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
+ "login-id-1",
+ ]);
+ });
+
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx
index 502338a2..ff365f08 100644
--- a/adminfront/src/features/integrity/DataIntegrityPage.tsx
+++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx
@@ -1,17 +1,21 @@
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
+import { useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
+ type OrphanUserLoginID,
+ deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
+ fetchOrphanUserLoginIDs,
} from "../../lib/adminApi";
function statusLabel(status: DataIntegrityStatus) {
@@ -58,11 +62,138 @@ function CheckIcon({ check }: { check: DataIntegrityCheck }) {
return ;
}
+function reasonLabel(reason: string) {
+ switch (reason) {
+ case "missing_user":
+ return "사용자 없음";
+ case "deleted_user":
+ return "삭제된 사용자";
+ case "missing_tenant":
+ return "테넌트 없음";
+ case "deleted_tenant":
+ return "삭제된 테넌트";
+ default:
+ return reason;
+ }
+}
+
+function OrphanLoginIDTable({
+ items,
+ selectedIds,
+ onToggle,
+}: {
+ items: OrphanUserLoginID[];
+ selectedIds: string[];
+ onToggle: (id: string) => void;
+}) {
+ if (items.length === 0) {
+ return (
+
+ 삭제할 유령 로그인 ID가 없습니다.
+
+ );
+ }
+
+ const selectedSet = new Set(selectedIds);
+ return (
+
+
+
+
+ | 선택 |
+ Login ID |
+ Field |
+ User |
+ Tenant |
+ 사유 |
+
+
+
+ {items.map((item) => (
+
+ |
+ onToggle(item.id)}
+ className="h-4 w-4 rounded border-input"
+ />
+ |
+ {item.loginId} |
+
+ {item.fieldKey}
+ |
+
+ {item.userEmail || "-"}
+
+ {item.userId}
+
+ |
+
+ {item.tenantSlug || "-"}
+
+ {item.tenantId}
+
+ |
+
+
+ {item.reasons.map((reason) => (
+
+ {reasonLabel(reason)}
+
+ ))}
+
+ |
+
+ ))}
+
+
+
+ );
+}
+
function DataIntegrityContent() {
+ const queryClient = useQueryClient();
+ const [selectedOrphanIds, setSelectedOrphanIds] = useState([]);
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
+ const orphanLoginIDsQuery = useQuery({
+ queryKey: ["orphan-user-login-ids"],
+ queryFn: fetchOrphanUserLoginIDs,
+ });
+ const deleteMutation = useMutation({
+ mutationFn: deleteOrphanUserLoginIDs,
+ onSuccess: async () => {
+ setSelectedOrphanIds([]);
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
+ queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
+ ]);
+ },
+ });
+
+ const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
+ const toggleOrphanID = (id: string) => {
+ setSelectedOrphanIds((current) =>
+ current.includes(id)
+ ? current.filter((selectedID) => selectedID !== id)
+ : [...current, id],
+ );
+ };
+ const handleDeleteSelected = () => {
+ if (selectedOrphanIds.length === 0) {
+ return;
+ }
+ const confirmed = window.confirm(
+ `선택한 ${selectedOrphanIds.length}개의 유령 로그인 ID를 삭제하시겠습니까?`,
+ );
+ if (confirmed) {
+ deleteMutation.mutate(selectedOrphanIds);
+ }
+ };
return (
@@ -184,6 +315,44 @@ function DataIntegrityContent() {
))}
+
+
+
+
+
유령 로그인 ID 정리
+
+ 삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를
+ 확인한 뒤 선택 삭제합니다.
+
+
+
+
+ {orphanLoginIDsQuery.isError ? (
+
+ 유령 로그인 ID 대상을 불러오지 못했습니다.
+
+ ) : null}
+ {deleteMutation.data ? (
+
+ {deleteMutation.data.deletedCount}개의 유령 로그인 ID를
+ 삭제했습니다.
+
+ ) : null}
+
+
);
}
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx
index 70c65dc5..d5b80f49 100644
--- a/adminfront/src/features/users/UserCreatePage.tsx
+++ b/adminfront/src/features/users/UserCreatePage.tsx
@@ -648,12 +648,6 @@ function UserCreatePage() {
-
-
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx
index 89251f06..e373cfca 100644
--- a/adminfront/src/features/users/UserDetailPage.tsx
+++ b/adminfront/src/features/users/UserDetailPage.tsx
@@ -67,7 +67,7 @@ import {
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
-import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
+import { normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
type OrgChartTenantSelection,
@@ -740,6 +740,7 @@ function UserDetailPage() {
...data,
metadata,
};
+ payload.role = undefined;
if (userCategory === "personal") {
try {
@@ -1059,38 +1060,6 @@ function UserDetailPage() {
-
-
-
-
-
-
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 03e7adde..8c915492 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -180,6 +180,30 @@ export type DataIntegrityReport = {
sections: DataIntegritySection[];
};
+export type OrphanUserLoginID = {
+ id: string;
+ userId: string;
+ userEmail?: string;
+ userDeletedAt?: string;
+ tenantId: string;
+ tenantSlug?: string;
+ tenantDeletedAt?: string;
+ fieldKey: string;
+ loginId: string;
+ reasons: string[];
+};
+
+export type OrphanUserLoginIDListResponse = {
+ items: OrphanUserLoginID[];
+ total: number;
+};
+
+export type DeleteOrphanUserLoginIDsResult = {
+ deletedCount: number;
+ deleted: OrphanUserLoginID[];
+ skippedIds: string[];
+};
+
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get("/v1/audit", {
params: { limit, cursor },
@@ -199,6 +223,21 @@ export async function fetchDataIntegrityReport() {
return data;
}
+export async function fetchOrphanUserLoginIDs() {
+ const { data } = await apiClient.get(
+ "/v1/admin/integrity/orphan-user-login-ids",
+ );
+ return data;
+}
+
+export async function deleteOrphanUserLoginIDs(ids: string[]) {
+ const { data } = await apiClient.delete(
+ "/v1/admin/integrity/orphan-user-login-ids",
+ { data: { ids } },
+ );
+ return data;
+}
+
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get(
"/v1/admin/projections/users",
diff --git a/adminfront/tests/bulk_actions.spec.ts b/adminfront/tests/bulk_actions.spec.ts
index 1af44c29..cd7b77f7 100644
--- a/adminfront/tests/bulk_actions.spec.ts
+++ b/adminfront/tests/bulk_actions.spec.ts
@@ -235,6 +235,135 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
+ test("should only expose super admin grant and revoke options in bulk permission select", async ({
+ page,
+ }) => {
+ await page.goto("/users");
+ await expect(page.locator("table")).toContainText("User One", {
+ timeout: 20000,
+ });
+
+ await page.locator('table input[type="checkbox"]').nth(1).click();
+ await expect(page.getByTestId("bulk-action-bar")).toBeVisible({
+ timeout: 15000,
+ });
+
+ await page.getByTestId("bulk-permission-select").click();
+ await expect(
+ page.getByRole("option", { name: /시스템 관리자|Super Admin/i }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: /일반 사용자|User/i }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: /테넌트 관리자|Tenant Admin/i }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("option", {
+ name: /서비스 관리자|RP Admin|Service Admin/i,
+ }),
+ ).toHaveCount(0);
+ });
+
+ test("should let super admins revoke selected super admin permission", 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();
+ await expect(page.getByTestId("bulk-action-bar")).toBeVisible({
+ timeout: 15000,
+ });
+
+ await page.getByTestId("bulk-permission-select").click();
+ await page.getByRole("option", { name: /일반 사용자|User/i }).click();
+ await page.getByTestId("bulk-apply-permission-btn").click();
+
+ await expect
+ .poll(() => capturedPayload)
+ .toEqual({
+ userIds: ["u-1"],
+ role: "user",
+ });
+ });
+
+ test("should not render role field on user detail page", 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("/auth/password/policy")) {
+ return route.fulfill({ json: { minLength: 12 }, headers });
+ }
+ if (url.includes("/admin/users/u-1/rp-history")) {
+ return route.fulfill({ json: [], headers });
+ }
+ if (url.includes("/admin/users/u-1")) {
+ return route.fulfill({
+ json: {
+ id: "u-1",
+ name: "User One",
+ email: "u1@test.com",
+ phone: "",
+ status: "active",
+ role: "user",
+ tenantSlug: "main",
+ createdAt: new Date().toISOString(),
+ metadata: {},
+ },
+ headers,
+ });
+ }
+ if (url.includes("/admin/tenants")) {
+ return route.fulfill({
+ json: {
+ items: [
+ { id: "t-1", name: "Main Tenant", slug: "main", type: "COMPANY" },
+ ],
+ total: 1,
+ },
+ headers,
+ });
+ }
+
+ return route.fulfill({ json: { items: [], total: 0 }, headers });
+ });
+
+ await page.goto("/users/u-1");
+ await expect(page.getByRole("heading", { name: "User One" })).toBeVisible({
+ timeout: 20000,
+ });
+ await expect(page.locator("#role")).toHaveCount(0);
+ await expect(page.getByLabel("역할")).toHaveCount(0);
+ });
+
test("should let canonical super admin aliases promote selected users", async ({
page,
}) => {
diff --git a/adminfront/tests/data_integrity.spec.ts b/adminfront/tests/data_integrity.spec.ts
index 3958e3d1..44ae06c2 100644
--- a/adminfront/tests/data_integrity.spec.ts
+++ b/adminfront/tests/data_integrity.spec.ts
@@ -2,9 +2,12 @@ import { expect, test } from "@playwright/test";
test.describe("Data integrity management", () => {
test.beforeEach(async ({ page }) => {
+ let orphanLoginIDDeleted = false;
+
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
+ window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
@@ -87,6 +90,48 @@ test.describe("Data integrity management", () => {
});
return;
}
+ if (url.includes("/api/v1/admin/integrity/orphan-user-login-ids")) {
+ if (route.request().method() === "DELETE") {
+ orphanLoginIDDeleted = true;
+ await route.fulfill({
+ json: {
+ deletedCount: 1,
+ deleted: [
+ {
+ id: "login-id-1",
+ userId: "user-1",
+ tenantId: "tenant-1",
+ fieldKey: "emp_id",
+ loginId: "EMP001",
+ reasons: ["deleted_tenant"],
+ },
+ ],
+ skippedIds: [],
+ },
+ });
+ return;
+ }
+ await route.fulfill({
+ json: orphanLoginIDDeleted
+ ? { items: [], total: 0 }
+ : {
+ items: [
+ {
+ id: "login-id-1",
+ userId: "user-1",
+ userEmail: "missing@example.com",
+ tenantId: "tenant-1",
+ tenantSlug: "deleted-tenant",
+ fieldKey: "emp_id",
+ loginId: "EMP001",
+ reasons: ["deleted_tenant"],
+ },
+ ],
+ total: 1,
+ },
+ });
+ return;
+ }
if (url.includes("/api/v1/admin/integrity")) {
await route.fulfill({
json: {
@@ -139,6 +184,28 @@ test.describe("Data integrity management", () => {
await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible();
});
+ test("deletes selected orphan login ID targets after confirmation", async ({
+ page,
+ }) => {
+ page.on("dialog", async (dialog) => {
+ await dialog.accept();
+ });
+
+ await page.goto("/system/data-integrity");
+
+ await expect(page.getByText("EMP001")).toBeVisible();
+ await expect(page.getByText("삭제된 테넌트")).toBeVisible();
+ await page.getByRole("checkbox", { name: "EMP001 선택" }).check();
+ await page.getByRole("button", { name: "선택 삭제" }).click();
+
+ await expect(
+ page.getByText("1개의 유령 로그인 ID를 삭제했습니다."),
+ ).toBeVisible();
+ await expect(
+ page.getByText("삭제할 유령 로그인 ID가 없습니다."),
+ ).toBeVisible();
+ });
+
test("shows the latest integrity summary on the overview page", async ({
page,
}) => {
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 9e1d6f49..6e62480c 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -716,6 +716,8 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/integrity", requireSuperAdmin, adminHandler.GetDataIntegrity)
+ admin.Get("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.ListOrphanUserLoginIDs)
+ admin.Delete("/integrity/orphan-user-login-ids", requireSuperAdmin, adminHandler.DeleteOrphanUserLoginIDs)
admin.Get("/projections/users", requireSuperAdmin, adminHandler.GetUserProjectionStatus)
admin.Post("/projections/users/reconcile", requireSuperAdmin, adminHandler.ReconcileUserProjection)
admin.Post("/projections/users/reset", requireSuperAdmin, adminHandler.ResetUserProjection)
diff --git a/backend/internal/domain/data_integrity.go b/backend/internal/domain/data_integrity.go
index 6966a250..3b291e42 100644
--- a/backend/internal/domain/data_integrity.go
+++ b/backend/internal/domain/data_integrity.go
@@ -39,3 +39,22 @@ type DataIntegrityCheck struct {
Severity string `json:"severity"`
Count int64 `json:"count"`
}
+
+type OrphanUserLoginID struct {
+ ID string `json:"id"`
+ UserID string `json:"userId"`
+ UserEmail string `json:"userEmail,omitempty"`
+ UserDeletedAt *time.Time `json:"userDeletedAt,omitempty"`
+ TenantID string `json:"tenantId"`
+ TenantSlug string `json:"tenantSlug,omitempty"`
+ TenantDeletedAt *time.Time `json:"tenantDeletedAt,omitempty"`
+ FieldKey string `json:"fieldKey"`
+ LoginID string `json:"loginId"`
+ Reasons []string `json:"reasons"`
+}
+
+type DeleteOrphanUserLoginIDsResult struct {
+ DeletedCount int64 `json:"deletedCount"`
+ Deleted []OrphanUserLoginID `json:"deleted"`
+ SkippedIDs []string `json:"skippedIds"`
+}
diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go
index 1ef8f34d..e7732323 100644
--- a/backend/internal/handler/admin_handler.go
+++ b/backend/internal/handler/admin_handler.go
@@ -169,6 +169,43 @@ func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error {
return c.JSON(report)
}
+func (h *AdminHandler) ListOrphanUserLoginIDs(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"})
+ }
+ items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context())
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(fiber.Map{
+ "items": items,
+ "total": len(items),
+ })
+}
+
+func (h *AdminHandler) DeleteOrphanUserLoginIDs(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"})
+ }
+ var req struct {
+ IDs []string `json:"ids"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
+ }
+ result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ return c.JSON(result)
+}
+
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats
diff --git a/backend/internal/handler/admin_integrity_test.go b/backend/internal/handler/admin_integrity_test.go
index f5744851..9324418a 100644
--- a/backend/internal/handler/admin_integrity_test.go
+++ b/backend/internal/handler/admin_integrity_test.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
@@ -14,9 +15,14 @@ import (
)
type fakeDataIntegrityChecker struct {
- calls int
- report domain.DataIntegrityReport
- err error
+ calls int
+ listCalls int
+ deleteCalls int
+ deletedIDs []string
+ report domain.DataIntegrityReport
+ orphans []domain.OrphanUserLoginID
+ deleteResult domain.DeleteOrphanUserLoginIDsResult
+ err error
}
func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error) {
@@ -24,6 +30,17 @@ func (f *fakeDataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (doma
return f.report, f.err
}
+func (f *fakeDataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
+ f.listCalls++
+ return f.orphans, f.err
+}
+
+func (f *fakeDataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
+ f.deleteCalls++
+ f.deletedIDs = append([]string(nil), ids...)
+ return f.deleteResult, f.err
+}
+
func TestAdminHandler_GetDataIntegrityRequiresSuperAdmin(t *testing.T) {
checker := &fakeDataIntegrityChecker{}
h := &AdminHandler{IntegrityChecker: checker}
@@ -90,3 +107,90 @@ func TestAdminHandler_GetDataIntegrityReturnsReportForSuperAdmin(t *testing.T) {
require.Len(t, body.Sections, 1)
require.Equal(t, "tenant_integrity", body.Sections[0].Key)
}
+
+func TestAdminHandler_ListOrphanUserLoginIDsReturnsTargetsForSuperAdmin(t *testing.T) {
+ checker := &fakeDataIntegrityChecker{
+ orphans: []domain.OrphanUserLoginID{
+ {
+ ID: "login-id-1",
+ UserID: "user-1",
+ TenantID: "tenant-1",
+ FieldKey: "emp_id",
+ LoginID: "EMP001",
+ Reasons: []string{"missing_tenant"},
+ },
+ },
+ }
+ 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/orphan-user-login-ids", h.ListOrphanUserLoginIDs)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/integrity/orphan-user-login-ids", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, 1, checker.listCalls)
+
+ var body struct {
+ Items []domain.OrphanUserLoginID `json:"items"`
+ Total int `json:"total"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ require.Equal(t, 1, body.Total)
+ require.Equal(t, "login-id-1", body.Items[0].ID)
+ require.Equal(t, []string{"missing_tenant"}, body.Items[0].Reasons)
+}
+
+func TestAdminHandler_DeleteOrphanUserLoginIDsRequiresSuperAdminAndDeletesSelectedTargets(t *testing.T) {
+ checker := &fakeDataIntegrityChecker{
+ deleteResult: domain.DeleteOrphanUserLoginIDsResult{
+ DeletedCount: 1,
+ Deleted: []domain.OrphanUserLoginID{
+ {ID: "login-id-1", LoginID: "EMP001", Reasons: []string{"missing_user"}},
+ },
+ SkippedIDs: []string{"valid-login-id"},
+ },
+ }
+ 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.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1","valid-login-id"]}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, 1, checker.deleteCalls)
+ require.Equal(t, []string{"login-id-1", "valid-login-id"}, checker.deletedIDs)
+
+ var body domain.DeleteOrphanUserLoginIDsResult
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ require.Equal(t, int64(1), body.DeletedCount)
+ require.Equal(t, []string{"valid-login-id"}, body.SkippedIDs)
+}
+
+func TestAdminHandler_DeleteOrphanUserLoginIDsRejectsTenantAdmin(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.Delete("/api/v1/admin/integrity/orphan-user-login-ids", h.DeleteOrphanUserLoginIDs)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/integrity/orphan-user-login-ids", strings.NewReader(`{"ids":["login-id-1"]}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusForbidden, resp.StatusCode)
+ require.Equal(t, 0, checker.deleteCalls)
+}
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index 4a6abcbf..5d49d3be 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -210,6 +210,14 @@ func roleFromTraits(traits map[string]interface{}) string {
return domain.RoleUser
}
+func normalizeAssignableSystemRole(value string) (string, bool) {
+ role, ok := domain.NormalizeRoleAlias(value)
+ if !ok {
+ return "", false
+ }
+ return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
+}
+
func gradeFromTraits(traits map[string]interface{}) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
@@ -661,9 +669,19 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
- role := domain.NormalizeRole(req.Role)
- if role == "" {
- role = domain.RoleUser
+ role := domain.RoleUser
+ if strings.TrimSpace(req.Role) != "" {
+ normalizedRole, ok := normalizeAssignableSystemRole(req.Role)
+ if !ok {
+ return errorJSON(c, fiber.StatusBadRequest, "invalid role")
+ }
+ if normalizedRole == domain.RoleSuperAdmin {
+ requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
+ if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
+ return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can assign super admin role")
+ }
+ }
+ role = normalizedRole
}
attributes := map[string]interface{}{
@@ -1532,7 +1550,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
- role, ok := domain.NormalizeRoleAlias(*req.Role)
+ role, ok := normalizeAssignableSystemRole(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
@@ -1841,7 +1859,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
- role, ok := domain.NormalizeRoleAlias(*req.Role)
+ role, ok := normalizeAssignableSystemRole(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index 85a61eb5..4d93fd1a 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -1020,6 +1020,21 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
})
+ t.Run("Fail - Super admin cannot assign tenant or RP admin roles", func(t *testing.T) {
+ for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
+ payload := map[string]interface{}{
+ "userIds": []string{"u-1"},
+ "role": role,
+ }
+ body, _ := json.Marshal(payload)
+ req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, _ := app.Test(req)
+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ }
+ })
+
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
app := fiber.New()
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
@@ -1141,6 +1156,33 @@ func TestUserHandler_UpdateUser_AdminOnlyField(t *testing.T) {
})
}
+func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
+ app := fiber.New()
+ mockKratos := new(MockKratosAdmin)
+ h := &UserHandler{KratosAdmin: mockKratos}
+ app.Put("/users/:id", func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin})
+ return h.UpdateUser(c)
+ })
+
+ for _, role := range []string{domain.RoleTenantAdmin, domain.RoleRPAdmin} {
+ mockKratos.On("GetIdentity", mock.Anything, "u-1").Return(&service.KratosIdentity{
+ ID: "u-1",
+ Traits: map[string]interface{}{"email": "user@test.com", "role": domain.RoleUser},
+ State: "active",
+ }, nil).Once()
+
+ payload := map[string]interface{}{"role": role}
+ body, _ := json.Marshal(payload)
+ req := httptest.NewRequest("PUT", "/users/u-1", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, _ := app.Test(req)
+ assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ }
+ mockKratos.AssertExpectations(t)
+}
+
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()
diff --git a/backend/internal/repository/data_integrity_repository.go b/backend/internal/repository/data_integrity_repository.go
index 7b21b221..5acbf61f 100644
--- a/backend/internal/repository/data_integrity_repository.go
+++ b/backend/internal/repository/data_integrity_repository.go
@@ -3,6 +3,8 @@ package repository
import (
"baron-sso-backend/internal/domain"
"context"
+ "slices"
+ "strings"
"time"
"gorm.io/gorm"
@@ -10,6 +12,8 @@ import (
type DataIntegrityChecker interface {
CheckDataIntegrity(ctx context.Context) (domain.DataIntegrityReport, error)
+ ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error)
+ DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error)
}
type dataIntegrityChecker struct {
@@ -24,6 +28,14 @@ func (c *dataIntegrityChecker) CheckDataIntegrity(ctx context.Context) (domain.D
return CheckDataIntegrity(ctx, c.db)
}
+func (c *dataIntegrityChecker) ListOrphanUserLoginIDs(ctx context.Context) ([]domain.OrphanUserLoginID, error) {
+ return ListOrphanUserLoginIDs(ctx, c.db, nil)
+}
+
+func (c *dataIntegrityChecker) DeleteOrphanUserLoginIDs(ctx context.Context, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
+ return DeleteOrphanUserLoginIDs(ctx, c.db, ids)
+}
+
func CheckDataIntegrity(ctx context.Context, db *gorm.DB) (domain.DataIntegrityReport, error) {
tenantChecks := []domain.DataIntegrityCheck{
{
@@ -224,3 +236,145 @@ func summarizeSectionStatus(sections []domain.DataIntegritySection) domain.DataI
}
return status
}
+
+func ListOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) ([]domain.OrphanUserLoginID, error) {
+ type orphanRow struct {
+ ID string
+ UserID string
+ UserEmail string
+ UserDeletedAt *time.Time
+ TenantID string
+ TenantSlug string
+ TenantDeletedAt *time.Time
+ FieldKey string
+ LoginID string
+ MissingUser bool
+ DeletedUser bool
+ MissingTenant bool
+ DeletedTenant bool
+ }
+
+ query := `
+SELECT
+ uli.id,
+ uli.user_id,
+ COALESCE(u.email, '') AS user_email,
+ u.deleted_at AS user_deleted_at,
+ uli.tenant_id,
+ COALESCE(t.slug, '') AS tenant_slug,
+ t.deleted_at AS tenant_deleted_at,
+ uli.field_key,
+ uli.login_id,
+ (u.id IS NULL) AS missing_user,
+ (u.id IS NOT NULL AND u.deleted_at IS NOT NULL) AS deleted_user,
+ (t.id IS NULL) AS missing_tenant,
+ (t.id IS NOT NULL AND t.deleted_at IS NOT NULL) AS deleted_tenant
+FROM user_login_ids AS uli
+LEFT JOIN users AS u ON u.id = uli.user_id
+LEFT JOIN tenants AS t ON t.id = uli.tenant_id
+WHERE (
+ u.id IS NULL
+ OR u.deleted_at IS NOT NULL
+ OR t.id IS NULL
+ OR t.deleted_at IS NOT NULL
+)
+`
+ args := []any{}
+ if len(ids) > 0 {
+ query += " AND uli.id IN ?\n"
+ args = append(args, ids)
+ }
+ query += "ORDER BY uli.login_id, uli.id"
+
+ var rows []orphanRow
+ if err := db.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
+ return nil, err
+ }
+
+ items := make([]domain.OrphanUserLoginID, 0, len(rows))
+ for _, row := range rows {
+ reasons := make([]string, 0, 4)
+ if row.MissingUser {
+ reasons = append(reasons, "missing_user")
+ }
+ if row.DeletedUser {
+ reasons = append(reasons, "deleted_user")
+ }
+ if row.MissingTenant {
+ reasons = append(reasons, "missing_tenant")
+ }
+ if row.DeletedTenant {
+ reasons = append(reasons, "deleted_tenant")
+ }
+ items = append(items, domain.OrphanUserLoginID{
+ ID: row.ID,
+ UserID: row.UserID,
+ UserEmail: row.UserEmail,
+ UserDeletedAt: row.UserDeletedAt,
+ TenantID: row.TenantID,
+ TenantSlug: row.TenantSlug,
+ TenantDeletedAt: row.TenantDeletedAt,
+ FieldKey: row.FieldKey,
+ LoginID: row.LoginID,
+ Reasons: reasons,
+ })
+ }
+ return items, nil
+}
+
+func DeleteOrphanUserLoginIDs(ctx context.Context, db *gorm.DB, ids []string) (domain.DeleteOrphanUserLoginIDsResult, error) {
+ ids = normalizeIDList(ids)
+ result := domain.DeleteOrphanUserLoginIDsResult{
+ Deleted: []domain.OrphanUserLoginID{},
+ SkippedIDs: []string{},
+ }
+ if len(ids) == 0 {
+ return result, nil
+ }
+
+ err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ items, err := ListOrphanUserLoginIDs(ctx, tx, ids)
+ if err != nil {
+ return err
+ }
+
+ deletableIDs := make([]string, 0, len(items))
+ deletableSet := make(map[string]bool, len(items))
+ for _, item := range items {
+ deletableIDs = append(deletableIDs, item.ID)
+ deletableSet[item.ID] = true
+ }
+ for _, id := range ids {
+ if !deletableSet[id] {
+ result.SkippedIDs = append(result.SkippedIDs, id)
+ }
+ }
+ if len(deletableIDs) == 0 {
+ return nil
+ }
+
+ deleteResult := tx.Exec("DELETE FROM user_login_ids WHERE id IN ?", deletableIDs)
+ if deleteResult.Error != nil {
+ return deleteResult.Error
+ }
+ result.Deleted = items
+ result.DeletedCount = deleteResult.RowsAffected
+ return nil
+ })
+ return result, err
+}
+
+func normalizeIDList(ids []string) []string {
+ normalized := make([]string, 0, len(ids))
+ seen := map[string]bool{}
+ for _, id := range ids {
+ id = strings.TrimSpace(id)
+ if id == "" || seen[id] {
+ continue
+ }
+ seen[id] = true
+ normalized = append(normalized, id)
+ }
+ slices.Sort(normalized)
+ return normalized
+}
diff --git a/backend/internal/repository/data_integrity_repository_test.go b/backend/internal/repository/data_integrity_repository_test.go
index 0a6cb97c..f530e7ef 100644
--- a/backend/internal/repository/data_integrity_repository_test.go
+++ b/backend/internal/repository/data_integrity_repository_test.go
@@ -88,6 +88,110 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1)
}
+func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) {
+ ctx := context.Background()
+ suffix := uuid.NewString()
+
+ validTenant := domain.Tenant{
+ ID: uuid.NewString(),
+ Name: "Valid Tenant " + suffix,
+ Slug: "valid-tenant-" + suffix,
+ Type: domain.TenantTypeCompany,
+ Status: domain.TenantStatusActive,
+ }
+ deletedTenant := domain.Tenant{
+ ID: uuid.NewString(),
+ Name: "Deleted Tenant " + suffix,
+ Slug: "deleted-tenant-" + suffix,
+ Type: domain.TenantTypeCompany,
+ Status: domain.TenantStatusActive,
+ }
+ require.NoError(t, testDB.Create(&validTenant).Error)
+ require.NoError(t, testDB.Create(&deletedTenant).Error)
+
+ validUser := domain.User{
+ ID: uuid.NewString(),
+ Email: "valid-login-" + suffix + "@example.com",
+ Name: "Valid Login User",
+ Role: domain.RoleUser,
+ TenantID: &validTenant.ID,
+ Status: domain.UserStatusActive,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ deletedUser := domain.User{
+ ID: uuid.NewString(),
+ Email: "deleted-login-" + suffix + "@example.com",
+ Name: "Deleted Login User",
+ Role: domain.RoleUser,
+ TenantID: &validTenant.ID,
+ Status: domain.UserStatusActive,
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
+ }
+ require.NoError(t, testDB.Create(&validUser).Error)
+ require.NoError(t, testDB.Create(&deletedUser).Error)
+
+ validLogin := domain.UserLoginID{
+ ID: uuid.NewString(),
+ UserID: validUser.ID,
+ TenantID: validTenant.ID,
+ FieldKey: "emp_id",
+ LoginID: "VALID-" + suffix,
+ }
+ deletedTenantLogin := domain.UserLoginID{
+ ID: uuid.NewString(),
+ UserID: validUser.ID,
+ TenantID: deletedTenant.ID,
+ FieldKey: "emp_id",
+ LoginID: "DELETED-TENANT-" + suffix,
+ }
+ deletedUserLogin := domain.UserLoginID{
+ ID: uuid.NewString(),
+ UserID: deletedUser.ID,
+ TenantID: validTenant.ID,
+ FieldKey: "emp_id",
+ LoginID: "DELETED-USER-" + suffix,
+ }
+ require.NoError(t, testDB.Create(&validLogin).Error)
+ require.NoError(t, testDB.Create(&deletedTenantLogin).Error)
+ require.NoError(t, testDB.Create(&deletedUserLogin).Error)
+ require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
+ require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedUser.ID).Error)
+
+ items, err := ListOrphanUserLoginIDs(ctx, testDB, nil)
+ require.NoError(t, err)
+ orphanReasons := map[string][]string{}
+ for _, item := range items {
+ orphanReasons[item.ID] = item.Reasons
+ }
+ require.Equal(t, []string{"deleted_tenant"}, orphanReasons[deletedTenantLogin.ID])
+ require.Equal(t, []string{"deleted_user"}, orphanReasons[deletedUserLogin.ID])
+ require.NotContains(t, orphanReasons, validLogin.ID)
+
+ result, err := DeleteOrphanUserLoginIDs(ctx, testDB, []string{
+ deletedTenantLogin.ID,
+ validLogin.ID,
+ "00000000-0000-0000-0000-000000000000",
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(1), result.DeletedCount)
+ require.Len(t, result.Deleted, 1)
+ require.Equal(t, deletedTenantLogin.ID, result.Deleted[0].ID)
+ require.ElementsMatch(t, []string{
+ validLogin.ID,
+ "00000000-0000-0000-0000-000000000000",
+ }, result.SkippedIDs)
+
+ var deletedTenantLoginCount int64
+ require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", deletedTenantLogin.ID).Count(&deletedTenantLoginCount).Error)
+ require.Equal(t, int64(0), deletedTenantLoginCount)
+
+ var validLoginCount int64
+ require.NoError(t, testDB.Model(&domain.UserLoginID{}).Where("id = ?", validLogin.ID).Count(&validLoginCount).Error)
+ require.Equal(t, int64(1), validLoginCount)
+}
+
func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) {
t.Helper()
for _, section := range report.Sections {
diff --git a/docs/data-integrity-management.md b/docs/data-integrity-management.md
index c2274311..eade096a 100644
--- a/docs/data-integrity-management.md
+++ b/docs/data-integrity-management.md
@@ -14,6 +14,22 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
응답은 전체 상태, 검사 시각, 요약, 섹션별 검사 결과를 포함합니다.
+### 유령 로그인 ID 대상 조회
+
+- Method: `GET`
+- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
+- 권한: `super_admin`
+
+`user_login_ids`가 존재하지 않거나 soft-deleted 된 `users`, `tenants`를 참조하는 행을 반환합니다. 각 행은 `loginId`, `fieldKey`, 사용자/테넌트 식별 정보, `missing_user`, `deleted_user`, `missing_tenant`, `deleted_tenant` 중 하나 이상의 사유를 포함합니다.
+
+### 유령 로그인 ID 삭제
+
+- Method: `DELETE`
+- Path: `/api/v1/admin/integrity/orphan-user-login-ids`
+- 권한: `super_admin`
+
+요청 본문은 `{ "ids": ["..."] }` 형식입니다. 서버는 삭제 직전에 같은 트랜잭션 안에서 대상 행이 여전히 유령 로그인 ID인지 재검증하고, 재검증을 통과한 `user_login_ids` 행만 삭제합니다. 정상화되었거나 존재하지 않는 ID는 `skippedIds`로 반환합니다.
+
## 검사 항목
### 테넌트 정합성
@@ -30,6 +46,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
## adminfront 동작
- `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다.
+- `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다.
- `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다.
- `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다.
- 개요 화면의 전체 테넌트 수는 `fetchAllTenants()`로 실제 cursor pagination을 끝까지 수집한 리스트 수를 우선 사용합니다. 이로써 super가 보는 전체 테넌트 수와 리스트 기반 수치가 같은 소스에서 나오도록 맞춥니다.
@@ -37,7 +54,14 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이
## 운영 주의
-현재 기능은 read-only 검증입니다. 자동 정리 작업은 별도 이슈와 승인된 maintenance action으로 분리해야 합니다.
+정합성 검증 리포트는 read-only입니다. 단, 유령 로그인 ID 삭제는 `super_admin`이 대상 행을 확인하고 선택한 경우에만 수행되는 명시적 maintenance action입니다.
+
+삭제 작업의 기본 운영 순서는 다음과 같습니다.
+
+1. `데이터 정합성` 메뉴에서 실패 항목과 유령 로그인 ID 대상 행을 확인합니다.
+2. 사용자/테넌트가 실제로 복구 대상인지 먼저 판단합니다.
+3. 복구 대상이 아니라 read model 잔여 데이터가 맞는 행만 선택합니다.
+4. `선택 삭제` 실행 후 정합성 리포트가 다시 조회되어 실패 건수가 줄었는지 확인합니다.
이미 존재하는 orphan 사용자 소속 정리 경로는 다음과 같습니다.