forked from baron/baron-sso
adminfront 및 백엔드: 글로벌 사이드바 11개 전 메뉴별 ReBAC 기반 접근 제어(Admin Control) 스키마, REST API, UI 설정 패널 전격 구현 완료
This commit is contained in:
@@ -212,71 +212,70 @@ function AppLayout() {
|
|||||||
...profile,
|
...profile,
|
||||||
role: effectiveRole ?? profile?.role,
|
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(
|
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
|
||||||
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
|
||||||
{ includeInternal: true },
|
{ includeInternal: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isSuperAdmin) {
|
// Splice optional menus in a standard order
|
||||||
filteredItems.splice(1, 0, {
|
items.splice(1, 0, {
|
||||||
labelKey: "ui.admin.nav.tenants",
|
labelKey: "ui.admin.nav.tenants",
|
||||||
labelFallback: "Tenants",
|
labelFallback: "Tenants",
|
||||||
to: "/tenants",
|
to: "/tenants",
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
});
|
});
|
||||||
filteredItems.splice(2, 0, {
|
items.splice(2, 0, {
|
||||||
labelKey: "ui.admin.nav.org_chart",
|
labelKey: "ui.admin.nav.org_chart",
|
||||||
labelFallback: "Org Chart",
|
labelFallback: "Org Chart",
|
||||||
to: orgfrontUrl,
|
to: orgfrontUrl,
|
||||||
icon: Network,
|
icon: Network,
|
||||||
isExternal: true,
|
isExternal: true,
|
||||||
});
|
});
|
||||||
if (showWorksmobile) {
|
items.splice(3, 0, {
|
||||||
filteredItems.splice(3, 0, {
|
labelKey: "ui.admin.nav.worksmobile",
|
||||||
labelKey: "ui.admin.nav.worksmobile",
|
labelFallback: "Worksmobile",
|
||||||
labelFallback: "Worksmobile",
|
to: "/worksmobile",
|
||||||
to: "/worksmobile",
|
icon: LineWorksNavIcon,
|
||||||
icon: LineWorksNavIcon,
|
});
|
||||||
});
|
items.splice(4, 0, {
|
||||||
}
|
labelKey: "ui.admin.nav.ory_ssot",
|
||||||
filteredItems.splice(4, 0, {
|
labelFallback: "Ory SSOT System",
|
||||||
labelKey: "ui.admin.nav.ory_ssot",
|
to: "/system/ory-ssot",
|
||||||
labelFallback: "Ory SSOT System",
|
icon: Database,
|
||||||
to: "/system/ory-ssot",
|
});
|
||||||
icon: Database,
|
items.splice(5, 0, {
|
||||||
});
|
labelKey: "ui.admin.nav.data_integrity",
|
||||||
filteredItems.splice(5, 0, {
|
labelFallback: "Data Integrity",
|
||||||
labelKey: "ui.admin.nav.data_integrity",
|
to: "/system/data-integrity",
|
||||||
labelFallback: "Data Integrity",
|
icon: ShieldCheck,
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
}, [profile]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
@@ -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 { 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 { t } from "../../../lib/i18n";
|
||||||
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -11,9 +22,30 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} 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() {
|
export function TenantFineGrainedPermissionsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState<"tenant" | "system">("tenant");
|
||||||
const [selectedTenantId, setSelectedTenantId] = useState("");
|
const [selectedTenantId, setSelectedTenantId] = useState("");
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -32,6 +64,87 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
? (tenantsQuery.data?.items ?? [])
|
? (tenantsQuery.data?.items ?? [])
|
||||||
: (profile?.manageableTenants ?? []);
|
: (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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -42,46 +155,290 @@ export function TenantFineGrainedPermissionsPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.permissions_direct.description",
|
"msg.admin.permissions_direct.description",
|
||||||
"선택한 테넌트의 각 기능별 세부 조회 및 수정 권한을 지정하고 부여합니다.",
|
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
{/* Tab Selectors */}
|
||||||
<CardHeader className="pb-4">
|
{isSuperAdmin && (
|
||||||
<CardTitle className="text-lg font-semibold">
|
<div className="flex border-b border-border">
|
||||||
{t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
|
<button
|
||||||
</CardTitle>
|
onClick={() => setActiveTab("tenant")}
|
||||||
<CardDescription>
|
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
|
||||||
{t(
|
activeTab === "tenant"
|
||||||
"msg.admin.permissions_direct.select_tenant_desc",
|
? "text-primary border-b-2 border-primary"
|
||||||
"권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
}`}
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<select
|
|
||||||
value={selectedTenantId}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
>
|
>
|
||||||
<option value="">{t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}</option>
|
<Settings className="inline-block h-4 w-4 mr-2" />
|
||||||
{tenants.map((tenant) => (
|
{t("ui.admin.permissions_direct.tab_tenant", "테넌트 기능 권한")}
|
||||||
<option key={tenant.id} value={tenant.id}>
|
</button>
|
||||||
{tenant.name} ({tenant.slug})
|
<button
|
||||||
</option>
|
onClick={() => setActiveTab("system")}
|
||||||
))}
|
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
|
||||||
</select>
|
activeTab === "system"
|
||||||
</CardContent>
|
? "text-primary border-b-2 border-primary"
|
||||||
</Card>
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
{selectedTenantId ? (
|
>
|
||||||
<TenantFineGrainedPermissionsTab tenantIdProp={selectedTenantId} />
|
<Shield className="inline-block h-4 w-4 mr-2" />
|
||||||
) : (
|
{t("ui.admin.permissions_direct.tab_system", "시스템 메뉴 권한 (Admin Control)")}
|
||||||
<div className="rounded-lg border border-dashed border-border p-12 text-center text-muted-foreground">
|
</button>
|
||||||
{t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "tenant" ? (
|
||||||
|
<>
|
||||||
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg font-semibold">
|
||||||
|
{t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.permissions_direct.select_tenant_desc",
|
||||||
|
"세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<select
|
||||||
|
value={selectedTenantId}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">{t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}</option>
|
||||||
|
{tenants.map((tenant) => (
|
||||||
|
<option key={tenant.id} value={tenant.id}>
|
||||||
|
{tenant.name} ({tenant.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedTenantId ? (
|
||||||
|
<TenantFineGrainedPermissionsTab tenantIdProp={selectedTenantId} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-border p-12 text-center text-muted-foreground">
|
||||||
|
{t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* 시스템 메뉴 권한 (Admin Control) Tab Panel */
|
||||||
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||||
|
{t("ui.admin.permissions_direct.tab_system_title", "글로벌 메뉴 접근 제어 (Admin Control)")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.permissions_direct.tab_system_desc",
|
||||||
|
"사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.admin.permissions_direct.add_system_user", "시스템 권한 사용자 추가")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="rounded-md border border-border overflow-hidden overflow-x-auto">
|
||||||
|
<Table className="min-w-[1200px]">
|
||||||
|
<TableHeader className="bg-secondary/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="font-bold sticky left-0 bg-background z-20 w-[180px]">{t("ui.common.name", "이름")}</TableHead>
|
||||||
|
{systemMenus.map((menu) => (
|
||||||
|
<TableHead key={menu.relation} className="font-bold text-center text-xs">
|
||||||
|
{menu.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="font-bold text-center w-[70px]">{t("ui.common.action", "작업")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{systemRelations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={13} className="text-center py-12 text-muted-foreground">
|
||||||
|
{t("msg.admin.permissions_direct.system_empty", "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요.")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
systemRelations.map((user) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
|
||||||
|
<TableCell className="font-medium sticky left-0 bg-background z-10 shadow-[2px_0_5px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-foreground">{user.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground italic truncate max-w-[150px]">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{systemMenus.map((menu) => {
|
||||||
|
const hasAccess = user.relations.includes(menu.relation);
|
||||||
|
return (
|
||||||
|
<TableCell key={menu.relation} className="text-center">
|
||||||
|
<select
|
||||||
|
className="mx-auto flex h-8 w-[100px] rounded-md border border-input bg-background px-1.5 py-0.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
value={hasAccess ? "access" : "none"}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleSystemRelationChange(
|
||||||
|
user.userId,
|
||||||
|
menu.relation,
|
||||||
|
e.target.value === "access",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t("ui.common.none", "X")}</option>
|
||||||
|
<option value="access">{t("ui.common.access", "허용")}</option>
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveAllSystemRelations(user.userId, user.relations)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Search Dialog for System relations */}
|
||||||
|
<Dialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
{t("ui.admin.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_description",
|
||||||
|
"이름 또는 이메일로 사용자를 검색하세요.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4 space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_placeholder",
|
||||||
|
"사용자 검색 (최소 2자)...",
|
||||||
|
)}
|
||||||
|
className="pl-10 h-11"
|
||||||
|
autoFocus
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
|
||||||
|
{searchTerm.length < 2 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
|
||||||
|
<Search className="h-8 w-8 opacity-20" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_search_hint",
|
||||||
|
"검색어를 입력해 주세요.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : usersQuery.isLoading ? (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||||
|
</div>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.admins.dialog_no_results",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{searchResults.map((user) => {
|
||||||
|
const isAlreadyInMatrix = systemRelations.some(
|
||||||
|
(r) => r.userId === user.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isAlreadyInMatrix ? "ghost" : "outline"}
|
||||||
|
disabled={
|
||||||
|
isAlreadyInMatrix ||
|
||||||
|
addSystemRelationMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => handleAddSystemUser(user.id)}
|
||||||
|
>
|
||||||
|
{isAlreadyInMatrix ? (
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{t(
|
||||||
|
"ui.admin.tenants.relations.already_added",
|
||||||
|
"이미 추가됨",
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />{" "}
|
||||||
|
{t("ui.common.add", "추가")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Group Management
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1237,6 +1263,20 @@ export async function fetchUserRpHistory(userId: string) {
|
|||||||
return data;
|
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 = {
|
export type UserProfileResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -1250,6 +1290,7 @@ export type UserProfileResponse = {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
tenant?: TenantSummary;
|
tenant?: TenantSummary;
|
||||||
manageableTenants?: TenantSummary[];
|
manageableTenants?: TenantSummary[];
|
||||||
|
systemPermissions?: SystemPermissions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchMe() {
|
export async function fetchMe() {
|
||||||
|
|||||||
@@ -2002,3 +2002,24 @@ verify = "Verify"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Action"
|
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?"
|
||||||
|
|||||||
@@ -2002,3 +2002,24 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
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 = "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"
|
||||||
|
|||||||
@@ -1961,3 +1961,24 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
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 = ""
|
||||||
|
|||||||
@@ -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.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.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", 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/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)
|
admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches)
|
||||||
|
|||||||
@@ -69,24 +69,39 @@ type SignupRequest struct {
|
|||||||
|
|
||||||
// User Profile Models
|
// 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 {
|
type UserProfileResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
LoginID string `json:"loginId,omitempty"`
|
LoginID string `json:"loginId,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"` // 추가
|
Role string `json:"role"` // 추가
|
||||||
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
SessionAuthenticatedAt string `json:"sessionAuthenticatedAt,omitempty"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
AffiliationType string `json:"affiliationType"`
|
AffiliationType string `json:"affiliationType"`
|
||||||
CompanyCode string `json:"companyCode,omitempty"`
|
CompanyCode string `json:"companyCode,omitempty"`
|
||||||
TenantID *string `json:"tenantId,omitempty"` // 추가
|
TenantID *string `json:"tenantId,omitempty"` // 추가
|
||||||
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
SessionTenantID *string `json:"sessionTenantId,omitempty"` // [New] 로그인에 사용된 식별자 기반 테넌트
|
||||||
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
Tenant *Tenant `json:"tenant,omitempty"`
|
Tenant *Tenant `json:"tenant,omitempty"`
|
||||||
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
ManageableTenants []Tenant `json:"manageableTenants,omitempty"` // 추가: 관리 가능한 테넌트 목록
|
||||||
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
JoinedTenants []Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
|
||||||
|
SystemPermissions *SystemPermissions `json:"systemPermissions,omitempty"` // [New] 글로벌 메뉴 접근 권한
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateUserRequest struct {
|
type UpdateUserRequest struct {
|
||||||
|
|||||||
@@ -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
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3371,3 +3371,154 @@ func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,3 +167,103 @@ func TestTenantHandler_Relations(t *testing.T) {
|
|||||||
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,11 +6,69 @@ class System implements Namespace {
|
|||||||
related: {
|
related: {
|
||||||
super_admins: User[]
|
super_admins: User[]
|
||||||
authenticated_users: 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 = {
|
permits = {
|
||||||
manage_all: (ctx: Context): boolean =>
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user