From 6ebcb43b16f6227a6d997c43835bb947d836bf22 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 10 Jun 2026 15:44:07 +0900 Subject: [PATCH] =?UTF-8?q?adminfront:=20=ED=83=AD=EB=B3=84=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=20=EA=B6=8C=ED=95=9C=20=EA=B2=A9=EB=A6=AC=20=EB=B6=80?= =?UTF-8?q?=EC=97=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=8F=85=EC=9E=90?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=205=EB=B2=88=EC=A7=B8=20=ED=83=AD(=EC=84=B8?= =?UTF-8?q?=EB=B6=80=20=EA=B6=8C=ED=95=9C)=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 2 + .../coverage/adminTenantTabs.test.tsx | 27 ++ .../routes/TenantAdminsAndOwnersTab.tsx | 2 +- .../tenants/routes/TenantDetailPage.tsx | 12 + .../TenantFineGrainedPermissionsTab.tsx | 431 ++++++++++++++++++ adminfront/src/lib/adminApi.ts | 35 ++ backend/cmd/server/main.go | 4 + backend/internal/handler/tenant_handler.go | 165 +++++++ .../handler/tenant_handler_relations_test.go | 169 +++++++ docker/ory/keto/namespaces.ts | 54 +++ ...ront-tab-level-direct-permission-design.md | 181 ++++++++ 11 files changed, 1081 insertions(+), 1 deletion(-) create mode 100644 adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx create mode 100644 backend/internal/handler/tenant_handler_relations_test.go create mode 100644 docs/adminfront-tab-level-direct-permission-design.md diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 339a14d7..472d42e4 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -17,6 +17,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; +import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab"; import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage"; import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage"; @@ -59,6 +60,7 @@ export const adminRoutes: RouteObject[] = [ { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, + { path: "relations", element: }, ], }, { diff --git a/adminfront/src/features/coverage/adminTenantTabs.test.tsx b/adminfront/src/features/coverage/adminTenantTabs.test.tsx index eeee7afd..732eff3c 100644 --- a/adminfront/src/features/coverage/adminTenantTabs.test.tsx +++ b/adminfront/src/features/coverage/adminTenantTabs.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createI18nMock } from "../../test/i18nMock"; import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab"; +import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab"; import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab"; const exportUsersCSVMock = vi.hoisted(() => @@ -106,6 +107,16 @@ vi.mock("../../lib/adminApi", () => ({ addTenantAdmin: vi.fn(async () => undefined), removeTenantOwner: 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 () => ({ items: users, total: users.length, @@ -165,6 +176,22 @@ describe("admin tenant tab coverage smoke", () => { expect(screen.getByText("admin@example.com")).toBeInTheDocument(); }); + it("renders tenant fine-grained relations list", async () => { + renderWithProviders( + + } + /> + , + "/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 () => { renderWithProviders( diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 67d858b6..f0495d45 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -365,7 +365,7 @@ export function TenantAdminsAndOwnersTab() { ); return ( -
+
{/* Owners Card */} diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 8dff791a..c59a2bea 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -117,6 +117,18 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} )} + {hasPermission("view") && ( + + {t("ui.admin.tenants.detail.tab_relations", "세부 권한")} + + )}
{/* Outlet for nested routes */} diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx new file mode 100644 index 00000000..d2020ce5 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx @@ -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 ( +
+ + +
+ + + {t("ui.admin.tenants.relations.title", "세부 권한 설정 (Fine-grained Permissions)")} + + + {t( + "msg.admin.tenants.relations.subtitle", + "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.", + )} + +
+ +
+ +
+ + + + {t("ui.common.name", "이름")} + {t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")} + {t("ui.admin.tenants.detail.tab_permissions", "권한 관리")} + {t("ui.admin.tenants.detail.tab_organization", "조직 관리")} + {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} + {t("ui.common.action", "작업")} + + + + {relations.length === 0 ? ( + + + {t("msg.admin.tenants.relations.empty", "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.")} + + + ) : ( + 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 ( + + +
+ {user.name} + {user.email} +
+
+ + + + + + + + + + + + + + + +
+ ); + }) + )} +
+
+
+
+
+ + {/* Common Dialog for adding users */} + { + if (!open) { + setIsDialogOpen(false); + setSearchTerm(""); + } + }} + > + + + + {t("ui.admin.tenants.relations.dialog_title", "세부 권한 관리 유저 추가")} + + + {t( + "ui.admin.tenants.admins.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + )} + + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ {searchTerm.length < 2 ? ( +
+ +

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

+
+ ) : usersQuery.isLoading ? ( +
+
+
+ ) : searchResults.length === 0 ? ( +
+ {t( + "ui.admin.tenants.admins.dialog_no_results", + "검색 결과가 없습니다.", + )} +
+ ) : ( +
+ {searchResults.map((user) => { + const isAlreadyInMatrix = relations.some( + (r) => r.userId === user.id, + ); + + return ( +
+
+
+ {user.name.charAt(0)} +
+
+ + {user.name} + + + {user.email} + +
+
+ +
+ ); + })} +
+ )} +
+
+ +
+
+ ); +} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 9a921438..8c55706a 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -491,6 +491,41 @@ export async function removeTenantOwner(tenantId: string, userId: string) { 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 export type GroupMember = { id: string; diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 63a1f033..036e8863 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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.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/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison) admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches) diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 90ae5ed2..ca5cc54a 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -3206,3 +3206,168 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { "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) +} diff --git a/backend/internal/handler/tenant_handler_relations_test.go b/backend/internal/handler/tenant_handler_relations_test.go new file mode 100644 index 00000000..fd127b6b --- /dev/null +++ b/backend/internal/handler/tenant_handler_relations_test.go @@ -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) + }) +} diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 2d387e27..0a4e0a52 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -22,9 +22,63 @@ class Tenant implements Namespace { parents: Tenant[] developer_console_viewer: (User | SubjectSet)[] developer_console_grant_manager: (User | SubjectSet)[] + + // 🌟 신규 직접 관계 (Direct Relations) 정의 + profile_viewers: (User | SubjectSet)[] + profile_managers: (User | SubjectSet)[] + + permissions_viewers: (User | SubjectSet)[] + permissions_managers: (User | SubjectSet)[] + + organization_viewers: (User | SubjectSet)[] + organization_managers: (User | SubjectSet)[] + + schema_viewers: (User | SubjectSet)[] + schema_managers: (User | SubjectSet)[] } 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) || diff --git a/docs/adminfront-tab-level-direct-permission-design.md b/docs/adminfront-tab-level-direct-permission-design.md new file mode 100644 index 00000000..37b58b6e --- /dev/null +++ b/docs/adminfront-tab-level-direct-permission-design.md @@ -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)[] + admins: (User | SubjectSet)[] + members: (User | SubjectSet | SubjectSet | SubjectSet)[] + parents: Tenant[] + developer_console_viewer: (User | SubjectSet)[] + developer_console_grant_manager: (User | SubjectSet)[] + + // 🌟 신규 직접 관계 (Direct Relations) 정의 + profile_viewers: (User | SubjectSet)[] + profile_managers: (User | SubjectSet)[] + + permissions_viewers: (User | SubjectSet)[] + permissions_managers: (User | SubjectSet)[] + + organization_viewers: (User | SubjectSet)[] + organization_managers: (User | SubjectSet)[] + + schema_viewers: (User | SubjectSet)[] + schema_managers: (User | SubjectSet)[] + } + + 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:#profile_managers@User:` 튜플 반영. + +#### 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 렌더 테스트를 작성합니다.