From dd93a3450ab5dd0f4727650ba301bcc45a0af400 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 16:04:25 +0900 Subject: [PATCH] =?UTF-8?q?Dev=20API=EC=97=90=20RP=20operator=20relation?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C/=EB=B6=80=EC=97=AC/=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 5 +- backend/internal/handler/dev_handler.go | 259 ++++++++++- backend/internal/handler/dev_handler_test.go | 198 ++++++++ devfront/src/app/routes.tsx | 2 + .../features/clients/ClientConsentsPage.tsx | 6 + .../features/clients/ClientDetailsPage.tsx | 6 + .../features/clients/ClientGeneralPage.tsx | 6 + .../features/clients/ClientRelationsPage.tsx | 423 ++++++++++++++++++ devfront/src/lib/devApi.ts | 45 ++ 9 files changed, 948 insertions(+), 2 deletions(-) create mode 100644 devfront/src/features/clients/ClientRelationsPage.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 080fe23f..3d5344ff 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -290,7 +290,7 @@ func main() { authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler.HeadlessJWKS = headlessJWKSCache adminHandler := handler.NewAdminHandler(ketoService) - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, ketoOutboxRepo, tenantService, authHandler) devHandler.HeadlessJWKS = headlessJWKSCache devHandler.AuditRepo = auditRepo tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService, sharedLinkService) @@ -708,6 +708,9 @@ func main() { dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", devHandler.GetClient) + dev.Get("/clients/:id/relations", devHandler.ListClientRelations) + dev.Post("/clients/:id/relations", devHandler.AddClientRelation) + dev.Delete("/clients/:id/relations", devHandler.RemoveClientRelation) dev.Put("/clients/:id", devHandler.UpdateClient) dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache) dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index b3f248e7..257ee81a 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -31,6 +31,7 @@ type DevHandler struct { KratosAdmin service.KratosAdminService ConsentRepo repository.ClientConsentRepository Keto service.KetoService + KetoOutbox repository.KetoOutboxRepository RPSvc service.RelyingPartyService TenantSvc service.TenantService Auth interface { @@ -43,7 +44,9 @@ func NewDevHandler( secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService, - keto service.KetoService, tenantSvc service.TenantService, + keto service.KetoService, + ketoOutbox repository.KetoOutboxRepository, + tenantSvc service.TenantService, auth ...interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) }, @@ -64,6 +67,7 @@ func NewDevHandler( KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, Keto: keto, + KetoOutbox: ketoOutbox, RPSvc: rpSvc, TenantSvc: tenantSvc, Auth: authProvider, @@ -118,6 +122,23 @@ type clientEndpoints struct { UserInfo string `json:"userinfo"` } +type clientRelationSummary struct { + Relation string `json:"relation"` + Subject string `json:"subject"` + SubjectType string `json:"subjectType"` + SubjectID string `json:"subjectId"` +} + +type clientRelationListResponse struct { + Items []clientRelationSummary `json:"items"` +} + +type clientRelationUpsertRequest struct { + Relation string `json:"relation"` + Subject string `json:"subject"` + UserID string `json:"userId"` +} + type consentSummary struct { Subject string `json:"subject"` UserName string `json:"userName,omitempty"` @@ -160,6 +181,19 @@ var reservedSystemClientNames = map[string]string{ "devfront": "devfront", } +var allowedRelyingPartyOperatorRelations = map[string]struct{}{ + "admins": {}, + "creator": {}, + "config_editor": {}, + "secret_rotator": {}, + "jwks_viewer": {}, + "jwks_operator": {}, + "consent_viewer": {}, + "consent_revoker": {}, + "relationship_viewer": {}, + "status_operator": {}, +} + func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } @@ -314,6 +348,32 @@ func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.User return err == nil && allowed } +func (h *DevHandler) canViewClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if h.canOperateClientByPermit(c, profile, summary, "view_relationships") { + return true + } + return canAccessClientByLegacyScope(profile, summary) +} + +func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true + } + if h.canOperateClientByPermit(c, profile, summary, "manage") { + return true + } + + clientTenantID := resolveClientTenantID(summary) + if clientTenantID != "" && h.canManageTenantClientsByPermit(c, profile, clientTenantID) { + return true + } + + return canAccessClientByLegacyScope(profile, summary) +} + func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false @@ -389,6 +449,79 @@ func reservedSystemClientOwnerID(name string) (string, bool) { return ownerID, ok } +func normalizeRelyingPartyRelation(relation string) string { + return strings.TrimSpace(relation) +} + +func isAllowedRelyingPartyOperatorRelation(relation string) bool { + _, ok := allowedRelyingPartyOperatorRelations[normalizeRelyingPartyRelation(relation)] + return ok +} + +func normalizeClientRelationSubject(subject, userID string) string { + subject = strings.TrimSpace(subject) + if subject != "" { + return subject + } + userID = strings.TrimSpace(userID) + if userID == "" { + return "" + } + return "User:" + userID +} + +func parseClientRelationSubject(subject string) (string, string) { + subject = strings.TrimSpace(subject) + if subject == "" { + return "", "" + } + parts := strings.SplitN(subject, ":", 2) + if len(parts) != 2 { + return "", "" + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) +} + +func validateClientRelationWriteInput(relation, subject string) error { + relation = normalizeRelyingPartyRelation(relation) + if !isAllowedRelyingPartyOperatorRelation(relation) { + return fmt.Errorf("unsupported relation") + } + + subjectType, subjectID := parseClientRelationSubject(subject) + if subjectType != "User" || subjectID == "" || strings.Contains(subjectID, "#") { + return fmt.Errorf("subject must be in User: format") + } + + return nil +} + +func mapRelationTupleSummary(tuple service.RelationTuple) clientRelationSummary { + subjectType, subjectID := parseClientRelationSubject(tuple.SubjectID) + return clientRelationSummary{ + Relation: tuple.Relation, + Subject: tuple.SubjectID, + SubjectType: subjectType, + SubjectID: subjectID, + } +} + +func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) { + clientID = strings.TrimSpace(clientID) + if clientID == "" { + return clientSummary{}, fmt.Errorf("client id is required") + } + client, err := h.Hydra.GetClient(ctx, clientID) + if err != nil { + return clientSummary{}, err + } + return h.mapClientSummary(*client), nil +} + +func (h *DevHandler) getRelationRequestProfile(c *fiber.Ctx) *domain.UserProfileResponse { + return h.getCurrentProfile(c) +} + func validateReservedSystemClientName(clientID, name string) error { ownerID, reserved := reservedSystemClientOwnerID(name) if !reserved { @@ -733,6 +866,130 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { }) } +func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error { + clientID := strings.TrimSpace(c.Params("id")) + if clientID == "" { + return errorJSON(c, fiber.StatusBadRequest, "client id is required") + } + + profile := h.getRelationRequestProfile(c) + summary, err := h.loadClientSummary(c.Context(), clientID) + if err != nil { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + + if !h.canViewClientRelations(c, profile, summary) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h.Keto == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable") + } + + items := make([]clientRelationSummary, 0) + for relation := range allowedRelyingPartyOperatorRelations { + tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + for _, tuple := range tuples { + items = append(items, mapRelationTupleSummary(tuple)) + } + } + + return c.JSON(clientRelationListResponse{Items: items}) +} + +func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { + clientID := strings.TrimSpace(c.Params("id")) + if clientID == "" { + return errorJSON(c, fiber.StatusBadRequest, "client id is required") + } + + var req clientRelationUpsertRequest + if err := c.BodyParser(&req); err != nil { + return errorJSON(c, fiber.StatusBadRequest, "invalid request body") + } + + req.Relation = normalizeRelyingPartyRelation(req.Relation) + req.Subject = normalizeClientRelationSubject(req.Subject, req.UserID) + if err := validateClientRelationWriteInput(req.Relation, req.Subject); err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + + profile := h.getRelationRequestProfile(c) + summary, err := h.loadClientSummary(c.Context(), clientID) + if err != nil { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + if !h.canManageClientRelations(c, profile, summary) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h.Keto == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable") + } + if h.KetoOutbox == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable") + } + + existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, req.Relation, req.Subject) + if err == nil && len(existing) > 0 { + return errorJSON(c, fiber.StatusConflict, "relation already exists") + } + + if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "RelyingParty", + Object: clientID, + Relation: req.Relation, + Subject: req.Subject, + Action: domain.KetoOutboxActionCreate, + }); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{ + Object: clientID, + Relation: req.Relation, + SubjectID: req.Subject, + })) +} + +func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error { + clientID := strings.TrimSpace(c.Params("id")) + if clientID == "" { + return errorJSON(c, fiber.StatusBadRequest, "client id is required") + } + + relation := normalizeRelyingPartyRelation(c.Query("relation")) + subject := normalizeClientRelationSubject(c.Query("subject"), c.Query("userId")) + if err := validateClientRelationWriteInput(relation, subject); err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + + profile := h.getRelationRequestProfile(c) + summary, err := h.loadClientSummary(c.Context(), clientID) + if err != nil { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + if !h.canManageClientRelations(c, profile, summary) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h.KetoOutbox == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable") + } + + if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "RelyingParty", + Object: clientID, + Relation: relation, + Subject: subject, + Action: domain.KetoOutboxActionDelete, + }); err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.SendStatus(fiber.StatusNoContent) +} + func (h *DevHandler) GetClient(c *fiber.Ctx) error { h.injectTenantContextFromHeader(c) clientID := c.Params("id") diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index f6804b8f..bfaf47b4 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -16,6 +16,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "gorm.io/gorm" ) // --- Mocks with Unique Names to Avoid Collisions --- @@ -51,6 +52,31 @@ type devMockRedisRepo struct { data map[string]string } +type devMockKetoOutboxRepository struct { + mock.Mock +} + +func (m *devMockKetoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error { + return m.Called(ctx, entry).Error(0) +} + +func (m *devMockKetoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error { + return m.Called(tx, entry).Error(0) +} + +func (m *devMockKetoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) { + args := m.Called(ctx, limit) + return args.Get(0).([]domain.KetoOutbox), args.Error(1) +} + +func (m *devMockKetoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error { + return m.Called(ctx, id, status, retryCount, lastError).Error(0) +} + +func (m *devMockKetoOutboxRepository) MarkProcessed(ctx context.Context, id string) error { + return m.Called(ctx, id).Error(0) +} + func (m *devMockRedisRepo) Set(key, value string, exp time.Duration) error { if m.data == nil { m.data = make(map[string]string) @@ -1223,3 +1249,175 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) { assert.Len(t, result.Items, 1) assert.Equal(t, "evt-1", result.Items[0].EventID) } + +func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]any{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_relationships").Return(true, nil) + mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "").Return([]service.RelationTuple{ + {Object: "client-1", Relation: "config_editor", SubjectID: "User:user-2"}, + }, nil) + for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "status_operator"} { + mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) + } + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-1" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id/relations", h.ListClientRelations) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1/relations", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result clientRelationListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Len(t, result.Items, 1) + assert.Equal(t, "config_editor", result.Items[0].Relation) + assert.Equal(t, "User", result.Items[0].SubjectType) + assert.Equal(t, "user-2", result.Items[0].SubjectID) +} + +func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]any{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-1", "grant_dev_permissions").Return(true, nil) + mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", "config_editor", "User:user-2").Return([]service.RelationTuple{}, nil) + + mockOutbox := new(devMockKetoOutboxRepository) + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "RelyingParty" && + entry.Object == "client-1" && + entry.Relation == "config_editor" && + entry.Subject == "User:user-2" && + entry.Action == domain.KetoOutboxActionCreate + })).Return(nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + KetoOutbox: mockOutbox, + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-1" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Post("/api/v1/dev/clients/:id/relations", h.AddClientRelation) + + body, _ := json.Marshal(map[string]any{ + "relation": "config_editor", + "userId": "user-2", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-1/relations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + mockOutbox.AssertExpectations(t) +} + +func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]any{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "manage").Return(true, nil) + + mockOutbox := new(devMockKetoOutboxRepository) + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(entry *domain.KetoOutbox) bool { + return entry.Namespace == "RelyingParty" && + entry.Object == "client-1" && + entry.Relation == "config_editor" && + entry.Subject == "User:user-2" && + entry.Action == domain.KetoOutboxActionDelete + })).Return(nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + KetoOutbox: mockOutbox, + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-1" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Delete("/api/v1/dev/clients/:id/relations", h.RemoveClientRelation) + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-1/relations?relation=config_editor&subject=User:user-2", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + mockOutbox.AssertExpectations(t) +} diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index 1586062e..fdd27597 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -7,6 +7,7 @@ import LoginPage from "../features/auth/LoginPage"; import ClientConsentsPage from "../features/clients/ClientConsentsPage"; import ClientDetailsPage from "../features/clients/ClientDetailsPage"; import ClientGeneralPage from "../features/clients/ClientGeneralPage"; +import ClientRelationsPage from "../features/clients/ClientRelationsPage"; import ClientsPage from "../features/clients/ClientsPage"; import ProfilePage from "../features/profile/ProfilePage"; @@ -33,6 +34,7 @@ export const router = createBrowserRouter( { path: "clients/:id", element: }, { path: "clients/:id/consents", element: }, { path: "clients/:id/settings", element: }, + { path: "clients/:id/relationships", element: }, { path: "audit-logs", element: }, { path: "profile", element: }, ], diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index 75c85219..de941fd7 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -230,6 +230,12 @@ function ClientConsentsPage() { > {t("ui.dev.clients.details.tab.settings", "Settings")} + + {t("ui.dev.clients.details.tab.relationships", "Relationships")} + diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 570d7b01..eabdfc1c 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -272,6 +272,12 @@ function ClientDetailsPage() { > {t("ui.dev.clients.details.tab.settings", "Settings")} + + {t("ui.dev.clients.details.tab.relationships", "Relationships")} + diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 7d4faa51..2da52255 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -680,6 +680,12 @@ function ClientGeneralPage() { > {t("ui.dev.clients.details.tab.consents", "Consent & Users")} + + {t("ui.dev.clients.details.tab.relationships", "Relationships")} + {t("ui.dev.clients.details.tab.settings", "Settings")} diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx new file mode 100644 index 00000000..bb0ee916 --- /dev/null +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -0,0 +1,423 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; +import { toast } from "../../components/ui/use-toast"; +import { + addClientRelation, + fetchClient, + fetchClientRelations, + removeClientRelation, +} from "../../lib/devApi"; +import { t } from "../../lib/i18n"; + +const relationOptions = [ + "admins", + "creator", + "config_editor", + "secret_rotator", + "jwks_viewer", + "jwks_operator", + "consent_viewer", + "consent_revoker", + "relationship_viewer", + "status_operator", +] as const; + +function ClientRelationsPage() { + const params = useParams(); + const queryClient = useQueryClient(); + const clientId = params.id ?? ""; + const [relation, setRelation] = useState<(typeof relationOptions)[number]>( + "config_editor", + ); + const [userId, setUserId] = useState(""); + + const { data: clientData } = useQuery({ + queryKey: ["client", clientId], + queryFn: () => fetchClient(clientId), + enabled: clientId.length > 0, + }); + + const { + data: relationData, + isLoading, + error, + } = useQuery({ + queryKey: ["client-relations", clientId], + queryFn: () => fetchClientRelations(clientId), + enabled: clientId.length > 0, + }); + + const sortedItems = useMemo(() => { + return [...(relationData?.items ?? [])].sort((a, b) => { + const relationCompare = a.relation.localeCompare(b.relation); + if (relationCompare !== 0) { + return relationCompare; + } + return a.subject.localeCompare(b.subject); + }); + }, [relationData?.items]); + + const addMutation = useMutation({ + mutationFn: () => + addClientRelation(clientId, { + relation, + userId: userId.trim(), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] }); + setUserId(""); + toast( + t( + "msg.dev.clients.relationships.added", + "Relationship가 추가되었습니다.", + ), + ); + }, + onError: (err) => { + toast( + t( + "msg.dev.clients.relationships.add_error", + "Relationship 추가 실패: {{error}}", + { + error: + (err as AxiosError<{ error?: string }>).response?.data?.error ?? + (err as Error).message, + }, + ), + "error", + ); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (payload: { relation: string; subject: string }) => + removeClientRelation(clientId, payload.relation, payload.subject), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] }); + toast( + t( + "msg.dev.clients.relationships.removed", + "Relationship가 제거되었습니다.", + ), + ); + }, + onError: (err) => { + toast( + t( + "msg.dev.clients.relationships.remove_error", + "Relationship 제거 실패: {{error}}", + { + error: + (err as AxiosError<{ error?: string }>).response?.data?.error ?? + (err as Error).message, + }, + ), + "error", + ); + }, + }); + + const handleAdd = () => { + if (!userId.trim()) { + toast( + t( + "msg.dev.clients.relationships.user_required", + "추가할 User ID를 입력하세요.", + ), + "error", + ); + return; + } + addMutation.mutate(); + }; + + const handleRemove = (targetRelation: string, subject: string) => { + if ( + window.confirm( + t( + "msg.dev.clients.relationships.remove_confirm", + "이 relationship를 제거하시겠습니까?", + ), + ) + ) { + removeMutation.mutate({ relation: targetRelation, subject }); + } + }; + + if (!clientId) { + return ( +
+ {t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")} +
+ ); + } + + return ( +
+
+
+
+ +
+ +
+

+ {t( + "ui.dev.clients.relationships.title", + "Client Relationships", + )} +

+

+ {t( + "msg.dev.clients.relationships.subtitle", + "RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.", + )} +

+
+
+
+
+ + {clientData?.client?.status === "active" + ? t("ui.common.status.active", "Active") + : t("ui.common.status.inactive", "Inactive")} + +
+
+
+ + {t("ui.dev.clients.details.tab.connection", "Federation")} + + + {t("ui.dev.clients.details.tab.consents", "Consent & Users")} + + + {t("ui.dev.clients.details.tab.settings", "Settings")} + + + {t("ui.dev.clients.details.tab.relationships", "Relationships")} + +
+
+ + + + + {t("ui.dev.clients.relationships.add_title", "Add Relationship")} + + + {t( + "msg.dev.clients.relationships.add_description", + "현재는 direct User assignment만 지원합니다. subject는 자동으로 User: 형식으로 전송됩니다.", + )} + + + +
+ + +
+
+ + setUserId(e.target.value)} + placeholder={t( + "ui.dev.clients.relationships.user_id_placeholder", + "kratos user id", + )} + /> +
+ +
+
+ + + + + + {t( + "ui.dev.clients.relationships.list_title", + "Assigned Relationships", + )} + + + {t( + "msg.dev.clients.relationships.list_description", + "현재 RP에 직접 부여된 operator relation 목록입니다.", + )} + + + + {error ? ( +
+ {t( + "msg.dev.clients.relationships.load_error", + "Relationship 조회 실패: {{error}}", + { + error: + (error as AxiosError<{ error?: string }>).response?.data + ?.error ?? (error as Error).message, + }, + )} +
+ ) : isLoading ? ( +
+ {t( + "msg.dev.clients.relationships.loading", + "Relationship를 불러오는 중입니다...", + )} +
+ ) : sortedItems.length === 0 ? ( +
+ {t( + "msg.dev.clients.relationships.empty", + "직접 부여된 relationship가 없습니다.", + )} +
+ ) : ( + + + + + {t("ui.dev.clients.relationships.relation", "Relation")} + + + {t("ui.dev.clients.relationships.subject", "Subject")} + + + {t("ui.dev.clients.relationships.subject_type", "Type")} + + + {t("ui.dev.clients.table.actions", "액션")} + + + + + {sortedItems.map((item) => ( + + + {item.relation} + + +
+
{item.subject}
+ {item.subjectId && ( +
+ ID: {item.subjectId} +
+ )} +
+
+ + {item.subjectType || "-"} + + + + +
+ ))} +
+
+ )} +
+
+
+ ); +} + +export default ClientRelationsPage; diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index db25ad10..df430ecb 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -99,6 +99,23 @@ export type ClientUpsertRequest = { metadata?: Record; }; +export type ClientRelation = { + relation: string; + subject: string; + subjectType: string; + subjectId: string; +}; + +export type ClientRelationListResponse = { + items: ClientRelation[]; +}; + +export type ClientRelationUpsertRequest = { + relation: string; + subject?: string; + userId?: string; +}; + export type ConsentSummary = { subject: string; userName?: string; @@ -164,6 +181,34 @@ export async function fetchClient(clientId: string) { return data; } +export async function fetchClientRelations(clientId: string) { + const { data } = await apiClient.get( + `/dev/clients/${clientId}/relations`, + ); + return data; +} + +export async function addClientRelation( + clientId: string, + payload: ClientRelationUpsertRequest, +) { + const { data } = await apiClient.post( + `/dev/clients/${clientId}/relations`, + payload, + ); + return data; +} + +export async function removeClientRelation( + clientId: string, + relation: string, + subject: string, +) { + await apiClient.delete(`/dev/clients/${clientId}/relations`, { + params: { relation, subject }, + }); +} + export async function updateClientStatus( clientId: string, status: ClientStatus,