From 52936b2b88512d16d053dcacbcd451259df89923 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 30 Apr 2026 10:01:00 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EB=84=8C=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=ED=95=9C/=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=81=B4=EB=A0=88=EC=9E=84=20=EA=B4=80=EA=B3=84=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 14 +- backend/internal/handler/dev_handler_test.go | 127 ++++++++++++++++++ .../features/clients/ClientGeneralPage.tsx | 78 ++++++++++- .../features/clients/ClientRelationsPage.tsx | 1 - devfront/src/locales/en.toml | 2 + devfront/src/locales/ko.toml | 2 + devfront/src/locales/template.toml | 2 + 7 files changed, 214 insertions(+), 12 deletions(-) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 5c0c96b2..9f9d5fdd 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1544,12 +1544,16 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { } canChangeStatusByPermit := h.canOperateClientByPermit(c, profile, summary, "change_status") - if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatusByPermit { + canEditConfigByPermit := h.canOperateClientByPermit(c, profile, summary, "edit_config") + canChangeStatus := canChangeStatusByPermit || canEditConfigByPermit + if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatus { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } - if summary.Type == "private" && !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") { - if !canChangeStatusByPermit { + if summary.Type == "private" && + !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") && + !h.canBypassPrivateClientRestriction(c, profile, summary, "edit_config") { + if !canChangeStatus { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -1812,8 +1816,8 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") + if !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") { + return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required") } clientType := "" diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index f04f05f4..0eb992cb 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -453,6 +453,72 @@ func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(t *testing.T) { mockKeto.AssertExpectations(t) } +func TestUpdateClient_ManagedRPAdminRequiresEditConfigPermission(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", + "redirect_uris": []string{ + "http://localhost/cb", + }, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "scope": "openid profile email offline_access", + "token_endpoint_auth_method": "none", + "metadata": map[string]any{ + "status": "active", + "tenant_id": "tenant-1", + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + t.Fatalf("hydra update should not be called without edit_config permission") + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-1" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + Metadata: map[string]any{ + "managed_client_ids": []any{"client-1"}, + }, + }) + return c.Next() + }) + app.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "App One Updated", + "metadata": map[string]any{ + "tenant_access_restricted": true, + "allowed_tenants": []string{"tenant-1"}, + }, + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/dev/clients/client-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + func TestListClients_ProtectedSystemClientHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -634,6 +700,67 @@ func TestUpdateClientStatus_UserAllowedByStatusPermission(t *testing.T) { mockKeto := new(devMockKetoService) mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(false, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + app := fiber.New() + tenantID := "tenant-1" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Patch("/api/v1/dev/clients/:id/status", h.UpdateClientStatus) + + body, _ := json.Marshal(map[string]interface{}{"status": "inactive"}) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/dev/clients/client-1/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var res clientDetailResponse + _ = json.NewDecoder(resp.Body).Decode(&res) + assert.Equal(t, "inactive", res.Client.Status) + mockKeto.AssertExpectations(t) +} + +func TestUpdateClientStatus_UserAllowedByEditConfigPermission(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]interface{}{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-1", + "status": "active", + }, + }), nil + } + if r.Method == http.MethodPatch && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]interface{}{ + "client_id": "client-1", + "client_name": "App One", + "metadata": map[string]interface{}{ + "tenant_id": "tenant-1", + "status": "inactive", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "change_status").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index b6bfce9a..3f7db64b 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -15,6 +15,7 @@ import { X, } from "lucide-react"; import { useEffect, useState } from "react"; +import { useAuth } from "react-oidc-context"; import { Link, useNavigate, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -31,9 +32,11 @@ import { Switch } from "../../components/ui/switch"; import { Textarea } from "../../components/ui/textarea"; import { toast } from "../../components/ui/use-toast"; import { + type ClientRelation, createClient, deleteClient, fetchClient, + fetchClientRelations, fetchMyTenants, refreshHeadlessJwksCache, revokeHeadlessJwksCache, @@ -48,6 +51,7 @@ import type { TenantSummary, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; import { ClientDetailTabs } from "./ClientDetailTabs"; @@ -275,16 +279,27 @@ function formatDateTime(value?: string) { } function ClientGeneralPage() { + const auth = useAuth(); const params = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const clientId = params.id; const isCreate = !clientId; + const currentUserId = auth.user?.profile.sub; + const systemRole = resolveProfileRole( + auth.user?.profile as Record | undefined, + ); const { data, isLoading, error } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId as string), enabled: !isCreate, }); + const { data: relationData } = useQuery({ + queryKey: ["client-relations", clientId], + queryFn: () => fetchClientRelations(clientId as string), + enabled: !isCreate, + retry: false, + }); const { data: tenantData } = useQuery({ queryKey: ["my-tenants"], queryFn: fetchMyTenants, @@ -440,6 +455,17 @@ function ClientGeneralPage() { const securityProfile: SecurityProfile = clientType === "pkce" ? "pkce" : "private"; + const canEditExistingClientGeneralSettings = + systemRole === "super_admin" || + relationData?.items?.some( + (item: ClientRelation) => + item.subject === `User:${currentUserId}` && + (item.relation === "admins" || item.relation === "config_editor"), + ) === true; + const isGeneralSettingsReadOnly = + !isCreate && + relationData != null && + !canEditExistingClientGeneralSettings; const trimmedLogoUrl = logoUrl.trim(); const trimmedAutoLoginUrl = autoLoginUrl.trim(); const hasLogoUrl = trimmedLogoUrl.length > 0; @@ -869,6 +895,14 @@ function ClientGeneralPage() { ), ); } + if (isGeneralSettingsReadOnly) { + throw new Error( + t( + "msg.dev.clients.general.read_only_forbidden", + "이 RP의 일반 설정을 수정할 권한이 없습니다.", + ), + ); + } if (autoLoginSupported && !hasValidAutoLoginUrl) { throw new Error( t( @@ -1114,6 +1148,14 @@ function ClientGeneralPage() { {!isCreate && ( )} + {isGeneralSettingsReadOnly && ( +
+ {t( + "msg.dev.clients.general.read_only_hint", + "이 RP의 일반 설정은 `RP 관리자` 또는 `RP 일반 설정` 관계가 있는 사용자만 수정할 수 있습니다.", + )} +
+ )} {/* 1. Application Identity */} @@ -1148,6 +1190,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.name_placeholder", "My Awesome Application", )} + disabled={isGeneralSettingsReadOnly} />
@@ -1165,6 +1208,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.description_placeholder", "앱에 대한 간단한 설명을 입력하세요.", )} + disabled={isGeneralSettingsReadOnly} />
@@ -1184,6 +1228,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.identity.logo_placeholder", "https://example.com/logo.png", )} + disabled={isGeneralSettingsReadOnly} />

{t( @@ -1302,6 +1347,7 @@ function ClientGeneralPage() { size="sm" variant={status === "active" ? "default" : "outline"} onClick={() => handleStatusChange("active")} + disabled={isGeneralSettingsReadOnly} > {t("ui.common.status.active", "활성")} @@ -1310,6 +1356,7 @@ function ClientGeneralPage() { size="sm" variant={status === "inactive" ? "default" : "outline"} onClick={() => handleStatusChange("inactive")} + disabled={isGeneralSettingsReadOnly} > {t("ui.common.status.inactive", "비활성")} @@ -1416,6 +1463,7 @@ function ClientGeneralPage() { size="sm" onClick={addScope} className="gap-2" + disabled={isGeneralSettingsReadOnly} > {t("ui.dev.clients.general.scopes.add", "Scope 추가")} @@ -1436,6 +1484,7 @@ function ClientGeneralPage() { "https://app.example.com/callback, http://localhost:3000/auth/callback (콤마로 구분)", )} className="font-mono text-sm" + disabled={isGeneralSettingsReadOnly} />

{t( @@ -1493,7 +1542,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.name_placeholder", "e.g. profile", )} - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} /> @@ -1507,7 +1556,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.description_placeholder", "권한에 대한 설명", )} - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} /> @@ -1517,7 +1566,7 @@ function ClientGeneralPage() { onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked) } - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} /> @@ -1527,7 +1576,7 @@ function ClientGeneralPage() { size="icon" onClick={() => removeScope(s.id)} className="h-8 w-8 text-muted-foreground hover:text-destructive" - disabled={s.locked} + disabled={s.locked || isGeneralSettingsReadOnly} > @@ -1594,6 +1643,7 @@ function ClientGeneralPage() { checked={tenantAccessRestricted} onCheckedChange={handleTenantAccessToggle} id="tenant-access-toggle" + disabled={isGeneralSettingsReadOnly} /> @@ -1638,7 +1688,9 @@ function ClientGeneralPage() { "테넌트 이름 또는 슬러그로 검색", )} className="pl-10" - disabled={!tenantAccessRestricted} + disabled={ + isGeneralSettingsReadOnly || !tenantAccessRestricted + } /> {tenantAccessRestricted && isTenantSearchOpen && (

@@ -1652,6 +1704,7 @@ function ClientGeneralPage() { event.preventDefault(); handleSelectAllowedTenant(tenant.id); }} + disabled={isGeneralSettingsReadOnly} >
@@ -1719,6 +1772,7 @@ function ClientGeneralPage() { aria-label={t("ui.common.delete", "삭제")} onClick={() => toggleAllowedTenant(tenant.id)} className="text-muted-foreground transition hover:text-destructive" + disabled={isGeneralSettingsReadOnly} > @@ -1744,6 +1798,7 @@ function ClientGeneralPage() { aria-label={t("ui.common.delete", "삭제")} onClick={() => toggleAllowedTenant(tenantId)} className="text-muted-foreground transition hover:text-destructive" + disabled={isGeneralSettingsReadOnly} > @@ -1786,7 +1841,11 @@ function ClientGeneralPage() { )}
- @@ -1849,6 +1908,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.id_token_claims.key_placeholder", "e.g. locale", )} + disabled={isGeneralSettingsReadOnly} /> @@ -1866,6 +1926,7 @@ function ClientGeneralPage() { "Claim namespace", )} className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={isGeneralSettingsReadOnly} >
)} @@ -2589,6 +2654,7 @@ function ClientGeneralPage() {