From df543d62034a43fde97b5d2d870c27d9ce91131e Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 14 May 2026 09:04:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EC=9C=84?= =?UTF-8?q?=EB=B0=98=EC=82=AC=ED=95=AD=20=ED=99=95=EC=9D=B8=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A1=B0=EC=B9=98=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrity/DataIntegrityPage.test.tsx | 58 +++++- .../features/integrity/DataIntegrityPage.tsx | 171 +++++++++++++++++- .../src/features/users/UserCreatePage.tsx | 6 - .../src/features/users/UserDetailPage.tsx | 35 +--- .../src/features/users/UserListPage.tsx | 39 ++-- adminfront/src/lib/adminApi.ts | 39 ++++ adminfront/tests/bulk_actions.spec.ts | 129 +++++++++++++ adminfront/tests/data_integrity.spec.ts | 67 +++++++ backend/cmd/server/main.go | 2 + backend/internal/domain/data_integrity.go | 19 ++ backend/internal/handler/admin_handler.go | 37 ++++ .../internal/handler/admin_integrity_test.go | 110 ++++++++++- backend/internal/handler/user_handler.go | 28 ++- backend/internal/handler/user_handler_test.go | 42 +++++ .../repository/data_integrity_repository.go | 154 ++++++++++++++++ .../data_integrity_repository_test.go | 104 +++++++++++ docs/data-integrity-management.md | 26 ++- 17 files changed, 988 insertions(+), 78 deletions(-) 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 ( +
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
선택Login IDFieldUserTenant사유
+ 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 사용자 소속 정리 경로는 다음과 같습니다.