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",
- "권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
- )}
-
-
-
- setSelectedTenantId(e.target.value)}
- className="flex h-10 w-full max-w-[360px] 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"
+ {/* Tab Selectors */}
+ {isSuperAdmin && (
+
+
setActiveTab("tenant")}
+ className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
+ activeTab === "tenant"
+ ? "text-primary border-b-2 border-primary"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
>
- {t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}
- {tenants.map((tenant) => (
-
- {tenant.name} ({tenant.slug})
-
- ))}
-
-
-
-
- {selectedTenantId ? (
-
- ) : (
-
- {t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
+
+ {t("ui.admin.permissions_direct.tab_tenant", "테넌트 기능 권한")}
+
+ setActiveTab("system")}
+ className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
+ activeTab === "system"
+ ? "text-primary border-b-2 border-primary"
+ : "text-muted-foreground hover:text-foreground"
+ }`}
+ >
+
+ {t("ui.admin.permissions_direct.tab_system", "시스템 메뉴 권한 (Admin Control)")}
+
)}
+
+ {activeTab === "tenant" ? (
+ <>
+
+
+
+ {t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
+
+
+ {t(
+ "msg.admin.permissions_direct.select_tenant_desc",
+ "세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
+ )}
+
+
+
+ setSelectedTenantId(e.target.value)}
+ className="flex h-10 w-full max-w-[360px] 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"
+ >
+ {t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}
+ {tenants.map((tenant) => (
+
+ {tenant.name} ({tenant.slug})
+
+ ))}
+
+
+
+
+ {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)는 기본적으로 언제나 모든 권한을 우회 통과합니다.",
+ )}
+
+
+ setIsDialogOpen(true)}
+ >
+
+ {t("ui.admin.permissions_direct.add_system_user", "시스템 권한 사용자 추가")}
+
+
+
+
+
+
+
+ {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 (
+
+
+ handleSystemRelationChange(
+ user.userId,
+ menu.relation,
+ e.target.value === "access",
+ )
+ }
+ >
+ {t("ui.common.none", "X")}
+ {t("ui.common.access", "허용")}
+
+
+ );
+ })}
+
+ handleRemoveAllSystemRelations(user.userId, user.relations)}
+ >
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+ )}
+
+ {/* 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}
+
+
+
+
handleAddSystemUser(user.id)}
+ >
+ {isAlreadyInMatrix ? (
+
+ {t(
+ "ui.admin.tenants.relations.already_added",
+ "이미 추가됨",
+ )}
+
+ ) : (
+ <>
+ {" "}
+ {t("ui.common.add", "추가")}
+ >
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
);
}
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)
}
}