{/* 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",
+ "사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
+ )}
+
+
+ setIsDialogOpen(true)}
+ disabled={!isWritable}
+ >
+
+ {t("ui.admin.tenants.relations.add_button", "세부 권한 사용자 추가")}
+
+
+
+
+
+
+
+ {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}
+
+
+
+
+ handleRelationChange(
+ user.userId,
+ "profile",
+ profileVal,
+ e.target.value as "none" | "read" | "write",
+ )
+ }
+ >
+ {t("ui.common.none", "권한 없음")}
+ {t("ui.common.read", "조회 가능 (Read)")}
+ {t("ui.common.write", "수정 가능 (Write)")}
+
+
+
+
+ handleRelationChange(
+ user.userId,
+ "permissions",
+ permissionsVal,
+ e.target.value as "none" | "read" | "write",
+ )
+ }
+ >
+ {t("ui.common.none", "권한 없음")}
+ {t("ui.common.read", "조회 가능 (Read)")}
+ {t("ui.common.write", "수정 가능 (Write)")}
+
+
+
+
+ handleRelationChange(
+ user.userId,
+ "organization",
+ organizationVal,
+ e.target.value as "none" | "read" | "write",
+ )
+ }
+ >
+ {t("ui.common.none", "권한 없음")}
+ {t("ui.common.read", "조회 가능 (Read)")}
+ {t("ui.common.write", "수정 가능 (Write)")}
+
+
+
+
+ handleRelationChange(
+ user.userId,
+ "schema",
+ schemaVal,
+ e.target.value as "none" | "read" | "write",
+ )
+ }
+ >
+ {t("ui.common.none", "권한 없음")}
+ {t("ui.common.read", "조회 가능 (Read)")}
+ {t("ui.common.write", "수정 가능 (Write)")}
+
+
+
+ handleRemoveAllRelations(user.userId, user.relations)}
+ >
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+ {/* 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}
+
+
+
+
handleAddUser(user.id)}
+ >
+ {isAlreadyInMatrix ? (
+
+ {t(
+ "ui.admin.tenants.relations.already_added",
+ "이미 추가됨",
+ )}
+
+ ) : (
+ <>
+ {" "}
+ {t("ui.common.add", "추가")}
+ >
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index 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 렌더 테스트를 작성합니다.