diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index b10dcc4d..0b44a8c7 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -212,71 +212,70 @@ function AppLayout() { ...profile, role: effectiveRole ?? profile?.role, }); - const filteredItems = items.filter((item) => { - if (item.to === "/api-keys") return isSuperAdmin; - if (item.to === "/permissions-direct") return isSuperAdmin || _manageableCount > 0; - return true; - }); - const orgfrontUrl = buildAuthenticatedOrgChartUrl( import.meta.env.ORGFRONT_URL || "http://localhost:5175", { includeInternal: true }, ); - if (isSuperAdmin) { - filteredItems.splice(1, 0, { - labelKey: "ui.admin.nav.tenants", - labelFallback: "Tenants", - to: "/tenants", - icon: Building2, - }); - filteredItems.splice(2, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); - if (showWorksmobile) { - filteredItems.splice(3, 0, { - labelKey: "ui.admin.nav.worksmobile", - labelFallback: "Worksmobile", - to: "/worksmobile", - icon: LineWorksNavIcon, - }); - } - filteredItems.splice(4, 0, { - labelKey: "ui.admin.nav.ory_ssot", - labelFallback: "Ory SSOT System", - to: "/system/ory-ssot", - icon: Database, - }); - filteredItems.splice(5, 0, { - labelKey: "ui.admin.nav.data_integrity", - labelFallback: "Data Integrity", - to: "/system/data-integrity", - icon: ShieldCheck, - }); - } else { - // Non-superadmins - filteredItems.splice(1, 0, { - labelKey: "ui.admin.nav.org_chart", - labelFallback: "Org Chart", - to: orgfrontUrl, - icon: Network, - isExternal: true, - }); - if (showWorksmobile) { - filteredItems.splice(2, 0, { - labelKey: "ui.admin.nav.worksmobile", - labelFallback: "Worksmobile", - to: "/worksmobile", - icon: LineWorksNavIcon, - }); - } - } + // Splice optional menus in a standard order + items.splice(1, 0, { + labelKey: "ui.admin.nav.tenants", + labelFallback: "Tenants", + to: "/tenants", + icon: Building2, + }); + items.splice(2, 0, { + labelKey: "ui.admin.nav.org_chart", + labelFallback: "Org Chart", + to: orgfrontUrl, + icon: Network, + isExternal: true, + }); + items.splice(3, 0, { + labelKey: "ui.admin.nav.worksmobile", + labelFallback: "Worksmobile", + to: "/worksmobile", + icon: LineWorksNavIcon, + }); + items.splice(4, 0, { + labelKey: "ui.admin.nav.ory_ssot", + labelFallback: "Ory SSOT System", + to: "/system/ory-ssot", + icon: Database, + }); + items.splice(5, 0, { + labelKey: "ui.admin.nav.data_integrity", + labelFallback: "Data Integrity", + to: "/system/data-integrity", + icon: ShieldCheck, + }); - return filteredItems; + const permissions = profile?.systemPermissions; + + return items.filter((item) => { + // Super Admin ALWAYS bypasses and gets full access to everything + if (isSuperAdmin) { + if (item.to === "/worksmobile") return showWorksmobile; + return true; + } + + // For others, check their fine-grained systemPermissions + if (!permissions) return false; + + if (item.to === "/") return permissions.overview; + if (item.to === "/users") return permissions.users; + if (item.to === "/auth") return permissions.auth_guard; + if (item.to === "/api-keys") return permissions.api_keys; + if (item.to === "/audit-logs") return permissions.audit_logs; + if (item.to === "/permissions-direct") return permissions.permissions_direct || _manageableCount > 0; + if (item.to === "/tenants") return permissions.tenants; + if (item.to === orgfrontUrl) return permissions.org_chart; + if (item.to === "/worksmobile") return permissions.worksmobile && showWorksmobile; + if (item.to === "/system/ory-ssot") return permissions.ory_ssot; + if (item.to === "/system/data-integrity") return permissions.data_integrity; + + return true; + }); }, [profile]); const handleLogout = () => { diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index cc7cfaca..dc47f741 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -1,9 +1,20 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import { useState } from "react"; -import { fetchAllTenants, fetchMe } from "../../../lib/adminApi"; +import { + fetchAllTenants, + fetchMe, + fetchUsers, + fetchSystemRelations, + addSystemRelation, + removeSystemRelation, + type TenantRelation, +} from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab"; -import { ShieldCheck } from "lucide-react"; +import { ShieldCheck, Search, Plus, UserPlus, Trash2, Settings, Shield } from "lucide-react"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; import { Card, CardContent, @@ -11,9 +22,30 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { toast } from "../../../components/ui/use-toast"; export function TenantFineGrainedPermissionsPage() { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState<"tenant" | "system">("tenant"); const [selectedTenantId, setSelectedTenantId] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [isDialogOpen, setIsDialogOpen] = useState(false); const { data: profile } = useQuery({ queryKey: ["me"], @@ -32,6 +64,87 @@ export function TenantFineGrainedPermissionsPage() { ? (tenantsQuery.data?.items ?? []) : (profile?.manageableTenants ?? []); + // System Relations (Admin Control) Queries & Mutations + const systemRelationsQuery = useQuery({ + queryKey: ["system-relations"], + queryFn: fetchSystemRelations, + enabled: isSuperAdmin && activeTab === "system", + }); + const systemRelations = systemRelationsQuery.data ?? []; + + const addSystemRelationMutation = useMutation({ + mutationFn: (payload: { userId: string; relation: string }) => + addSystemRelation(payload.userId, payload.relation), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다.")); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다.")); + }, + }); + + const removeSystemRelationMutation = useMutation({ + mutationFn: (payload: { userId: string; relation: string }) => + removeSystemRelation(payload.userId, payload.relation), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["system-relations"] }); + toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다.")); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다.")); + }, + }); + + const handleSystemRelationChange = async ( + userId: string, + relation: string, + hasAccess: boolean, + ) => { + if (hasAccess) { + await addSystemRelationMutation.mutateAsync({ userId, relation }); + } else { + await removeSystemRelationMutation.mutateAsync({ userId, relation }); + } + }; + + const handleRemoveAllSystemRelations = async (userId: string, userRelations: string[]) => { + if (!window.confirm(t("msg.admin.system.relations.remove_all_confirm", "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"))) { + return; + } + for (const rel of userRelations) { + await removeSystemRelationMutation.mutateAsync({ userId, relation: rel }); + } + }; + + const usersQuery = useQuery({ + queryKey: ["admin-users-search", searchTerm], + queryFn: () => fetchUsers(20, 0, searchTerm), + enabled: isDialogOpen && searchTerm.length >= 2, + }); + + const handleAddSystemUser = (userId: string) => { + addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" }); + setIsDialogOpen(false); + setSearchTerm(""); + }; + + const searchResults = usersQuery.data?.items || []; + + const systemMenus = [ + { label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers" }, + { label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers" }, + { label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers" }, + { label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile_viewers" }, + { label: t("ui.admin.nav.ory_ssot", "Ory SSOT"), relation: "ory_ssot_viewers" }, + { label: t("ui.admin.nav.data_integrity", "정합성"), relation: "data_integrity_viewers" }, + { label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers" }, + { label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers" }, + { label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers" }, + { label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers" }, + { label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers" }, + ]; + return (
@@ -42,46 +155,290 @@ export function TenantFineGrainedPermissionsPage() {

{t( "msg.admin.permissions_direct.description", - "선택한 테넌트의 각 기능별 세부 조회 및 수정 권한을 지정하고 부여합니다.", + "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.", )}

- - - - {t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")} - - - {t( - "msg.admin.permissions_direct.select_tenant_desc", - "권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.", - )} - - - - - - - - {selectedTenantId ? ( - - ) : ( -
- {t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")} + + {t("ui.admin.permissions_direct.tab_tenant", "테넌트 기능 권한")} + +
)} + + {activeTab === "tenant" ? ( + <> + + + + {t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")} + + + {t( + "msg.admin.permissions_direct.select_tenant_desc", + "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.", + )} + + + + + + + + {selectedTenantId ? ( + + ) : ( +
+ {t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")} +
+ )} + + ) : ( + /* 시스템 메뉴 권한 (Admin Control) Tab Panel */ + + +
+ + + {t("ui.admin.permissions_direct.tab_system_title", "글로벌 메뉴 접근 제어 (Admin Control)")} + + + {t( + "msg.admin.permissions_direct.tab_system_desc", + "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다.", + )} + +
+ +
+ +
+ + + + {t("ui.common.name", "이름")} + {systemMenus.map((menu) => ( + + {menu.label} + + ))} + {t("ui.common.action", "작업")} + + + + {systemRelations.length === 0 ? ( + + + {t("msg.admin.permissions_direct.system_empty", "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요.")} + + + ) : ( + systemRelations.map((user) => { + return ( + + +
+ {user.name} + {user.email} +
+
+ {systemMenus.map((menu) => { + const hasAccess = user.relations.includes(menu.relation); + return ( + + + + ); + })} + + + +
+ ); + }) + )} +
+
+
+
+
+ )} + + {/* User Search Dialog for System relations */} + { + if (!open) { + setIsDialogOpen(false); + setSearchTerm(""); + } + }} + > + + + + {t("ui.admin.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")} + + + {t( + "ui.admin.tenants.admins.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + )} + + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ {searchTerm.length < 2 ? ( +
+ +

+ {t( + "ui.admin.tenants.admins.dialog_search_hint", + "검색어를 입력해 주세요.", + )} +

+
+ ) : usersQuery.isLoading ? ( +
+
+
+ ) : searchResults.length === 0 ? ( +
+ {t( + "ui.admin.tenants.admins.dialog_no_results", + "검색 결과가 없습니다.", + )} +
+ ) : ( +
+ {searchResults.map((user) => { + const isAlreadyInMatrix = systemRelations.some( + (r) => r.userId === user.id, + ); + + return ( +
+
+
+ {user.name.charAt(0)} +
+
+ + {user.name} + + + {user.email} + +
+
+ +
+ ); + })} +
+ )} +
+
+ +
); } diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 8c55706a..5360ea97 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -526,6 +526,32 @@ export async function removeTenantRelation( }); } +export async function fetchSystemRelations() { + const { data } = await apiClient.get<{ items: TenantRelation[] }>( + `/v1/admin/system/relations`, + ); + return data.items; +} + +export async function addSystemRelation( + userId: string, + relation: string, +) { + await apiClient.post(`/v1/admin/system/relations`, { + userId, + relation, + }); +} + +export async function removeSystemRelation( + userId: string, + relation: string, +) { + await apiClient.delete(`/v1/admin/system/relations`, { + data: { userId, relation }, + }); +} + // Group Management export type GroupMember = { id: string; @@ -1237,6 +1263,20 @@ export async function fetchUserRpHistory(userId: string) { return data; } +export type SystemPermissions = { + overview: boolean; + tenants: boolean; + org_chart: boolean; + worksmobile: boolean; + ory_ssot: boolean; + data_integrity: boolean; + users: boolean; + permissions_direct: boolean; + auth_guard: boolean; + api_keys: boolean; + audit_logs: boolean; +}; + export type UserProfileResponse = { id: string; email: string; @@ -1250,6 +1290,7 @@ export type UserProfileResponse = { metadata?: Record; tenant?: TenantSummary; manageableTenants?: TenantSummary[]; + systemPermissions?: SystemPermissions; }; export async function fetchMe() { diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 83f3dc47..e57c90bf 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -2002,3 +2002,24 @@ verify = "Verify" [ui.userfront.signup.success] action = "Action" + +[ui.admin.permissions_direct] +tab_tenant = "Tenant Features" +tab_system = "Admin Control" +tab_system_title = "Global Sidebar Access Control" +select_tenant = "Select target tenant" +select_tenant_desc = "Select target tenant to assign fine-grained permissions." +placeholder = "-- Select Tenant --" +add_system_user = "Add User to Admin Control" +dialog_title_system = "Add User to Global Permissions" + +[msg.admin.permissions_direct] +description = "Directly assign and manage tab-level direct permissions and global sidebar menu access." +tab_system_desc = "Directly grant users access to each sidebar menu page. Super admins always bypass and pass all access checks." +system_empty = "No users with custom global menu permissions found. Add users to start managing." +select_prompt = "Select a tenant from the dropdown above to manage its fine-grained features." + +[msg.admin.system.relations] +add_success = "Global menu permission added successfully." +remove_success = "Global menu permission revoked successfully." +remove_all_confirm = "Are you sure you want to revoke all global menu permissions for this user?" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index d6a83836..d0697454 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -2002,3 +2002,24 @@ verify = "본인인증" [ui.userfront.signup.success] action = "로그인하기" + +[ui.admin.permissions_direct] +tab_tenant = "테넌트 기능 권한" +tab_system = "시스템 메뉴 권한 (Admin Control)" +tab_system_title = "글로벌 메뉴 접근 제어 (Admin Control)" +select_tenant = "대상 테넌트 선택" +select_tenant_desc = "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요." +placeholder = "-- 테넌트 선택 --" +add_system_user = "시스템 권한 사용자 추가" +dialog_title_system = "시스템 권한 관리 유저 추가" + +[msg.admin.permissions_direct] +description = "테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다." +tab_system_desc = "사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다." +system_empty = "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요." +select_prompt = "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다." + +[msg.admin.system.relations] +add_success = "시스템 메뉴 권한이 추가되었습니다." +remove_success = "시스템 메뉴 권한이 회수되었습니다." +remove_all_confirm = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 1db0b185..6276f532 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -1961,3 +1961,24 @@ verify = "" [ui.userfront.signup.success] action = "" + +[ui.admin.permissions_direct] +tab_tenant = "" +tab_system = "" +tab_system_title = "" +select_tenant = "" +select_tenant_desc = "" +placeholder = "" +add_system_user = "" +dialog_title_system = "" + +[msg.admin.permissions_direct] +description = "" +tab_system_desc = "" +system_empty = "" +select_prompt = "" + +[msg.admin.system.relations] +add_success = "" +remove_success = "" +remove_all_confirm = "" diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 036e8863..8377d0b6 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -759,6 +759,10 @@ func main() { admin.Post("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.AddRelation) admin.Delete("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.RemoveRelation) + admin.Get("/system/relations", requireSuperAdmin, tenantHandler.ListSystemRelations) + admin.Post("/system/relations", requireSuperAdmin, tenantHandler.AddSystemRelation) + admin.Delete("/system/relations", requireSuperAdmin, tenantHandler.RemoveSystemRelation) + admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview) admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison) admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches) diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 999a2229..21577181 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -69,24 +69,39 @@ type SignupRequest struct { // User Profile Models +type SystemPermissions struct { + Overview bool `json:"overview"` + Tenants bool `json:"tenants"` + OrgChart bool `json:"org_chart"` + Worksmobile bool `json:"worksmobile"` + OrySSOT bool `json:"ory_ssot"` + DataIntegrity bool `json:"data_integrity"` + Users bool `json:"users"` + PermissionsDirect bool `json:"permissions_direct"` + AuthGuard bool `json:"auth_guard"` + ApiKeys bool `json:"api_keys"` + AuditLogs bool `json:"audit_logs"` +} + type UserProfileResponse struct { - ID string `json:"id"` - Email string `json:"email"` - LoginID string `json:"loginId,omitempty"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` // 추가 - SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"` - Department string `json:"department"` - AffiliationType string `json:"affiliationType"` - CompanyCode string `json:"companyCode,omitempty"` - TenantID *string `json:"tenantId,omitempty"` // 추가 - SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트 - RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 - Metadata map[string]any `json:"metadata,omitempty"` - Tenant *Tenant `json:"tenant,omitempty"` - ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 - JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록 + ID string `json:"id"` + Email string `json:"email"` + LoginID string `json:"loginId,omitempty"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` // 추가 + SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"` + Department string `json:"department"` + AffiliationType string `json:"affiliationType"` + CompanyCode string `json:"companyCode,omitempty"` + TenantID *string `json:"tenantId,omitempty"` // 추가 + SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트 + RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가 + Metadata map[string]any `json:"metadata,omitempty"` + Tenant *Tenant `json:"tenant,omitempty"` + ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록 + JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록 + SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한 } type UpdateUserRequest struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 868de7b3..7a87d643 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4770,6 +4770,81 @@ func (h *AuthHandler) hydrateResolvedProfile(ctx context.Context, profile *domai } } + if h.KetoService != nil { + subject := "User:" + profile.ID + var sp domain.SystemPermissions + + 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, + } + } else { + // Query Keto in parallel for maximum performance + type checkResult struct { + menu string + 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", + } + ch := make(chan checkResult, len(menus)) + for m, rel := range menus { + go func(menuName, relation string) { + allowed, _ := h.KetoService.CheckPermission(ctx, subject, "System", "system", relation) + ch <- checkResult{menu: menuName, allowed: allowed} + }(m, rel) + } + for range menus { + res := <-ch + switch res.menu { + case "overview": + sp.Overview = res.allowed + case "tenants": + sp.Tenants = res.allowed + case "org_chart": + sp.OrgChart = res.allowed + case "worksmobile": + sp.Worksmobile = res.allowed + case "ory_ssot": + sp.OrySSOT = res.allowed + case "data_integrity": + sp.DataIntegrity = res.allowed + case "users": + sp.Users = res.allowed + case "permissions_direct": + sp.PermissionsDirect = res.allowed + case "auth_guard": + sp.AuthGuard = res.allowed + case "api_keys": + sp.ApiKeys = res.allowed + case "audit_logs": + sp.AuditLogs = res.allowed + } + } + } + profile.SystemPermissions = &sp + } + return profile } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index ca5cc54a..540d5041 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -3371,3 +3371,154 @@ func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } + +func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error { + relations, err := h.Keto.ListRelations(c.Context(), "System", "system", "", "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + allowedRelations := map[string]bool{ + "overview_viewers": true, + "tenants_viewers": true, + "org_chart_viewers": true, + "worksmobile_viewers": true, + "ory_ssot_viewers": true, + "data_integrity_viewers": true, + "users_viewers": true, + "permissions_direct_viewers": true, + "auth_guard_viewers": true, + "api_keys_viewers": true, + "audit_logs_viewers": true, + } + + type userRelationInfo struct { + UserID string `json:"userId"` + Name string `json:"name"` + Email string `json:"email"` + Relations []string `json:"relations"` + } + + userMap := make(map[string][]string) + for _, rel := range relations { + if !allowedRelations[rel.Relation] { + continue + } + if !strings.HasPrefix(rel.SubjectID, "User:") { + continue + } + userID := strings.TrimPrefix(rel.SubjectID, "User:") + userMap[userID] = append(userMap[userID], rel.Relation) + } + + items := []userRelationInfo{} + for userID, rels := range userMap { + name := "Unknown" + email := "Unknown" + + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + } + } + + if name == "Unknown" && email == "Unknown" && h.UserRepo != nil { + user, err := h.UserRepo.FindByID(c.Context(), userID) + if err == nil && user != nil { + name = user.Name + email = user.Email + } else if userID == "00000000-0000-0000-0000-000000000000" { + name = "Dev Mock User" + email = "mock@hmac.kr" + } + } + + items = append(items, userRelationInfo{ + UserID: userID, + Name: name, + Email: email, + Relations: rels, + }) + } + + return c.JSON(fiber.Map{ + "items": items, + }) +} + +func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error { + var req tenantRelationRequest + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if req.UserID == "" || req.Relation == "" { + return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") + } + + allowedRelations := map[string]bool{ + "overview_viewers": true, + "tenants_viewers": true, + "org_chart_viewers": true, + "worksmobile_viewers": true, + "ory_ssot_viewers": true, + "data_integrity_viewers": true, + "users_viewers": true, + "permissions_direct_viewers": true, + "auth_guard_viewers": true, + "api_keys_viewers": true, + "audit_logs_viewers": true, + } + + if !allowedRelations[req.Relation] { + return errorJSON(c, fiber.StatusBadRequest, "invalid or unsupported relation") + } + + if h.Keto != nil { + relations, err := h.Keto.ListRelations(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) + if err == nil && len(relations) > 0 { + return errorJSON(c, fiber.StatusConflict, "이미 해당 세부 권한이 등록된 사용자입니다.") + } + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "System", + Object: "system", + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionCreate, + }) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error { + var req tenantRelationRequest + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + if req.UserID == "" || req.Relation == "" { + return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "System", + Object: "system", + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionDelete, + }) + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/backend/internal/handler/tenant_handler_relations_test.go b/backend/internal/handler/tenant_handler_relations_test.go index fd127b6b..fea8583e 100644 --- a/backend/internal/handler/tenant_handler_relations_test.go +++ b/backend/internal/handler/tenant_handler_relations_test.go @@ -167,3 +167,103 @@ func TestTenantHandler_Relations(t *testing.T) { assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) }) } + +func TestTenantHandler_SystemRelations(t *testing.T) { + if !testsupport.DockerAvailable() { + t.Skip("Docker provider is unavailable in this environment") + } + + db := newTenantHandlerSeedDeleteDB(t) + if err := db.AutoMigrate(&domain.KetoOutbox{}); err != nil { + t.Fatalf("failed to migrate outbox: %v", err) + } + + mockSvc := new(MockTenantService) + mockKeto := new(devMockKetoService) + realOutbox := repository.NewKetoOutboxRepository(db) + + h := &TenantHandler{ + DB: db, + Service: mockSvc, + Keto: mockKeto, + KetoOutbox: realOutbox, + } + + userID := "user-system-1" + + t.Run("ListSystemRelations - Returns correct system relations", func(t *testing.T) { + app := fiber.New() + app.Get("/system/relations", h.ListSystemRelations) + + mockKeto.On("ListRelations", mock.Anything, "System", "system", "", "").Return([]service.RelationTuple{ + { + Namespace: "System", + Object: "system", + Relation: "ory_ssot_viewers", + SubjectID: "User:" + userID, + }, + { + Namespace: "System", + Object: "system", + Relation: "audit_logs_viewers", + SubjectID: "User:" + userID, + }, + }, nil).Once() + + req := httptest.NewRequest("GET", "/system/relations", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var got struct { + Items []struct { + UserID string `json:"userId"` + Relations []string `json:"relations"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&got) + if err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + assert.Len(t, got.Items, 1) + assert.Equal(t, userID, got.Items[0].UserID) + assert.Contains(t, got.Items[0].Relations, "ory_ssot_viewers") + assert.Contains(t, got.Items[0].Relations, "audit_logs_viewers") + mockKeto.AssertExpectations(t) + }) + + t.Run("AddSystemRelation - Inserts into KetoOutbox DB table with System namespace", func(t *testing.T) { + app := fiber.New() + app.Post("/system/relations", h.AddSystemRelation) + + mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once() + + body, _ := json.Marshal(map[string]string{ + "userId": userID, + "relation": "ory_ssot_viewers", + }) + req := httptest.NewRequest("POST", "/system/relations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var outboxEntries []domain.KetoOutbox + if err := db.Where("object = ? AND relation = ? AND action = ?", "system", "ory_ssot_viewers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil { + t.Fatalf("failed to query outbox: %v", err) + } + + assert.Len(t, outboxEntries, 1) + assert.Equal(t, "System", outboxEntries[0].Namespace) + assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) + mockKeto.AssertExpectations(t) + }) +} diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 0a4e0a52..a608c1a0 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -6,11 +6,69 @@ class System implements Namespace { related: { super_admins: User[] authenticated_users: User[] + + // 🌟 신규 글로벌 메뉴 권한 (Admin Control) 정의 + overview_viewers: User[] + tenants_viewers: User[] + org_chart_viewers: User[] + worksmobile_viewers: User[] + ory_ssot_viewers: User[] + data_integrity_viewers: User[] + users_viewers: User[] + permissions_direct_viewers: User[] + auth_guard_viewers: User[] + api_keys_viewers: User[] + audit_logs_viewers: User[] } permits = { manage_all: (ctx: Context): boolean => - this.related.super_admins.includes(ctx.subject) + this.related.super_admins.includes(ctx.subject), + + // 🌟 글로벌 메뉴 허가 규칙 (Permit Rules) - Super Admin은 언제나 무조건 패스 + access_overview: (ctx: Context): boolean => + this.related.overview_viewers.includes(ctx.subject) || + this.permits.manage_all(ctx), + + access_tenants: (ctx: Context): boolean => + this.related.tenants_viewers.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_all(ctx), + + access_worksmobile: (ctx: Context): boolean => + this.related.worksmobile_viewers.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_all(ctx), + + access_data_integrity: (ctx: Context): boolean => + this.related.data_integrity_viewers.includes(ctx.subject) || + this.permits.manage_all(ctx), + + access_users: (ctx: Context): boolean => + this.related.users_viewers.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_all(ctx), + + access_auth_guard: (ctx: Context): boolean => + this.related.auth_guard_viewers.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_all(ctx), + + access_audit_logs: (ctx: Context): boolean => + this.related.audit_logs_viewers.includes(ctx.subject) || + this.permits.manage_all(ctx) } }