From 85707500ef1d80d5456a644e7cc1a20b160f86e9 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 10 Jun 2026 10:01:30 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20=EB=B0=8F=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C:=20ReBAC=20=EA=B8=B0=EB=B0=98=20=EA=B0=81=20=ED=83=AD?= =?UTF-8?q?=EB=B3=84=20=EC=9D=BD=EA=B8=B0/=EC=93=B0=EA=B8=B0=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=A0=9C=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coverage/adminTenantTabs.test.tsx | 7 + .../tenants/components/DomainTagInput.tsx | 21 ++- .../components/ParentTenantSelector.tsx | 6 +- .../components/TenantPermissionGuard.tsx | 26 +++ .../hooks/useTenantPermission.test.tsx | 126 ++++++++++++++ .../tenants/hooks/useTenantPermission.ts | 26 +++ .../routes/TenantAdminsAndOwnersTab.tsx | 5 + .../tenants/routes/TenantDetailPage.tsx | 11 +- .../tenants/routes/TenantGroupsPage.tsx | 18 +- .../tenants/routes/TenantProfilePage.tsx | 30 +++- adminfront/src/lib/adminApi.ts | 5 + backend/internal/handler/tenant_handler.go | 80 +++++++-- .../handler/tenant_handler_get_test.go | 164 ++++++++++++++++++ 13 files changed, 485 insertions(+), 40 deletions(-) create mode 100644 adminfront/src/features/tenants/components/TenantPermissionGuard.tsx create mode 100644 adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx create mode 100644 adminfront/src/features/tenants/hooks/useTenantPermission.ts create mode 100644 backend/internal/handler/tenant_handler_get_test.go diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index 722494d9..eeee7afd 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -93,6 +93,13 @@ vi.mock("react-oidc-context", () => ({ })); vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => users[0]), + fetchTenant: vi.fn(async (tenantId) => ({ + id: tenantId, + name: "Test Tenant", + slug: "test-tenant", + userPermissions: { view: true, manage: true, manage_admins: true }, + })), fetchTenantOwners: vi.fn(async () => [users[0]]), fetchTenantAdmins: vi.fn(async () => [users[1]]), addTenantOwner: vi.fn(async () => undefined), diff --git a/adminfront/src/features/tenants/components/DomainTagInput.tsx b/adminfront/src/features/tenants/components/DomainTagInput.tsx index ecfc5513..f2dc39eb 100644 --- a/adminfront/src/features/tenants/components/DomainTagInput.tsx +++ b/adminfront/src/features/tenants/components/DomainTagInput.tsx @@ -29,6 +29,7 @@ type DomainTagInputProps = { confirmedConflicts?: string[]; onConfirmedConflictsChange?: (domains: string[]) => void; placeholder?: string; + disabled?: boolean; }; export function DomainTagInput({ @@ -40,6 +41,7 @@ export function DomainTagInput({ confirmedConflicts = [], onConfirmedConflictsChange, placeholder, + disabled = false, }: DomainTagInputProps) { const [input, setInput] = useState(""); const [pendingConflict, setPendingConflict] = useState( @@ -107,14 +109,16 @@ export function DomainTagInput({ className="gap-1 rounded-md" > {domain} - + {!disabled && ( + + )} ))} diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 3b8830b7..17fbcd33 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -35,6 +35,7 @@ type ParentTenantSelectorProps = { localTenantFilter?: (tenant: TenantSummary) => boolean; compact?: boolean; controlTestId?: string; + disabled?: boolean; }; export function ParentTenantSelector({ @@ -53,6 +54,7 @@ export function ParentTenantSelector({ localTenantFilter, compact = false, controlTestId, + disabled = false, }: ParentTenantSelectorProps) { const [pickerOpen, setPickerOpen] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false); @@ -112,6 +114,7 @@ export function ParentTenantSelector({ variant="outline" size="sm" className={compact ? "h-8 shrink-0 px-2" : undefined} + disabled={disabled} > {orgChartPickerLabel ?? @@ -141,7 +144,7 @@ export function ParentTenantSelector({ {localPickerLabel && ( - @@ -228,6 +231,7 @@ export function ParentTenantSelector({ className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"} onClick={() => onChange("")} aria-label={noneLabel} + disabled={disabled} > diff --git a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx new file mode 100644 index 00000000..7ddee376 --- /dev/null +++ b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx @@ -0,0 +1,26 @@ +import type React from "react"; +import { useTenantPermission } from "../hooks/useTenantPermission"; + +interface TenantPermissionGuardProps { + tenantId: string; + relation: "view" | "manage" | "manage_admins"; + fallback?: React.ReactNode; + children: React.ReactNode; +} + +export function TenantPermissionGuard({ + tenantId, + relation, + fallback = null, + children, +}: TenantPermissionGuardProps) { + const { hasPermission, isLoading } = useTenantPermission(tenantId); + + if (isLoading) return null; + + if (!hasPermission(relation)) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx new file mode 100644 index 00000000..d5233b6a --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.test.tsx @@ -0,0 +1,126 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { renderHook } from "@testing-library/react"; +import type React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fetchTenant, fetchMe } from "../../../lib/adminApi"; +import { useTenantPermission } from "./useTenantPermission"; +import { TenantPermissionGuard } from "../components/TenantPermissionGuard"; + +vi.mock("../../../lib/adminApi", () => ({ + fetchMe: vi.fn(), + fetchTenant: vi.fn(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("useTenantPermission", () => { + it("returns true for all permissions if user is super_admin", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-super", + role: "super_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-1", + name: "Super Tenant", + userPermissions: { view: false, manage: false, manage_admins: false }, + } as any); + + const { result } = renderHook(() => useTenantPermission("tenant-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(true); + }); + + it("returns permissions mapped from userPermissions for normal admins/users", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-2", + name: "Tenant Admin Corp", + userPermissions: { view: true, manage: true, manage_admins: false }, + } as any); + + const { result } = renderHook(() => useTenantPermission("tenant-2"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.hasPermission("view")).toBe(true); + expect(result.current.hasPermission("manage")).toBe(true); + expect(result.current.hasPermission("manage_admins")).toBe(false); + }); +}); + +describe("TenantPermissionGuard", () => { + it("renders children when user has permission", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-3", + userPermissions: { view: true, manage: true, manage_admins: false }, + } as any); + + render( + Access Denied}> +
Access Granted
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(screen.getByText("Access Granted")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Denied")).not.toBeInTheDocument(); + }); + + it("renders fallback when user lacks permission", async () => { + vi.mocked(fetchMe).mockResolvedValue({ + id: "user-admin", + role: "tenant_admin", + } as any); + + vi.mocked(fetchTenant).mockResolvedValue({ + id: "tenant-4", + userPermissions: { view: true, manage: false, manage_admins: false }, + } as any); + + render( + Access Denied}> +
Access Granted
+
, + { wrapper: createWrapper() } + ); + + await waitFor(() => { + expect(screen.getByText("Access Denied")).toBeInTheDocument(); + }); + expect(screen.queryByText("Access Granted")).not.toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts new file mode 100644 index 00000000..532627fc --- /dev/null +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchTenant, fetchMe } from "../../../lib/adminApi"; +import { normalizeAdminRole } from "../../../lib/roles"; + +export function useTenantPermission(tenantId: string) { + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + }); + + const { data: tenant } = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId), + enabled: !!tenantId, + }); + + const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => { + // Super Admin always has full bypass access + if (normalizeAdminRole(profile?.role) === "super_admin") { + return true; + } + return !!tenant?.userPermissions?.[requiredRelation]; + }; + + return { hasPermission, isLoading: !tenant }; +} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index fc78435d..67d858b6 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -11,6 +11,7 @@ import { import { useState } from "react"; import { useAuth } from "react-oidc-context"; import { useNavigate, useParams } from "react-router-dom"; +import { useTenantPermission } from "../hooks/useTenantPermission"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -69,6 +70,8 @@ export function TenantAdminsAndOwnersTab() { const _currentUserId = auth.user?.profile.sub; const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; + const { hasPermission } = useTenantPermission(tenantId); + const isWritable = hasPermission("manage_admins"); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); @@ -382,6 +385,7 @@ export function TenantAdminsAndOwnersTab() { @@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC = ({ e.stopPropagation(); onDelete(node.id); }} + disabled={!isWritable} > @@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC = ({ onAddSubGroup={onAddSubGroup} addMemberMutation={addMemberMutation} removeMemberMutation={removeMemberMutation} + isWritable={isWritable} /> ))} @@ -240,6 +246,9 @@ function TenantGroupsPage() { const tenantId = params.tenantId ?? ""; const _queryClient = useQueryClient(); + const { hasPermission } = useTenantPermission(tenantId); + const isWritable = hasPermission("manage"); + const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); @@ -423,6 +432,7 @@ function TenantGroupsPage() { id="name" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} + disabled={!isWritable} placeholder={t( "ui.admin.groups.form.name_placeholder", "예: 개발팀, 인사팀", @@ -437,6 +447,7 @@ function TenantGroupsPage() { id="unitType" value={newGroupUnitType} onChange={(e) => setNewGroupUnitType(e.target.value)} + disabled={!isWritable} placeholder={t( "ui.admin.groups.form.unit_level_placeholder", "예: 본부, 팀, 셀", @@ -449,9 +460,10 @@ function TenantGroupsPage() { setName(e.target.value)} /> + setName(e.target.value)} disabled={!isWritable} />
- setSlug(e.target.value)} /> + setSlug(e.target.value)} disabled={!isWritable} />
@@ -300,6 +305,7 @@ export function TenantProfilePage() { className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" value={type} onChange={(e) => setType(e.target.value)} + disabled={!isWritable} > {orgUnitTypeOptions.map((option) => ( @@ -365,13 +372,14 @@ export function TenantProfilePage() { setWorksmobileExcluded(event.target.value === "excluded") } + disabled={!isWritable} >