From a70755e993d5b7edcdec1a281ca62d6ee3286643 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 12 Jun 2026 11:40:56 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20=EB=B0=8F=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C:=20=EC=A0=84=20=EB=A9=94=EB=89=B4=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=AD=20=EC=88=98=EC=A4=80=20ReBAC=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4(Admin=20Control)=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/layout/AppLayout.test.tsx | 1 + .../coverage/adminTenantGroupsPage.test.tsx | 1 + .../components/TenantPermissionGuard.tsx | 4 +- .../tenants/hooks/useTenantPermission.ts | 15 +- .../routes/TenantAdminsAndOwnersTab.tsx | 13 +- .../TenantFineGrainedPermissionsPage.tsx | 131 ++++++++++++++---- .../tenants/routes/TenantGroupsPage.tsx | 13 +- .../tenants/routes/TenantProfilePage.tsx | 13 +- .../tenants/routes/TenantSchemaPage.tsx | 45 +++--- .../src/features/users/UserListPage.tsx | 26 +++- adminfront/src/lib/adminApi.ts | 20 +++ backend/internal/domain/auth_models.go | 12 ++ backend/internal/handler/auth_handler.go | 88 +++++++++--- .../handler/tenant_handler_get_test.go | 1 + docker/ory/keto/namespaces.ts | 61 +++++++- 15 files changed, 360 insertions(+), 84 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.test.tsx b/adminfront/src/components/layout/AppLayout.test.tsx index 47c5d890..47f44e2f 100644 --- a/adminfront/src/components/layout/AppLayout.test.tsx +++ b/adminfront/src/components/layout/AppLayout.test.tsx @@ -116,6 +116,7 @@ describe("admin AppLayout", () => { "Ory SSOT System", "Data Integrity", "Users", + "권한 부여", "Auth Guard", "API Keys", "Audit Logs", diff --git a/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx index 4fb0dd45..51369b73 100644 --- a/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx +++ b/adminfront/src/features/coverage/adminTenantGroupsPage.test.tsx @@ -29,6 +29,7 @@ const members = [ vi.mock("../../lib/i18n", () => createI18nMock()); vi.mock("../../lib/adminApi", () => ({ + fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })), fetchTenant: vi.fn(async () => tenant), fetchUsers: vi.fn(async () => ({ items: [ diff --git a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx index 7ddee376..33c32697 100644 --- a/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx +++ b/adminfront/src/features/tenants/components/TenantPermissionGuard.tsx @@ -1,9 +1,9 @@ import type React from "react"; -import { useTenantPermission } from "../hooks/useTenantPermission"; +import { useTenantPermission, type TenantPermissionKey } from "../hooks/useTenantPermission"; interface TenantPermissionGuardProps { tenantId: string; - relation: "view" | "manage" | "manage_admins"; + relation: TenantPermissionKey; fallback?: React.ReactNode; children: React.ReactNode; } diff --git a/adminfront/src/features/tenants/hooks/useTenantPermission.ts b/adminfront/src/features/tenants/hooks/useTenantPermission.ts index 532627fc..95d2673d 100644 --- a/adminfront/src/features/tenants/hooks/useTenantPermission.ts +++ b/adminfront/src/features/tenants/hooks/useTenantPermission.ts @@ -2,6 +2,19 @@ import { useQuery } from "@tanstack/react-query"; import { fetchTenant, fetchMe } from "../../../lib/adminApi"; import { normalizeAdminRole } from "../../../lib/roles"; +export type TenantPermissionKey = + | "view" + | "manage" + | "manage_admins" + | "view_profile" + | "manage_profile" + | "view_permissions" + | "manage_permissions" + | "view_organization" + | "manage_organization" + | "view_schema" + | "manage_schema"; + export function useTenantPermission(tenantId: string) { const { data: profile } = useQuery({ queryKey: ["me"], @@ -14,7 +27,7 @@ export function useTenantPermission(tenantId: string) { enabled: !!tenantId, }); - const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => { + const hasPermission = (requiredRelation: TenantPermissionKey): boolean => { // Super Admin always has full bypass access if (normalizeAdminRole(profile?.role) === "super_admin") { return true; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index f0495d45..c7b9817b 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -71,7 +71,8 @@ export function TenantAdminsAndOwnersTab() { const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const tenantId = tenantIdParam ?? ""; const { hasPermission } = useTenantPermission(tenantId); - const isWritable = hasPermission("manage_admins"); + const isWritable = hasPermission("manage_permissions") || hasPermission("manage_admins"); + const canView = hasPermission("view_permissions") || hasPermission("view"); const queryClient = useQueryClient(); const [searchTerm, setSearchTerm] = useState(""); const [dialogMode, setDialogMode] = useState(null); @@ -341,6 +342,16 @@ export function TenantAdminsAndOwnersTab() { if (!tenantId) return null; + if (!canView) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+
+ ); + } + const serverOwners = ownersQuery.data || []; const serverAdmins = adminsQuery.data || []; const currentOwners = mergePendingMembers(serverOwners, pendingOwners); diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 1a1c48b3..80e26a11 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { fetchAllTenants, fetchMe, @@ -62,6 +62,9 @@ export function TenantFineGrainedPermissionsPage() { const [activeUserId, setActiveUserId] = useState(null); const [userSearchTerm, setUserSearchTerm] = useState(""); + // 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언 + const [localSystemPermissions, setLocalSystemPermissions] = useState>>({}); + const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, @@ -87,6 +90,27 @@ export function TenantFineGrainedPermissionsPage() { }); const systemRelations = systemRelationsQuery.data ?? []; + // 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화 + useEffect(() => { + if (systemRelationsQuery.data) { + const initialMap: Record> = {}; + for (const user of systemRelationsQuery.data) { + initialMap[user.userId] = {}; + const menus = [ + "overview", "audit_logs", "tenants", "org_chart", "users", + "worksmobile", "api_keys", "ory_ssot", "data_integrity", + "auth_guard", "permissions_direct" + ]; + for (const m of menus) { + const isWrite = user.relations.includes(`${m}_managers`); + const isRead = user.relations.includes(`${m}_viewers`); + initialMap[user.userId][m] = isWrite ? "write" : isRead ? "read" : "none"; + } + } + setLocalSystemPermissions(initialMap); + } + }, [systemRelationsQuery.data]); + const addSystemRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => addSystemRelation(payload.userId, payload.relation), @@ -118,10 +142,15 @@ export function TenantFineGrainedPermissionsPage() { toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다.")); }, onSuccess: () => { - toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다.")); + // Quiet mutate }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + }, 500); }, }); @@ -154,22 +183,43 @@ export function TenantFineGrainedPermissionsPage() { toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다.")); }, onSuccess: () => { - toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다.")); + // Quiet mutate }, onSettled: () => { queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + }, 500); }, }); const handleSystemRelationChange = async ( userId: string, - relation: string, - hasAccess: boolean, + menuKey: string, + currentVal: "none" | "read" | "write", + newVal: "none" | "read" | "write", ) => { - if (hasAccess) { - await addSystemRelationMutation.mutateAsync({ userId, relation }); - } else { - await removeSystemRelationMutation.mutateAsync({ userId, relation }); + if (currentVal === newVal) return; + + try { + if (currentVal === "read") { + await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` }); + } else if (currentVal === "write") { + await removeSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` }); + } + + if (newVal === "read") { + await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_viewers` }); + } else if (newVal === "write") { + await addSystemRelationMutation.mutateAsync({ userId, relation: `${menuKey}_managers` }); + } + + // 🌟 Trigger a single consolidated success toast at the very end + toast.success(t("msg.admin.system.relations.update_success", "시스템 메뉴 권한이 성공적으로 변경되었습니다.")); + } catch { + // Individual mutations handle error toast via onError } }; @@ -205,32 +255,32 @@ export function TenantFineGrainedPermissionsPage() { { title: t("ui.admin.permissions_direct.cat_dashboard", "핵심 대시보드 및 분석"), menus: [ - { label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard }, - { label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs }, + { label: t("ui.admin.nav.overview", "개요"), relation: "overview", desc: t("msg.admin.permissions_direct.desc_overview", "바론 전체 사양 및 시스템 상태 개요 정보"), icon: LayoutDashboard }, + { label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs", desc: t("msg.admin.permissions_direct.desc_audit_logs", "시스템 전역 보안 감사 및 접속 이력 로그"), icon: NotebookTabs }, ] }, { title: t("ui.admin.permissions_direct.cat_resources", "핵심 리소스 관리"), menus: [ - { label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers", desc: t("msg.admin.permissions_direct.desc_tenants", "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"), icon: Building2 }, - { label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers", desc: t("msg.admin.permissions_direct.desc_org_chart", "조직도 가시화 및 트리 배치 확인"), icon: Network }, - { label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers", desc: t("msg.admin.permissions_direct.desc_users", "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"), icon: Users }, + { label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants", desc: t("msg.admin.permissions_direct.desc_tenants", "고객 테넌트 목록, 신규 부모-자식 테넌트 관리"), icon: Building2 }, + { label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart", desc: t("msg.admin.permissions_direct.desc_org_chart", "조직도 가시화 및 트리 배치 확인"), icon: Network }, + { label: t("ui.admin.nav.users", "사용자"), relation: "users", desc: t("msg.admin.permissions_direct.desc_users", "가입 사용자 목록, 승인 및 커스텀 클레임 수동 주입"), icon: Users }, ] }, { title: t("ui.admin.permissions_direct.cat_integrations", "인프라 연동 및 보안"), menus: [ - { label: t("ui.admin.nav.worksmobile", "WORKS 연동"), relation: "worksmobile_viewers", desc: t("msg.admin.permissions_direct.desc_worksmobile", "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"), icon: Share2 }, - { label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers", desc: t("msg.admin.permissions_direct.desc_api_keys", "조직도 연동을 위한 전역 서드파티 토큰 관리"), icon: Key }, + { label: t("ui.admin.nav.worksmobile", "WORKS 연동"), relation: "worksmobile", desc: t("msg.admin.permissions_direct.desc_worksmobile", "라인웍스 연동 및 사내 임직원 패스워드 강제 동기화"), icon: Share2 }, + { label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys", desc: t("msg.admin.permissions_direct.desc_api_keys", "조직도 연동을 위한 전역 서드파티 토큰 관리"), icon: Key }, ] }, { title: t("ui.admin.permissions_direct.cat_system", "아이덴티티 및 게이트 관리"), menus: [ - { label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), relation: "ory_ssot_viewers", desc: t("msg.admin.permissions_direct.desc_ory_ssot", "Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신"), icon: Database }, - { label: t("ui.admin.nav.data_integrity", "데이터 정합성"), relation: "data_integrity_viewers", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck }, - { label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound }, - { label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield }, + { label: t("ui.admin.nav.ory_ssot", "Ory SSOT 시스템"), relation: "ory_ssot", desc: t("msg.admin.permissions_direct.desc_ory_ssot", "Redis 아이덴티티 미러 캐시 및 PostgreSQL read model 정합성 갱신"), icon: Database }, + { label: t("ui.admin.nav.data_integrity", "데이터 정합성"), relation: "data_integrity", desc: t("msg.admin.permissions_direct.desc_data_integrity", "고아 레코드 검출 및 DB 정합성 최종 검증기"), icon: ShieldCheck }, + { label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard", desc: t("msg.admin.permissions_direct.desc_auth_guard", "정책엔진 기준으로 Keto ReBAC 관계 검증 시뮬레이터"), icon: KeyRound }, + { label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct", desc: t("msg.admin.permissions_direct.desc_permissions_direct", "본 사이드바 메뉴 세부 권한 격자 및 테넌트 인가 설정 패널"), icon: Shield }, ] } ]; @@ -302,6 +352,7 @@ export function TenantFineGrainedPermissionsPage() { { + const nextVal = e.target.value as "none" | "read" | "write"; + // 🌟 1단계: 로컬 임시 상태 즉시 갱신 (0ms 반응 보장) + setLocalSystemPermissions(prev => ({ + ...prev, + [selectedUser.userId]: { + ...(prev[selectedUser.userId] ?? {}), + [menu.relation]: nextVal + } + })); + // 🌟 2단계: 백그라운드 비동기 API 요청 수행 + handleSystemRelationChange( + selectedUser.userId, + menu.relation, + permissionValue, + nextVal, + ); + }} + className="flex h-9 w-[180px] rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + > + + + + ); })} @@ -528,6 +604,7 @@ export function TenantFineGrainedPermissionsPage() { className="pl-10 h-11" autoFocus value={searchTerm} + name="system-user-dialog-search" onChange={(e) => setSearchTerm(e.target.value)} /> diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 4696f54e..c142577b 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -247,7 +247,8 @@ function TenantGroupsPage() { const _queryClient = useQueryClient(); const { hasPermission } = useTenantPermission(tenantId); - const isWritable = hasPermission("manage"); + const isWritable = hasPermission("manage_organization") || hasPermission("manage"); + const canView = hasPermission("view_organization") || hasPermission("view"); const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); @@ -396,6 +397,16 @@ function TenantGroupsPage() { }, }); + if (!canView) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+
+ ); + } + const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data, tenantId) : []; diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index 209f11e9..ad25ff7d 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -54,7 +54,8 @@ export function TenantProfilePage() { }); const { hasPermission } = useTenantPermission(tenantId); - const isWritable = hasPermission("manage"); + const isWritable = hasPermission("manage_profile") || hasPermission("manage"); + const canView = hasPermission("view_profile") || hasPermission("view"); const parentQuery = useQuery({ queryKey: ["tenants", "list-all"], @@ -207,6 +208,16 @@ export function TenantProfilePage() { ); } + if (!canView) { + return ( +
+

+ {t("msg.common.forbidden", "접근 권한이 없습니다.")} +

+
+ ); + } + const handleDelete = () => { if (isProtectedSeedTenant) { return; diff --git a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx index 0d9704df..9117840a 100644 --- a/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantSchemaPage.tsx @@ -14,9 +14,9 @@ import { import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { toast } from "../../../components/ui/use-toast"; -import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; +import { fetchTenant, updateTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; -import { normalizeAdminRole } from "../../../lib/roles"; +import { useTenantPermission } from "../hooks/useTenantPermission"; import { createSchemaField, isSchemaFieldType, @@ -28,13 +28,9 @@ export function TenantSchemaPage() { const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); - const { data: profile, isLoading: isProfileLoading } = useQuery({ - queryKey: ["me"], - queryFn: fetchMe, - }); - - const profileRole = normalizeAdminRole(profile?.role); - const canAccess = profileRole === "super_admin"; + const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(tenantId ?? ""); + const canView = hasPermission("view_schema") || hasPermission("view"); + const isWritable = hasPermission("manage_schema") || hasPermission("manage"); const tenantQuery = useQuery({ queryKey: ["tenant", tenantId], @@ -42,7 +38,7 @@ export function TenantSchemaPage() { if (!tenantId) throw new Error("Tenant ID is required"); return fetchTenant(tenantId); }, - enabled: !!tenantId && canAccess, + enabled: !!tenantId && canView, }); const [fields, setFields] = useState([]); @@ -85,7 +81,7 @@ export function TenantSchemaPage() { }, }); - if (isProfileLoading) { + if (isPermissionLoading) { return (
{t("msg.common.loading", "로딩 중...")} @@ -93,7 +89,7 @@ export function TenantSchemaPage() { ); } - if (!canAccess) { + if (!canView) { return (

@@ -147,7 +143,7 @@ export function TenantSchemaPage() { )}

- @@ -182,6 +178,7 @@ export function TenantSchemaPage() { "예: employee_id", )} className="h-10" + disabled={!isWritable} />
@@ -198,6 +195,7 @@ export function TenantSchemaPage() { "예: 사번", )} className="h-10" + disabled={!isWritable} />
@@ -207,8 +205,9 @@ export function TenantSchemaPage() { updateField(index, { validation: e.target.value }) } @@ -375,6 +379,7 @@ export function TenantSchemaPage() { size="icon" className="text-destructive hover:bg-destructive/10 h-10 w-10" onClick={() => removeField(index)} + disabled={!isWritable} > @@ -388,7 +393,7 @@ export function TenantSchemaPage() {
+ ) : ( + + + )} } /> @@ -1095,7 +1104,8 @@ function UserListPage() { } disabled={ statusMutation.isPending || - user.id === profile?.id + user.id === profile?.id || + !isWritable } > @@ -1291,6 +1302,7 @@ function UserListPage() { size="sm" className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5" onClick={handleBulkDelete} + disabled={!isWritable} data-testid="bulk-delete-btn" > diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 5360ea97..56b05a7b 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -37,6 +37,14 @@ export type TenantSummary = { view: boolean; manage: boolean; manage_admins: boolean; + view_profile?: boolean; + manage_profile?: boolean; + view_permissions?: boolean; + manage_permissions?: boolean; + view_organization?: boolean; + manage_organization?: boolean; + view_schema?: boolean; + manage_schema?: boolean; }; createdAt: string; updatedAt: string; @@ -1275,6 +1283,18 @@ export type SystemPermissions = { auth_guard: boolean; api_keys: boolean; audit_logs: boolean; + + manage_overview?: boolean; + manage_tenants?: boolean; + manage_org_chart?: boolean; + manage_worksmobile?: boolean; + manage_ory_ssot?: boolean; + manage_data_integrity?: boolean; + manage_users?: boolean; + manage_permissions_direct?: boolean; + manage_auth_guard?: boolean; + manage_api_keys?: boolean; + manage_audit_logs?: boolean; }; export type UserProfileResponse = { diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 21577181..015d8028 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -81,6 +81,18 @@ type SystemPermissions struct { AuthGuard bool `json:"auth_guard"` ApiKeys bool `json:"api_keys"` AuditLogs bool `json:"audit_logs"` + + ManageOverview bool `json:"manage_overview"` + ManageTenants bool `json:"manage_tenants"` + ManageOrgChart bool `json:"manage_org_chart"` + ManageWorksmobile bool `json:"manage_worksmobile"` + ManageOrySSOT bool `json:"manage_ory_ssot"` + ManageDataIntegrity bool `json:"manage_data_integrity"` + ManageUsers bool `json:"manage_users"` + ManagePermissionsDirect bool `json:"manage_permissions_direct"` + ManageAuthGuard bool `json:"manage_auth_guard"` + ManageApiKeys bool `json:"manage_api_keys"` + ManageAuditLogs bool `json:"manage_audit_logs"` } type UserProfileResponse struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 7a87d643..e2ef4a48 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4776,17 +4776,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai if profile.Role == "super_admin" { sp = domain.SystemPermissions{ - Overview: true, - Tenants: true, - OrgChart: true, - Worksmobile: true, - OrySSOT: true, - DataIntegrity: true, - Users: true, - PermissionsDirect: true, - AuthGuard: true, - ApiKeys: true, - AuditLogs: true, + Overview: true, + Tenants: true, + OrgChart: true, + Worksmobile: true, + OrySSOT: true, + DataIntegrity: true, + Users: true, + PermissionsDirect: true, + AuthGuard: true, + ApiKeys: true, + AuditLogs: true, + ManageOverview: true, + ManageTenants: true, + ManageOrgChart: true, + ManageWorksmobile: true, + ManageOrySSOT: true, + ManageDataIntegrity: true, + ManageUsers: true, + ManagePermissionsDirect: true, + ManageAuthGuard: true, + ManageApiKeys: true, + ManageAuditLogs: true, } } else { // Query Keto in parallel for maximum performance @@ -4795,17 +4806,28 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai allowed bool } menus := map[string]string{ - "overview": "access_overview", - "tenants": "access_tenants", - "org_chart": "access_org_chart", - "worksmobile": "access_worksmobile", - "ory_ssot": "access_ory_ssot", - "data_integrity": "access_data_integrity", - "users": "access_users", - "permissions_direct": "access_permissions_direct", - "auth_guard": "access_auth_guard", - "api_keys": "access_api_keys", - "audit_logs": "access_audit_logs", + "overview": "access_overview", + "manage_overview": "manage_overview", + "tenants": "access_tenants", + "manage_tenants": "manage_tenants", + "org_chart": "access_org_chart", + "manage_org_chart": "manage_org_chart", + "worksmobile": "access_worksmobile", + "manage_worksmobile": "manage_worksmobile", + "ory_ssot": "access_ory_ssot", + "manage_ory_ssot": "manage_ory_ssot", + "data_integrity": "access_data_integrity", + "manage_data_integrity": "manage_data_integrity", + "users": "access_users", + "manage_users": "manage_users", + "permissions_direct": "access_permissions_direct", + "manage_permissions_direct": "manage_permissions_direct", + "auth_guard": "access_auth_guard", + "manage_auth_guard": "manage_auth_guard", + "api_keys": "access_api_keys", + "manage_api_keys": "manage_api_keys", + "audit_logs": "access_audit_logs", + "manage_audit_logs": "manage_audit_logs", } ch := make(chan checkResult, len(menus)) for m, rel := range menus { @@ -4819,26 +4841,48 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai switch res.menu { case "overview": sp.Overview = res.allowed + case "manage_overview": + sp.ManageOverview = res.allowed case "tenants": sp.Tenants = res.allowed + case "manage_tenants": + sp.ManageTenants = res.allowed case "org_chart": sp.OrgChart = res.allowed + case "manage_org_chart": + sp.ManageOrgChart = res.allowed case "worksmobile": sp.Worksmobile = res.allowed + case "manage_worksmobile": + sp.ManageWorksmobile = res.allowed case "ory_ssot": sp.OrySSOT = res.allowed + case "manage_ory_ssot": + sp.ManageOrySSOT = res.allowed case "data_integrity": sp.DataIntegrity = res.allowed + case "manage_data_integrity": + sp.ManageDataIntegrity = res.allowed case "users": sp.Users = res.allowed + case "manage_users": + sp.ManageUsers = res.allowed case "permissions_direct": sp.PermissionsDirect = res.allowed + case "manage_permissions_direct": + sp.ManagePermissionsDirect = res.allowed case "auth_guard": sp.AuthGuard = res.allowed + case "manage_auth_guard": + sp.ManageAuthGuard = res.allowed case "api_keys": sp.ApiKeys = res.allowed + case "manage_api_keys": + sp.ManageApiKeys = res.allowed case "audit_logs": sp.AuditLogs = res.allowed + case "manage_audit_logs": + sp.ManageAuditLogs = res.allowed } } } diff --git a/backend/internal/handler/tenant_handler_get_test.go b/backend/internal/handler/tenant_handler_get_test.go index 3e059729..7df1ed37 100644 --- a/backend/internal/handler/tenant_handler_get_test.go +++ b/backend/internal/handler/tenant_handler_get_test.go @@ -128,6 +128,7 @@ func TestTenantHandler_GetTenant_NormalUser(t *testing.T) { mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil) mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", mock.Anything).Return(false, nil).Maybe() // We'll simulate middleware setting "user_profile" for a regular admin/user app.Get("/tenants/:id", func(c *fiber.Ctx) error { diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index a608c1a0..b39b25fb 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -7,7 +7,7 @@ class System implements Namespace { super_admins: User[] authenticated_users: User[] - // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 + // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 조회(Read) overview_viewers: User[] tenants_viewers: User[] org_chart_viewers: User[] @@ -19,55 +19,112 @@ class System implements Namespace { auth_guard_viewers: User[] api_keys_viewers: User[] audit_logs_viewers: User[] + + // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 - 수정(Write) + overview_managers: User[] + tenants_managers: User[] + org_chart_managers: User[] + worksmobile_managers: User[] + ory_ssot_managers: User[] + data_integrity_managers: User[] + users_managers: User[] + permissions_direct_managers: User[] + auth_guard_managers: User[] + api_keys_managers: User[] + audit_logs_managers: User[] } permits = { manage_all: (ctx: Context): boolean => this.related.super_admins.includes(ctx.subject), - // 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - Super Admin은 언제나 무조건 패스 + // 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - 조회(access_)와 수정(manage_) 완전 분리 이원화 access_overview: (ctx: Context): boolean => this.related.overview_viewers.includes(ctx.subject) || + this.permits.manage_overview(ctx), + + manage_overview: (ctx: Context): boolean => + this.related.overview_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_tenants: (ctx: Context): boolean => this.related.tenants_viewers.includes(ctx.subject) || + this.permits.manage_tenants(ctx), + + manage_tenants: (ctx: Context): boolean => + this.related.tenants_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_org_chart: (ctx: Context): boolean => this.related.org_chart_viewers.includes(ctx.subject) || + this.permits.manage_org_chart(ctx), + + manage_org_chart: (ctx: Context): boolean => + this.related.org_chart_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_worksmobile: (ctx: Context): boolean => this.related.worksmobile_viewers.includes(ctx.subject) || + this.permits.manage_worksmobile(ctx), + + manage_worksmobile: (ctx: Context): boolean => + this.related.worksmobile_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_ory_ssot: (ctx: Context): boolean => this.related.ory_ssot_viewers.includes(ctx.subject) || + this.permits.manage_ory_ssot(ctx), + + manage_ory_ssot: (ctx: Context): boolean => + this.related.ory_ssot_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_data_integrity: (ctx: Context): boolean => this.related.data_integrity_viewers.includes(ctx.subject) || + this.permits.manage_data_integrity(ctx), + + manage_data_integrity: (ctx: Context): boolean => + this.related.data_integrity_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_users: (ctx: Context): boolean => this.related.users_viewers.includes(ctx.subject) || + this.permits.manage_users(ctx), + + manage_users: (ctx: Context): boolean => + this.related.users_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_permissions_direct: (ctx: Context): boolean => this.related.permissions_direct_viewers.includes(ctx.subject) || + this.permits.manage_permissions_direct(ctx), + + manage_permissions_direct: (ctx: Context): boolean => + this.related.permissions_direct_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_auth_guard: (ctx: Context): boolean => this.related.auth_guard_viewers.includes(ctx.subject) || + this.permits.manage_auth_guard(ctx), + + manage_auth_guard: (ctx: Context): boolean => + this.related.auth_guard_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_api_keys: (ctx: Context): boolean => this.related.api_keys_viewers.includes(ctx.subject) || + this.permits.manage_api_keys(ctx), + + manage_api_keys: (ctx: Context): boolean => + this.related.api_keys_managers.includes(ctx.subject) || this.permits.manage_all(ctx), access_audit_logs: (ctx: Context): boolean => this.related.audit_logs_viewers.includes(ctx.subject) || + this.permits.manage_audit_logs(ctx), + + manage_audit_logs: (ctx: Context): boolean => + this.related.audit_logs_managers.includes(ctx.subject) || this.permits.manage_all(ctx) } }