From d0bdc54286fb3c912c26e63404042ca687f081e1 Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 12 Jun 2026 11:39:28 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20=EB=B0=8F=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C:=20=EC=84=B8=EB=B6=80=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20Keto=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=EC=8B=9D=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EC=A0=81=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0/=EB=A1=A4=EB=B0=B1=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TenantFineGrainedPermissionsTab.tsx | 159 +++++++++++---- backend/internal/handler/tenant_handler.go | 189 +++++++++++++++--- .../handler/tenant_handler_relations_test.go | 7 + 3 files changed, 291 insertions(+), 64 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx index 7c4eac3d..e119b31a 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsTab.tsx @@ -6,7 +6,7 @@ import { ShieldCheck, UserPlus, } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useParams } from "react-router-dom"; import { useTenantPermission } from "../hooks/useTenantPermission"; import { Badge } from "../../../components/ui/badge"; @@ -58,13 +58,45 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai const [searchTerm, setSearchTerm] = useState(""); const [isDialogOpen, setIsDialogOpen] = useState(false); + // 🌟 ν…Œλ„ŒνŠΈ 탭별 λ“œλ‘­λ‹€μš΄ 즉각 변경을 μœ„ν•œ μž„μ‹œ 둜컬 λ§΅ μ„ μ–Έ + const [localTenantPermissions, setLocalTenantPermissions] = useState>>({}); + const relationsQuery = useQuery({ queryKey: ["tenant-relations", tenantId], queryFn: () => fetchTenantRelations(tenantId), enabled: !!tenantId, }); + const relationsData = relationsQuery.data ?? []; + + // 🌟 μ„œλ²„ 데이터λ₯Ό μˆ˜μ‹ ν•˜λ©΄ 둜컬 λ³€κ²½ μƒνƒœ 맡을 μ‹€μ‹œκ°„ 동기화 + useEffect(() => { + if (relationsQuery.data) { + const initialMap: Record> = {}; + for (const user of relationsQuery.data) { + initialMap[user.userId] = {}; + const tabs = ["profile", "permissions", "organization", "schema"]; + for (const tab of tabs) { + const isWrite = user.relations.includes(`${tab}_managers`); + const isRead = user.relations.includes(`${tab}_viewers`); + initialMap[user.userId][tab] = isWrite ? "write" : isRead ? "read" : "none"; + } + } + setLocalTenantPermissions(initialMap); + } + }, [relationsQuery.data]); const relations = relationsQuery.data ?? []; + const invalidateAllQueries = () => { + queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + }, 500); + }; + const addRelationMutation = useMutation({ mutationFn: (payload: { userId: string; relation: string }) => addTenantRelation(tenantId, payload.userId, payload.relation), @@ -96,10 +128,7 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai toast.error(err.response?.data?.error || t("msg.common.error", "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); }, onSuccess: () => { - toast.success(t("msg.admin.tenants.relations.add_success", "μ„ΈλΆ€ κΆŒν•œμ΄ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); + // Quiet mutate }, }); @@ -132,10 +161,7 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai toast.error(err.response?.data?.error || t("msg.common.error", "였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.")); }, onSuccess: () => { - toast.success(t("msg.admin.tenants.relations.remove_success", "μ„ΈλΆ€ κΆŒν•œμ΄ νšŒμˆ˜λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] }); + // Quiet mutate }, }); @@ -150,16 +176,25 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai if (currentVal === newVal) return; - if (currentVal === "read") { - await removeRelationMutation.mutateAsync({ userId, relation: readRel }); - } else if (currentVal === "write") { - await removeRelationMutation.mutateAsync({ userId, relation: writeRel }); - } + try { + 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 }); + if (newVal === "read") { + await addRelationMutation.mutateAsync({ userId, relation: readRel }); + } else if (newVal === "write") { + await addRelationMutation.mutateAsync({ userId, relation: writeRel }); + } + + invalidateAllQueries(); + + // 🌟 Trigger a single consolidated success toast at the very end + toast.success(t("msg.admin.tenants.relations.update_success", "μ„ΈλΆ€ κΆŒν•œμ΄ μ„±κ³΅μ μœΌλ‘œ λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")); + } catch { + // Individual mutations handle error toast via onError } }; @@ -170,6 +205,7 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai for (const rel of userRelations) { await removeRelationMutation.mutateAsync({ userId, relation: rel }); } + invalidateAllQueries(); }; const usersQuery = useQuery({ @@ -179,7 +215,11 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai }); const handleAddUser = (userId: string) => { - addRelationMutation.mutate({ userId, relation: "profile_viewers" }); + addRelationMutation.mutate({ userId, relation: "profile_viewers" }, { + onSettled: () => { + invalidateAllQueries(); + } + }); setIsDialogOpen(false); setSearchTerm(""); }; @@ -259,6 +299,11 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai ? "read" : "none"; + const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal; + const curPermissionsVal = localTenantPermissions[user.userId]?.permissions ?? permissionsVal; + const curOrganizationVal = localTenantPermissions[user.userId]?.organization ?? organizationVal; + const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal; + return ( @@ -270,16 +315,25 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai + name={`tenant-fine-grained-permissions-${user.userId}`} + onChange={(e) => { + const nextVal = e.target.value as "none" | "read" | "write"; + setLocalTenantPermissions(prev => ({ + ...prev, + [user.userId]: { + ...(prev[user.userId] ?? {}), + permissions: nextVal + } + })); handleRelationChange( user.userId, "permissions", permissionsVal, - e.target.value as "none" | "read" | "write", - ) - } + nextVal, + ); + }} > @@ -308,16 +371,25 @@ export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrai + name={`tenant-fine-grained-schema-${user.userId}`} + onChange={(e) => { + const nextVal = e.target.value as "none" | "read" | "write"; + setLocalTenantPermissions(prev => ({ + ...prev, + [user.userId]: { + ...(prev[user.userId] ?? {}), + schema: nextVal + } + })); handleRelationChange( user.userId, "schema", schemaVal, - e.target.value as "none" | "read" | "write", - ) - } + nextVal, + ); + }} > diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 540d5041..0674155f 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -88,6 +88,15 @@ type tenantPermissions struct { View bool `json:"view"` Manage bool `json:"manage"` ManageAdmins bool `json:"manage_admins"` + + ViewProfile bool `json:"view_profile"` + ManageProfile bool `json:"manage_profile"` + ViewPermissions bool `json:"view_permissions"` + ManagePermissions bool `json:"manage_permissions"` + ViewOrganization bool `json:"view_organization"` + ManageOrganization bool `json:"manage_organization"` + ViewSchema bool `json:"view_schema"` + ManageSchema bool `json:"manage_schema"` } type tenantSummary struct { @@ -1691,9 +1700,17 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { role := domain.NormalizeRole(profile.Role) if role == domain.RoleSuperAdmin { summary.UserPermissions = &tenantPermissions{ - View: true, - Manage: true, - ManageAdmins: true, + View: true, + Manage: true, + ManageAdmins: true, + ViewProfile: true, + ManageProfile: true, + ViewPermissions: true, + ManagePermissions: true, + ViewOrganization: true, + ManageOrganization: true, + ViewSchema: true, + ManageSchema: true, } } else { // Query Keto in parallel for maximum performance @@ -1703,8 +1720,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { allowed bool err error } - ch := make(chan checkResult, 3) - relations := []string{"view", "manage", "manage_admins"} + ch := make(chan checkResult, 11) + relations := []string{ + "view", "manage", "manage_admins", + "view_profile", "manage_profile", + "view_permissions", "manage_permissions", + "view_organization", "manage_organization", + "view_schema", "manage_schema", + } for _, rel := range relations { go func(r string) { allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r) @@ -1726,6 +1749,22 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { perms.Manage = res.allowed case "manage_admins": perms.ManageAdmins = res.allowed + case "view_profile": + perms.ViewProfile = res.allowed + case "manage_profile": + perms.ManageProfile = res.allowed + case "view_permissions": + perms.ViewPermissions = res.allowed + case "manage_permissions": + perms.ManagePermissions = res.allowed + case "view_organization": + perms.ViewOrganization = res.allowed + case "manage_organization": + perms.ManageOrganization = res.allowed + case "view_schema": + perms.ViewSchema = res.allowed + case "manage_schema": + perms.ManageSchema = res.allowed } } summary.UserPermissions = perms @@ -3331,16 +3370,35 @@ func (h *TenantHandler) AddRelation(c *fiber.Ctx) error { } } + var directWriteErr error + if h.Keto != nil { + directWriteErr = h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID) + } + if h.KetoOutbox != nil { + status := domain.KetoOutboxStatusPending + var processedAt *time.Time + if directWriteErr == nil && h.Keto != nil { + status = domain.KetoOutboxStatusProcessed + now := time.Now() + processedAt = &now + } + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenantID, - Relation: req.Relation, - Subject: "User:" + req.UserID, - Action: domain.KetoOutboxActionCreate, + Namespace: "Tenant", + Object: tenantID, + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionCreate, + Status: status, + ProcessedAt: processedAt, }) } + if directWriteErr != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 μ‹€νŒ¨: "+directWriteErr.Error()) + } + return c.SendStatus(fiber.StatusOK) } @@ -3359,16 +3417,35 @@ func (h *TenantHandler) RemoveRelation(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } + var directWriteErr error + if h.Keto != nil { + directWriteErr = h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, req.Relation, "User:"+req.UserID) + } + if h.KetoOutbox != nil { + status := domain.KetoOutboxStatusPending + var processedAt *time.Time + if directWriteErr == nil && h.Keto != nil { + status = domain.KetoOutboxStatusProcessed + now := time.Now() + processedAt = &now + } + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenantID, - Relation: req.Relation, - Subject: "User:" + req.UserID, - Action: domain.KetoOutboxActionDelete, + Namespace: "Tenant", + Object: tenantID, + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionDelete, + Status: status, + ProcessedAt: processedAt, }) } + if directWriteErr != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 μ‹€νŒ¨: "+directWriteErr.Error()) + } + return c.SendStatus(fiber.StatusOK) } @@ -3390,6 +3467,18 @@ func (h *TenantHandler) ListSystemRelations(c *fiber.Ctx) error { "auth_guard_viewers": true, "api_keys_viewers": true, "audit_logs_viewers": true, + + "overview_managers": true, + "tenants_managers": true, + "org_chart_managers": true, + "worksmobile_managers": true, + "ory_ssot_managers": true, + "data_integrity_managers": true, + "users_managers": true, + "permissions_direct_managers": true, + "auth_guard_managers": true, + "api_keys_managers": true, + "audit_logs_managers": true, } type userRelationInfo struct { @@ -3474,6 +3563,18 @@ func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error { "auth_guard_viewers": true, "api_keys_viewers": true, "audit_logs_viewers": true, + + "overview_managers": true, + "tenants_managers": true, + "org_chart_managers": true, + "worksmobile_managers": true, + "ory_ssot_managers": true, + "data_integrity_managers": true, + "users_managers": true, + "permissions_direct_managers": true, + "auth_guard_managers": true, + "api_keys_managers": true, + "audit_logs_managers": true, } if !allowedRelations[req.Relation] { @@ -3487,16 +3588,35 @@ func (h *TenantHandler) AddSystemRelation(c *fiber.Ctx) error { } } + var directWriteErr error + if h.Keto != nil { + directWriteErr = h.Keto.CreateRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) + } + if h.KetoOutbox != nil { + status := domain.KetoOutboxStatusPending + var processedAt *time.Time + if directWriteErr == nil && h.Keto != nil { + status = domain.KetoOutboxStatusProcessed + now := time.Now() + processedAt = &now + } + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "System", - Object: "system", - Relation: req.Relation, - Subject: "User:" + req.UserID, - Action: domain.KetoOutboxActionCreate, + Namespace: "System", + Object: "system", + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionCreate, + Status: status, + ProcessedAt: processedAt, }) } + if directWriteErr != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 μ‹€νŒ¨: "+directWriteErr.Error()) + } + return c.SendStatus(fiber.StatusOK) } @@ -3510,15 +3630,34 @@ func (h *TenantHandler) RemoveSystemRelation(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "userId and relation are required") } + var directWriteErr error + if h.Keto != nil { + directWriteErr = h.Keto.DeleteRelation(c.Context(), "System", "system", req.Relation, "User:"+req.UserID) + } + if h.KetoOutbox != nil { + status := domain.KetoOutboxStatusPending + var processedAt *time.Time + if directWriteErr == nil && h.Keto != nil { + status = domain.KetoOutboxStatusProcessed + now := time.Now() + processedAt = &now + } + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ - Namespace: "System", - Object: "system", - Relation: req.Relation, - Subject: "User:" + req.UserID, - Action: domain.KetoOutboxActionDelete, + Namespace: "System", + Object: "system", + Relation: req.Relation, + Subject: "User:" + req.UserID, + Action: domain.KetoOutboxActionDelete, + Status: status, + ProcessedAt: processedAt, }) } + if directWriteErr != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Keto 동기화 μ‹€νŒ¨: "+directWriteErr.Error()) + } + return c.SendStatus(fiber.StatusOK) } diff --git a/backend/internal/handler/tenant_handler_relations_test.go b/backend/internal/handler/tenant_handler_relations_test.go index fea8583e..6ca709b5 100644 --- a/backend/internal/handler/tenant_handler_relations_test.go +++ b/backend/internal/handler/tenant_handler_relations_test.go @@ -111,6 +111,7 @@ func TestTenantHandler_Relations(t *testing.T) { app.Post("/tenants/:id/relations", h.AddRelation) mockKeto.On("ListRelations", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once() + mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once() body, _ := json.Marshal(map[string]string{ "userId": userID, @@ -135,6 +136,7 @@ func TestTenantHandler_Relations(t *testing.T) { assert.Len(t, outboxEntries, 1) assert.Equal(t, "Tenant", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) + assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) mockKeto.AssertExpectations(t) }) @@ -142,6 +144,8 @@ func TestTenantHandler_Relations(t *testing.T) { app := fiber.New() app.Delete("/tenants/:id/relations", h.RemoveRelation) + mockKeto.On("DeleteRelation", mock.Anything, "Tenant", tenantID, "schema_managers", "User:"+userID).Return(nil).Once() + body, _ := json.Marshal(map[string]string{ "userId": userID, "relation": "schema_managers", @@ -165,6 +169,7 @@ func TestTenantHandler_Relations(t *testing.T) { assert.Len(t, outboxEntries, 1) assert.Equal(t, "Tenant", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) + assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) }) } @@ -241,6 +246,7 @@ func TestTenantHandler_SystemRelations(t *testing.T) { app.Post("/system/relations", h.AddSystemRelation) mockKeto.On("ListRelations", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return([]service.RelationTuple{}, nil).Once() + mockKeto.On("CreateRelation", mock.Anything, "System", "system", "ory_ssot_viewers", "User:"+userID).Return(nil).Once() body, _ := json.Marshal(map[string]string{ "userId": userID, @@ -264,6 +270,7 @@ func TestTenantHandler_SystemRelations(t *testing.T) { assert.Len(t, outboxEntries, 1) assert.Equal(t, "System", outboxEntries[0].Namespace) assert.Equal(t, "User:"+userID, outboxEntries[0].Subject) + assert.Equal(t, domain.KetoOutboxStatusProcessed, outboxEntries[0].Status) mockKeto.AssertExpectations(t) }) }