From 790f006f93eeccd6a79cbd317e1352ed216e2e8e Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 14:57:51 +0900 Subject: [PATCH 01/18] =?UTF-8?q?=EB=84=A4=EC=9E=84=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20=EB=AC=B8=EC=84=9C=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker/ory/keto/namespaces.ts | 77 +++++++++++++++++++++++++-- docs/keto-rebac-namespaces-diagram.md | 50 ++++++++++++++--- docs/keto-rebac-policy-guide.md | 59 ++++++++++++++++++-- docs/user-group-rebac-architecture.md | 16 ++++-- 4 files changed, 182 insertions(+), 20 deletions(-) diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index f2666ac2..ce7970c6 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -1,4 +1,4 @@ -import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions" +import { Namespace, Context, SubjectSet } from "@ory/keto-definitions" class User implements Namespace {} @@ -20,6 +20,8 @@ class Tenant implements Namespace { admins: (User | SubjectSet)[] members: (User | SubjectSet | SubjectSet | SubjectSet)[] parents: Tenant[] + developer_console_viewer: (User | SubjectSet)[] + developer_console_grant_manager: (User | SubjectSet)[] } permits = { @@ -39,7 +41,18 @@ class Tenant implements Namespace { this.related.parents.traverse((p) => p.permits.manage_admins(ctx)), create_subtenant: (ctx: Context): boolean => - this.permits.manage(ctx) + this.permits.manage(ctx), + + view_dev_console: (ctx: Context): boolean => + this.related.developer_console_viewer.includes(ctx.subject) || + this.permits.grant_dev_permissions(ctx) || + this.permits.manage(ctx) || + this.related.parents.traverse((p) => p.permits.view_dev_console(ctx)), + + grant_dev_permissions: (ctx: Context): boolean => + this.related.developer_console_grant_manager.includes(ctx.subject) || + this.permits.manage_admins(ctx) || + this.related.parents.traverse((p) => p.permits.grant_dev_permissions(ctx)) } } @@ -48,17 +61,75 @@ class RelyingParty implements Namespace { admins: (User | SubjectSet | SubjectSet | SubjectSet)[] parents: Tenant[] access: (User | SubjectSet | SubjectSet | SubjectSet)[] + creator: (User | SubjectSet)[] + config_editor: (User | SubjectSet)[] + secret_rotator: (User | SubjectSet)[] + jwks_viewer: (User | SubjectSet)[] + jwks_operator: (User | SubjectSet)[] + consent_viewer: (User | SubjectSet)[] + consent_revoker: (User | SubjectSet)[] + relationship_viewer: (User | SubjectSet)[] + status_operator: (User | SubjectSet)[] } permits = { view: (ctx: Context): boolean => this.related.admins.includes(ctx.subject) || - this.related.parents.traverse((t) => t.permits.view(ctx)), + this.related.config_editor.includes(ctx.subject) || + this.related.secret_rotator.includes(ctx.subject) || + this.related.jwks_viewer.includes(ctx.subject) || + this.related.jwks_operator.includes(ctx.subject) || + this.related.consent_viewer.includes(ctx.subject) || + this.related.consent_revoker.includes(ctx.subject) || + this.related.relationship_viewer.includes(ctx.subject) || + this.related.status_operator.includes(ctx.subject) || + this.related.parents.traverse((t) => t.permits.view(ctx)) || + this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)), manage: (ctx: Context): boolean => this.related.admins.includes(ctx.subject) || this.related.parents.traverse((t) => t.permits.manage(ctx)), + create: (ctx: Context): boolean => + this.related.creator.includes(ctx.subject) || + this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) || + this.permits.manage(ctx), + + edit_config: (ctx: Context): boolean => + this.related.config_editor.includes(ctx.subject) || + this.permits.manage(ctx), + + rotate_secret: (ctx: Context): boolean => + this.related.secret_rotator.includes(ctx.subject) || + this.permits.manage(ctx), + + view_jwks: (ctx: Context): boolean => + this.related.jwks_viewer.includes(ctx.subject) || + this.permits.operate_jwks(ctx) || + this.permits.manage(ctx), + + operate_jwks: (ctx: Context): boolean => + this.related.jwks_operator.includes(ctx.subject) || + this.permits.manage(ctx), + + view_consents: (ctx: Context): boolean => + this.related.consent_viewer.includes(ctx.subject) || + this.permits.revoke_consents(ctx) || + this.permits.manage(ctx), + + revoke_consents: (ctx: Context): boolean => + this.related.consent_revoker.includes(ctx.subject) || + this.permits.manage(ctx), + + view_relationships: (ctx: Context): boolean => + this.related.relationship_viewer.includes(ctx.subject) || + this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) || + this.permits.manage(ctx), + + change_status: (ctx: Context): boolean => + this.related.status_operator.includes(ctx.subject) || + this.permits.manage(ctx), + access: (ctx: Context): boolean => this.related.access.includes(ctx.subject) || this.permits.manage(ctx) diff --git a/docs/keto-rebac-namespaces-diagram.md b/docs/keto-rebac-namespaces-diagram.md index 1c6247dc..c1418a2e 100644 --- a/docs/keto-rebac-namespaces-diagram.md +++ b/docs/keto-rebac-namespaces-diagram.md @@ -34,13 +34,18 @@ classDiagram <> -- Relations -- owners: User[] - admins: User[] | SubjectSet~Tenant, owners~ + admins: User[] | SubjectSet~System, super_admins~ members: User[] parents: Tenant[] + developer_console_viewer: User[] + developer_console_grant_manager: User[] -- Permits -- view: members OR admins OR parents.view - manage: admins OR parents.manage + manage: admins OR owners OR parents.manage + manage_admins: owners OR parents.manage_admins create_subtenant: manage + view_dev_console: developer_console_viewer OR grant_dev_permissions OR manage OR parents.view_dev_console + grant_dev_permissions: developer_console_grant_manager OR manage_admins OR parents.grant_dev_permissions } class RelyingParty { @@ -49,19 +54,38 @@ classDiagram admins: User[] parents: Tenant[] access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~ + creator: User[] + config_editor: User[] + secret_rotator: User[] + jwks_viewer: User[] + jwks_operator: User[] + consent_viewer: User[] + consent_revoker: User[] + relationship_viewer: User[] + status_operator: User[] -- Permits -- - view: admins OR parents.view + view: admins OR direct operator relations OR parents.view OR parents.view_dev_console manage: admins OR parents.manage + create: creator OR parents.grant_dev_permissions OR manage + edit_config: config_editor OR manage + rotate_secret: secret_rotator OR manage + view_jwks: jwks_viewer OR operate_jwks OR manage + operate_jwks: jwks_operator OR manage + view_consents: consent_viewer OR revoke_consents OR manage + revoke_consents: consent_revoker OR manage + view_relationships: relationship_viewer OR parents.grant_dev_permissions OR manage + change_status: status_operator OR manage access: access OR manage } %% Relationship lines indicating references (SubjectSets or Direct inclusion) User ..> System : super_admins, authenticated_users - User ..> Tenant : owners, admins, members - User ..> RelyingParty : admins, access + User ..> Tenant : owners, admins, members, developer_console_* + User ..> RelyingParty : admins, access, operators Tenant "1" --> "*" Tenant : parents (상위 조직 상속) Tenant ..> RelyingParty : parents (소유권 상속) + Tenant ..> RelyingParty : view_dev_console / grant_dev_permissions (범위 권한) Tenant ..> RelyingParty : access (members 접근 권한) System ..> RelyingParty : access (authenticated_users) @@ -77,11 +101,21 @@ classDiagram - **Tenant (테넌트/조직):** - `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. - - `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. - - _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다. + - `manage` (관리): 테넌트의 관리자(`admins`), 조직장(`owners`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - `manage_admins`: 조직장(`owners`)과 상위 테넌트의 `manage_admins` 상속 권한으로 판정합니다. + - `view_dev_console`: 직접 부여된 DevFront 조회 relation, `grant_dev_permissions`, `manage`, 상위 tenant 상속으로 판정합니다. + - `grant_dev_permissions`: 직접 부여된 DevFront 권한 부여 relation, `manage_admins`, 상위 tenant 상속으로 판정합니다. - **RelyingParty (OIDC 앱):** - - `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다. + - `view` (조회): 앱의 직접 관리자(`admins`), 직접 운영 relation 보유자(`config_editor`, `jwks_viewer` 등), 또는 **이 앱을 소유한 테넌트(parents)에서 `view` 또는 `view_dev_console` 권한을 가진 자**가 조회할 수 있습니다. - `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. + - `edit_config`, `rotate_secret`, `operate_jwks`, `revoke_consents`, `change_status`: 각 직접 relation 또는 `manage`로 판정합니다. + - `view_relationships`: 직접 `relationship_viewer`, 상위 tenant의 `grant_dev_permissions`, 또는 `manage`로 판정합니다. - `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다. - _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._ + +### 설계 원칙 메모 + +- `view_dev_console`는 RP 목록/기본 정보 조회 범위를 주는 tenant 범위 권한입니다. +- `view_dev_console`만으로 RP의 개별 운영 액션 permit이 자동 부여되지는 않습니다. +- `manage`는 1차 하위호환 permit으로 유지하며, 세부 permit이 완전히 backend/API에 반영되기 전까지 상위 호환 의미를 가집니다. diff --git a/docs/keto-rebac-policy-guide.md b/docs/keto-rebac-policy-guide.md index fc2f1a91..37bdf6f5 100644 --- a/docs/keto-rebac-policy-guide.md +++ b/docs/keto-rebac-policy-guide.md @@ -7,11 +7,12 @@ 따라서 조직도나 권한이 변경될 때마다 이를 Keto의 관계 튜플로 실시간 변환/전송하고, 권한 검증(Check)은 초고속 병렬 처리가 가능한 Keto 엔진으로 오프로딩(Offloading)합니다. ## 2. 네임스페이스 (Namespaces) -과거 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 철저한 권한 통제를 위해 아래 3개의 네임스페이스만 존재합니다. +과거 혼용되던 `UserGroup` 네임스페이스는 폐기되며, 현재 Baron SSO는 아래 4개의 네임스페이스를 기준으로 ReBAC를 구성합니다. -1. **`Tenant`**: 모든 격리 공간 (회사, 지주사, 사내 부서, 개인 워크스페이스) -2. **`RelyingParty`**: 테넌트가 소유하는 자원/앱 (OIDC 클라이언트) -3. **`System`**: 테넌트에 종속되지 않는 전역 권한 (Super Admin 등) +1. **`User`**: 권한의 subject가 되는 사용자 +2. **`Tenant`**: 모든 격리 공간 (회사, 지주사, 사내 부서, 개인 워크스페이스, 유저 그룹) +3. **`RelyingParty`**: 테넌트가 소유하는 자원/앱 (OIDC 클라이언트) +4. **`System`**: 테넌트에 종속되지 않는 전역 권한 (Super Admin 등) ## 3. 관계 튜플 규칙 (Relationship Tuples) @@ -20,6 +21,18 @@ - **어드민 자동 상속**: `Tenant:<조직ID>#admins@Tenant:<조직ID>#owners` - **테넌트 계층(부모-자식)**: `Tenant:<하위ID>#parents@Tenant:<상위ID>` *(상위 테넌트의 `admins`는 하위 테넌트의 모든 권한을 상속받습니다.)* +- **DevFront 조회 범위 부여**: `Tenant:<조직ID>#developer_console_viewer@User:<유저ID>` +- **DevFront 권한 부여 범위 부여**: `Tenant:<조직ID>#developer_console_grant_manager@User:<유저ID>` + +### 3.1.1 Tenant permit 원칙 +- `view`: 멤버/관리자/상위 tenant 상속 기준의 tenant 조회 권한 +- `manage`: 관리자/오너/상위 tenant 상속 기준의 tenant 관리 권한 +- `manage_admins`: 오너 및 상위 tenant 상속 기준의 관리자 관계 관리 권한 +- `create_subtenant`: `manage`를 가진 주체가 하위 tenant를 생성하는 권한 +- `view_dev_console`: DevFront 진입 및 tenant 범위 RP 목록/기본 정보 조회 권한 +- `grant_dev_permissions`: tenant 범위 RP 운영 관계를 부여/회수할 수 있는 상위 권한 + +`view_dev_console`와 `grant_dev_permissions`는 `Tenant#manage`와 별개 축으로 분리합니다. 다만 1차 구현에서는 하위호환을 위해 `manage` 또는 `manage_admins`를 가진 주체가 각각 `view_dev_console`, `grant_dev_permissions`도 함께 가지는 모델로 둡니다. ### 3.2 Relying Party (앱 자원) 제어 및 RP Admin RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 다중 상속을 사용합니다. @@ -27,6 +40,44 @@ RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 - **전담 관리자(RP Admin) 직접 할당**: `RelyingParty:<앱ID>#admins@User:<유저ID>` - **Private 앱 접근 허용**: `RelyingParty:<앱ID>#access@Tenant:<소유테넌트ID>#members` - **Public 앱 접근 허용**: `RelyingParty:<앱ID>#access@System:authenticated_users#members` +- **RP 생성 권한 부여**: `RelyingParty:<앱ID>#creator@User:<유저ID>` +- **RP 설정 수정 권한 부여**: `RelyingParty:<앱ID>#config_editor@User:<유저ID>` +- **Client Secret rotate 권한 부여**: `RelyingParty:<앱ID>#secret_rotator@User:<유저ID>` +- **JWKS 조회 권한 부여**: `RelyingParty:<앱ID>#jwks_viewer@User:<유저ID>` +- **JWKS 운영 권한 부여**: `RelyingParty:<앱ID>#jwks_operator@User:<유저ID>` +- **Consent 조회 권한 부여**: `RelyingParty:<앱ID>#consent_viewer@User:<유저ID>` +- **Consent 회수 권한 부여**: `RelyingParty:<앱ID>#consent_revoker@User:<유저ID>` +- **Relationship 조회 권한 부여**: `RelyingParty:<앱ID>#relationship_viewer@User:<유저ID>` +- **상태 변경 권한 부여**: `RelyingParty:<앱ID>#status_operator@User:<유저ID>` + +### 3.2.1 RelyingParty permit 원칙 +- `view`: RP 상세 및 기본 메타데이터 조회 권한 +- `manage`: 기존 호환용 상위 관리 권한 +- `create`: RP 생성 권한 +- `edit_config`: RP 일반 설정 수정 권한 +- `rotate_secret`: client secret 재발급/rotate 권한 +- `view_jwks`: JWKS 상태/캐시/key summary 조회 권한 +- `operate_jwks`: JWKS refresh/revoke 수행 권한 +- `view_consents`: consent 목록/상세 조회 권한 +- `revoke_consents`: consent 회수 권한 +- `view_relationships`: direct / inherited relationship 조회 권한 +- `change_status`: 활성/비활성 상태 변경 권한 +- `access`: 실제 서비스 로그인 및 리소스 접근 권한 + +1차 구현 원칙은 다음과 같습니다. +- `RelyingParty#manage`는 제거하지 않고 유지합니다. +- `manage`는 신규 세부 permit의 상위 호환 permit으로 동작합니다. +- `access`는 서비스 접근 권한이며 DevFront 운영 권한과 동일시하지 않습니다. +- `Tenant#view_dev_console`는 RP 목록/기본 정보 조회 범위를 주지만, `edit_config`, `operate_jwks`, `revoke_consents` 같은 개별 운영 액션 permit을 자동 부여하지 않습니다. +- RP 개별 운영 액션은 `RelyingParty` 세부 permit으로 직접 판정합니다. + +### 3.3 유저 그룹 subject set 규칙 +현재 구현에서 유저 그룹은 별도 `UserGroup` namespace를 사용하지 않고, `Tenant` namespace 내부의 유저 그룹 tenant와 subject set으로 표현합니다. + +- **유저 그룹 멤버십**: `Tenant:#members@User:` +- **유저 그룹 전체에 tenant role 부여**: `Tenant:#@Tenant:#members` + +즉, 문서에서 말하는 “유저 그룹 멤버 전체”는 실제 Keto tuple에서 `Tenant:#members` subject set으로 표현됩니다. ## 4. 트랜잭셔널 아웃박스를 통한 정합성 확보 Keto와의 데이터 일관성 문제는 시스템의 치명적인 아킬레스건입니다. diff --git a/docs/user-group-rebac-architecture.md b/docs/user-group-rebac-architecture.md index 49f978ec..d2f0e910 100644 --- a/docs/user-group-rebac-architecture.md +++ b/docs/user-group-rebac-architecture.md @@ -57,13 +57,13 @@ Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples) ### 3.1 그룹 멤버십 (Group Membership) 사용자를 특정 유저 그룹의 멤버로 등록합니다. -- **Tuple:** `UserGroup:#members@User:` -- **의미:** `UserID` 사용자는 `GroupID` 유저 그룹의 멤버이다. +- **Tuple:** `Tenant:#members@User:` +- **의미:** `GroupID`에 해당하는 유저 그룹 tenant의 멤버로 `UserID` 사용자를 등록한다. ### 3.2 테넌트 권한 할당 (Tenant Role Assignment) 유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다. -- **Tuple:** `Tenant:#@UserGroup:#members` -- **의미:** `GroupID` 유저 그룹의 모든 멤버는 `TenantID` 테넌트에 대해 ``(예: `view`, `manage`, `admins`) 권한을 가진다. +- **Tuple:** `Tenant:#@Tenant:#members` +- **의미:** `GroupID` 유저 그룹 tenant의 모든 멤버는 `TenantID` 테넌트에 대해 ``(예: `view`, `manage`, `admins`) 권한을 가진다. ### 3.3 자원 소유 및 전파 (Resource Ownership) 테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다. @@ -76,7 +76,13 @@ Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples) 2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다. 3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다. -## 5. 관련 구현 파일 +## 5. 현재 구현 기준 주의사항 + +- 현재 Baron SSO는 별도 `UserGroup` namespace를 사용하지 않습니다. +- 유저 그룹은 `Tenant` namespace 내부의 특수 tenant(`type = USER_GROUP`)로 표현합니다. +- 따라서 group membership과 group-based role assignment는 모두 `Tenant:#members` subject set을 기준으로 해석해야 합니다. + +## 6. 관련 구현 파일 - **Backend Service:** `backend/internal/service/user_group_service.go` - **Backend Handler:** `backend/internal/handler/user_group_handler.go` - **Frontend API:** `adminfront/src/lib/adminApi.ts` From 8f7c328d22a3a257f4b446aa7bc0d8677899c8e6 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 15:21:26 +0900 Subject: [PATCH 02/18] =?UTF-8?q?dev/rp=20=EA=B6=8C=ED=95=9C=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20permit=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 6 +- backend/internal/handler/dev_handler.go | 197 ++++++++++++------- backend/internal/handler/dev_handler_test.go | 99 ++++++++++ 3 files changed, 229 insertions(+), 73 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index fabc6639..080fe23f 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -662,12 +662,12 @@ func main() { // Relying Party Management (Tenant Context) admin.Post("/tenants/:tenantId/relying-parties", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "grant_dev_permissions"), relyingPartyHandler.Create) admin.Get("/tenants/:tenantId/relying-parties", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view_dev_console"), relyingPartyHandler.List) admin.Get("/relying-parties/:id", @@ -677,7 +677,7 @@ func main() { admin.Put("/relying-parties/:id", requireAdmin, - middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), + middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "edit_config"), relyingPartyHandler.Update) admin.Delete("/relying-parties/:id", diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 93dbbea3..b3f248e7 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -254,6 +254,88 @@ func managedClientIDsFromProfile(profile *domain.UserProfileResponse) map[string return ids } +func ketoSubjectFromProfile(profile *domain.UserProfileResponse) string { + if profile == nil { + return "" + } + id := strings.TrimSpace(profile.ID) + if id == "" { + return "" + } + return "User:" + id +} + +func (h *DevHandler) checkProfileKetoPermission(c *fiber.Ctx, profile *domain.UserProfileResponse, namespace, object, relation string) (bool, error) { + if profile == nil { + return false, nil + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true, nil + } + if h.Keto == nil { + return false, nil + } + subject := ketoSubjectFromProfile(profile) + if subject == "" { + return false, nil + } + return h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation) +} + +func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return true + } + + clientTenantID := resolveClientTenantID(summary) + if clientTenantID != "" { + if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed { + return true + } + } + + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") + return err == nil && allowed +} + +func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { + if strings.TrimSpace(tenantID) == "" { + return false + } + allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions") + return err == nil && allowed +} + +func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { + allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, relation) + return err == nil && allowed +} + +func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { + if profile == nil { + return false + } + + role := normalizeUserRole(profile.Role) + if role == domain.RoleSuperAdmin { + return true + } + if !isDevConsoleRoleAllowed(role) { + return false + } + + userTenantID := tenantIDFromProfile(profile) + clientTenantID := resolveClientTenantID(summary) + if userTenantID != "" && clientTenantID != "" && clientTenantID != userTenantID { + return false + } + + return isRPAdminClientAllowed(profile, summary.ID) +} + func resolveClientTenantID(summary clientSummary) string { if summary.Metadata == nil { return "" @@ -364,7 +446,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { } // Check with Keto: System:global#manage_all - allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "global", "manage_all") + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+subject, "System", "global", "manage_all") if err != nil { // Fail closed for dev private endpoints: deny on permission backend error. slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err) @@ -443,7 +525,7 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { } // Check with Keto: System:global#manage_all - allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "global", "manage_all") + allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+tokenSubject, "System", "global", "manage_all") if err != nil { // Fail closed for dev private endpoints: deny on permission backend error. slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err) @@ -589,9 +671,6 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { userTenantID := tenantIDFromProfile(profile) isSuperAdmin := role == domain.RoleSuperAdmin allowedClientIDs := managedClientIDsFromProfile(profile) - if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") - } isAppManager, err := h.checkAppManagerPermission(c) if err != nil { @@ -626,18 +705,24 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant if !isSuperAdmin { clientTenantID, _ := summary.Metadata["tenant_id"].(string) - if clientTenantID != userTenantID { + if clientTenantID != userTenantID && !h.canViewClientByPermit(c, profile, summary) { continue } } - // 3. [Role Scope] RP Admin can only access managed RP IDs - if role == domain.RoleRPAdmin { + // 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists + if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { if _, ok := allowedClientIDs[summary.ID]; !ok { - continue + if !h.canViewClientByPermit(c, profile, summary) { + continue + } } } + if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { + continue + } + items = append(items, summary) } @@ -677,16 +762,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] Check if user has access to this client - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -784,16 +860,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "change_status") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -847,6 +914,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } + if tenantID == "" && profile.TenantID != nil { + tenantID = *profile.TenantID + } + if role == domain.RoleRPAdmin && !h.canManageTenantClientsByPermit(c, profile, tenantID) { + return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required") + } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { @@ -1035,16 +1108,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(currentSummary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, currentSummary.ID) { + 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") } @@ -1214,16 +1278,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "manage") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1273,7 +1328,15 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if !isRPAdminClientAllowed(profile, clientID) { + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + summary := h.mapClientSummary(*client) + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "view_consents") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1390,8 +1453,18 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if clientID != "" && !isRPAdminClientAllowed(profile, clientID) { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") + if clientID != "" { + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + summary := h.mapClientSummary(*client) + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "revoke_consents") { + return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") + } } // If subject is not a UUID, try to resolve it as an identifier (email/username) @@ -1454,15 +1527,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { } // [Tenant Isolation] - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "rotate_secret") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1550,15 +1615,7 @@ func (h *DevHandler) RefreshHeadlessJWKSCache(c *fiber.Ctx) error { if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index b9a73fa5..f6804b8f 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -510,6 +510,52 @@ func TestGetClient_ProtectedSystemClientHidden(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode) } +func TestGetClient_RPAdminAllowedByKetoViewPermission(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-b", + "status": "active", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "RelyingParty", "client-1", "view").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "rp-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -554,6 +600,59 @@ func TestRotateClientSecret_Success(t *testing.T) { assert.Equal(t, res.Client.ClientSecret, dbS) } +func TestCreateClient_RPAdminAllowedByTenantGrantPermission(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.URL.Path == "/clients" { + var body map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&body) + body["client_secret"] = "generated-secret" + return httpJSONAny(r, http.StatusCreated, body), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:rp-1", "Tenant", "tenant-a", "grant_dev_permissions").Return(true, nil) + + secretRepo := &mockSecretRepo{secrets: make(map[string]string)} + redisRepo := &devMockRedisRepo{data: make(map[string]string)} + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + SecretRepo: secretRepo, + Redis: redisRepo, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "rp-1", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Post("/api/v1/dev/clients", h.CreateClient) + + body, _ := json.Marshal(map[string]any{ + "id": "client-1", + "name": "App One", + "type": "pkce", + "redirectUris": []string{"http://localhost/cb"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + mockKeto.AssertExpectations(t) +} + func TestGetStats_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { From 91299b1a0a0b1f95243e419f5f87337c9e0e3c70 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 15:23:50 +0900 Subject: [PATCH 03/18] =?UTF-8?q?RP=20=EC=83=9D=EC=84=B1/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=9A=B4=EC=98=81=20relation=20=EC=84=B8=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/service/relying_party_service.go | 84 ++++++++++++++----- .../service/relying_party_service_test.go | 16 ++++ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index c2fa0bc1..26b0ef02 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "log/slog" + "strings" ) type RelyingPartyService interface { @@ -24,6 +25,19 @@ type relyingPartyService struct { outboxRepo repository.KetoOutboxRepository } +var defaultRelyingPartyOperatorRelations = []string{ + "admins", + "creator", + "config_editor", + "secret_rotator", + "jwks_viewer", + "jwks_operator", + "consent_viewer", + "consent_revoker", + "relationship_viewer", + "status_operator", +} + func NewRelyingPartyService( hydraService *HydraAdminService, ketoService KetoService, @@ -36,6 +50,51 @@ func NewRelyingPartyService( } } +func extractRelyingPartyCreatorSubject(client *domain.HydraClient) string { + if client == nil || client.Metadata == nil { + return "" + } + raw, _ := client.Metadata["user_id"].(string) + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + return "User:" + raw +} + +func (s *relyingPartyService) enqueueRelyingPartyTuple(ctx context.Context, action, object, relation, subject string) { + if s.outboxRepo == nil || strings.TrimSpace(object) == "" || strings.TrimSpace(relation) == "" || strings.TrimSpace(subject) == "" { + return + } + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "RelyingParty", + Object: object, + Relation: relation, + Subject: subject, + Action: action, + }) +} + +func (s *relyingPartyService) enqueueDefaultRelyingPartyRelations(ctx context.Context, action string, client *domain.HydraClient, tenantID string) { + if client == nil { + return + } + + tenantID = strings.TrimSpace(tenantID) + if tenantID != "" { + s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, "parents", "Tenant:"+tenantID) + } + + creatorSubject := extractRelyingPartyCreatorSubject(client) + if creatorSubject == "" { + return + } + + for _, relation := range defaultRelyingPartyOperatorRelations { + s.enqueueRelyingPartyTuple(ctx, action, client.ClientID, relation, creatorSubject) + } +} + func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { // 1. Create Client in Hydra if client.Metadata == nil { @@ -48,17 +107,8 @@ func (s *relyingPartyService) Create(ctx context.Context, tenantID string, clien return nil, fmt.Errorf("failed to create hydra client: %w", err) } - // 2. Create Relation in Keto via Outbox - // RelyingParty:#parents@Tenant: - if s.outboxRepo != nil { - _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "RelyingParty", - Object: createdClient.ClientID, - Relation: "parents", - Subject: "Tenant:" + tenantID, - Action: domain.KetoOutboxActionCreate, - }) - } + // 2. Create default relations in Keto via Outbox. + s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionCreate, createdClient, tenantID) return s.mapHydraToDomain(createdClient), nil } @@ -137,16 +187,8 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error return err } - // 3. Delete from Keto via Outbox - if s.outboxRepo != nil && tenantID != "" { - _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "RelyingParty", - Object: clientID, - Relation: "parents", - Subject: "Tenant:" + tenantID, - Action: domain.KetoOutboxActionDelete, - }) - } + // 3. Delete default relations from Keto via Outbox. + s.enqueueDefaultRelyingPartyRelations(ctx, domain.KetoOutboxActionDelete, client, tenantID) return nil } diff --git a/backend/internal/service/relying_party_service_test.go b/backend/internal/service/relying_party_service_test.go index e0b26c76..510b5802 100644 --- a/backend/internal/service/relying_party_service_test.go +++ b/backend/internal/service/relying_party_service_test.go @@ -52,6 +52,9 @@ func TestRelyingPartyService_Create_Success(t *testing.T) { tenantID := "tenant-1" inputClient := domain.HydraClient{ ClientName: "Test App", + Metadata: map[string]interface{}{ + "user_id": "creator-1", + }, } // Hydra Mock @@ -81,6 +84,12 @@ func TestRelyingPartyService_Create_Success(t *testing.T) { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID })).Return(nil) + for _, relation := range defaultRelyingPartyOperatorRelations { + rel := relation + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == rel && e.Subject == "User:creator-1" + })).Return(nil) + } svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox) rp, err := svc.Create(context.Background(), tenantID, inputClient) @@ -173,6 +182,7 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) { ClientID: clientID, Metadata: map[string]interface{}{ "tenant_id": tenantID, + "user_id": "creator-1", }, }) return @@ -192,6 +202,12 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) { mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID })).Return(nil) + for _, relation := range defaultRelyingPartyOperatorRelations { + rel := relation + mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == rel && e.Subject == "User:creator-1" + })).Return(nil) + } svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox) err := svc.Delete(context.Background(), clientID) From dd93a3450ab5dd0f4727650ba301bcc45a0af400 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 16:04:25 +0900 Subject: [PATCH 04/18] =?UTF-8?q?Dev=20API=EC=97=90=20RP=20operator=20rela?= =?UTF-8?q?tion=20=EC=A1=B0=ED=9A=8C/=EB=B6=80=EC=97=AC/=ED=9A=8C=EC=88=98?= =?UTF-8?q?=20=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, From 8d0982b89c482bfca2f8ee0f8063e8f284ff1ad2 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 17:18:04 +0900 Subject: [PATCH 05/18] =?UTF-8?q?devfront=20RP=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=ED=83=AD=20i18n=20=EB=B0=8F=20=EC=88=9C=EC=84=9C=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientConsentsPage.tsx | 25 +------ .../src/features/clients/ClientDetailTabs.tsx | 54 +++++++++++++++ .../features/clients/ClientDetailsPage.tsx | 28 +------- .../features/clients/ClientGeneralPage.tsx | 31 ++------- .../features/clients/ClientRelationsPage.tsx | 43 +++--------- devfront/src/locales/en.toml | 1 + devfront/src/locales/ko.toml | 1 + devfront/src/locales/template.toml | 1 + devfront/tests/devfront-client-tabs.spec.ts | 68 +++++++++++++++++++ 9 files changed, 144 insertions(+), 108 deletions(-) create mode 100644 devfront/src/features/clients/ClientDetailTabs.tsx create mode 100644 devfront/tests/devfront-client-tabs.spec.ts diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index de941fd7..c2498d7d 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -29,6 +29,7 @@ import { import { fetchClient, fetchConsents, revokeConsent } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; +import { ClientDetailTabs } from "./ClientDetailTabs"; function ClientConsentsPage() { const params = useParams(); @@ -214,29 +215,7 @@ function ClientConsentsPage() { -
- - {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")} - -
+ diff --git a/devfront/src/features/clients/ClientDetailTabs.tsx b/devfront/src/features/clients/ClientDetailTabs.tsx new file mode 100644 index 00000000..8b5372c1 --- /dev/null +++ b/devfront/src/features/clients/ClientDetailTabs.tsx @@ -0,0 +1,54 @@ +import { Link } from "react-router-dom"; +import { t } from "../../lib/i18n"; +import { cn } from "../../lib/utils"; + +type ClientDetailTab = "connection" | "consents" | "settings" | "relationships"; + +interface ClientDetailTabsProps { + activeTab: ClientDetailTab; + clientId: string; +} + +const tabOrder: Array<{ + key: ClientDetailTab; + href: (clientId: string) => string; +}> = [ + { key: "connection", href: (clientId) => `/clients/${clientId}` }, + { key: "consents", href: (clientId) => `/clients/${clientId}/consents` }, + { key: "settings", href: (clientId) => `/clients/${clientId}/settings` }, + { + key: "relationships", + href: (clientId) => `/clients/${clientId}/relationships`, + }, +]; + +export function ClientDetailTabs({ + activeTab, + clientId, +}: ClientDetailTabsProps) { + return ( +
+ {tabOrder.map((tab) => { + const isActive = tab.key === activeTab; + return isActive ? ( + + {t(`ui.dev.clients.details.tab.${tab.key}`)} + + ) : ( + + {t(`ui.dev.clients.details.tab.${tab.key}`)} + + ); + })} +
+ ); +} diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index eabdfc1c..7d6825fb 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -38,6 +38,7 @@ import { } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; +import { ClientDetailTabs } from "./ClientDetailTabs"; function ClientDetailsPage() { const params = useParams(); @@ -253,32 +254,7 @@ function ClientDetailsPage() { : t("msg.common.loading", "Loading...")} -
- - {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")} - -
+
diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 2da52255..dedff139 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -43,6 +43,7 @@ import type { } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; +import { ClientDetailTabs } from "./ClientDetailTabs"; interface ScopeItem { id: string; @@ -665,33 +666,9 @@ function ClientGeneralPage() { )}
-
- {!isCreate && ( - <> - - {t("ui.dev.clients.details.tab.connection", "Federation")} - - - {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")} - - - )} -
+ {!isCreate && ( + + )} {/* 1. Application Identity */} diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index bb0ee916..af1120f7 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -30,6 +30,7 @@ import { removeClientRelation, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ "admins", @@ -48,9 +49,8 @@ function ClientRelationsPage() { const params = useParams(); const queryClient = useQueryClient(); const clientId = params.id ?? ""; - const [relation, setRelation] = useState<(typeof relationOptions)[number]>( - "config_editor", - ); + const [relation, setRelation] = + useState<(typeof relationOptions)[number]>("config_editor"); const [userId, setUserId] = useState(""); const { data: clientData } = useQuery({ @@ -86,7 +86,9 @@ function ClientRelationsPage() { userId: userId.trim(), }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] }); + queryClient.invalidateQueries({ + queryKey: ["client-relations", clientId], + }); setUserId(""); toast( t( @@ -115,7 +117,9 @@ function ClientRelationsPage() { mutationFn: (payload: { relation: string; subject: string }) => removeClientRelation(clientId, payload.relation, payload.subject), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["client-relations", clientId] }); + queryClient.invalidateQueries({ + queryKey: ["client-relations", clientId], + }); toast( t( "msg.dev.clients.relationships.removed", @@ -191,10 +195,7 @@ function ClientRelationsPage() { {clientData?.client?.name || clientId} / - {t( - "ui.dev.clients.details.tab.relationships", - "Relationships", - )} + {t("ui.dev.clients.details.tab.relationships", "Relationships")}
@@ -231,29 +232,7 @@ function ClientRelationsPage() {
-
- - {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")} - -
+ diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 87256821..e9e51705 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1363,6 +1363,7 @@ title = "Security Note" connection = "Federation" consents = "Consent & Users" settings = "Settings" +relationships = "Relationships" [ui.dev.clients.general] create = "Create Application" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 2ac3c74a..00ec0f3d 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1362,6 +1362,7 @@ title = "보안 메모" connection = "연동 설정" consents = "동의 및 사용자" settings = "설정" +relationships = "관계" [ui.dev.clients.general] create = "앱 생성" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 7bff5ee6..d6252524 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -1363,6 +1363,7 @@ title = "" connection = "" consents = "" settings = "" +relationships = "" [ui.dev.clients.general] create = "" diff --git a/devfront/tests/devfront-client-tabs.spec.ts b/devfront/tests/devfront-client-tabs.spec.ts new file mode 100644 index 00000000..69d0b5a0 --- /dev/null +++ b/devfront/tests/devfront-client-tabs.spec.ts @@ -0,0 +1,68 @@ +import { type Page, expect, test } from "@playwright/test"; +import { + type ClientRelation, + type Consent, + installDevApiMock, + makeClient, + seedAuth, +} from "./helpers/devfront-fixtures"; + +function expectClientTabsOrder(pagePath: string, expectedActive: RegExp) { + return async ({ page }: { page: Page }) => { + const state = { + clients: [makeClient("client-tabs", { name: "탭 테스트 앱" })], + consents: [] as Consent[], + relations: { + "client-tabs": [ + { + relation: "config_editor", + subject: "User:user-1", + subjectType: "User", + subjectId: "user-1", + }, + ] satisfies ClientRelation[], + }, + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.goto(pagePath); + + const header = page + .locator("header") + .filter({ hasText: "탭 테스트 앱" }) + .first(); + const tabs = header.locator( + "div.border-b.border-border .whitespace-nowrap", + ); + + await expect(tabs).toHaveText([ + "연동 설정", + "동의 및 사용자", + "설정", + "관계", + ]); + + await expect( + header + .locator("div.border-b.border-border .text-primary") + .filter({ hasText: expectedActive }), + ).toHaveCount(1); + }; +} + +test.describe("DevFront client detail tabs", () => { + test.beforeEach(async ({ page }) => { + await seedAuth(page, "rp_admin"); + }); + + test( + "settings page keeps tab order and uses localized relationships label", + expectClientTabsOrder("/clients/client-tabs/settings", /^설정$/), + ); + + test( + "relationships page keeps tab order and uses localized relationships label", + expectClientTabsOrder("/clients/client-tabs/relationships", /^관계$/), + ); +}); From 034789b8cba32cfd5be1d737f377695403a74253 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 17:22:23 +0900 Subject: [PATCH 06/18] =?UTF-8?q?devfront=20ReBAC=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 1 - devfront/playwright.config.ts | 23 +++--- devfront/tests/clients.spec.ts | 7 ++ devfront/tests/devfront-audit.spec.ts | 7 ++ .../tests/devfront-clients-lifecycle.spec.ts | 7 ++ devfront/tests/devfront-consents.spec.ts | 7 ++ devfront/tests/devfront-relationships.spec.ts | 63 +++++++++++++++++ devfront/tests/devfront-security.spec.ts | 7 ++ devfront/tests/devfront-tenant-switch.spec.ts | 7 ++ devfront/tests/example.spec.ts | 7 ++ devfront/tests/helpers/devfront-fixtures.ts | 70 +++++++++++++++++++ docker/ory/oathkeeper/rules.active.json | 2 +- 12 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 devfront/tests/devfront-relationships.spec.ts diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 257ee81a..63776688 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1619,7 +1619,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { var consents []domain.ClientConsentWithTenantInfo var total int64 - var err error if subject != "" { // Resolve subject if it's email/name (Legacy support) diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index e1d16054..d0ba28d0 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test"; const configuredWorkers = process.env.PLAYWRIGHT_WORKERS ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) : undefined; +const skipWebServer = + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; +const baseURL = + process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; /** * Read environment variables from file. @@ -30,7 +35,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:5174", + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -55,11 +60,13 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: process.env.CI - ? "npm run build && npm run preview -- --port 5174" - : "npm run dev -- --port 5174", - url: "http://localhost:5174", - reuseExistingServer: !process.env.CI, - }, + webServer: skipWebServer + ? undefined + : { + command: process.env.CI + ? "npm run build && npm run preview -- --port 5174" + : "npm run dev -- --port 5174", + url: baseURL, + reuseExistingServer: !process.env.CI, + }, }); diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index b304583e..09b492f8 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -5,6 +5,13 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } +}); test("clients page loads correctly", async ({ page }) => { await seedAuth(page); diff --git a/devfront/tests/devfront-audit.spec.ts b/devfront/tests/devfront-audit.spec.ts index 8a0890d2..9158fa4e 100644 --- a/devfront/tests/devfront-audit.spec.ts +++ b/devfront/tests/devfront-audit.spec.ts @@ -6,10 +6,17 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; test.describe("DevFront audit logs", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept().catch(() => {}); diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index a048eaa7..9a66341b 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -6,11 +6,18 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; const jwksUri = "https://rp.example.com/.well-known/jwks.json"; test.describe("DevFront clients lifecycle", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts index 27518c3a..abcc4db6 100644 --- a/devfront/tests/devfront-consents.spec.ts +++ b/devfront/tests/devfront-consents.spec.ts @@ -5,8 +5,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront consents", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts new file mode 100644 index 00000000..b88fb3a3 --- /dev/null +++ b/devfront/tests/devfront-relationships.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { + type ClientRelation, + type Consent, + installDevApiMock, + makeClient, + seedAuth, +} from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +test.describe("DevFront relationships", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + + test.beforeEach(async ({ page }) => { + page.on("dialog", async (dialog) => { + await dialog.accept(); + }); + await seedAuth(page, "rp_admin"); + }); + + test("list add and remove direct RP relationships", async ({ page }) => { + const state = { + clients: [makeClient("client-rel", { name: "Relations app" })], + consents: [] as Consent[], + relations: { + "client-rel": [ + { + relation: "config_editor", + subject: "User:user-1", + subjectType: "User", + subjectId: "user-1", + }, + ] satisfies ClientRelation[], + }, + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.goto("/clients/client-rel/relationships"); + await expect(page.getByText("Client Relationships")).toBeVisible(); + await expect(page.getByText("User:user-1")).toBeVisible(); + + await page.getByLabel(/Relation/i).selectOption("secret_rotator"); + await page.getByLabel(/User ID/i).fill("user-2"); + await page.getByRole("button", { name: /^Add$/i }).click(); + + await expect(page.getByText("User:user-2")).toBeVisible(); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(2); + + await page + .locator("tr") + .filter({ hasText: "User:user-2" }) + .getByRole("button", { name: /Delete|삭제/i }) + .click(); + + await expect(page.getByText("User:user-2")).toHaveCount(0); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(1); + }); +}); diff --git a/devfront/tests/devfront-security.spec.ts b/devfront/tests/devfront-security.spec.ts index 5ac03361..0e451a59 100644 --- a/devfront/tests/devfront-security.spec.ts +++ b/devfront/tests/devfront-security.spec.ts @@ -5,8 +5,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront security and isolation", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-tenant-switch.spec.ts b/devfront/tests/devfront-tenant-switch.spec.ts index 7471b1f7..8591b9fb 100644 --- a/devfront/tests/devfront-tenant-switch.spec.ts +++ b/devfront/tests/devfront-tenant-switch.spec.ts @@ -4,8 +4,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront tenant switch", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + const MOCK_STATE = { clients: [makeClient("client-a", { name: "Tenant A App" })], consents: [], diff --git a/devfront/tests/example.spec.ts b/devfront/tests/example.spec.ts index 53b304ae..565aa49a 100644 --- a/devfront/tests/example.spec.ts +++ b/devfront/tests/example.spec.ts @@ -1,4 +1,11 @@ import { expect, test } from "@playwright/test"; +import { captureEvidence } from "./helpers/evidence"; + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } +}); test("has title", async ({ page }) => { await page.goto("/"); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index d914f364..a375f4f6 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -53,6 +53,13 @@ export type Consent = { tenantName: string; }; +export type ClientRelation = { + relation: string; + subject: string; + subjectType: string; + subjectId: string; +}; + export type AuditLog = { event_id: string; timestamp: string; @@ -67,6 +74,7 @@ export type AuditLog = { export type DevApiMockState = { clients: Client[]; consents: Consent[]; + relations?: Record; auditLogsByCursor?: Record< string, { items: AuditLog[]; next_cursor?: string } @@ -292,6 +300,68 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); } + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "GET" + ) { + const clientId = pathname.split("/")[5] ?? ""; + return json(route, { + items: state.relations?.[clientId] ?? [], + }); + } + + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "POST" + ) { + const clientId = pathname.split("/")[5] ?? ""; + const payload = (request.postDataJSON() as { + relation?: string; + subject?: string; + userId?: string; + }) || { relation: "config_editor" }; + const subject = + payload.subject || + (payload.userId ? `User:${payload.userId}` : "User:playwright-user"); + const subjectId = subject.startsWith("User:") + ? subject.slice("User:".length) + : subject; + const created: ClientRelation = { + relation: payload.relation ?? "config_editor", + subject, + subjectType: "User", + subjectId, + }; + if (!state.relations) { + state.relations = {}; + } + if (!state.relations[clientId]) { + state.relations[clientId] = []; + } + state.relations[clientId].push(created); + appendAuditLog("CLIENT_RELATION_CREATE", "ADD_RELATION", clientId); + return json(route, created, 201); + } + + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "DELETE" + ) { + const clientId = pathname.split("/")[5] ?? ""; + const relation = searchParams.get("relation") || ""; + const subject = searchParams.get("subject") || ""; + if (state.relations?.[clientId]) { + state.relations[clientId] = state.relations[clientId].filter( + (item) => !(item.relation === relation && item.subject === subject), + ); + } + appendAuditLog("CLIENT_RELATION_DELETE", "REMOVE_RELATION", clientId); + return route.fulfill({ status: 204 }); + } + if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/status") && diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index fd6bfb2d..4a0735da 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -156,4 +156,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] +] \ No newline at end of file From f494d8e50a02fff74e07072f5d2e3f4d6d189f1d Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 17:43:20 +0900 Subject: [PATCH 07/18] =?UTF-8?q?relationships=20=ED=83=AD=20i18n=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20=EB=B0=8F=20=ED=83=AD=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/locales/en.toml | 11 +++++++++++ devfront/src/locales/ko.toml | 11 +++++++++++ devfront/src/locales/template.toml | 11 +++++++++++ devfront/tests/devfront-relationships.spec.ts | 14 ++++++++++---- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index e9e51705..811f7b26 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1443,6 +1443,17 @@ cache_status = "Status" cache_uri = "JWKS URI" revoke_cache = "Revoke Cache" +[ui.dev.clients.relationships] +title = "Client Relationships" +add_title = "Add Relationship" +relation = "Relation" +user_id = "User ID" +user_id_placeholder = "kratos user id" +add = "Add" +list_title = "Assigned Relationships" +subject = "Subject" +subject_type = "Type" + [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." docs_title = "Docs & Examples" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 00ec0f3d..27aae064 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1442,6 +1442,17 @@ cache_status = "상태" cache_uri = "JWKS URI" revoke_cache = "캐시 삭제" +[ui.dev.clients.relationships] +title = "클라이언트 관계" +add_title = "관계 추가" +relation = "관계" +user_id = "사용자 ID" +user_id_placeholder = "kratos 사용자 id" +add = "추가" +list_title = "부여된 관계" +subject = "주체" +subject_type = "유형" + [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." docs_title = "Docs & Examples" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index d6252524..eb5792b0 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -1442,6 +1442,17 @@ cache_status = "" cache_uri = "" revoke_cache = "" +[ui.dev.clients.relationships] +title = "" +add_title = "" +relation = "" +user_id = "" +user_id_placeholder = "" +add = "" +list_title = "" +subject = "" +subject_type = "" + [ui.dev.clients.help] docs_body = "" docs_title = "" diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts index b88fb3a3..e74bd33b 100644 --- a/devfront/tests/devfront-relationships.spec.ts +++ b/devfront/tests/devfront-relationships.spec.ts @@ -41,12 +41,18 @@ test.describe("DevFront relationships", () => { await installDevApiMock(page, state); await page.goto("/clients/client-rel/relationships"); - await expect(page.getByText("Client Relationships")).toBeVisible(); + await expect(page.getByText("클라이언트 관계")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "관계 추가" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "부여된 관계" }), + ).toBeVisible(); await expect(page.getByText("User:user-1")).toBeVisible(); - await page.getByLabel(/Relation/i).selectOption("secret_rotator"); - await page.getByLabel(/User ID/i).fill("user-2"); - await page.getByRole("button", { name: /^Add$/i }).click(); + await page.getByLabel(/^관계$/).selectOption("secret_rotator"); + await page.getByLabel(/^사용자 ID$/).fill("user-2"); + await page.getByRole("button", { name: /^추가$/ }).click(); await expect(page.getByText("User:user-2")).toBeVisible(); await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(2); From f955d23ef12acee3942ecc61f0351a364e783b52 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 18:23:07 +0900 Subject: [PATCH 08/18] =?UTF-8?q?dev=20API=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=80=EC=83=89=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EA=B3=84=20=EB=AA=A9=EB=A1=9D=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=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 | 1 + backend/internal/handler/dev_handler.go | 156 ++++++++++++++++++- backend/internal/handler/dev_handler_test.go | 130 +++++++++++++++- 3 files changed, 282 insertions(+), 5 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3d5344ff..c2aedead 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -705,6 +705,7 @@ func main() { dev := api.Group("/dev") dev.Get("/stats", devHandler.GetStats) dev.Get("/my-tenants", devHandler.ListMyTenants) + dev.Get("/users", devHandler.SearchUsers) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", devHandler.GetClient) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 63776688..2507e6a9 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -127,12 +127,26 @@ type clientRelationSummary struct { Subject string `json:"subject"` SubjectType string `json:"subjectType"` SubjectID string `json:"subjectId"` + UserName string `json:"userName,omitempty"` + UserEmail string `json:"userEmail,omitempty"` + UserLoginID string `json:"userLoginId,omitempty"` } type clientRelationListResponse struct { Items []clientRelationSummary `json:"items"` } +type devUserSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + LoginID string `json:"loginId,omitempty"` +} + +type devUserListResponse struct { + Items []devUserSummary `json:"items"` +} + type clientRelationUpsertRequest struct { Relation string `json:"relation"` Subject string `json:"subject"` @@ -416,6 +430,68 @@ func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string return ok } +func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} { + keys := make(map[string]struct{}) + if profile == nil { + return keys + } + + addKey := func(value string) { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed != "" { + keys[trimmed] = struct{}{} + } + } + + addKey(profile.CompanyCode) + if profile.TenantID != nil { + addKey(*profile.TenantID) + } + for _, tenant := range profile.ManageableTenants { + addKey(tenant.ID) + addKey(tenant.Slug) + } + for _, tenant := range profile.JoinedTenants { + addKey(tenant.ID) + addKey(tenant.Slug) + } + + return keys +} + +func canAccessIdentityByTenant(profile *domain.UserProfileResponse, identity service.KratosIdentity) bool { + if normalizeUserRole(profileRole(profile)) == domain.RoleSuperAdmin { + return true + } + + keys := manageableTenantKeysFromProfile(profile) + if len(keys) == 0 { + return false + } + + for _, raw := range []string{ + extractTraitString(identity.Traits, "tenant_id"), + extractTraitString(identity.Traits, "companyCode"), + extractTraitString(identity.Traits, "company_code"), + } { + if _, ok := keys[strings.ToLower(strings.TrimSpace(raw))]; ok { + return true + } + } + + return false +} + +func mapDevUserSummary(identity service.KratosIdentity) devUserSummary { + traits := identity.Traits + return devUserSummary{ + ID: identity.ID, + Name: extractTraitString(traits, "name"), + Email: extractTraitString(traits, "email"), + LoginID: resolvePasswordLoginID(traits), + } +} + func profileRole(profile *domain.UserProfileResponse) string { if profile == nil { return "" @@ -496,14 +572,20 @@ func validateClientRelationWriteInput(relation, subject string) error { return nil } -func mapRelationTupleSummary(tuple service.RelationTuple) clientRelationSummary { +func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.KratosIdentity) clientRelationSummary { subjectType, subjectID := parseClientRelationSubject(tuple.SubjectID) - return clientRelationSummary{ + summary := clientRelationSummary{ Relation: tuple.Relation, Subject: tuple.SubjectID, SubjectType: subjectType, SubjectID: subjectID, } + if identity != nil { + summary.UserName = extractTraitString(identity.Traits, "name") + summary.UserEmail = extractTraitString(identity.Traits, "email") + summary.UserLoginID = resolvePasswordLoginID(identity.Traits) + } + return summary } func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) { @@ -522,6 +604,65 @@ func (h *DevHandler) getRelationRequestProfile(c *fiber.Ctx) *domain.UserProfile return h.getCurrentProfile(c) } +func (h *DevHandler) SearchUsers(c *fiber.Ctx) error { + profile := h.getCurrentProfile(c) + if profile == nil { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + if !isDevConsoleRoleAllowed(normalizeUserRole(profile.Role)) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + if h.KratosAdmin == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") + } + + search := strings.ToLower(strings.TrimSpace(c.Query("search"))) + limit := c.QueryInt("limit", 10) + if limit <= 0 { + limit = 10 + } + if limit > 20 { + limit = 20 + } + + identities, err := h.KratosAdmin.ListIdentities(c.Context()) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + items := make([]devUserSummary, 0, limit) + for _, identity := range identities { + if !canAccessIdentityByTenant(profile, identity) { + continue + } + + summary := mapDevUserSummary(identity) + if search != "" { + matched := false + for _, candidate := range []string{ + strings.ToLower(summary.Name), + strings.ToLower(summary.Email), + strings.ToLower(summary.LoginID), + } { + if candidate != "" && strings.Contains(candidate, search) { + matched = true + break + } + } + if !matched { + continue + } + } + + items = append(items, summary) + if len(items) >= limit { + break + } + } + + return c.JSON(devUserListResponse{Items: items}) +} + func validateReservedSystemClientName(clientID, name string) error { ownerID, reserved := reservedSystemClientOwnerID(name) if !reserved { @@ -892,7 +1033,14 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } for _, tuple := range tuples { - items = append(items, mapRelationTupleSummary(tuple)) + var identity *service.KratosIdentity + if tuple.SubjectID != "" && h.KratosAdmin != nil { + _, subjectID := parseClientRelationSubject(tuple.SubjectID) + if subjectID != "" { + identity, _ = h.KratosAdmin.GetIdentity(c.Context(), subjectID) + } + } + items = append(items, mapRelationTupleSummary(tuple, identity)) } } @@ -950,7 +1098,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { Object: clientID, Relation: req.Relation, SubjectID: req.Subject, - })) + }, nil)) } func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index bfaf47b4..0ef00aea 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -52,6 +52,66 @@ type devMockRedisRepo struct { data map[string]string } +type devMockKratosAdmin struct { + mock.Mock +} + +func (m *devMockKratosAdmin) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) { + args := m.Called(ctx) + return args.Get(0).([]service.KratosIdentity), args.Error(1) +} + +func (m *devMockKratosAdmin) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { + args := m.Called(ctx, identifier) + return args.String(0), args.Error(1) +} + +func (m *devMockKratosAdmin) GetIdentity(ctx context.Context, identityID string) (*service.KratosIdentity, error) { + args := m.Called(ctx, identityID) + if identity, ok := args.Get(0).(*service.KratosIdentity); ok { + return identity, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) { + args := m.Called(ctx, identityID, traits, state) + if identity, ok := args.Get(0).(*service.KratosIdentity); ok { + return identity, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { + return m.Called(ctx, identityID, newPassword).Error(0) +} + +func (m *devMockKratosAdmin) DeleteIdentity(ctx context.Context, identityID string) error { + return m.Called(ctx, identityID).Error(0) +} + +func (m *devMockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + args := m.Called(ctx, user, password) + return args.String(0), args.Error(1) +} + +func (m *devMockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) { + args := m.Called(ctx, identityID) + return args.Get(0).([]service.KratosSession), args.Error(1) +} + +func (m *devMockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) { + args := m.Called(ctx, sessionID) + if session, ok := args.Get(0).(*service.KratosSession); ok { + return session, args.Error(1) + } + return nil, args.Error(1) +} + +func (m *devMockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error { + return m.Called(ctx, sessionID).Error(0) +} + type devMockKetoOutboxRepository struct { mock.Mock } @@ -1273,13 +1333,23 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test 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) } + mockKratos := new(devMockKratosAdmin) + mockKratos.On("GetIdentity", mock.Anything, "user-2").Return(&service.KratosIdentity{ + ID: "user-2", + Traits: map[string]interface{}{ + "name": "김용연", + "email": "kyy@example.com", + "id": "kyy01", + }, + }, nil) h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, - Keto: mockKeto, + Keto: mockKeto, + KratosAdmin: mockKratos, } app := fiber.New() @@ -1304,6 +1374,9 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test 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) + assert.Equal(t, "김용연", result.Items[0].UserName) + assert.Equal(t, "kyy@example.com", result.Items[0].UserEmail) + assert.Equal(t, "kyy01", result.Items[0].UserLoginID) } func TestAddClientRelation_RPAdminAllowedByTenantGrantPermission(t *testing.T) { @@ -1421,3 +1494,58 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { assert.Equal(t, http.StatusNoContent, resp.StatusCode) mockOutbox.AssertExpectations(t) } + +func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { + mockKratos := new(devMockKratosAdmin) + mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ + { + ID: "user-1", + Traits: map[string]interface{}{ + "name": "Alice Kim", + "email": "alice@example.com", + "id": "alice01", + "tenant_id": "tenant-1", + }, + }, + { + ID: "user-2", + Traits: map[string]interface{}{ + "name": "Bob Lee", + "email": "bob@example.com", + "id": "bob01", + "tenant_id": "tenant-2", + }, + }, + }, nil) + + h := &DevHandler{ + KratosAdmin: mockKratos, + } + + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + tenantID := "tenant-1" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-9", + Role: domain.RoleRPAdmin, + TenantID: &tenantID, + ManageableTenants: []domain.Tenant{ + {ID: "tenant-1", Slug: "tenant-one"}, + }, + }) + return c.Next() + }) + app.Get("/api/v1/dev/users", h.SearchUsers) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result devUserListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Len(t, result.Items, 1) + assert.Equal(t, "user-1", result.Items[0].ID) + assert.Equal(t, "Alice Kim", result.Items[0].Name) + assert.Equal(t, "alice@example.com", result.Items[0].Email) + mockKratos.AssertExpectations(t) +} From a79c350831f18f8a0a2748a945495f5a694d8f41 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 18:23:23 +0900 Subject: [PATCH 09/18] =?UTF-8?q?devfront=20=EA=B4=80=EA=B3=84=20=ED=83=AD?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=80=EC=83=89=C2=B7?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=EC=84=A0=ED=83=9D=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientRelationsPage.tsx | 321 +++++++++++++++--- devfront/src/lib/devApi.ts | 24 ++ devfront/src/locales/en.toml | 60 ++++ devfront/src/locales/ko.toml | 60 ++++ devfront/src/locales/template.toml | 60 ++++ devfront/tests/devfront-relationships.spec.ts | 29 +- devfront/tests/helpers/devfront-fixtures.ts | 25 ++ 7 files changed, 519 insertions(+), 60 deletions(-) diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index af1120f7..e4ba79e4 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -1,7 +1,7 @@ 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 { useDeferredValue, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -24,9 +24,11 @@ import { } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; import { + type DevAssignableUser, addClientRelation, fetchClient, fetchClientRelations, + fetchDevUsers, removeClientRelation, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; @@ -45,13 +47,37 @@ const relationOptions = [ "status_operator", ] as const; +type RelationOption = (typeof relationOptions)[number]; + +function relationLabel(relation: RelationOption) { + return t(`ui.dev.clients.relationships.option.${relation}.label`, relation); +} + +function relationDescription(relation: RelationOption) { + return t( + `ui.dev.clients.relationships.option.${relation}.description`, + relation, + ); +} + +function formatUserLabel(user: DevAssignableUser) { + const primary = user.name.trim() || user.email.trim(); + return `${primary} (${user.email.trim()})`; +} + 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 [selectedRelations, setSelectedRelations] = useState( + [], + ); + const [userSearch, setUserSearch] = useState(""); + const deferredUserSearch = useDeferredValue(userSearch.trim()); + const [selectedUser, setSelectedUser] = useState( + null, + ); + const [isSearchOpen, setIsSearchOpen] = useState(false); const { data: clientData } = useQuery({ queryKey: ["client", clientId], @@ -69,6 +95,15 @@ function ClientRelationsPage() { enabled: clientId.length > 0, }); + const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({ + queryKey: ["dev-users", deferredUserSearch], + queryFn: () => fetchDevUsers(deferredUserSearch), + enabled: + clientId.length > 0 && + deferredUserSearch.length > 0 && + selectedUser == null, + }); + const sortedItems = useMemo(() => { return [...(relationData?.items ?? [])].sort((a, b) => { const relationCompare = a.relation.localeCompare(b.relation); @@ -79,17 +114,47 @@ function ClientRelationsPage() { }); }, [relationData?.items]); + const selectedUserExistingRelations = useMemo(() => { + if (!selectedUser) { + return new Set(); + } + + return new Set( + sortedItems + .filter((item) => item.subjectId === selectedUser.id) + .map((item) => item.relation), + ); + }, [selectedUser, sortedItems]); + const addMutation = useMutation({ - mutationFn: () => - addClientRelation(clientId, { - relation, - userId: userId.trim(), - }), + mutationFn: async () => { + if (!selectedUser) { + throw new Error( + t( + "msg.dev.clients.relationships.user_required", + "추가할 사용자를 선택하세요.", + ), + ); + } + + const pendingRelations = selectedRelations.filter( + (relation) => !selectedUserExistingRelations.has(relation), + ); + for (const relation of pendingRelations) { + await addClientRelation(clientId, { + relation, + userId: selectedUser.id, + }); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["client-relations", clientId], }); - setUserId(""); + setSelectedRelations([]); + setSelectedUser(null); + setUserSearch(""); + setIsSearchOpen(false); toast( t( "msg.dev.clients.relationships.added", @@ -144,19 +209,48 @@ function ClientRelationsPage() { }); const handleAdd = () => { - if (!userId.trim()) { + if (!selectedUser) { toast( t( "msg.dev.clients.relationships.user_required", - "추가할 User ID를 입력하세요.", + "추가할 사용자를 선택하세요.", ), "error", ); return; } + + const pendingRelations = selectedRelations.filter( + (relation) => !selectedUserExistingRelations.has(relation), + ); + if (pendingRelations.length === 0) { + toast( + t( + "msg.dev.clients.relationships.relation_required", + "추가할 관계를 하나 이상 선택하세요.", + ), + "error", + ); + return; + } + addMutation.mutate(); }; + const handleRelationToggle = (relation: RelationOption) => { + setSelectedRelations((current) => + current.includes(relation) + ? current.filter((item) => item !== relation) + : [...current, relation], + ); + }; + + const handleSelectUser = (user: DevAssignableUser) => { + setSelectedUser(user); + setUserSearch(formatUserLabel(user)); + setIsSearchOpen(false); + }; + const handleRemove = (targetRelation: string, subject: string) => { if ( window.confirm( @@ -243,54 +337,152 @@ function ClientRelationsPage() { {t( "msg.dev.clients.relationships.add_description", - "현재는 direct User assignment만 지원합니다. subject는 자동으로 User: 형식으로 전송됩니다.", + "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.", )} - +
-
+ +
+ - handleRelationToggle(relation)} + /> +
+
+ {relationLabel(relation)} +
+
+ {relationDescription(relation)} +
+
+ {relation} +
+
+ + ); + })} +
+ + +
+
-
- - setUserId(e.target.value)} - placeholder={t( - "ui.dev.clients.relationships.user_id_placeholder", - "kratos user id", - )} - /> -
-
@@ -358,12 +550,31 @@ function ClientRelationsPage() { {sortedItems.map((item) => ( - - {item.relation} + +
+
+ {relationLabel(item.relation as RelationOption)} +
+
+ {relationDescription(item.relation as RelationOption)} +
+
-
{item.subject}
+
+ {item.userName || item.userEmail || item.subject} +
+ {(item.userEmail || item.userLoginId) && ( +
+ {[item.userEmail, item.userLoginId] + .filter(Boolean) + .join(" · ")} +
+ )} +
+ {item.subject} +
{item.subjectId && (
ID: {item.subjectId} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index df430ecb..6446d4f0 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -104,6 +104,9 @@ export type ClientRelation = { subject: string; subjectType: string; subjectId: string; + userName?: string; + userEmail?: string; + userLoginId?: string; }; export type ClientRelationListResponse = { @@ -116,6 +119,17 @@ export type ClientRelationUpsertRequest = { userId?: string; }; +export type DevAssignableUser = { + id: string; + name: string; + email: string; + loginId?: string; +}; + +export type DevAssignableUserListResponse = { + items: DevAssignableUser[]; +}; + export type ConsentSummary = { subject: string; userName?: string; @@ -188,6 +202,16 @@ export async function fetchClientRelations(clientId: string) { return data; } +export async function fetchDevUsers(search: string, limit = 10) { + const { data } = await apiClient.get( + "/dev/users", + { + params: { search, limit }, + }, + ); + return data; +} + export async function addClientRelation( clientId: string, payload: ClientRelationUpsertRequest, diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 811f7b26..a321c0d3 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -370,6 +370,24 @@ saved = "Saved" save_error = "Failed to save: {{error}}" status_changed = "Status changed to {{status}}." +[msg.dev.clients.relationships] +subtitle = "Review direct RP operator relations and add or remove them per user." +add_description = "Search for a user, select them, and grant one or more operator relations at once." +added = "Relationship added." +add_error = "Failed to add relationship: {{error}}" +removed = "Relationship removed." +remove_error = "Failed to remove relationship: {{error}}" +remove_confirm = "Remove this relationship?" +user_required = "Select a user to add." +relation_required = "Select at least one relationship to add." +list_description = "Lists operator relations directly assigned to this RP." +load_error = "Failed to load relationships: {{error}}" +loading = "Loading relationships..." +empty = "No direct relationships assigned." +search_loading = "Searching users..." +search_empty = "No users found." +selected_user = "Selected user: {{user}}" + [msg.dev.clients.federation] subtitle = "Manage external identity providers for this application." add_subtitle = "Connect an external OIDC provider." @@ -1453,6 +1471,48 @@ add = "Add" list_title = "Assigned Relationships" subject = "Subject" subject_type = "Type" +user_search = "User" +user_search_placeholder = "Search by name or email..." + +[ui.dev.clients.relationships.option.admins] +label = "RP Admin" +description = "Full administrator relationship for RP operations." + +[ui.dev.clients.relationships.option.creator] +label = "RP Creator" +description = "Marks the operator who created this RP." + +[ui.dev.clients.relationships.option.config_editor] +label = "RP General Settings" +description = "Edit the name, redirect URIs, and general metadata." + +[ui.dev.clients.relationships.option.secret_rotator] +label = "Secret Rotation" +description = "Rotate and reissue the client secret." + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "JWKS View" +description = "View JWKS status, cache details, and key summaries." + +[ui.dev.clients.relationships.option.jwks_operator] +label = "JWKS Operations" +description = "Run operational actions such as refresh and revoke." + +[ui.dev.clients.relationships.option.consent_viewer] +label = "Consent View" +description = "View consent grants for this RP." + +[ui.dev.clients.relationships.option.consent_revoker] +label = "Consent Revoke" +description = "Revoke consent grants for this RP." + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "Relationship View" +description = "View direct relations assigned to this RP." + +[ui.dev.clients.relationships.option.status_operator] +label = "Status Change" +description = "Change the active or inactive state of the RP." [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 27aae064..7f5903f5 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -370,6 +370,24 @@ save_error = "저장 실패: {{error}}" saved = "설정이 저장되었습니다." status_changed = "상태가 {{status}}로 변경되었습니다." +[msg.dev.clients.relationships] +subtitle = "RP direct operator relation을 조회하고 사용자 단위로 추가·삭제합니다." +add_description = "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다." +added = "관계가 추가되었습니다." +add_error = "관계 추가 실패: {{error}}" +removed = "관계가 제거되었습니다." +remove_error = "관계 제거 실패: {{error}}" +remove_confirm = "이 관계를 제거하시겠습니까?" +user_required = "추가할 사용자를 선택하세요." +relation_required = "추가할 관계를 하나 이상 선택하세요." +list_description = "현재 RP에 직접 부여된 operator relation 목록입니다." +load_error = "관계 조회 실패: {{error}}" +loading = "관계를 불러오는 중입니다..." +empty = "직접 부여된 관계가 없습니다." +search_loading = "사용자를 찾는 중입니다..." +search_empty = "검색 결과가 없습니다." +selected_user = "선택된 사용자: {{user}}" + [msg.dev.clients.federation] add_subtitle = "외부 OIDC 제공자를 연결합니다." empty = "등록된 IdP 설정이 없습니다." @@ -1452,6 +1470,48 @@ add = "추가" list_title = "부여된 관계" subject = "주체" subject_type = "유형" +user_search = "사용자" +user_search_placeholder = "이름 또는 이메일 검색..." + +[ui.dev.clients.relationships.option.admins] +label = "RP 관리자" +description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다." + +[ui.dev.clients.relationships.option.creator] +label = "RP 생성자" +description = "이 RP를 생성한 운영 주체를 표시합니다." + +[ui.dev.clients.relationships.option.config_editor] +label = "RP 일반 설정" +description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다." + +[ui.dev.clients.relationships.option.secret_rotator] +label = "시크릿 재발급" +description = "Client secret 재발급과 회전을 수행합니다." + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "JWKS 조회" +description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다." + +[ui.dev.clients.relationships.option.jwks_operator] +label = "JWKS 운영" +description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다." + +[ui.dev.clients.relationships.option.consent_viewer] +label = "동의 조회" +description = "이 RP의 consent 내역을 조회합니다." + +[ui.dev.clients.relationships.option.consent_revoker] +label = "동의 회수" +description = "이 RP의 consent를 회수합니다." + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "관계 조회" +description = "이 RP에 부여된 direct relation을 조회합니다." + +[ui.dev.clients.relationships.option.status_operator] +label = "상태 변경" +description = "RP 활성/비활성 상태를 변경합니다." [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index eb5792b0..a7f4d16c 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -370,6 +370,24 @@ saved = "" save_error = "" status_changed = "" +[msg.dev.clients.relationships] +subtitle = "" +add_description = "" +added = "" +add_error = "" +removed = "" +remove_error = "" +remove_confirm = "" +user_required = "" +relation_required = "" +list_description = "" +load_error = "" +loading = "" +empty = "" +search_loading = "" +search_empty = "" +selected_user = "" + [msg.dev.clients.federation] subtitle = "" add_subtitle = "" @@ -1452,6 +1470,48 @@ add = "" list_title = "" subject = "" subject_type = "" +user_search = "" +user_search_placeholder = "" + +[ui.dev.clients.relationships.option.admins] +label = "" +description = "" + +[ui.dev.clients.relationships.option.creator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.config_editor] +label = "" +description = "" + +[ui.dev.clients.relationships.option.secret_rotator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.jwks_operator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.consent_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.consent_revoker] +label = "" +description = "" + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.status_operator] +label = "" +description = "" [ui.dev.clients.help] docs_body = "" diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts index e74bd33b..5ddeee3b 100644 --- a/devfront/tests/devfront-relationships.spec.ts +++ b/devfront/tests/devfront-relationships.spec.ts @@ -26,6 +26,14 @@ test.describe("DevFront relationships", () => { const state = { clients: [makeClient("client-rel", { name: "Relations app" })], consents: [] as Consent[], + users: [ + { + id: "user-2", + name: "홍길동", + email: "hong@example.com", + loginId: "hong01", + }, + ], relations: { "client-rel": [ { @@ -33,6 +41,8 @@ test.describe("DevFront relationships", () => { subject: "User:user-1", subjectType: "User", subjectId: "user-1", + userName: "기존 사용자", + userEmail: "existing@example.com", }, ] satisfies ClientRelation[], }, @@ -48,14 +58,17 @@ test.describe("DevFront relationships", () => { await expect( page.getByRole("heading", { name: "부여된 관계" }), ).toBeVisible(); + await expect(page.getByText("기존 사용자")).toBeVisible(); await expect(page.getByText("User:user-1")).toBeVisible(); - await page.getByLabel(/^관계$/).selectOption("secret_rotator"); - await page.getByLabel(/^사용자 ID$/).fill("user-2"); + await page.getByLabel(/^사용자$/).fill("홍길동"); + await page.getByRole("button", { name: /홍길동/ }).click(); + await page.getByLabel(/시크릿 재발급/).check(); + await page.getByLabel(/동의 조회/).check(); await page.getByRole("button", { name: /^추가$/ }).click(); await expect(page.getByText("User:user-2")).toBeVisible(); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(2); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(3); await page .locator("tr") @@ -63,7 +76,13 @@ test.describe("DevFront relationships", () => { .getByRole("button", { name: /Delete|삭제/i }) .click(); - await expect(page.getByText("User:user-2")).toHaveCount(0); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(1); + await expect + .poll( + () => + state.relations["client-rel"]?.filter( + (item) => item.subject === "User:user-2", + ).length ?? 0, + ) + .toBe(1); }); }); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index a375f4f6..e8200f9b 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -58,6 +58,16 @@ export type ClientRelation = { subject: string; subjectType: string; subjectId: string; + userName?: string; + userEmail?: string; + userLoginId?: string; +}; + +export type DevAssignableUser = { + id: string; + name: string; + email: string; + loginId?: string; }; export type AuditLog = { @@ -75,6 +85,7 @@ export type DevApiMockState = { clients: Client[]; consents: Consent[]; relations?: Record; + users?: DevAssignableUser[]; auditLogsByCursor?: Record< string, { items: AuditLog[]; next_cursor?: string } @@ -261,6 +272,20 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); } + if (pathname === "/api/v1/dev/users" && method === "GET") { + const search = (searchParams.get("search") || "").toLowerCase(); + const limit = Number.parseInt(searchParams.get("limit") || "10", 10); + const items = (state.users ?? []) + .filter((user) => { + if (!search) return true; + return [user.name, user.email, user.loginId ?? ""].some((value) => + value.toLowerCase().includes(search), + ); + }) + .slice(0, Number.isFinite(limit) ? limit : 10); + return json(route, { items }); + } + if (pathname === "/api/v1/dev/clients" && method === "POST") { const payload = (request.postDataJSON() as { name?: string; From 6322ff563077f49adef238be381f98172e4c9d7e Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 10:31:04 +0900 Subject: [PATCH 10/18] =?UTF-8?q?DevFront=20RP=20=EA=B4=80=EA=B3=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/devfront-rp-relationships-guide.md | 169 ++++++++++++++++++++++++ docs/keto-rebac-namespaces-diagram.md | 3 + docs/keto-rebac-policy-guide.md | 2 + 3 files changed, 174 insertions(+) create mode 100644 docs/devfront-rp-relationships-guide.md diff --git a/docs/devfront-rp-relationships-guide.md b/docs/devfront-rp-relationships-guide.md new file mode 100644 index 00000000..b6a04fa2 --- /dev/null +++ b/docs/devfront-rp-relationships-guide.md @@ -0,0 +1,169 @@ +# DevFront RP 관계 설정 가이드 + +이 문서는 DevFront의 RP 상세 화면 > `관계` 탭에서 사용자에게 부여할 수 있는 관계 목록과 각 관계가 의미하는 기능 범위를 정리한다. + +## 적용 범위 + +- 대상 namespace: `RelyingParty` +- 대상 화면: DevFront RP 상세 화면의 `Relationships` 탭 +- 대상 subject: 현재 1차 구현에서는 direct `User:` assignment +- 기준 구현: + - `docker/ory/keto/namespaces.ts` + - `devfront/src/features/clients/ClientRelationsPage.tsx` + - `backend/internal/handler/dev_handler.go` + +## 기본 원칙 + +- 관계 탭에서 부여하는 관계는 **DevFront 운영 권한**이다. +- `RelyingParty#access`는 실제 서비스 로그인/접근 권한이며, DevFront 운영 권한과 별도이다. +- 아래 관계 중 하나라도 있으면 해당 RP에 대한 기본 조회 권한(`RelyingParty#view`)도 함께 생긴다. +- `RP 관리자(admins)`는 상위 관리 관계이며, 대부분의 세부 운영 권한을 포함한다. +- 세부 관계는 필요한 기능만 최소 권한으로 부여할 때 사용한다. + +## 관계 목록 + +| 화면 표시명 | Relation key | 의미 | 주요 허용 기능 | +|---|---|---|---| +| RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 | +| RP 생성자 | `creator` | 이 RP를 생성한 운영 주체를 표시하거나 RP 생성 권한 모델에 연결되는 관계 | RP 조회, RP 생성 정책과 연결 | +| RP 일반 설정 | `config_editor` | RP 이름, Redirect URI, 메타데이터 같은 일반 설정을 수정할 수 있는 관계 | RP 조회, 일반 설정 수정 | +| 시크릿 재발급 | `secret_rotator` | Client secret 재발급과 rotation을 수행할 수 있는 관계 | RP 조회, client secret 재발급 | +| JWKS 조회 | `jwks_viewer` | JWKS 상태, 캐시 정보, key summary를 조회할 수 있는 관계 | RP 조회, JWKS 상태/캐시/key summary 조회 | +| JWKS 운영 | `jwks_operator` | JWKS refresh/revoke 같은 운영 작업을 수행할 수 있는 관계 | RP 조회, JWKS 조회, JWKS refresh/revoke | +| 동의 조회 | `consent_viewer` | 이 RP의 consent 내역을 조회할 수 있는 관계 | RP 조회, 동의 및 사용자 목록 조회 | +| 동의 회수 | `consent_revoker` | 이 RP의 consent를 회수할 수 있는 관계 | RP 조회, 동의 조회, 동의 회수 | +| 관계 조회 | `relationship_viewer` | 이 RP에 부여된 direct relation을 조회할 수 있는 관계 | RP 조회, 관계 목록 조회 | +| 감사 로그 조회 | `audit_viewer` | 이 RP의 DevFront 감사 로그를 조회할 수 있는 관계 | RP 조회, 해당 RP 감사 로그 조회 | +| 상태 변경 | `status_operator` | RP 활성/비활성 상태를 변경할 수 있는 관계 | RP 조회, 활성/비활성 상태 변경 | + +## Permit 매핑 + +Keto namespace 기준으로 relation은 다음 permit으로 계산된다. + +| Permit | 허용 relation / 조건 | 기능 의미 | +|---|---|---| +| `view` | `admins`, `config_editor`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 | +| `manage` | `admins`, 부모 tenant의 `manage` | RP 관리 상위 권한 | +| `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성 | +| `edit_config` | `config_editor`, `manage` | RP 일반 설정 수정 | +| `rotate_secret` | `secret_rotator`, `manage` | client secret 재발급/회전 | +| `view_jwks` | `jwks_viewer`, `operate_jwks`, `manage` | JWKS 상태/캐시/key summary 조회 | +| `operate_jwks` | `jwks_operator`, `manage` | JWKS refresh/revoke | +| `view_consents` | `consent_viewer`, `revoke_consents`, `manage` | consent 목록/상세 조회 | +| `revoke_consents` | `consent_revoker`, `manage` | consent 회수 | +| `view_relationships` | `relationship_viewer`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 관계 목록 조회 | +| `view_audit_logs` | `audit_viewer`, `manage` | 해당 RP의 DevFront 감사 로그 조회 | +| `change_status` | `status_operator`, `manage` | RP 활성/비활성 상태 변경 | +| `access` | `access`, `manage` | 실제 서비스 로그인/리소스 접근 | + +## 권한별 운영 예시 + +### 특정 사용자가 RP만 조회해야 하는 경우 + +최소 관계: + +- `audit_viewer`, `consent_viewer`, `jwks_viewer` 등 필요한 세부 조회 관계 중 하나 + +위 세부 관계는 모두 `RelyingParty#view`를 포함하므로, 사용자는 DevFront에서 해당 RP를 볼 수 있다. + +### 특정 사용자가 동의 및 사용자 목록만 봐야 하는 경우 + +부여 관계: + +- `consent_viewer` + +허용 결과: + +- RP 목록/상세 조회 +- `동의 및 사용자 목록` 조회 + +허용하지 않는 기능: + +- 동의 회수 +- secret 재발급 +- JWKS refresh/revoke +- 관계 부여/회수 +- 상태 변경 + +### 특정 사용자가 동의를 조회하고 회수도 해야 하는 경우 + +부여 관계: + +- `consent_revoker` + +허용 결과: + +- `revoke_consents` permit +- `view_consents` permit도 함께 허용 + +### 특정 사용자가 감사 로그만 봐야 하는 경우 + +부여 관계: + +- `audit_viewer` + +허용 결과: + +- RP 목록/상세 조회 +- 해당 RP와 연결된 DevFront 감사 로그 조회 + +주의: + +- 감사 로그 필터링은 audit details의 `target_id` 또는 `client_id`가 RP client id와 일치하는지 기준으로 동작한다. +- 오래된 로그 또는 일부 경로에서 `target_id`/`client_id`가 누락된 로그는 RP별 권한 사용자에게 보이지 않을 수 있다. + +### 특정 사용자를 RP 운영 담당자로 지정해야 하는 경우 + +부여 관계: + +- `admins` + +허용 결과: + +- `manage` permit +- 대부분의 세부 운영 권한 허용 +- consent 조회/회수, 감사 로그 조회, 관계 조회, 상태 변경 등 포함 + +주의: + +- `admins`는 강한 권한이다. +- 단순 조회나 특정 작업만 필요하면 세부 relation을 우선 사용한다. + +## 자동 부여 관계 + +RP 생성 시 `metadata.user_id`가 존재하면 생성자에게 기본 운영 relation 세트가 outbox로 적재된다. + +현재 자동 부여 대상: + +- `admins` +- `creator` +- `config_editor` +- `secret_rotator` +- `jwks_viewer` +- `jwks_operator` +- `consent_viewer` +- `consent_revoker` +- `relationship_viewer` +- `audit_viewer` +- `status_operator` + +## 관련 tuple 예시 + +```text +RelyingParty:client-a#admins@User:user-1 +RelyingParty:client-a#consent_viewer@User:user-2 +RelyingParty:client-a#consent_revoker@User:user-3 +RelyingParty:client-a#audit_viewer@User:user-4 +RelyingParty:client-a#relationship_viewer@User:user-5 +``` + +## 운영 주의사항 + +- 관계 부여/회수는 direct Keto write가 아니라 outbox 적재 방식으로 처리한다. +- 관계를 부여한 직후 실제 Keto 반영까지 worker 처리 지연이 있을 수 있다. +- 사용자가 DevFront에서 기대 권한을 얻지 못하면 다음을 우선 확인한다. + - relation tuple의 subject가 실제 로그인한 Kratos identity id와 같은지 + - outbox worker가 tuple을 Keto에 반영했는지 + - 대상 RP의 client id가 tuple object와 같은지 + - audit/consent 로그에 `client_id` 또는 `target_id`가 정확히 기록되는지 + diff --git a/docs/keto-rebac-namespaces-diagram.md b/docs/keto-rebac-namespaces-diagram.md index c1418a2e..f8ad62c9 100644 --- a/docs/keto-rebac-namespaces-diagram.md +++ b/docs/keto-rebac-namespaces-diagram.md @@ -62,6 +62,7 @@ classDiagram consent_viewer: User[] consent_revoker: User[] relationship_viewer: User[] + audit_viewer: User[] status_operator: User[] -- Permits -- view: admins OR direct operator relations OR parents.view OR parents.view_dev_console @@ -74,6 +75,7 @@ classDiagram view_consents: consent_viewer OR revoke_consents OR manage revoke_consents: consent_revoker OR manage view_relationships: relationship_viewer OR parents.grant_dev_permissions OR manage + view_audit_logs: audit_viewer OR manage change_status: status_operator OR manage access: access OR manage } @@ -111,6 +113,7 @@ classDiagram - `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다. - `edit_config`, `rotate_secret`, `operate_jwks`, `revoke_consents`, `change_status`: 각 직접 relation 또는 `manage`로 판정합니다. - `view_relationships`: 직접 `relationship_viewer`, 상위 tenant의 `grant_dev_permissions`, 또는 `manage`로 판정합니다. + - `view_audit_logs`: 직접 `audit_viewer` 또는 `manage`로 판정합니다. - `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다. - _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._ diff --git a/docs/keto-rebac-policy-guide.md b/docs/keto-rebac-policy-guide.md index 37bdf6f5..e665b765 100644 --- a/docs/keto-rebac-policy-guide.md +++ b/docs/keto-rebac-policy-guide.md @@ -48,6 +48,7 @@ RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 - **Consent 조회 권한 부여**: `RelyingParty:<앱ID>#consent_viewer@User:<유저ID>` - **Consent 회수 권한 부여**: `RelyingParty:<앱ID>#consent_revoker@User:<유저ID>` - **Relationship 조회 권한 부여**: `RelyingParty:<앱ID>#relationship_viewer@User:<유저ID>` +- **감사 로그 조회 권한 부여**: `RelyingParty:<앱ID>#audit_viewer@User:<유저ID>` - **상태 변경 권한 부여**: `RelyingParty:<앱ID>#status_operator@User:<유저ID>` ### 3.2.1 RelyingParty permit 원칙 @@ -61,6 +62,7 @@ RP에 별도의 가상 테넌트를 만들지 않고, 자원 객체 자체의 - `view_consents`: consent 목록/상세 조회 권한 - `revoke_consents`: consent 회수 권한 - `view_relationships`: direct / inherited relationship 조회 권한 +- `view_audit_logs`: 해당 RP의 DevFront 감사 로그 조회 권한 - `change_status`: 활성/비활성 상태 변경 권한 - `access`: 실제 서비스 로그인 및 리소스 접근 권한 From 2a9b044992774d3680dd2dfae142e0526b28c076 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 10:43:57 +0900 Subject: [PATCH 11/18] =?UTF-8?q?RP=20=EC=88=98=EC=A0=95=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=95=88=EB=82=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/clients/ClientDetailsPage.tsx | 17 ++++++++++++++++- .../src/features/clients/ClientGeneralPage.tsx | 13 ++++++++++++- devfront/src/locales/en.toml | 9 +++++++++ devfront/src/locales/ko.toml | 9 +++++++++ devfront/src/locales/template.toml | 9 +++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) diff --git a/devfront/src/features/clients/ClientDetailsPage.tsx b/devfront/src/features/clients/ClientDetailsPage.tsx index 7d6825fb..82e43d43 100644 --- a/devfront/src/features/clients/ClientDetailsPage.tsx +++ b/devfront/src/features/clients/ClientDetailsPage.tsx @@ -84,9 +84,24 @@ function ClientDetailsPage() { ); }, onError: (err) => { + const axiosError = err as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 403) { + toast( + t( + "msg.dev.clients.details.save_forbidden", + "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", + ), + "error", + ); + return; + } + toast( t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", { - error: (err as Error).message, + error: + axiosError.response?.data?.error ?? + (err as Error).message ?? + t("msg.common.unknown_error", "unknown error"), }), "error", ); diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index dedff139..a19ee5ed 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -527,8 +527,19 @@ function ClientGeneralPage() { alert(t("msg.dev.clients.general.saved", "설정이 저장되었습니다.")); }, onError: (err) => { + const axiosError = err as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 403) { + alert( + t( + "msg.dev.clients.general.save_forbidden", + "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.", + ), + ); + return; + } + const errorMessage = - (err as AxiosError<{ error?: string }>).response?.data?.error ?? + axiosError.response?.data?.error ?? (err as Error)?.message ?? t("msg.common.unknown_error", "unknown error"); alert( diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index a321c0d3..bf5ff541 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -332,6 +332,8 @@ showing = "Showing {{shown}} of {{total}} apps" deleted = "App deleted." delete_error = "Failed to delete: {{error}}" delete_confirm = "Are you sure you want to delete this app? This action cannot be undone." +empty = "No RPs are available." +empty_detail = "RPs will appear here when a relationship is assigned to your account." [msg.dev.clients.consents] empty = "No consents found." @@ -352,6 +354,7 @@ redirect_saved = "Redirect URIs saved." rotate_confirm = "Rotate Confirm" rotate_error = "Rotate Error" save_error = "Save Error" +save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship." secret_rotated = "Secret Rotated" secret_unavailable = "SECRET_NOT_AVAILABLE" subtitle = "Manage OIDC credentials and endpoints." @@ -368,6 +371,7 @@ load_error = "Error loading client: {{error}}" loading = "Loading client..." saved = "Saved" save_error = "Failed to save: {{error}}" +save_forbidden = "You do not have permission to edit this RP. Ask an administrator to grant RP General Settings or RP Admin relationship." status_changed = "Status changed to {{status}}." [msg.dev.clients.relationships] @@ -1310,6 +1314,7 @@ untitled = "Untitled" [ui.dev.clients.badge] admin_session = "Admin Session" +dev_session = "DevFront Session" tenant_selected = "Tenant Selected" [ui.dev.clients.filter] @@ -1510,6 +1515,10 @@ description = "Revoke consent grants for this RP." label = "Relationship View" description = "View direct relations assigned to this RP." +[ui.dev.clients.relationships.option.audit_viewer] +label = "Audit Log View" +description = "View DevFront audit logs for this RP." + [ui.dev.clients.relationships.option.status_operator] label = "Status Change" description = "Change the active or inactive state of the RP." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 7f5903f5..61fcc171 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -329,6 +329,8 @@ subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합 deleted = "앱이 삭제되었습니다." delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." delete_error = "삭제 실패: {{error}}" +empty = "조회 가능한 RP가 없습니다." +empty_detail = "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다." load_error = "앱 정보를 불러오지 못했습니다: {{error}}" loading = "앱 정보를 불러오는 중..." showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다." @@ -352,6 +354,7 @@ redirect_saved = "Redirect URIs가 저장되었습니다." rotate_confirm = "경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?" rotate_error = "재발급 실패: {{error}}" save_error = "저장 실패: {{error}}" +save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." secret_rotated = "Client Secret이 재발급되었습니다." secret_unavailable = "SECRET_NOT_AVAILABLE" subtitle = "OIDC 자격 증명과 엔드포인트를 관리합니다." @@ -367,6 +370,7 @@ note = "엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행 load_error = "클라이언트 정보를 불러오지 못했습니다: {{error}}" loading = "클라이언트 정보를 불러오는 중..." save_error = "저장 실패: {{error}}" +save_forbidden = "이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요." saved = "설정이 저장되었습니다." status_changed = "상태가 {{status}}로 변경되었습니다." @@ -1310,6 +1314,7 @@ untitled = "Untitled" [ui.dev.clients.badge] admin_session = "관리자 세션" +dev_session = "DevFront 세션" tenant_selected = "테넌트: 선택됨" [ui.dev.clients.filter] @@ -1509,6 +1514,10 @@ description = "이 RP의 consent를 회수합니다." label = "관계 조회" description = "이 RP에 부여된 direct relation을 조회합니다." +[ui.dev.clients.relationships.option.audit_viewer] +label = "감사 로그 조회" +description = "이 RP의 DevFront 감사 로그를 조회합니다." + [ui.dev.clients.relationships.option.status_operator] label = "상태 변경" description = "RP 활성/비활성 상태를 변경합니다." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index a7f4d16c..94c2c4fe 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -332,6 +332,8 @@ showing = "" deleted = "" delete_error = "" delete_confirm = "" +empty = "" +empty_detail = "" [msg.dev.clients.consents] empty = "" @@ -352,6 +354,7 @@ redirect_saved = "" rotate_confirm = "" rotate_error = "" save_error = "" +save_forbidden = "" secret_rotated = "" secret_unavailable = "" subtitle = "" @@ -368,6 +371,7 @@ load_error = "" loading = "" saved = "" save_error = "" +save_forbidden = "" status_changed = "" [msg.dev.clients.relationships] @@ -1311,6 +1315,7 @@ untitled = "" [ui.dev.clients.badge] admin_session = "" +dev_session = "" tenant_selected = "" [ui.dev.clients.filter] @@ -1509,6 +1514,10 @@ description = "" label = "" description = "" +[ui.dev.clients.relationships.option.audit_viewer] +label = "" +description = "" + [ui.dev.clients.relationships.option.status_operator] label = "" description = "" From 0b8eaec636f8ffc64645ec72c467f4a48e10590a Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 10:45:14 +0900 Subject: [PATCH 12/18] =?UTF-8?q?=EC=88=98=EB=8F=99=20=ED=95=A0=EB=8B=B9?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EC=88=A8=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientRelationsPage.tsx | 2 +- docs/devfront-rp-relationships-guide.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index e4ba79e4..f99f923b 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -36,7 +36,6 @@ import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ "admins", - "creator", "config_editor", "secret_rotator", "jwks_viewer", @@ -44,6 +43,7 @@ const relationOptions = [ "consent_viewer", "consent_revoker", "relationship_viewer", + "audit_viewer", "status_operator", ] as const; diff --git a/docs/devfront-rp-relationships-guide.md b/docs/devfront-rp-relationships-guide.md index b6a04fa2..ae2543c6 100644 --- a/docs/devfront-rp-relationships-guide.md +++ b/docs/devfront-rp-relationships-guide.md @@ -16,16 +16,16 @@ - 관계 탭에서 부여하는 관계는 **DevFront 운영 권한**이다. - `RelyingParty#access`는 실제 서비스 로그인/접근 권한이며, DevFront 운영 권한과 별도이다. -- 아래 관계 중 하나라도 있으면 해당 RP에 대한 기본 조회 권한(`RelyingParty#view`)도 함께 생긴다. +- 아래 수동 부여 관계 중 하나라도 있으면 해당 RP에 대한 기본 조회 권한(`RelyingParty#view`)도 함께 생긴다. - `RP 관리자(admins)`는 상위 관리 관계이며, 대부분의 세부 운영 권한을 포함한다. - 세부 관계는 필요한 기능만 최소 권한으로 부여할 때 사용한다. +- `creator`는 생성 이력/자동 동기화용 내부 relation이며 관계 탭의 수동 부여 목록에는 노출하지 않는다. ## 관계 목록 | 화면 표시명 | Relation key | 의미 | 주요 허용 기능 | |---|---|---|---| | RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 | -| RP 생성자 | `creator` | 이 RP를 생성한 운영 주체를 표시하거나 RP 생성 권한 모델에 연결되는 관계 | RP 조회, RP 생성 정책과 연결 | | RP 일반 설정 | `config_editor` | RP 이름, Redirect URI, 메타데이터 같은 일반 설정을 수정할 수 있는 관계 | RP 조회, 일반 설정 수정 | | 시크릿 재발급 | `secret_rotator` | Client secret 재발급과 rotation을 수행할 수 있는 관계 | RP 조회, client secret 재발급 | | JWKS 조회 | `jwks_viewer` | JWKS 상태, 캐시 정보, key summary를 조회할 수 있는 관계 | RP 조회, JWKS 상태/캐시/key summary 조회 | @@ -44,7 +44,7 @@ Keto namespace 기준으로 relation은 다음 permit으로 계산된다. |---|---|---| | `view` | `admins`, `config_editor`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 | | `manage` | `admins`, 부모 tenant의 `manage` | RP 관리 상위 권한 | -| `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성 | +| `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성. `creator`는 현재 수동 부여하지 않는 내부 relation이다. | | `edit_config` | `config_editor`, `manage` | RP 일반 설정 수정 | | `rotate_secret` | `secret_rotator`, `manage` | client secret 재발급/회전 | | `view_jwks` | `jwks_viewer`, `operate_jwks`, `manage` | JWKS 상태/캐시/key summary 조회 | @@ -147,6 +147,8 @@ RP 생성 시 `metadata.user_id`가 존재하면 생성자에게 기본 운영 r - `audit_viewer` - `status_operator` +`creator`는 이 자동 부여 세트에는 포함되지만, 운영자가 관계 탭에서 수동으로 선택하는 관계는 아니다. 생성자 표시는 장기적으로 relation 부여 여부가 아니라 RP metadata 또는 audit read model 기반의 읽기 전용 정보로 제공하는 방향이 적절하다. + ## 관련 tuple 예시 ```text @@ -166,4 +168,3 @@ RelyingParty:client-a#relationship_viewer@User:user-5 - outbox worker가 tuple을 Keto에 반영했는지 - 대상 RP의 client id가 tuple object와 같은지 - audit/consent 로그에 `client_id` 또는 `target_id`가 정확히 기록되는지 - From 51e46a4d0062beb1bd5beef4fec5f5667fece5f7 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 10:46:17 +0900 Subject: [PATCH 13/18] =?UTF-8?q?RP=20=EA=B4=80=EA=B3=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EC=9D=98=20=EC=BD=98=EC=86=94=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/common_test.go | 19 +- backend/internal/handler/dev_handler.go | 112 ++++++++--- backend/internal/handler/dev_handler_test.go | 177 +++++++++++++++++- .../internal/service/relying_party_service.go | 1 + devfront/src/components/layout/AppLayout.tsx | 40 ++-- devfront/src/features/auth/AuthGuard.tsx | 36 ---- devfront/src/features/clients/ClientsPage.tsx | 66 +++++-- .../tests/devfront-role-switch-report.spec.ts | 9 +- devfront/tests/devfront-security.spec.ts | 19 +- docker/ory/keto/namespaces.ts | 6 + 10 files changed, 376 insertions(+), 109 deletions(-) diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index c499eb39..cf86510a 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -156,11 +156,26 @@ func (m *mockConsentRepo) ListBySubject(ctx context.Context, subject string) ([] } func (m *mockConsentRepo) Delete(ctx context.Context, clientID, subject string) error { return nil } func (m *mockConsentRepo) List(ctx context.Context, clientID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { - return nil, 0, nil + results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents)) + for _, consent := range m.consents { + if consent.ClientID == clientID { + results = append(results, domain.ClientConsentWithTenantInfo{ClientConsent: consent}) + } + } + return results, int64(len(results)), nil } func (m *mockConsentRepo) ListByTenant(ctx context.Context, clientID, tenantID string, limit, offset int) ([]domain.ClientConsentWithTenantInfo, int64, error) { - return nil, 0, nil + results := make([]domain.ClientConsentWithTenantInfo, 0, len(m.consents)) + for _, consent := range m.consents { + if consent.ClientID == clientID { + results = append(results, domain.ClientConsentWithTenantInfo{ + ClientConsent: consent, + TenantID: tenantID, + }) + } + } + return results, int64(len(results)), nil } // --- Mock Secret Repository --- diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 2507e6a9..08f3faea 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -205,6 +205,7 @@ var allowedRelyingPartyOperatorRelations = map[string]struct{}{ "consent_viewer": {}, "consent_revoker": {}, "relationship_viewer": {}, + "audit_viewer": {}, "status_operator": {}, } @@ -221,6 +222,15 @@ func isDevConsoleRoleAllowed(role string) bool { } } +func isDevConsoleViewerRole(role string) bool { + switch normalizeUserRole(role) { + case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser: + return true + default: + return false + } +} + func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse { if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { return profile @@ -388,6 +398,51 @@ func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.User return canAccessClientByLegacyScope(profile, summary) } +func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} { + ids := make(map[string]struct{}) + if profile == nil || h.Hydra == nil { + return ids + } + if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { + return ids + } + + clientFilter = strings.TrimSpace(clientFilter) + if clientFilter != "" { + summary, err := h.loadClientSummary(c.Context(), clientFilter) + if err == nil && h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") { + ids[summary.ID] = struct{}{} + } + return ids + } + + clients, err := h.Hydra.ListClients(c.Context(), 500, 0) + if err != nil { + slog.Warn("Failed to list clients for audit permission filtering", "error", err) + return ids + } + for _, client := range clients { + if isHiddenSystemClient(client) { + continue + } + summary := h.mapClientSummary(client) + if h.canOperateClientByPermit(c, profile, summary, "view_audit_logs") { + ids[summary.ID] = struct{}{} + } + } + return ids +} + +func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[string]struct{} { + if dst == nil { + dst = make(map[string]struct{}, len(src)) + } + for key := range src { + dst[key] = struct{}{} + } + return dst +} + func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false @@ -938,7 +993,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -972,14 +1027,16 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { summary := h.mapClientSummary(client) // 1. [Security] Filter out 'private' clients if user is not an AppManager - if summary.Type == "private" && !isAppManager { + canViewByPermit := h.canViewClientByPermit(c, profile, summary) + + if summary.Type == "private" && !isAppManager && !canViewByPermit { continue } // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant if !isSuperAdmin { clientTenantID, _ := summary.Metadata["tenant_id"].(string) - if clientTenantID != userTenantID && !h.canViewClientByPermit(c, profile, summary) { + if clientTenantID != userTenantID && !canViewByPermit { continue } } @@ -987,13 +1044,13 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { // 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 { if _, ok := allowedClientIDs[summary.ID]; !ok { - if !h.canViewClientByPermit(c, profile, summary) { + if !canViewByPermit { continue } } } - if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { + if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit { continue } @@ -1163,7 +1220,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1172,7 +1229,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { } // Check permission for private clients - if summary.Type == "private" { + if summary.Type == "private" && !h.canViewClientByPermit(c, profile, summary) { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") @@ -1730,7 +1787,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } client, err := h.Hydra.GetClient(c.Context(), clientID) @@ -1741,7 +1798,8 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*client) - if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "view_consents") { + canViewConsentsByPermit := h.canOperateClientByPermit(c, profile, summary, "view_consents") + if !canAccessClientByLegacyScope(profile, summary) && !canViewConsentsByPermit { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } @@ -1755,7 +1813,7 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { // [Isolation] Get admin tenant ID from locals or header adminTenantID := "" if profile != nil { - if role != domain.RoleSuperAdmin && profile.TenantID != nil { + if role != domain.RoleSuperAdmin && !canViewConsentsByPermit && profile.TenantID != nil { adminTenantID = *profile.TenantID } } @@ -1813,12 +1871,14 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { } userName := "" - identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) - if err == nil && identity != nil { - if name, ok := identity.Traits["name"].(string); ok { - userName = name - } else if email, ok := identity.Traits["email"].(string); ok { - userName = email + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) + if err == nil && identity != nil { + if name, ok := identity.Traits["name"].(string); ok { + userName = name + } else if email, ok := identity.Traits["email"].(string); ok { + userName = email + } } } @@ -1854,7 +1914,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if clientID != "" { @@ -2123,13 +2183,9 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - allowedClientIDs := managedClientIDsFromProfile(profile) - if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { - return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") - } limit := c.QueryInt("limit", 50) if limit <= 0 { @@ -2142,11 +2198,21 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action"))) clientFilter := strings.TrimSpace(c.Query("client_id")) statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status"))) + allowedClientIDs := managedClientIDsFromProfile(profile) + allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter)) + if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == domain.RoleRPAdmin || role == domain.RoleUser) { + return c.JSON(devAuditListResponse{ + Items: []domain.AuditLog{}, + Limit: limit, + Cursor: c.Query("cursor"), + }) + } + tenantFilter := strings.TrimSpace(c.Query("tenant_id")) if tenantFilter == "" { tenantFilter = h.resolveDevTenantScope(c) } - if role != domain.RoleSuperAdmin && tenantFilter == "" { + if role != domain.RoleSuperAdmin && tenantFilter == "" && len(allowedClientIDs) == 0 { tenantFilter = tenantIDFromProfile(profile) } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 0ef00aea..ce3315bb 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -229,6 +229,54 @@ func TestListClients_Success(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestListClients_UserSeesOnlyClientsAllowedByReBAC(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-a", "status": "active"}}, + {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-b", "status": "active"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-a", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "tenant-b", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view").Return(true, 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-a" + c.Locals("user_profile", &domain.UserProfileResponse{ + ID: "user-1", + Role: domain.RoleUser, + TenantID: &tenantID, + }) + return c.Next() + }) + app.Get("/api/v1/dev/clients", h.ListClients) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result clientListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "client-allowed", result.Items[0].ID) + } + mockKeto.AssertExpectations(t) +} + func TestCreateClient_ReservedSystemNameForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { t.Fatalf("hydra should not be called when reserved system name is rejected") @@ -1310,6 +1358,133 @@ func TestListAuditLogs_RPAdminScope(t *testing.T) { assert.Equal(t, "evt-1", result.Items[0].EventID) } +func TestListAuditLogs_UserAllowedByRPAuditPermission(t *testing.T) { + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + EventID: "evt-allowed", + EventType: "POST /api/v1/dev/clients/client-allowed/secret/rotate", + Status: "success", + Timestamp: time.Now().UTC(), + Details: `{"target_id":"client-allowed","tenant_id":"tenant-a","action":"ROTATE_SECRET"}`, + }, + { + EventID: "evt-denied", + EventType: "POST /api/v1/dev/clients/client-denied/secret/rotate", + Status: "success", + Timestamp: time.Now().UTC().Add(-time.Minute), + Details: `{"target_id":"client-denied","tenant_id":"tenant-b","action":"ROTATE_SECRET"}`, + }, + }, + } + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/clients" { + return httpJSONAny(r, http.StatusOK, []map[string]interface{}{ + {"client_id": "client-allowed", "client_name": "Allowed App", "metadata": map[string]interface{}{"tenant_id": "tenant-a"}}, + {"client_id": "client-denied", "client_name": "Denied App", "metadata": map[string]interface{}{"tenant_id": "tenant-b"}}, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-allowed", "view_audit_logs").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-denied", "view_audit_logs").Return(false, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + AuditRepo: auditRepo, + Keto: mockKeto, + } + + app := fiber.New() + tenantID := "tenant-a" + 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.Get("/api/v1/dev/audit-logs", h.ListAuditLogs) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs?limit=50", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result devAuditListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "evt-allowed", result.Items[0].EventID) + } + mockKeto.AssertExpectations(t) +} + +func TestListConsents_UserAllowedByRPAdminsRelation(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_consents").Return(true, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + ConsentRepo: &mockConsentRepo{ + consents: []domain.ClientConsent{ + { + ClientID: "client-1", + Subject: "subject-1", + GrantedScopes: []string{"openid", "profile"}, + CreatedAt: time.Now().UTC(), + }, + }, + }, + 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.Get("/api/v1/dev/consents", h.ListConsents) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/consents?client_id=client-1", nil) + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result consentListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "client-1", result.Items[0].ClientID) + assert.Equal(t, "subject-1", result.Items[0].Subject) + } + mockKeto.AssertExpectations(t) +} + 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" { @@ -1330,7 +1505,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test 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"} { + for _, relation := range []string{"admins", "creator", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } mockKratos := new(devMockKratosAdmin) diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 26b0ef02..152e321b 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -35,6 +35,7 @@ var defaultRelyingPartyOperatorRelations = []string{ "consent_viewer", "consent_revoker", "relationship_viewer", + "audit_viewer", "status_operator", } diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index 4e0eb33b..9dcaa72c 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -270,11 +270,6 @@ function AppLayout() { ); const displayRoleKey = profile?.role || currentRole; - const isDevConsoleAllowed = [ - "super_admin", - "tenant_admin", - "rp_admin", - ].includes(currentRole); const expiresAtSec = auth.user?.expires_at; const remainingMs = typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null; @@ -360,24 +355,23 @@ function AppLayout() {
- {isDevConsoleAllowed && - navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( - - [ - "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", - isActive - ? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]" - : "text-muted-foreground hover:bg-muted/10 hover:text-foreground", - ].join(" ") - } - > - - {t(labelKey, labelFallback)} - - ))} + {navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => ( + + [ + "flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition", + isActive + ? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]" + : "text-muted-foreground hover:bg-muted/10 hover:text-foreground", + ].join(" ") + } + > + + {t(labelKey, labelFallback)} + + ))}
diff --git a/devfront/src/features/auth/AuthGuard.tsx b/devfront/src/features/auth/AuthGuard.tsx index 0ab7c9fd..26069583 100644 --- a/devfront/src/features/auth/AuthGuard.tsx +++ b/devfront/src/features/auth/AuthGuard.tsx @@ -1,7 +1,5 @@ import { useAuth } from "react-oidc-context"; import { Navigate, Outlet } from "react-router-dom"; -import { t } from "../../lib/i18n"; -import { resolveProfileRole } from "../../lib/role"; export default function AuthGuard() { const auth = useAuth(); @@ -18,39 +16,5 @@ export default function AuthGuard() { return ; } - const normalizedRole = resolveProfileRole( - auth.user?.profile as Record | undefined, - ); - const isTenantMember = - normalizedRole === "user" || normalizedRole === "tenant_member"; - - if (isTenantMember) { - return ( -
-
-

- {t("msg.dev.auth.access_denied_title", "접근 권한이 없습니다.")} -

-

- {t( - "msg.dev.auth.access_denied_description", - "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요.", - )} -

- -
-
- ); - } - return ; } diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index a6813fd0..d47cd449 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -38,12 +38,17 @@ import { } from "../../components/ui/table"; import { fetchClients, fetchDevStats } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; import { cn } from "../../lib/utils"; function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); const hasAccessToken = Boolean(auth.user?.access_token); + const role = resolveProfileRole( + auth.user?.profile as Record | undefined, + ); + const canCreateClient = role !== "user" && role !== "tenant_member"; const { data, @@ -168,16 +173,18 @@ function ClientsPage() { )} -
- -
+ {canCreateClient && ( +
+ +
+ )}
@@ -217,7 +224,7 @@ function ClientsPage() { )} - {t("ui.dev.clients.badge.admin_session", "관리자 세션")} + {t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
@@ -319,12 +326,14 @@ function ClientsPage() { {t("ui.dev.clients.list.title", "클라이언트 목록")} -
- -
+ {canCreateClient && ( +
+ +
+ )} @@ -350,6 +359,29 @@ function ClientsPage() {
+ {filteredClients.length === 0 && ( + + +
+

+ {t( + "msg.dev.clients.empty", + "조회 가능한 RP가 없습니다.", + )} +

+

+ {t( + "msg.dev.clients.empty_detail", + "RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.", + )} +

+
+
+
+ )} {filteredClients.map((client) => ( diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts index 633e6b89..7b0066a3 100644 --- a/devfront/tests/devfront-role-switch-report.spec.ts +++ b/devfront/tests/devfront-role-switch-report.spec.ts @@ -17,7 +17,7 @@ test.describe("DevFront role report", () => { }); }); - test("user(tenant_member) is blocked with 안내 문구", async ({ + test("user(tenant_member) can enter and sees empty RP list", async ({ page, }, testInfo) => { await seedAuth(page, "user"); @@ -29,9 +29,12 @@ test.describe("DevFront role report", () => { await page.goto("/clients"); await expect( - page.getByText(/관리자 전용 화면|administrator only/i), + page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), ).toBeVisible(); - await captureEvidence(page, testInfo, "role-user-blocked"); + await expect( + page.getByText(/연동 앱|Connected Application/i), + ).toBeVisible(); + await captureEvidence(page, testInfo, "role-user-empty-rps"); }); test("rp_admin sees only assigned Gitea app and its logs", async ({ diff --git a/devfront/tests/devfront-security.spec.ts b/devfront/tests/devfront-security.spec.ts index 0e451a59..b993e557 100644 --- a/devfront/tests/devfront-security.spec.ts +++ b/devfront/tests/devfront-security.spec.ts @@ -59,14 +59,25 @@ test.describe("DevFront security and isolation", () => { await expect(page.getByText("Server side App")).not.toBeVisible(); }); - test("tenant_member user is blocked at AuthGuard", async ({ page }) => { + test("tenant_member user can enter DevFront and sees empty RP list", async ({ + page, + }) => { await seedAuth(page, "tenant_member"); + const state = { + clients: [] as ReturnType[], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); await page.goto("/clients"); - await expect( - page.getByText(/DevFront는 관리자 전용 화면입니다|administrator access/i), - ).toBeVisible(); await expect(page).toHaveURL(/\/clients$/); + await expect( + page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }), + ).not.toBeVisible(); }); test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({ diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index ce7970c6..2e142757 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -69,6 +69,7 @@ class RelyingParty implements Namespace { consent_viewer: (User | SubjectSet)[] consent_revoker: (User | SubjectSet)[] relationship_viewer: (User | SubjectSet)[] + audit_viewer: (User | SubjectSet)[] status_operator: (User | SubjectSet)[] } @@ -82,6 +83,7 @@ class RelyingParty implements Namespace { this.related.consent_viewer.includes(ctx.subject) || this.related.consent_revoker.includes(ctx.subject) || this.related.relationship_viewer.includes(ctx.subject) || + this.related.audit_viewer.includes(ctx.subject) || this.related.status_operator.includes(ctx.subject) || this.related.parents.traverse((t) => t.permits.view(ctx)) || this.related.parents.traverse((t) => t.permits.view_dev_console(ctx)), @@ -126,6 +128,10 @@ class RelyingParty implements Namespace { this.related.parents.traverse((t) => t.permits.grant_dev_permissions(ctx)) || this.permits.manage(ctx), + view_audit_logs: (ctx: Context): boolean => + this.related.audit_viewer.includes(ctx.subject) || + this.permits.manage(ctx), + change_status: (ctx: Context): boolean => this.related.status_operator.includes(ctx.subject) || this.permits.manage(ctx), From e15de6d33420b586e7086c2427d43926d0d4a419 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 14:16:24 +0900 Subject: [PATCH 14/18] =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20DevFront=20=EC=A0=91=EA=B7=BC=20=EB=B0=8F?= =?UTF-8?q?=20RP=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/dev_handler.go | 89 +++-- .../handler/dev_handler_isolation_test.go | 21 +- backend/internal/handler/dev_handler_test.go | 304 +++++++++++++++++- .../internal/service/relying_party_service.go | 1 + .../components/common/ForbiddenMessage.tsx | 2 +- .../features/clients/ClientRelationsPage.tsx | 295 +++++++++-------- devfront/src/lib/devApi.ts | 8 +- devfront/src/locales/en.toml | 6 + devfront/src/locales/ko.toml | 6 + devfront/src/locales/template.toml | 6 + docker/ory/keto/namespaces.ts | 7 + docs/devfront-rp-relationships-guide.md | 7 +- 12 files changed, 570 insertions(+), 182 deletions(-) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 08f3faea..54bb5eba 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -199,6 +199,7 @@ var allowedRelyingPartyOperatorRelations = map[string]struct{}{ "admins": {}, "creator": {}, "config_editor": {}, + "secret_viewer": {}, "secret_rotator": {}, "jwks_viewer": {}, "jwks_operator": {}, @@ -215,7 +216,7 @@ func normalizeUserRole(role string) string { func isDevConsoleRoleAllowed(role string) bool { switch normalizeUserRole(role) { - case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin: + case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser: return true default: return false @@ -372,6 +373,32 @@ func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.User return err == nil && allowed } +func (h *DevHandler) canViewClientSecret(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { + if canAccessClientByLegacyScope(profile, summary) { + return true + } + return h.canOperateClientByPermit(c, profile, summary, "view_secret") +} + +func (h *DevHandler) canBypassPrivateClientRestriction(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { + if h.canOperateClientByPermit(c, profile, summary, relation) { + return true + } + allowed, err := h.checkAppManagerPermission(c) + return err == nil && allowed +} + +func (h *DevHandler) redactClientSecretUnlessAllowed(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) clientSummary { + if summary.ClientSecret == "" { + return summary + } + if h.canViewClientSecret(c, profile, summary) { + return summary + } + summary.ClientSecret = "" + return summary +} + func (h *DevHandler) canViewClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if h.canOperateClientByPermit(c, profile, summary, "view_relationships") { return true @@ -474,7 +501,11 @@ func resolveClientTenantID(summary clientSummary) string { } func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool { - if normalizeUserRole(profileRole(profile)) != domain.RoleRPAdmin { + role := normalizeUserRole(profileRole(profile)) + if role == domain.RoleUser { + return false + } + if role != domain.RoleRPAdmin { return true } allowed := managedClientIDsFromProfile(profile) @@ -665,7 +696,11 @@ func (h *DevHandler) SearchUsers(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } if !isDevConsoleRoleAllowed(normalizeUserRole(profile.Role)) { - return errorJSON(c, fiber.StatusForbidden, "forbidden") + clientID := strings.TrimSpace(c.Query("clientId")) + summary, err := h.loadClientSummary(c.Context(), clientID) + if clientID == "" || err != nil || !h.canManageClientRelations(c, profile, summary) { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } } if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") @@ -993,7 +1028,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleViewerRole(role) { + if !isDevConsoleRoleAllowed(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1054,7 +1089,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { continue } - items = append(items, summary) + items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary)) } return c.JSON(clientListResponse{ @@ -1240,6 +1275,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { } cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID) + summary = h.redactClientSecretUnlessAllowed(c, profile, summary) return c.JSON(clientDetailResponse{ Client: summary, @@ -1318,17 +1354,17 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "change_status") { + canChangeStatusByPermit := h.canOperateClientByPermit(c, profile, summary, "change_status") + if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatusByPermit { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } - if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { + if summary.Type == "private" && !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") { + if !canChangeStatusByPermit { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -1352,6 +1388,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { } updatedSummary := h.mapClientSummary(*updated) + updatedSummary = h.redactClientSecretUnlessAllowed(c, profile, updatedSummary) cacheState, _ := h.publicHeadlessJWKSCacheState(updatedSummary.ID) return c.JSON(clientDetailResponse{ Client: updatedSummary, @@ -1566,7 +1603,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1584,11 +1621,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { // [Security] Check permission for private clients (both current and new type) if currentSummary.Type == "private" || clientType == "private" { - isAppManager, err := h.checkAppManagerPermission(c) - if err != nil { - return errorJSON(c, fiber.StatusInternalServerError, "permission check error") - } - if !isAppManager { + if !h.canBypassPrivateClientRestriction(c, profile, currentSummary, "edit_config") { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -1736,7 +1769,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1746,8 +1779,7 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { // [Security] Check permission for private clients if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { + if !h.canBypassPrivateClientRestriction(c, profile, summary, "manage") { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -1986,7 +2018,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } @@ -1997,8 +2029,7 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { // [Security] Check permission for private clients if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { + if !h.canBypassPrivateClientRestriction(c, profile, summary, "rotate_secret") { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -2076,7 +2107,7 @@ func (h *DevHandler) RefreshHeadlessJWKSCache(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { @@ -2141,18 +2172,10 @@ func (h *DevHandler) RevokeHeadlessJWKSCache(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } role := normalizeUserRole(profile.Role) - if !isDevConsoleRoleAllowed(role) { + if !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } - isSuperAdmin := role == domain.RoleSuperAdmin - userTenantID := tenantIDFromProfile(profile) - if !isSuperAdmin { - clientTenantID := resolveClientTenantID(summary) - if clientTenantID != userTenantID { - return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") - } - } - if !isRPAdminClientAllowed(profile, summary.ID) { + if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } diff --git a/backend/internal/handler/dev_handler_isolation_test.go b/backend/internal/handler/dev_handler_isolation_test.go index de329d23..7a075bd7 100644 --- a/backend/internal/handler/dev_handler_isolation_test.go +++ b/backend/internal/handler/dev_handler_isolation_test.go @@ -17,6 +17,8 @@ import ( func TestDevHandler_Isolation(t *testing.T) { mockKeto := new(devMockKetoService) + // Default Mock behavior: deny everything unless explicitly allowed + mockKeto.On("CheckPermission", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Maybe() h := &DevHandler{ Hydra: &service.HydraAdminService{ @@ -72,7 +74,6 @@ func TestDevHandler_Isolation(t *testing.T) { req.Header.Set("Origin", "http://localhost:5174") resp, _ := app.Test(req, -1) - // We expect 401 now because ListClients enforces authentication. assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) @@ -89,7 +90,8 @@ func TestDevHandler_Isolation(t *testing.T) { }) app.Get("/api/v1/dev/clients", h.ListClients) - mockKeto.On("CheckPermission", mock.Anything, "user-a", "System", "global", "manage_all").Return(true, nil) + // Explicit permission for private client check bypass + mockKeto.On("CheckPermission", mock.Anything, "User:user-a", "System", "global", "manage_all").Return(true, nil).Once() req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) @@ -100,17 +102,17 @@ func TestDevHandler_Isolation(t *testing.T) { } json.NewDecoder(resp.Body).Decode(&res) - // Should only see client-tenant-a + // Should only see client-tenant-a (tenant isolation) assert.Equal(t, 1, len(res.Items)) assert.Equal(t, "client-tenant-a", res.Items[0].ID) }) - t.Run("Tenant member should be forbidden from DevFront clients", func(t *testing.T) { + t.Run("Tenant member should see empty list from DevFront clients if no relation", func(t *testing.T) { app := fiber.New() tenantA := "tenant-a" app.Use(func(c *fiber.Ctx) error { c.Locals("user_profile", &domain.UserProfileResponse{ - ID: "user-a", + ID: "user-member", Role: domain.RoleUser, TenantID: &tenantA, }) @@ -120,7 +122,14 @@ func TestDevHandler_Isolation(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) resp, _ := app.Test(req, -1) - assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var res struct { + Items []clientSummary `json:"items"` + } + json.NewDecoder(resp.Body).Decode(&res) + // Empty list because we didn't mock any specific 'view' permissions for this user + assert.Equal(t, 0, len(res.Items)) }) t.Run("RP Admin should only see managed clients", func(t *testing.T) { diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index ce3315bb..2bee527d 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -357,6 +357,83 @@ func TestUpdateClient_ReservedSystemNameForbidden(t *testing.T) { assert.Equal(t, http.StatusForbidden, resp.StatusCode) } +func TestUpdateClient_PrivateClientAllowedByEditConfigPermission(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": "client_secret_basic", + "metadata": map[string]any{ + "status": "active", + "tenant_id": "tenant-1", + }, + }), nil + } + if r.Method == http.MethodPut && r.URL.Path == "/clients/client-1" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "client_id": "client-1", + "client_name": "App One Updated", + "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": "client_secret_basic", + "metadata": map[string]any{ + "status": "active", + "tenant_id": "tenant-1", + }, + }), nil + } + return httpJSONAny(r, http.StatusNotFound, nil), nil + }) + + mockKeto := new(devMockKetoService) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "edit_config").Return(true, 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.Put("/api/v1/dev/clients/:id", h.UpdateClient) + + body, _ := json.Marshal(map[string]any{ + "name": "App One Updated", + }) + 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.StatusOK, resp.StatusCode) + + var result clientDetailResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "App One Updated", result.Client.Name) + mockKeto.AssertExpectations(t) +} + func TestListClients_ProtectedSystemClientHidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -511,6 +588,65 @@ func TestUpdateClientStatus_Success(t *testing.T) { assert.Equal(t, "inactive", res.Client.Status) } +func TestUpdateClientStatus_UserAllowedByStatusPermission(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(true, 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_ProtectedSystemClientForbidden(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/oathkeeper-introspect" { @@ -690,6 +826,106 @@ func TestGetClient_RPAdminAllowedByKetoViewPermission(t *testing.T) { mockKeto.AssertExpectations(t) } +func TestGetClient_RedactsSecretWithoutViewSecretPermission(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", + "client_secret": "stored-secret", + "metadata": map[string]interface{}{ + "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", "Tenant", "tenant-1", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").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.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result clientDetailResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Empty(t, result.Client.ClientSecret) + mockKeto.AssertExpectations(t) +} + +func TestGetClient_UserAllowedToViewSecretByPermission(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", + "client_secret": "stored-secret", + "metadata": map[string]interface{}{ + "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", "Tenant", "tenant-1", "view_dev_console").Return(false, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "RelyingParty", "client-1", "view_secret").Return(true, 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.Get("/api/v1/dev/clients/:id", h.GetClient) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-1", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result clientDetailResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "stored-secret", result.Client.ClientSecret) + mockKeto.AssertExpectations(t) +} + func TestRotateClientSecret_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodGet && r.URL.Path == "/clients/client-1" { @@ -1505,7 +1741,7 @@ func TestListClientRelations_RPAdminAllowedByViewRelationshipsPermission(t *test 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", "audit_viewer", "status_operator"} { + for _, relation := range []string{"admins", "creator", "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", "consent_viewer", "consent_revoker", "relationship_viewer", "audit_viewer", "status_operator"} { mockKeto.On("ListRelations", mock.Anything, "RelyingParty", "client-1", relation, "").Return([]service.RelationTuple{}, nil) } mockKratos := new(devMockKratosAdmin) @@ -1724,3 +1960,69 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { assert.Equal(t, "alice@example.com", result.Items[0].Email) mockKratos.AssertExpectations(t) } + +func TestSearchUsers_UserAllowedByRPAdminRelation(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) + + mockKratos := new(devMockKratosAdmin) + mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ + { + ID: "target-user", + Traits: map[string]interface{}{ + "name": "김용연", + "email": "kyy@example.com", + "id": "kyy01", + "tenant_id": "tenant-1", + }, + }, + }, nil) + + h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, + Keto: mockKeto, + KratosAdmin: mockKratos, + } + + 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.Get("/api/v1/dev/users", h.SearchUsers) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=김용연", nil) + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var result devUserListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "target-user", result.Items[0].ID) + assert.Equal(t, "김용연", result.Items[0].Name) + } + mockKeto.AssertExpectations(t) + mockKratos.AssertExpectations(t) +} diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 152e321b..606f8a68 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -29,6 +29,7 @@ var defaultRelyingPartyOperatorRelations = []string{ "admins", "creator", "config_editor", + "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 9466ce36..2a496ba3 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -30,7 +30,7 @@ export function ForbiddenMessage({ resourceToken }: Props) { } else if (role === "user" || role === "tenant_member") { explanation = t( "msg.dev.forbidden.user", - "일반 사용자는 관리자 화면에 접근할 수 없습니다.", + "일반 사용자 계정은 담당 RP(앱) 관리자 권한이 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", ); } diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index f99f923b..df3113ed 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -37,6 +37,7 @@ import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ "admins", "config_editor", + "secret_viewer", "secret_rotator", "jwks_viewer", "jwks_operator", @@ -78,7 +79,6 @@ function ClientRelationsPage() { null, ); const [isSearchOpen, setIsSearchOpen] = useState(false); - const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), @@ -95,13 +95,21 @@ function ClientRelationsPage() { enabled: clientId.length > 0, }); + const isRelationshipViewForbidden = + (error as AxiosError | null)?.response?.status === 403; + const relationshipViewForbiddenMessage = t( + "msg.dev.clients.relationships.view_forbidden", + "이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.", + ); + const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({ queryKey: ["dev-users", deferredUserSearch], - queryFn: () => fetchDevUsers(deferredUserSearch), + queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId), enabled: clientId.length > 0 && deferredUserSearch.length > 0 && - selectedUser == null, + selectedUser == null && + !isRelationshipViewForbidden, }); const sortedItems = useMemo(() => { @@ -342,147 +350,156 @@ function ClientRelationsPage() { -
- -
- { - if (!selectedUser && userSearch.trim() !== "") { - setIsSearchOpen(true); - } - }} - onChange={(event) => { - setSelectedUser(null); - setUserSearch(event.target.value); - setIsSearchOpen(true); - }} - placeholder={t( - "ui.dev.clients.relationships.user_search_placeholder", - "이름 또는 이메일 검색...", - )} - /> - {isSearchOpen && - selectedUser == null && - userSearch.trim() !== "" && ( -
- {isUserSearchLoading ? ( -
- {t( - "msg.dev.clients.relationships.search_loading", - "사용자를 찾는 중입니다...", - )} -
- ) : (userSearchData?.items ?? []).length > 0 ? ( - (userSearchData?.items ?? []).map((user) => ( - - )) - ) : ( -
- {t( - "msg.dev.clients.relationships.search_empty", - "검색 결과가 없습니다.", + {isRelationshipViewForbidden ? ( +
+ {relationshipViewForbiddenMessage} +
+ ) : ( + <> +
+ +
+ { + if (!selectedUser && userSearch.trim() !== "") { + setIsSearchOpen(true); + } + }} + onChange={(event) => { + setSelectedUser(null); + setUserSearch(event.target.value); + setIsSearchOpen(true); + }} + placeholder={t( + "ui.dev.clients.relationships.user_search_placeholder", + "이름 또는 이메일 검색...", + )} + /> + {isSearchOpen && + selectedUser == null && + userSearch.trim() !== "" && ( +
+ {isUserSearchLoading ? ( +
+ {t( + "msg.dev.clients.relationships.search_loading", + "사용자를 찾는 중입니다...", + )} +
+ ) : (userSearchData?.items ?? []).length > 0 ? ( + (userSearchData?.items ?? []).map((user) => ( + + )) + ) : ( +
+ {t( + "msg.dev.clients.relationships.search_empty", + "검색 결과가 없습니다.", + )} +
)}
)} -
+
+ {selectedUser && ( +

+ {t( + "msg.dev.clients.relationships.selected_user", + "선택된 사용자: {{user}}", + { user: formatUserLabel(selectedUser) }, + )} +

)} -
- {selectedUser && ( -

- {t( - "msg.dev.clients.relationships.selected_user", - "선택된 사용자: {{user}}", - { user: formatUserLabel(selectedUser) }, - )} -

- )} -
+
-
- -
- {relationOptions.map((relation) => { - const disabled = selectedUserExistingRelations.has(relation); - const isSelected = selectedRelations.includes(relation); - return ( -
+ handleRelationToggle(relation)} + /> +
+
+ {relationLabel(relation)} +
+
+ {relationDescription(relation)} +
+
+ {relation} +
+
+ + ); + })} +
+
-
- -
+
+ +
+ + )}
@@ -503,7 +520,11 @@ function ClientRelationsPage() { - {error ? ( + {isRelationshipViewForbidden ? ( +
+ {relationshipViewForbiddenMessage} +
+ ) : error ? (
{t( "msg.dev.clients.relationships.load_error", diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 6446d4f0..6ef6b0fd 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -202,11 +202,15 @@ export async function fetchClientRelations(clientId: string) { return data; } -export async function fetchDevUsers(search: string, limit = 10) { +export async function fetchDevUsers( + search: string, + limit = 10, + clientId?: string, +) { const { data } = await apiClient.get( "/dev/users", { - params: { search, limit }, + params: { search, limit, clientId }, }, ); return data; diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index bf5ff541..ea885824 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -388,8 +388,10 @@ list_description = "Lists operator relations directly assigned to this RP." load_error = "Failed to load relationships: {{error}}" loading = "Loading relationships..." empty = "No direct relationships assigned." +view_forbidden = "You do not have permission to view relationships for this RP. Ask an administrator to grant Relationship Viewer or RP Admin relationship." search_loading = "Searching users..." search_empty = "No users found." +search_forbidden_user = "General users cannot use user search for relationship assignment." selected_user = "Selected user: {{user}}" [msg.dev.clients.federation] @@ -1491,6 +1493,10 @@ description = "Marks the operator who created this RP." label = "RP General Settings" description = "Edit the name, redirect URIs, and general metadata." +[ui.dev.clients.relationships.option.secret_viewer] +label = "Secret View" +description = "View the Client secret for this RP." + [ui.dev.clients.relationships.option.secret_rotator] label = "Secret Rotation" description = "Rotate and reissue the client secret." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 61fcc171..36b80e62 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -388,8 +388,10 @@ list_description = "현재 RP에 직접 부여된 operator relation 목록입니 load_error = "관계 조회 실패: {{error}}" loading = "관계를 불러오는 중입니다..." empty = "직접 부여된 관계가 없습니다." +view_forbidden = "이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요." search_loading = "사용자를 찾는 중입니다..." search_empty = "검색 결과가 없습니다." +search_forbidden_user = "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다." selected_user = "선택된 사용자: {{user}}" [msg.dev.clients.federation] @@ -1490,6 +1492,10 @@ description = "이 RP를 생성한 운영 주체를 표시합니다." label = "RP 일반 설정" description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다." +[ui.dev.clients.relationships.option.secret_viewer] +label = "시크릿 조회" +description = "이 RP의 Client secret을 조회합니다." + [ui.dev.clients.relationships.option.secret_rotator] label = "시크릿 재발급" description = "Client secret 재발급과 회전을 수행합니다." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 94c2c4fe..9e43f416 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -388,8 +388,10 @@ list_description = "" load_error = "" loading = "" empty = "" +view_forbidden = "" search_loading = "" search_empty = "" +search_forbidden_user = "" selected_user = "" [msg.dev.clients.federation] @@ -1490,6 +1492,10 @@ description = "" label = "" description = "" +[ui.dev.clients.relationships.option.secret_viewer] +label = "" +description = "" + [ui.dev.clients.relationships.option.secret_rotator] label = "" description = "" diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 2e142757..2d387e27 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -63,6 +63,7 @@ class RelyingParty implements Namespace { access: (User | SubjectSet | SubjectSet | SubjectSet)[] creator: (User | SubjectSet)[] config_editor: (User | SubjectSet)[] + secret_viewer: (User | SubjectSet)[] secret_rotator: (User | SubjectSet)[] jwks_viewer: (User | SubjectSet)[] jwks_operator: (User | SubjectSet)[] @@ -77,6 +78,7 @@ class RelyingParty implements Namespace { view: (ctx: Context): boolean => this.related.admins.includes(ctx.subject) || this.related.config_editor.includes(ctx.subject) || + this.related.secret_viewer.includes(ctx.subject) || this.related.secret_rotator.includes(ctx.subject) || this.related.jwks_viewer.includes(ctx.subject) || this.related.jwks_operator.includes(ctx.subject) || @@ -101,6 +103,11 @@ class RelyingParty implements Namespace { this.related.config_editor.includes(ctx.subject) || this.permits.manage(ctx), + view_secret: (ctx: Context): boolean => + this.related.secret_viewer.includes(ctx.subject) || + this.permits.rotate_secret(ctx) || + this.permits.manage(ctx), + rotate_secret: (ctx: Context): boolean => this.related.secret_rotator.includes(ctx.subject) || this.permits.manage(ctx), diff --git a/docs/devfront-rp-relationships-guide.md b/docs/devfront-rp-relationships-guide.md index ae2543c6..9cc6e931 100644 --- a/docs/devfront-rp-relationships-guide.md +++ b/docs/devfront-rp-relationships-guide.md @@ -25,8 +25,9 @@ | 화면 표시명 | Relation key | 의미 | 주요 허용 기능 | |---|---|---|---| -| RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 | +| RP 관리자 | `admins` | RP 운영 전반을 관리할 수 있는 관리자 관계 | RP 조회, 설정 관리, secret 조회/재발급, JWKS 운영, consent 조회/회수, 관계 조회, 감사 로그 조회, 상태 변경 | | RP 일반 설정 | `config_editor` | RP 이름, Redirect URI, 메타데이터 같은 일반 설정을 수정할 수 있는 관계 | RP 조회, 일반 설정 수정 | +| 시크릿 조회 | `secret_viewer` | Client secret을 조회할 수 있는 관계 | RP 조회, client secret 조회 | | 시크릿 재발급 | `secret_rotator` | Client secret 재발급과 rotation을 수행할 수 있는 관계 | RP 조회, client secret 재발급 | | JWKS 조회 | `jwks_viewer` | JWKS 상태, 캐시 정보, key summary를 조회할 수 있는 관계 | RP 조회, JWKS 상태/캐시/key summary 조회 | | JWKS 운영 | `jwks_operator` | JWKS refresh/revoke 같은 운영 작업을 수행할 수 있는 관계 | RP 조회, JWKS 조회, JWKS refresh/revoke | @@ -42,10 +43,11 @@ Keto namespace 기준으로 relation은 다음 permit으로 계산된다. | Permit | 허용 relation / 조건 | 기능 의미 | |---|---|---| -| `view` | `admins`, `config_editor`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 | +| `view` | `admins`, `config_editor`, `secret_viewer`, `secret_rotator`, `jwks_viewer`, `jwks_operator`, `consent_viewer`, `consent_revoker`, `relationship_viewer`, `audit_viewer`, `status_operator`, 부모 tenant의 `view` 또는 `view_dev_console` | RP 기본 조회 및 목록 노출 | | `manage` | `admins`, 부모 tenant의 `manage` | RP 관리 상위 권한 | | `create` | `creator`, 부모 tenant의 `grant_dev_permissions`, `manage` | RP 생성. `creator`는 현재 수동 부여하지 않는 내부 relation이다. | | `edit_config` | `config_editor`, `manage` | RP 일반 설정 수정 | +| `view_secret` | `secret_viewer`, `rotate_secret`, `manage` | client secret 조회 | | `rotate_secret` | `secret_rotator`, `manage` | client secret 재발급/회전 | | `view_jwks` | `jwks_viewer`, `operate_jwks`, `manage` | JWKS 상태/캐시/key summary 조회 | | `operate_jwks` | `jwks_operator`, `manage` | JWKS refresh/revoke | @@ -138,6 +140,7 @@ RP 생성 시 `metadata.user_id`가 존재하면 생성자에게 기본 운영 r - `admins` - `creator` - `config_editor` +- `secret_viewer` - `secret_rotator` - `jwks_viewer` - `jwks_operator` From 7e0680a71c423ccf324a34f069116945e6123e4b Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 20 Apr 2026 14:45:29 +0900 Subject: [PATCH 15/18] =?UTF-8?q?=EB=8F=99=EC=9D=98=20=EB=B0=8F=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=83=AD=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ForbiddenMessage.tsx | 34 ++++++--- .../features/clients/ClientConsentsPage.tsx | 70 +++++++++++++++++-- .../features/clients/ClientRelationsPage.tsx | 16 ++++- devfront/src/locales/en.toml | 9 +++ devfront/src/locales/ko.toml | 9 +++ 5 files changed, 122 insertions(+), 16 deletions(-) diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx index 2a496ba3..97c2af01 100644 --- a/devfront/src/components/common/ForbiddenMessage.tsx +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -4,7 +4,7 @@ import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; interface Props { - resourceToken: "audit" | "clients"; + resourceToken: "audit" | "clients" | "consents"; } export function ForbiddenMessage({ resourceToken }: Props) { @@ -28,17 +28,33 @@ export function ForbiddenMessage({ resourceToken }: Props) { "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", ); } else if (role === "user" || role === "tenant_member") { - explanation = t( - "msg.dev.forbidden.user", - "일반 사용자 계정은 담당 RP(앱) 관리자 권한이 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", - ); + if (resourceToken === "consents") { + explanation = t( + "msg.dev.forbidden.user.consents", + "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + ); + } else if (resourceToken === "audit") { + explanation = t( + "msg.dev.forbidden.user.audit", + "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + ); + } else { + explanation = t( + "msg.dev.forbidden.user.clients", + "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요.", + ); + } } + const resourceLabel = + resourceToken === "audit" + ? t("ui.dev.audit.title", "Audit Logs") + : resourceToken === "consents" + ? t("ui.dev.clients.consents.title", "User Consent Grants") + : t("ui.dev.clients.registry.subtitle", "연동 앱"); + const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", { - resource: - resourceToken === "audit" - ? t("ui.dev.audit.title", "Audit Logs") - : t("ui.dev.clients.registry.subtitle", "연동 앱"), + resource: resourceLabel, }); return ( diff --git a/devfront/src/features/clients/ClientConsentsPage.tsx b/devfront/src/features/clients/ClientConsentsPage.tsx index c2498d7d..728b80a3 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -1,4 +1,5 @@ import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import { ArrowLeft, ChevronLeft, @@ -9,6 +10,7 @@ import { } from "lucide-react"; import { useState } from "react"; import { Link, useParams } from "react-router-dom"; +import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -161,6 +163,57 @@ function ClientConsentsPage() { } }; + if (error) { + const axiosError = error as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 403) { + return ( +
+
+
+
+ +
+ +
+

+ {t( + "ui.dev.clients.consents.title", + "User Consent Grants", + )} +

+
+
+
+
+ +
+ +
+ ); + } + } + return (
@@ -359,18 +412,20 @@ function ClientConsentsPage() { {error && ( - + {t( "msg.dev.clients.consents.load_error", "Error loading consents: {{error}}", { - error: (error as Error).message, + error: + (error as AxiosError<{ error?: string }>).response?.data + ?.error ?? (error as Error).message, }, )} )} {isLoading && ( - + {t("msg.dev.clients.consents.loading", "Loading consents...")} )} @@ -408,10 +463,13 @@ function ClientConsentsPage() { - {filteredRows.length === 0 && !isLoading ? ( + {filteredRows.length === 0 && !isLoading && !error ? ( - - {t("msg.dev.clients.consents.empty", "No consents found.")} + +
+ +

{t("msg.dev.clients.consents.empty", "No consents found.")}

+
) : ( diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index df3113ed..e754f540 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -102,7 +102,11 @@ function ClientRelationsPage() { "이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.", ); - const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({ + const { + data: userSearchData, + isFetching: isUserSearchLoading, + error: userSearchError, + } = useQuery({ queryKey: ["dev-users", deferredUserSearch], queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId), enabled: @@ -280,6 +284,9 @@ function ClientRelationsPage() { ); } + const isUserSearchForbidden = + (userSearchError as AxiosError | null)?.response?.status === 403; + return (
@@ -390,6 +397,13 @@ function ClientRelationsPage() { "사용자를 찾는 중입니다...", )}
+ ) : isUserSearchForbidden ? ( +
+ {t( + "msg.dev.clients.relationships.search_forbidden_user", + "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", + )} +
) : (userSearchData?.items ?? []).length > 0 ? ( (userSearchData?.items ?? []).map((user) => (
) : isUserSearchForbidden ? ( -
- {t( - "msg.dev.clients.relationships.search_forbidden_user", - "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", - )} +
+

+ {t( + "msg.dev.clients.relationships.search_forbidden_user", + "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", + )} +

+

+ {t( + "msg.dev.clients.relationships.search_forbidden_user_hint", + "'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.", + )} +

) : (userSearchData?.items ?? []).length > 0 ? ( (userSearchData?.items ?? []).map((user) => ( )) ) : ( -
+
{t( "msg.dev.clients.relationships.search_empty", "검색 결과가 없습니다.", @@ -503,7 +542,7 @@ function ClientRelationsPage() {
+ ) : ( level > 0 &&
)} @@ -457,7 +453,7 @@ function TenantUserGroupsTab() { {selectedNode.slug}
- +
{selectedNode.recursiveMemberCount}{" "} {t("ui.admin.tenants.table.members", "명")} @@ -469,7 +465,7 @@ function TenantUserGroupsTab() { selectedNode.type, )} - +
diff --git a/adminfront/tests/bulk_actions.spec.ts b/adminfront/tests/bulk_actions.spec.ts index afef3fef..8b0b5e45 100644 --- a/adminfront/tests/bulk_actions.spec.ts +++ b/adminfront/tests/bulk_actions.spec.ts @@ -174,7 +174,7 @@ test.describe("Bulk Actions and Tree Search", () => { await searchInput.fill("Eng"); const engNode = page - .locator("button") + .locator('button, [role="button"]') .filter({ hasText: "Engineering" }) .first(); await expect(engNode).toBeVisible(); diff --git a/backend/cmd/fix_kratos_roles.go b/backend/cmd/fix_kratos_roles.go index 6a72f72e..51226a85 100644 --- a/backend/cmd/fix_kratos_roles.go +++ b/backend/cmd/fix_kratos_roles.go @@ -1,18 +1,17 @@ package main import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" "context" "fmt" "log" - - "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/service" ) func main() { kratosAdmin := service.NewKratosAdminService() ctx := context.Background() - + identities, err := kratosAdmin.ListIdentities(ctx) if err != nil { log.Fatalf("Failed to list identities: %v", err) @@ -22,7 +21,7 @@ func main() { for _, id := range identities { traits := id.Traits changed := false - + if r, ok := traits["role"].(string); ok { norm := domain.NormalizeRole(r) if norm != r && norm == domain.RoleUser { diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index fad84419..647bea2c 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -42,6 +42,6 @@ func migrateSchemas(db *gorm.DB) error { &domain.ClientConsent{}, &domain.KetoOutbox{}, &domain.SharedLink{}, - // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto - ) - } + // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto + ) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 903bd720..f7aed790 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -5413,7 +5413,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe profile.ManageableTenants = manageable } } - + joined, err := h.TenantService.ListJoinedTenants(c.Context(), profile.ID) if err == nil { profile.JoinedTenants = joined diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 213d26c7..52db7050 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1519,7 +1519,7 @@ func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) { assert.Nil(t, stored) } -func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { +func TestListAuditLogs_TenantMemberWithoutAuditPermissionReturnsEmpty(t *testing.T) { h := &DevHandler{ Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"}, AuditRepo: &mockAuditRepo{}, @@ -1540,7 +1540,11 @@ func TestListAuditLogs_TenantMemberForbidden(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/audit-logs", nil) resp, _ := app.Test(req, -1) - assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result devAuditListResponse + _ = json.NewDecoder(resp.Body).Decode(&result) + assert.Empty(t, result.Items) } func TestListAuditLogs_RPAdminScope(t *testing.T) { @@ -1915,6 +1919,20 @@ func TestRemoveClientRelation_RPAdminAllowedByManagePermission(t *testing.T) { } func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(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 + }) + mockKratos := new(devMockKratosAdmin) mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{ { @@ -1938,6 +1956,10 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { }, nil) h := &DevHandler{ + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: transport}, + }, KratosAdmin: mockKratos, } @@ -1951,21 +1973,25 @@ func TestSearchUsers_RPAdminSearchByNameOrEmailWithinTenantScope(t *testing.T) { ManageableTenants: []domain.Tenant{ {ID: "tenant-1", Slug: "tenant-one"}, }, + Metadata: map[string]any{ + "managed_client_ids": []any{"client-1"}, + }, }) return c.Next() }) app.Get("/api/v1/dev/users", h.SearchUsers) - req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?search=alice", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/users?clientId=client-1&search=alice", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var result devUserListResponse _ = json.NewDecoder(resp.Body).Decode(&result) - assert.Len(t, result.Items, 1) - assert.Equal(t, "user-1", result.Items[0].ID) - assert.Equal(t, "Alice Kim", result.Items[0].Name) - assert.Equal(t, "alice@example.com", result.Items[0].Email) + if assert.Len(t, result.Items, 1) { + assert.Equal(t, "user-1", result.Items[0].ID) + assert.Equal(t, "Alice Kim", result.Items[0].Name) + assert.Equal(t, "alice@example.com", result.Items[0].Email) + } mockKratos.AssertExpectations(t) } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 16e7641f..7060627a 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -868,7 +868,6 @@ func normalizeTenantType(value string) string { } } - func (h *TenantHandler) CreateShareLink(c *fiber.Ctx) error { tenantID := c.Params("id") var req struct { @@ -932,7 +931,9 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { curr := id for { p, exists := parentMap[curr] - if !exists || p == "" { break } + if !exists || p == "" { + break + } curr = p } return curr @@ -967,10 +968,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { var usersByID []domain.User h.DB.Where("tenant_id IN ?", tenantIDs).Preload("Tenant").Find(&usersByID) for _, u := range usersByID { - if u.Status != "active" || seen[u.ID] { continue } + if u.Status != "active" || seen[u.ID] { + continue + } seen[u.ID] = true cc := u.CompanyCode - if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } + if cc == "" && u.Tenant != nil { + cc = u.Tenant.Slug + } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) @@ -980,10 +985,14 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { var usersBySlug []domain.User h.DB.Where("company_code IN ?", slugs).Preload("Tenant").Find(&usersBySlug) for _, u := range usersBySlug { - if u.Status != "active" || seen[u.ID] { continue } + if u.Status != "active" || seen[u.ID] { + continue + } seen[u.ID] = true cc := u.CompanyCode - if cc == "" && u.Tenant != nil { cc = u.Tenant.Slug } + if cc == "" && u.Tenant != nil { + cc = u.Tenant.Slug + } publicUsers = append(publicUsers, publicUserSummary{ ID: u.ID, Name: u.Name, Position: u.Position, JobTitle: u.JobTitle, CompanyCode: cc, Status: u.Status, }) @@ -995,8 +1004,8 @@ func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ - "tenants": tenantSummaries, - "users": publicUsers, + "tenants": tenantSummaries, + "users": publicUsers, "sharedWith": link.Name, }) } diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 1372cda0..a3739e25 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -204,24 +204,24 @@ func TestTenantHandler_ListTenants(t *testing.T) { } app.Use(func(c *fiber.Ctx) error { - c.Locals("user_profile", &domain.UserProfileResponse{ - Role: "super_admin", - }) - return c.Next() + c.Locals("user_profile", &domain.UserProfileResponse{ + Role: "super_admin", + }) + return c.Next() }) app.Get("/tenants", h.ListTenants) tenants := []domain.Tenant{ - {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, - {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, + {ID: "t1", Name: "Tenant A", Slug: "slug-a"}, + {ID: "t2", Name: "Tenant B", Slug: "slug-b"}, } // Mocking for the new allTenants check in ListTenants mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe() mockUserRepo.On("CountByCompanyCodes", mock.Anything, mock.Anything). - Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() + Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil).Maybe() mockUserRepo.On("CountByTenantIDs", mock.Anything, mock.Anything). - Return(map[string]int64{}, nil).Maybe() + Return(map[string]int64{}, nil).Maybe() req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil) resp, _ := app.Test(req) @@ -263,6 +263,7 @@ func (m *MockTenantService) DeleteTenantsBulk(ctx context.Context, tenantIDs []s args := m.Called(ctx, tenantIDs) return args.Error(0) } + func (m *MockTenantService) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { args := m.Called(ctx, userID) if args.Get(0) != nil { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index f2c2a2da..4c0f0757 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -133,7 +133,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { parentMap[t.ID] = *t.ParentID } } - + // Function to find the root of any given tenant findRoot := func(id string) string { curr := id @@ -331,17 +331,17 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } var req struct { - Email string `json:"email"` - LoginID string `json:"loginId"` - Password string `json:"password"` - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role"` - CompanyCode string `json:"companyCode"` - Department string `json:"department"` - Position string `json:"position"` - JobTitle string `json:"jobTitle"` - Metadata map[string]any `json:"metadata"` + Email string `json:"email"` + LoginID string `json:"loginId"` + Password string `json:"password"` + Name string `json:"name"` + Phone string `json:"phone"` + Role string `json:"role"` + CompanyCode string `json:"companyCode"` + Department string `json:"department"` + Position string `json:"position"` + JobTitle string `json:"jobTitle"` + Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") @@ -1305,7 +1305,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { traits["tenant_id"] = tenant.ID } } - + // Add to existingCodes if not present found := false for _, existing := range existingCodes { diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 4ccaffad..7e562367 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -61,7 +61,7 @@ func (r *userRepository) Update(ctx context.Context, user *domain.User) error { } } - // 2. Perform Upsert based on ID. + // 2. Perform Upsert based on ID. // In GORM v2, true upsert requires Create() with OnConflict on the primary key. return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, diff --git a/backend/internal/service/mock_common_test.go b/backend/internal/service/mock_common_test.go index bb9c7692..d80e6c70 100644 --- a/backend/internal/service/mock_common_test.go +++ b/backend/internal/service/mock_common_test.go @@ -94,6 +94,7 @@ func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identity } return args.Get(0).(*KratosIdentity), args.Error(1) } + func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { args := m.Called(ctx, identityID, traits, state) if args.Get(0) == nil { @@ -120,9 +121,11 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { return nil, nil } + func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { return nil, nil } + func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error { return nil } diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index 68a11ca4..0a80c5b9 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -19,8 +19,10 @@ import ( "github.com/xuri/excelize/v2" ) -var whitespaceRegex = regexp.MustCompile(`\s+`) -var nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`) +var ( + whitespaceRegex = regexp.MustCompile(`\s+`) + nonAlphaNumRegex = regexp.MustCompile(`[^a-zA-Z0-9가-힣]+`) +) type ProgressData struct { Current int `json:"current"` @@ -30,12 +32,12 @@ type ProgressData struct { var ImportProgressCache sync.Map type ImportResult struct { - TotalRows int `json:"totalRows"` - Processed int `json:"processed"` - UserCreated int `json:"userCreated"` - UserUpdated int `json:"userUpdated"` - TenantCreated int `json:"tenantCreated"` - Errors []string `json:"errors"` + TotalRows int `json:"totalRows"` + Processed int `json:"processed"` + UserCreated int `json:"userCreated"` + UserUpdated int `json:"userUpdated"` + TenantCreated int `json:"tenantCreated"` + Errors []string `json:"errors"` } type OrgChartService interface { @@ -86,13 +88,13 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r } fieldMapping := map[string][]string{ - "email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"}, - "name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"}, - "position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"}, - "jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"}, - "phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"}, - "company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"}, - "is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"}, + "email": {"email", "이메일", "e-mail", "loginid", "login_id", "id", "계정", "사용자id", "사용자계정", "사번", "employeeid", "empno", "mail", "이메일주소"}, + "name": {"name", "이름", "성함", "성명", "username", "사용자명", "성함/이름", "사용자", "사원명"}, + "position": {"position", "직급", "직위", "직급/직위", "직급명", "직위명", "rank"}, + "jobtitle": {"jobtitle", "직무", "담당업무", "업무", "담당", "수행직무", "직종"}, + "phone": {"phone", "전화번호", "연락처", "휴대폰", "휴대폰번호", "핸드폰", "tel", "mobile"}, + "company": {"company", "소속", "법인", "회사", "소속회사", "소속법인", "사업부", "co"}, + "is_owner": {"is_owner", "구분", "직책", "권한", "role", "직책명", "장급여부", "리더", "leader", "manager", "pos"}, } var dataRows [][]string @@ -102,11 +104,15 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for sheetIdx, records := range allSheetsRecords { for i, row := range records { - if len(row) < 2 { continue } + if len(row) < 2 { + continue + } tempMap := make(map[string]int) for j, cell := range row { clean := s.cleanHeader(cell) - if clean != "" { tempMap[clean] = j } + if clean != "" { + tempMap[clean] = j + } } emailIdx := s.findBestMatch(tempMap, fieldMapping["email"]) nameIdx := s.findBestMatch(tempMap, fieldMapping["name"]) @@ -114,7 +120,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for j, cell := range row { c := s.cleanHeader(cell) if strings.Contains(c, "mail") || strings.Contains(c, "계정") || strings.Contains(c, "id") || strings.Contains(c, "사번") { - emailIdx = j; break + emailIdx = j + break } } } @@ -124,13 +131,17 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r for key, aliases := range fieldMapping { actualMap[key] = s.findBestMatch(tempMap, aliases) } - if actualMap["email"] == -1 { actualMap["email"] = emailIdx } + if actualMap["email"] == -1 { + actualMap["email"] = emailIdx + } found = true slog.Info("Found header row", "sheet", sheetIdx, "row", i) break } } - if found { break } + if found { + break + } } if !found { @@ -173,19 +184,25 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r } for rowIdx, record := range dataRows { - if len(record) == 0 { continue } + if len(record) == 0 { + continue + } email := s.getVal(record, actualMap["email"]) name := s.getVal(record, actualMap["name"]) - if email == "" || name == "" { continue } + if email == "" || name == "" { + continue + } position := s.getVal(record, actualMap["position"]) jobTitle := s.getVal(record, actualMap["jobtitle"]) phone := s.normalizePhone(s.getVal(record, actualMap["phone"])) companyName := s.getVal(record, actualMap["company"]) - if companyName == "" { companyName = "Main" } + if companyName == "" { + companyName = "Main" + } companySlug := s.generateCompanySlug(companyName) - + companyTenantID, err := s.ensureCompanyTenant(ctx, tenantID, companyName, companySlug, email, pathCache, result) if err != nil { result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Company fail: %v", rowIdx+2, err)) @@ -196,8 +213,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r var orgParts []string for _, idx := range hierarchyIdx { val := s.getVal(record, idx) - if val != "" && val != "-" { - orgParts = append(orgParts, val) + if val != "" && val != "-" { + orgParts = append(orgParts, val) } } orgPath := strings.Join(orgParts, " > ") @@ -217,9 +234,9 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r grade := "member" if idx := actualMap["is_owner"]; idx != -1 && idx < len(record) { grade = strings.TrimSpace(record[idx]) - isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" || - strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") || - strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장") + isOwner = strings.HasSuffix(grade, "장") || strings.EqualFold(grade, "true") || grade == "1" || + strings.Contains(grade, "Manager") || strings.Contains(grade, "Leader") || + strings.Contains(grade, "팀장") || strings.Contains(grade, "본부장") } kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) @@ -231,7 +248,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r brokerUser := &domain.BrokerUser{ Email: email, Name: name, PhoneNumber: phone, Attributes: map[string]interface{}{ - "affiliationType": "AFFILIATE", "companyCode": companySlug, + "affiliationType": "AFFILIATE", "companyCode": companySlug, "department": orgPath, "grade": grade, "position": position, "tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite }, @@ -244,7 +261,7 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r result.UserCreated++ } else { traits := map[string]interface{}{ - "name": name, "companyCode": companySlug, "department": orgPath, + "name": name, "companyCode": companySlug, "department": orgPath, "grade": grade, "position": position, "affiliationType": "AFFILIATE", "tenant_id": leafID, // [Matrix Fix] Sync leafID to Kratos to prevent lazy sync overwrite } @@ -257,8 +274,8 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r err = s.userRepo.Update(ctx, &domain.User{ ID: kratosID, Email: email, Name: name, Phone: phone, Position: position, - JobTitle: jobTitle, Department: orgPath, - TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity + JobTitle: jobTitle, Department: orgPath, + TenantID: &leafID, // [Matrix Fix] Local DB points to Leaf Department, while CompanyCode points to the Legal Entity CompanyCode: companySlug, AffiliationType: "AFFILIATE", Status: "active", UpdatedAt: time.Now(), Role: domain.RoleUser, }) if err != nil { @@ -269,31 +286,31 @@ func (s *orgChartService) ImportOrgChart(ctx context.Context, tenantID string, r if s.ketoOutboxRepo != nil { // 1. [Redundant Assignment] Always assign to the Legal Company Tenant _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: companyTenantID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: companyTenantID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) // 2. [Redundant Assignment] ALSO assign to the Logical Department Tenant (if exists) if leafID != companyTenantID { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: leafID, - Relation: "members", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: leafID, + Relation: "members", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } - + // 3. Assign ownership if leader if isOwner { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: leafID, - Relation: "owners", - Subject: "User:" + kratosID, + Namespace: "Tenant", + Object: leafID, + Relation: "owners", + Subject: "User:" + kratosID, Action: domain.KetoOutboxActionCreate, }) } @@ -315,26 +332,32 @@ func (s *orgChartService) cleanHeader(val string) string { func (s *orgChartService) findBestMatch(tempMap map[string]int, aliases []string) int { for _, alias := range aliases { ca := s.cleanHeader(alias) - if idx, ok := tempMap[ca]; ok { return idx } + if idx, ok := tempMap[ca]; ok { + return idx + } } for cleaned, idx := range tempMap { for _, alias := range aliases { ca := s.cleanHeader(alias) - if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { return idx } + if len(ca) >= 2 && (strings.Contains(cleaned, ca) || strings.Contains(ca, cleaned)) { + return idx + } } } return -1 } func (s *orgChartService) getVal(record []string, idx int) string { - if idx == -1 || idx >= len(record) { return "" } + if idx == -1 || idx >= len(record) { + return "" + } return strings.TrimSpace(record[idx]) } func (s *orgChartService) normalizePhone(phone string) string { normalized := strings.ReplaceAll(phone, "-", "") normalized = strings.ReplaceAll(normalized, " ", "") - + re := regexp.MustCompile(`[^0-9+]`) normalized = re.ReplaceAllString(normalized, "") @@ -354,13 +377,15 @@ func (s *orgChartService) normalizePhone(phone string) string { } return "+82" + normalized } - + return normalized } func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) { data, err := io.ReadAll(r) - if err != nil { return nil, err } + if err != nil { + return nil, err + } reader := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")))) reader.LazyQuotes = true reader.FieldsPerRecord = -1 @@ -369,11 +394,15 @@ func (s *orgChartService) readCSV(r io.Reader) ([][]string, error) { func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) { f, err := excelize.OpenReader(r) - if err != nil { return nil, err } + if err != nil { + return nil, err + } defer f.Close() var allRecords [][][]string for _, sheet := range f.GetSheetList() { - if rows, err := f.GetRows(sheet); err == nil { allRecords = append(allRecords, rows) } + if rows, err := f.GetRows(sheet); err == nil { + allRecords = append(allRecords, rows) + } } return allRecords, nil } @@ -381,18 +410,22 @@ func (s *orgChartService) readAllXLSXSheets(r io.Reader) ([][][]string, error) { func (s *orgChartService) generateCompanySlug(name string) string { n := strings.ToLower(whitespaceRegex.ReplaceAllString(name, "")) slugs := map[string]string{ - "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", + "한맥": "hanmac", "삼안": "saman", "장헌": "jangheon", "ptc": "ptc", "피티씨": "ptc", "바론": "baron", "한라": "halla", } for k, v := range slugs { - if strings.Contains(n, k) || strings.Contains(n, v) { return v } + if strings.Contains(n, k) || strings.Contains(n, v) { + return v + } } return utils.GenerateSlug(name) } func isAlphaNumeric(s string) bool { for _, r := range s { - if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { return false } + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return false + } } return true } @@ -411,8 +444,10 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, } cacheKey := "company:" + slug - if id, ok := cache[cacheKey]; ok { return id, nil } - + if id, ok := cache[cacheKey]; ok { + return id, nil + } + tenant, _ := s.tenantRepo.FindBySlug(ctx, slug) if tenant == nil { tenant, _ = s.tenantRepo.FindByName(ctx, name) @@ -420,17 +455,23 @@ func (s *orgChartService) ensureCompanyTenant(ctx context.Context, rootID, name, if tenant == nil { tenant = &domain.Tenant{ID: uuid.NewString(), Name: name, Slug: slug, Type: domain.TenantTypeCompany, Status: domain.TenantStatusActive, ParentID: &rootID} - if err := s.tenantRepo.Create(ctx, tenant); err != nil { return "", err } + if err := s.tenantRepo.Create(ctx, tenant); err != nil { + return "", err + } if s.ketoOutboxRepo != nil { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + rootID, Action: domain.KetoOutboxActionCreate}) } res.TenantCreated++ } - + domainPart := "" - if parts := strings.Split(email, "@"); len(parts) == 2 { domainPart = parts[1] } - if domainPart != "" { _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) } - + if parts := strings.Split(email, "@"); len(parts) == 2 { + domainPart = parts[1] + } + if domainPart != "" { + _ = s.tenantRepo.AddDomain(ctx, tenant.ID, domainPart, true) + } + cache[cacheKey] = tenant.ID return tenant.ID, nil } @@ -440,12 +481,19 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string currentPath := "" for i, part := range parts { part = strings.TrimSpace(part) - if part == "" || part == "-" { continue } - if currentPath == "" { currentPath = part } else { currentPath += "/" + part } - + if part == "" || part == "-" { + continue + } + if currentPath == "" { + currentPath = part + } else { + currentPath += "/" + part + } + cacheKey := rootTenantID + ":" + currentPath if id, ok := cache[cacheKey]; ok { - currentParentID = id; continue + currentParentID = id + continue } var existingID string @@ -454,7 +502,8 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string isTopMatch := (g.ParentID == nil && currentParentID == rootTenantID) isSubMatch := (g.ParentID != nil && *g.ParentID == currentParentID) if g.Name == part && (isTopMatch || isSubMatch) { - existingID = g.ID; break + existingID = g.ID + break } } } @@ -464,16 +513,16 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string groupSlug := fmt.Sprintf("ug-%s", existingID[:13]) if err := s.tenantRepo.Create(ctx, &domain.Tenant{ - ID: existingID, - Type: domain.TenantTypeUserGroup, - ParentID: ¤tParentID, - Name: part, - Slug: groupSlug, + ID: existingID, + Type: domain.TenantTypeUserGroup, + ParentID: ¤tParentID, + Name: part, + Slug: groupSlug, Status: domain.TenantStatusActive, }); err != nil { return "", err } - + var ugParentID *string if currentParentID != rootTenantID { pid := currentParentID @@ -481,10 +530,10 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string } if err := s.userGroupRepo.Create(ctx, &domain.UserGroup{ - ID: existingID, - TenantID: rootTenantID, - ParentID: ugParentID, - Name: part, + ID: existingID, + TenantID: rootTenantID, + ParentID: ugParentID, + Name: part, UnitType: s.guessUnitType(i, len(parts)), }); err != nil { return "", err @@ -501,7 +550,11 @@ func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string } func (s *orgChartService) guessUnitType(index, total int) string { - if total == 1 { return "Team" } - if index == 0 { return "Division" } + if total == 1 { + return "Team" + } + if index == 0 { + return "Division" + } return "Team" } diff --git a/backend/internal/service/org_chart_service_test.go b/backend/internal/service/org_chart_service_test.go index 32b685c5..17f31790 100644 --- a/backend/internal/service/org_chart_service_test.go +++ b/backend/internal/service/org_chart_service_test.go @@ -1,12 +1,12 @@ package service import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" "bytes" "context" "testing" - "baron-sso-backend/internal/domain" - "baron-sso-backend/internal/repository" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/xuri/excelize/v2" @@ -241,9 +241,11 @@ func TestImportOrgChart_MessyHeader(t *testing.T) { func (m *mockKratosService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) { return nil, nil } + func (m *mockKratosService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) { return nil, nil } + func (m *mockKratosService) DeleteSession(ctx context.Context, sessionID string) error { return nil } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 8ef748ee..8ba6a0a0 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -104,9 +104,15 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([ adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID) idMap := make(map[string]bool) - for _, id := range memberIDs { idMap[id] = true } - for _, id := range ownerIDs { idMap[id] = true } - for _, id := range adminIDs { idMap[id] = true } + for _, id := range memberIDs { + idMap[id] = true + } + for _, id := range ownerIDs { + idMap[id] = true + } + for _, id := range adminIDs { + idMap[id] = true + } allIDs := make([]string, 0, len(idMap)) for id := range idMap { diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index d0ba28d0..1910fa4c 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -6,8 +6,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; -const baseURL = - process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; /** * Read environment variables from file. diff --git a/devfront/src/app/routes.tsx b/devfront/src/app/routes.tsx index fdd27597..18272285 100644 --- a/devfront/src/app/routes.tsx +++ b/devfront/src/app/routes.tsx @@ -34,7 +34,10 @@ export const router = createBrowserRouter( { path: "clients/:id", element: }, { path: "clients/:id/consents", element: }, { path: "clients/:id/settings", element: }, - { path: "clients/:id/relationships", 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 728b80a3..0de25b12 100644 --- a/devfront/src/features/clients/ClientConsentsPage.tsx +++ b/devfront/src/features/clients/ClientConsentsPage.tsx @@ -465,10 +465,18 @@ function ClientConsentsPage() { {filteredRows.length === 0 && !isLoading && !error ? ( - +
-

{t("msg.dev.clients.consents.empty", "No consents found.")}

+

+ {t( + "msg.dev.clients.consents.empty", + "No consents found.", + )} +

diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index 64967498..23be2dfb 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -110,7 +110,8 @@ function ClientRelationsPage() { if (isSuperAdmin) return true; if (!relationData?.items || !myUserId) return false; return relationData.items.some( - (item) => item.subject === `User:${myUserId}` && item.relation === "admins" + (item) => + item.subject === `User:${myUserId}` && item.relation === "admins", ); }, [relationData?.items, myUserId, isSuperAdmin]); @@ -664,7 +665,9 @@ function ClientRelationsPage() { variant="ghost" size="sm" className="gap-2 text-destructive hover:text-destructive" - disabled={removeMutation.isPending || !canManageRelations} + disabled={ + removeMutation.isPending || !canManageRelations + } onClick={() => handleRemove(item.relation, item.subject) } diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts index 5ddeee3b..f1414d2a 100644 --- a/devfront/tests/devfront-relationships.spec.ts +++ b/devfront/tests/devfront-relationships.spec.ts @@ -36,6 +36,14 @@ test.describe("DevFront relationships", () => { ], relations: { "client-rel": [ + { + relation: "admins", + subject: "User:playwright-user", + subjectType: "User", + subjectId: "playwright-user", + userName: "Playwright User", + userEmail: "playwright@example.com", + }, { relation: "config_editor", subject: "User:user-1", @@ -67,13 +75,16 @@ test.describe("DevFront relationships", () => { await page.getByLabel(/동의 조회/).check(); await page.getByRole("button", { name: /^추가$/ }).click(); - await expect(page.getByText("User:user-2")).toBeVisible(); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(3); + await expect( + page.locator("tr").filter({ hasText: "User:user-2" }).first(), + ).toBeVisible(); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(4); await page .locator("tr") .filter({ hasText: "User:user-2" }) .getByRole("button", { name: /Delete|삭제/i }) + .first() .click(); await expect diff --git a/devfront/tests/devfront-role-switch-report.spec.ts b/devfront/tests/devfront-role-switch-report.spec.ts index 7b0066a3..073aa617 100644 --- a/devfront/tests/devfront-role-switch-report.spec.ts +++ b/devfront/tests/devfront-role-switch-report.spec.ts @@ -32,7 +32,9 @@ test.describe("DevFront role report", () => { page.getByText(/조회 가능한 RP가 없습니다|No RPs are available/i), ).toBeVisible(); await expect( - page.getByText(/연동 앱|Connected Application/i), + page.getByRole("heading", { + name: /^연동 앱$|^Connected Application$/i, + }), ).toBeVisible(); await captureEvidence(page, testInfo, "role-user-empty-rps"); }); diff --git a/locales/en.toml b/locales/en.toml index 5fb0d6fe..41a88adf 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -497,6 +497,9 @@ rp_admin = "RP administrators can only access resources for the apps they manage tenant_admin = "Tenant administrator permissions are not configured correctly or have expired." title = "Access Denied: {{resource}}" user = "Regular users cannot access the developer console." +user.audit = "Viewing audit logs for this App (RP) is only available when granted 'RP Admin' or 'Audit View' relationships. If you need access, please request it from an administrator." +user.clients = "General user accounts can only use this feature if they have been granted operational or management relationships for the relevant RP (App). If you need access, please request it from an administrator." +user.consents = "Viewing consent history for this App (RP) is only available when granted 'RP Admin', 'Consent View', or 'Consent Revoke' relationships. If you need access, please request it from an administrator." [msg.dev.sidebar] notice = "Developer Console" diff --git a/locales/ko.toml b/locales/ko.toml index 55bcbd9f..ca1e67d1 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -899,6 +899,9 @@ rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니 tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." title = "{{resource}} 접근 권한 없음" user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +user.audit = "해당 앱(RP)에 대한 감사 로그 조회는 'RP 관리자', '감사 조회' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." +user.clients = "일반 사용자 계정은 담당 RP(앱)에 대한 운영 또는 관리 관계가 부여된 경우에만 해당 기능을 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." +user.consents = "해당 앱(RP)에 대한 동의 내역 조회는 'RP 관리자', '동의 조회', '동의 회수' 관계가 부여된 경우에만 사용할 수 있습니다. 권한이 필요하면 관리자에게 요청하세요." [msg.dev.sidebar] notice = "개발자 전용 콘솔입니다." diff --git a/locales/template.toml b/locales/template.toml index 93634bfe..2ca4f152 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -774,6 +774,9 @@ rp_admin = "" tenant_admin = "" title = "" user = "" +user.audit = "" +user.clients = "" +user.consents = "" [msg.dev.sidebar] notice = "" diff --git a/scripts/run_adminfront_ci_tests.sh b/scripts/run_adminfront_ci_tests.sh index 17a89245..fe56cfc3 100755 --- a/scripts/run_adminfront_ci_tests.sh +++ b/scripts/run_adminfront_ci_tests.sh @@ -70,9 +70,11 @@ if [ "$provision_exit_code" -ne 0 ]; then fi set +e +port="$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")" +echo "==> adminfront using PORT=$port" ( cd adminfront - npm test + PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" npm test ) 2>&1 | tee reports/adminfront-test.log test_exit_code=${PIPESTATUS[0]} set -e