forked from baron/baron-sso
adminfront: 탭별 세부 권한 격리 부여를 위한 독자적인 5번째 탭(세부 권한) 추가 및 연동 완료
This commit is contained in:
@@ -17,6 +17,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
|||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||||
|
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
|
||||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||||
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
|
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
|
||||||
@@ -59,6 +60,7 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
|
{ path: "relations", element: <TenantFineGrainedPermissionsTab /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createI18nMock } from "../../test/i18nMock";
|
import { createI18nMock } from "../../test/i18nMock";
|
||||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
|
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||||
|
|
||||||
const exportUsersCSVMock = vi.hoisted(() =>
|
const exportUsersCSVMock = vi.hoisted(() =>
|
||||||
@@ -106,6 +107,16 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
addTenantAdmin: vi.fn(async () => undefined),
|
addTenantAdmin: vi.fn(async () => undefined),
|
||||||
removeTenantOwner: vi.fn(async () => undefined),
|
removeTenantOwner: vi.fn(async () => undefined),
|
||||||
removeTenantAdmin: vi.fn(async () => undefined),
|
removeTenantAdmin: vi.fn(async () => undefined),
|
||||||
|
fetchTenantRelations: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
userId: "user-relation-1",
|
||||||
|
name: "Relation User",
|
||||||
|
email: "relation@example.com",
|
||||||
|
relations: ["profile_managers", "schema_viewers"],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
addTenantRelation: vi.fn(async () => undefined),
|
||||||
|
removeTenantRelation: vi.fn(async () => undefined),
|
||||||
fetchUsers: vi.fn(async () => ({
|
fetchUsers: vi.fn(async () => ({
|
||||||
items: users,
|
items: users,
|
||||||
total: users.length,
|
total: users.length,
|
||||||
@@ -165,6 +176,22 @@ describe("admin tenant tab coverage smoke", () => {
|
|||||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders tenant fine-grained relations list", async () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/tenants/:tenantId/relations"
|
||||||
|
element={<TenantFineGrainedPermissionsTab />}
|
||||||
|
/>
|
||||||
|
</Routes>,
|
||||||
|
"/tenants/tenant-company/relations",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Relation User")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders tenant hierarchy and selected organization members", async () => {
|
it("renders tenant hierarchy and selected organization members", async () => {
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ export function TenantAdminsAndOwnersTab() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||||
{/* Owners Card */}
|
{/* Owners Card */}
|
||||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ function TenantDetailPage() {
|
|||||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission("view") && (
|
||||||
|
<Link
|
||||||
|
to={`/tenants/${tenantId}/relations`}
|
||||||
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
|
location.pathname.includes("/relations")
|
||||||
|
? "text-primary border-b-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Outlet for nested routes */}
|
{/* Outlet for nested routes */}
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||||
|
import { Badge } from "../../../components/ui/badge";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
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";
|
||||||
|
import {
|
||||||
|
fetchUsers,
|
||||||
|
fetchTenantRelations,
|
||||||
|
addTenantRelation,
|
||||||
|
removeTenantRelation,
|
||||||
|
type TenantRelation,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function TenantFineGrainedPermissionsTab() {
|
||||||
|
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||||
|
const tenantId = tenantIdParam ?? "";
|
||||||
|
const { hasPermission } = useTenantPermission(tenantId);
|
||||||
|
const isWritable = hasPermission("manage_admins");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const relationsQuery = useQuery({
|
||||||
|
queryKey: ["tenant-relations", tenantId],
|
||||||
|
queryFn: () => fetchTenantRelations(tenantId),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
});
|
||||||
|
const relations = relationsQuery.data ?? [];
|
||||||
|
|
||||||
|
const addRelationMutation = useMutation({
|
||||||
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
|
addTenantRelation(tenantId, payload.userId, payload.relation),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||||
|
toast.success(t("msg.admin.tenants.relations.add_success", "세부 권한이 추가되었습니다."));
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeRelationMutation = useMutation({
|
||||||
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||||
|
removeTenantRelation(tenantId, payload.userId, payload.relation),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||||
|
toast.success(t("msg.admin.tenants.relations.remove_success", "세부 권한이 회수되었습니다."));
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRelationChange = async (
|
||||||
|
userId: string,
|
||||||
|
tab: "profile" | "permissions" | "organization" | "schema",
|
||||||
|
currentVal: "none" | "read" | "write",
|
||||||
|
newVal: "none" | "read" | "write",
|
||||||
|
) => {
|
||||||
|
const readRel = `${tab}_viewers`;
|
||||||
|
const writeRel = `${tab}_managers`;
|
||||||
|
|
||||||
|
if (currentVal === newVal) return;
|
||||||
|
|
||||||
|
if (currentVal === "read") {
|
||||||
|
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||||
|
} else if (currentVal === "write") {
|
||||||
|
await removeRelationMutation.mutateAsync({ userId, relation: writeRel });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVal === "read") {
|
||||||
|
await addRelationMutation.mutateAsync({ userId, relation: readRel });
|
||||||
|
} else if (newVal === "write") {
|
||||||
|
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAllRelations = async (userId: string, userRelations: string[]) => {
|
||||||
|
if (!window.confirm(t("msg.admin.tenants.relations.remove_all_confirm", "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const rel of userRelations) {
|
||||||
|
await removeRelationMutation.mutateAsync({ userId, relation: rel });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["admin-users-search", searchTerm],
|
||||||
|
queryFn: () => fetchUsers(20, 0, searchTerm),
|
||||||
|
enabled: isDialogOpen && searchTerm.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddUser = (userId: string) => {
|
||||||
|
addRelationMutation.mutate({ userId, relation: "profile_viewers" });
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setSearchTerm("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
const searchResults = usersQuery.data?.items || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||||
|
<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.tenants.relations.title", "세부 권한 설정 (Fine-grained Permissions)")}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"msg.admin.tenants.relations.subtitle",
|
||||||
|
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
disabled={!isWritable}
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
{t("ui.admin.tenants.relations.add_button", "세부 권한 사용자 추가")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="rounded-md border border-border overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-secondary/40">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="font-bold">{t("ui.common.name", "이름")}</TableHead>
|
||||||
|
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}</TableHead>
|
||||||
|
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}</TableHead>
|
||||||
|
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}</TableHead>
|
||||||
|
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}</TableHead>
|
||||||
|
<TableHead className="font-bold text-center w-20">{t("ui.common.action", "작업")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{relations.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||||
|
{t("msg.admin.tenants.relations.empty", "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
relations.map((user) => {
|
||||||
|
const profileVal = user.relations.includes("profile_managers")
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("profile_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const permissionsVal = user.relations.includes("permissions_managers")
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("permissions_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const organizationVal = user.relations.includes("organization_managers")
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("organization_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
const schemaVal = user.relations.includes("schema_managers")
|
||||||
|
? "write"
|
||||||
|
: user.relations.includes("schema_viewers")
|
||||||
|
? "read"
|
||||||
|
: "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold text-foreground">{user.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground italic">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={profileVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"profile",
|
||||||
|
profileVal,
|
||||||
|
e.target.value as "none" | "read" | "write",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t("ui.common.none", "권한 없음")}</option>
|
||||||
|
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
|
||||||
|
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={permissionsVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"permissions",
|
||||||
|
permissionsVal,
|
||||||
|
e.target.value as "none" | "read" | "write",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t("ui.common.none", "권한 없음")}</option>
|
||||||
|
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
|
||||||
|
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={organizationVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"organization",
|
||||||
|
organizationVal,
|
||||||
|
e.target.value as "none" | "read" | "write",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t("ui.common.none", "권한 없음")}</option>
|
||||||
|
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
|
||||||
|
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full 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"
|
||||||
|
value={schemaVal}
|
||||||
|
disabled={!isWritable}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleRelationChange(
|
||||||
|
user.userId,
|
||||||
|
"schema",
|
||||||
|
schemaVal,
|
||||||
|
e.target.value as "none" | "read" | "write",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="none">{t("ui.common.none", "권한 없음")}</option>
|
||||||
|
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
|
||||||
|
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={!isWritable}
|
||||||
|
onClick={() => handleRemoveAllRelations(user.userId, user.relations)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Common Dialog for adding users */}
|
||||||
|
<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.tenants.relations.dialog_title", "세부 권한 관리 유저 추가")}
|
||||||
|
</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 = relations.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 ||
|
||||||
|
addRelationMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => handleAddUser(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -491,6 +491,41 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
|
|||||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TenantRelation = {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
relations: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchTenantRelations(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/relations`,
|
||||||
|
);
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantRelation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||||
|
userId,
|
||||||
|
relation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantRelation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
relation: string,
|
||||||
|
) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||||
|
data: { userId, relation },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Group Management
|
// Group Management
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -755,6 +755,10 @@ func main() {
|
|||||||
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||||
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||||
|
|
||||||
|
admin.Get("/tenants/:id/relations", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage_admins"), tenantHandler.ListRelations)
|
||||||
|
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("/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)
|
||||||
|
|||||||
@@ -3206,3 +3206,168 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
|
|||||||
"sharedWith": link.Name,
|
"sharedWith": link.Name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tenantRelationRequest struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Relation string `json:"relation"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ListRelations(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedRelations := map[string]bool{
|
||||||
|
"profile_viewers": true,
|
||||||
|
"profile_managers": true,
|
||||||
|
"permissions_viewers": true,
|
||||||
|
"permissions_managers": true,
|
||||||
|
"organization_viewers": true,
|
||||||
|
"organization_managers": true,
|
||||||
|
"schema_viewers": true,
|
||||||
|
"schema_managers": 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) AddRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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{
|
||||||
|
"profile_viewers": true,
|
||||||
|
"profile_managers": true,
|
||||||
|
"permissions_viewers": true,
|
||||||
|
"permissions_managers": true,
|
||||||
|
"organization_viewers": true,
|
||||||
|
"organization_managers": true,
|
||||||
|
"schema_viewers": true,
|
||||||
|
"schema_managers": 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(), "Tenant", tenantID, 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: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: req.Relation,
|
||||||
|
Subject: "User:" + req.UserID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
169
backend/internal/handler/tenant_handler_relations_test.go
Normal file
169
backend/internal/handler/tenant_handler_relations_test.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/repository"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"baron-sso-backend/internal/testsupport"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantHandler_Relations(t *testing.T) {
|
||||||
|
if !testsupport.DockerAvailable() {
|
||||||
|
t.Skip("Docker provider is unavailable in this environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
db := newTenantHandlerSeedDeleteDB(t)
|
||||||
|
if err := db.AutoMigrate(&domain.TenantDomain{}, &domain.KetoOutbox{}); err != nil {
|
||||||
|
t.Fatalf("failed to migrate tenant domains or outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test tenant in DB with a valid UUID
|
||||||
|
tenantID := "00000000-0000-0000-0000-000000000030"
|
||||||
|
tenant := domain.Tenant{
|
||||||
|
ID: tenantID,
|
||||||
|
Name: "Relation Test Tenant",
|
||||||
|
Slug: "relation-test-tenant",
|
||||||
|
Type: domain.TenantTypeCompany,
|
||||||
|
Status: domain.TenantStatusActive,
|
||||||
|
}
|
||||||
|
if err := db.Create(&tenant).Error; err != nil {
|
||||||
|
t.Fatalf("failed to create tenant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSvc := new(MockTenantService)
|
||||||
|
mockKeto := new(devMockKetoService)
|
||||||
|
realOutbox := repository.NewKetoOutboxRepository(db)
|
||||||
|
|
||||||
|
h := &TenantHandler{
|
||||||
|
DB: db,
|
||||||
|
Service: mockSvc,
|
||||||
|
Keto: mockKeto,
|
||||||
|
KetoOutbox: realOutbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := "user-relation-1"
|
||||||
|
|
||||||
|
t.Run("ListRelations - Returns correct relations aggregated by user", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/tenants/:id/relations", h.ListRelations)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "", "").Return([]service.RelationTuple{
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "schema_managers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "profile_viewers",
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "unrelated_relation", // Should be filtered out
|
||||||
|
SubjectID: "User:" + userID,
|
||||||
|
},
|
||||||
|
}, nil).Once()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/tenants/"+tenantID+"/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"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
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, "schema_managers")
|
||||||
|
assert.Contains(t, got.Items[0].Relations, "profile_viewers")
|
||||||
|
assert.NotContains(t, got.Items[0].Relations, "unrelated_relation")
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("AddRelation - Inserts into KetoOutbox DB table", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Post("/tenants/:id/relations", h.AddRelation)
|
||||||
|
|
||||||
|
mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"userId": userID,
|
||||||
|
"relation": "schema_managers",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("POST", "/tenants/"+tenantID+"/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)
|
||||||
|
|
||||||
|
// Verify row was written to the keto_outboxes DB table
|
||||||
|
var outboxEntries []domain.KetoOutbox
|
||||||
|
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionCreate).Find(&outboxEntries).Error; err != nil {
|
||||||
|
t.Fatalf("failed to query outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, outboxEntries, 1)
|
||||||
|
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
|
||||||
|
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
||||||
|
mockKeto.AssertExpectations(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RemoveRelation - Inserts delete action into KetoOutbox DB table", func(t *testing.T) {
|
||||||
|
app := fiber.New()
|
||||||
|
app.Delete("/tenants/:id/relations", h.RemoveRelation)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{
|
||||||
|
"userId": userID,
|
||||||
|
"relation": "schema_managers",
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest("DELETE", "/tenants/"+tenantID+"/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)
|
||||||
|
|
||||||
|
// Verify delete action row was written to the keto_outboxes DB table
|
||||||
|
var outboxEntries []domain.KetoOutbox
|
||||||
|
if err := db.Where("object = ? AND relation = ? AND action = ?", tenantID, "schema_managers", domain.KetoOutboxActionDelete).Find(&outboxEntries).Error; err != nil {
|
||||||
|
t.Fatalf("failed to query outbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, outboxEntries, 1)
|
||||||
|
assert.Equal(t, "Tenant", outboxEntries[0].Namespace)
|
||||||
|
assert.Equal(t, "User:"+userID, outboxEntries[0].Subject)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,9 +22,63 @@ class Tenant implements Namespace {
|
|||||||
parents: Tenant[]
|
parents: Tenant[]
|
||||||
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
// 🌟 신규 직접 관계 (Direct Relations) 정의
|
||||||
|
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
profile_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
organization_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
schema_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
}
|
}
|
||||||
|
|
||||||
permits = {
|
permits = {
|
||||||
|
// 1. 프로필 (Profile) 탭 허가 규칙
|
||||||
|
view_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_profile(ctx) ||
|
||||||
|
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
|
||||||
|
|
||||||
|
manage_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
|
||||||
|
|
||||||
|
// 2. 권한 관리 (Permissions) 탭 허가 규칙
|
||||||
|
view_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
|
||||||
|
|
||||||
|
// 3. 조직 관리 (Organization) 탭 허가 규칙
|
||||||
|
view_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_organization(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
|
||||||
|
view_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_schema(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// --- 기존 마스터 및 상속 규칙 보존 ---
|
||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.members.includes(ctx.subject) ||
|
this.related.members.includes(ctx.subject) ||
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
|||||||
181
docs/adminfront-tab-level-direct-permission-design.md
Normal file
181
docs/adminfront-tab-level-direct-permission-design.md
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
# [RFC/Design] adminfront: 각 탭별 ReBAC 기반 세부 권한 직접 부여 기능 설계
|
||||||
|
|
||||||
|
## 1. 배경 및 목적
|
||||||
|
|
||||||
|
현재 `adminfront` 테넌트 상세 페이지는 대략적인 역할 기반 제어(Coarse-grained RBAC/ReBAC) 형태로만 동작합니다.
|
||||||
|
운영자는 사용자를 **"소유자(Owner)"** 또는 **"테넌트 관리자(Admin)"**로만 임명할 수 있으며, 이 역할에 의해 테넌트 하위의 4개 탭(프로필, 권한 관리, 조직 관리, 사용자 스키마)의 읽기/쓰기 권한이 통째로 결정됩니다.
|
||||||
|
|
||||||
|
하지만 더욱 세밀한 운영 권한 관리가 필요하다는 비즈니스 요구사항에 따라, **"사용자 A에게는 조직 관리 및 스키마 읽기 권한만 부여"**, **"사용자 B에게는 스키마 수정 권한만 부여"**와 같이 탭 레벨에서 세분화된(Fine-grained) 권한을 직접 지정할 수 있는 기능을 신설합니다.
|
||||||
|
|
||||||
|
이 설계는 `devfront`에서 이슈 #1029를 통해 구현 완료한 **"RP 세부 관계 직접 부여"** 철학과 완벽히 동일하며, Ory Keto(ReBAC) 및 아웃박스 정합성 엔진을 관통하여 설계됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 세부 설계 사양
|
||||||
|
|
||||||
|
### 2.1 Ory Keto OPL 스키마 변경 (`docker/ory/keto/namespaces.ts`)
|
||||||
|
|
||||||
|
`Tenant` 네임스페이스 하위에 각 탭별 읽기(`_viewers`)와 쓰기(`_managers`)를 결정하는 **물리적인 직접 관계(Direct Relations)**를 추가합니다.
|
||||||
|
기존 `members`, `admins`, `owners`에 의한 상속 허가 식(Permits)을 유지하여 하위 호환성 및 기존 관리체계의 안정성을 완벽히 보장합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Tenant implements Namespace {
|
||||||
|
related: {
|
||||||
|
owners: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
admins: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
members: (User | SubjectSet<System, "super_admins"> | SubjectSet<Tenant, "admins"> | SubjectSet<Tenant, "owners">)[]
|
||||||
|
parents: Tenant[]
|
||||||
|
developer_console_viewer: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
developer_console_grant_manager: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
// 🌟 신규 직접 관계 (Direct Relations) 정의
|
||||||
|
profile_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
profile_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
permissions_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
permissions_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
organization_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
organization_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
|
||||||
|
schema_viewers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
schema_managers: (User | SubjectSet<System, "super_admins">)[]
|
||||||
|
}
|
||||||
|
|
||||||
|
permits = {
|
||||||
|
// 1. 프로필 (Profile) 탭 허가 규칙
|
||||||
|
view_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_profile(ctx) ||
|
||||||
|
this.permits.view(ctx), // 멤버/관리자/소유자는 기본 조회 가능
|
||||||
|
|
||||||
|
manage_profile: (ctx: Context): boolean =>
|
||||||
|
this.related.profile_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx), // 관리자/소유자는 기본 수정 가능
|
||||||
|
|
||||||
|
// 2. 권한 관리 (Permissions) 탭 허가 규칙
|
||||||
|
view_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_permissions(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_permissions: (ctx: Context): boolean =>
|
||||||
|
this.related.permissions_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_admins(ctx), // 소유자는 기본 관리 가능
|
||||||
|
|
||||||
|
// 3. 조직 관리 (Organization) 탭 허가 규칙
|
||||||
|
view_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_organization(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_organization: (ctx: Context): boolean =>
|
||||||
|
this.related.organization_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// 4. 사용자 스키마 (Schema) 탭 허가 규칙
|
||||||
|
view_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_viewers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage_schema(ctx) ||
|
||||||
|
this.permits.view(ctx),
|
||||||
|
|
||||||
|
manage_schema: (ctx: Context): boolean =>
|
||||||
|
this.related.schema_managers.includes(ctx.subject) ||
|
||||||
|
this.permits.manage(ctx),
|
||||||
|
|
||||||
|
// --- 기존 마스터 및 상속 규칙 보존 ---
|
||||||
|
view: (ctx: Context): boolean =>
|
||||||
|
this.related.members.includes(ctx.subject) ||
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||||
|
|
||||||
|
manage: (ctx: Context): boolean =>
|
||||||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
||||||
|
|
||||||
|
manage_admins: (ctx: Context): boolean =>
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.manage_admins(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 백엔드 API 설계 (`backend/internal/handler/tenant_handler.go`)
|
||||||
|
|
||||||
|
세부 권한 부여/회수 API는 해당 테넌트의 최상위 권한 관리자만 수행할 수 있도록 **`Tenant#manage_admins`** 허가 규칙으로 강력하게 인가 보호합니다.
|
||||||
|
|
||||||
|
#### A. 세부 권한 관계 전체 조회 API
|
||||||
|
* **Endpoint**: `GET /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **반환 DTO**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"name": "홍길동",
|
||||||
|
"email": "kildong@hmac.kr",
|
||||||
|
"relations": ["profile_managers", "schema_viewers"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### B. 세부 권한 관계 부여 API
|
||||||
|
* **Endpoint**: `POST /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"relation": "profile_managers"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto에 `Tenant:<ID>#profile_managers@User:<UserID>` 튜플 반영.
|
||||||
|
|
||||||
|
#### C. 세부 권한 관계 회수 API
|
||||||
|
* **Endpoint**: `DELETE /api/v1/admin/tenants/:id/relations`
|
||||||
|
* **인가 필터**: `RequireKetoPermission(config, "Tenant", "manage_admins")`
|
||||||
|
* **Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "00000000-0000-0000-0000-000000000010",
|
||||||
|
"relation": "profile_managers"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **동작**: 트랜잭셔널 아웃박스에 적재하여 Keto 내 튜플 삭제 반영.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 프론트엔드 UI 설계
|
||||||
|
|
||||||
|
사용자에게 역할(Role) 외에 세부적인 설정을 직관적으로 관리할 수 있도록, 기존 **"권한 관리"** 탭 하단에 **"세부 권한 설정 (Fine-grained Permissions)"** 섹션을 신설합니다.
|
||||||
|
|
||||||
|
#### A. 구성 요소
|
||||||
|
1. **유저 검색/추가 패널**: 테넌트 소속 사용자를 검색하여 격리 설정 테이블(Matrix)에 추가합니다.
|
||||||
|
2. **세부 권한 격리 매트릭스 (Matrix Table)**:
|
||||||
|
* 컬럼: `이름` | `이메일` | `테넌트 프로필` | `권한 관리` | `조직 관리` | `사용자 스키마` | `작업`
|
||||||
|
* 각 탭 컬럼은 드롭다운 셀렉트 박스로 채워집니다:
|
||||||
|
* **`권한 없음 (None)`** / **`조회 가능 (Read)`** / **`수정 가능 (Write)`**
|
||||||
|
3. **상태 동기화 연동**:
|
||||||
|
* 셀렉트 박스에서 `조회 가능(Read)` 선택 시: `_viewers` 관계 추가(`POST`) & `_managers` 관계 회수(`DELETE`).
|
||||||
|
* 셀렉트 박스에서 `수정 가능(Write)` 선택 시: `_managers` 관계 추가(`POST`) & `_viewers` 관계 회수(`DELETE`).
|
||||||
|
* 셀렉트 박스에서 `권한 없음(None)` 선택 시: 둘 다 회수(`DELETE`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 작업 계획 및 테스트 전략
|
||||||
|
|
||||||
|
1. **OPL 컴파일 및 빌드 검증**:
|
||||||
|
* namespaces.ts 수정 후 Keto OPL 테스트를 구동하여 컴파일 문법에 문제가 없는지 사전 검증합니다.
|
||||||
|
2. **백엔드 구현 및 DB 연동**:
|
||||||
|
* `tenant_handler.go`에 신규 핸들러 추가 후 gg/gorm 아웃박스 통합을 완료합니다.
|
||||||
|
3. **프론트엔드 연동 및 Matrix UI 개발**:
|
||||||
|
* `TenantAdminsAndOwnersTab.tsx` 하단부 카드에 매트릭스 테이블 영역을 추가합니다.
|
||||||
|
4. **유형 및 단위 테스트**:
|
||||||
|
* 신설된 REST API 명세를 테스트하는 고성능 백엔드 단위 테스트를 작성합니다.
|
||||||
|
* 프론트엔드에서 체크박스 변경 시 올바른 릴레이션이 트리거되는지 검증하는 Vitest 렌더 테스트를 작성합니다.
|
||||||
Reference in New Issue
Block a user