From 36c06ae1da52ebf515eaa5227c942f8343071188 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 13 May 2026 13:17:54 +0300 Subject: [PATCH] feat(auth): WorkflowRoles configurable claim path + ordinis:admin super-role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: token has roles в resource_access.ordinis.roles (client-scoped), backend WorkflowRoles читал hardcoded realm_access.roles → forbidden_role для всех reviewer actions. Backend: - WorkflowRoles: depends OrdinisAuthProperties → reads via rolesClaim path (же что ScopeContext). Default realm_access.roles, на staging/prod env var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles (уже wired в helm helper). - Nested claim path resolver — mirror ScopeContext.readClaimByPath - New super-role ADMIN ('ordinis:admin') — holder автоматически проходит любой workflow check без явного назначения отдельных ролей. Convenience для small teams. Frontend (usePermissions.ts): - tokenRoles() читает оба claim path (realm_access.roles + resource_access.ordinis.roles) и объединяет — UI gate работает независимо от backend config - hasRole() recognizes 'ordinis:admin' как super-role override - ROLE_ADMIN exported Docs: - docs/operator/rbac.md: добавлен ordinis:admin row, JWT claim path note, Admin profile updated (super-role vs composite explanation) --- docs/operator/rbac.md | 9 +- ordinis-admin-ui/src/auth/usePermissions.ts | 39 +++++++-- .../ordinis/restapi/auth/WorkflowRoles.java | 84 +++++++++++++++---- 3 files changed, 108 insertions(+), 24 deletions(-) diff --git a/docs/operator/rbac.md b/docs/operator/rbac.md index 2455ed9..6966b63 100644 --- a/docs/operator/rbac.md +++ b/docs/operator/rbac.md @@ -28,9 +28,12 @@ Ordinis использует **Keycloak realm roles** (claim `realm_access.roles | `ordinis:schema:reviewer` | Approve / Запросить правки / Отклонить **schema draft** | | `ordinis:schema:publisher` | Publish approved schema draft → live | | `ordinis:record:reviewer` | Approve / Reject **record draft** (Approval Workflow v2) | +| `ordinis:admin` | **Super-role** — автоматически разрешает любой workflow action. Convenience для small teams: assign one role вместо трёх. | Backend проверяет роль перед каждым действием. Без роли — `403 forbidden_role` с сообщением о требуемой роли. Frontend дополнительно скрывает кнопки, для которых нет роли. +> **JWT claim path:** на staging/prod backend читает роли из `resource_access.ordinis.roles` (client-scoped roles в Keycloak `ordinis` client). Default fallback — `realm_access.roles`. Настраивается через `ORDINIS_AUTH_ROLES_CLAIM` env var. + > **Maker side** (создание / submit / withdraw draft'а) специальной ролью не gated — любой authenticated user с required scope может создать draft. Backend защищает invariant maker-checker через `403 self_approve_forbidden` (один и тот же user не может approve свой draft). ## Полная матрица действий @@ -78,12 +81,16 @@ Backend проверяет роль перед каждым действием. Финальный gatekeeper для production публикации схем. Обычно более узкая группа чем reviewers. ### Admin (полный доступ) + +**Простой путь** — single role `ordinis:admin` (super-role): backend и frontend оба recognize её как override для любого workflow check'а. Достаточно одного назначения. + +**Канонический путь (composite в Keycloak)** — `ordinis:admin` как composite, агрегирующий: - `ordinis:client:restricted` - `ordinis:schema:reviewer` - `ordinis:schema:publisher` - `ordinis:record:reviewer` -Или **composite role** `ordinis:admin`, которая агрегирует все четыре. +Composite-роль в Keycloak автоматически expands в JWT — token содержит все включённые роли. Это работает и без super-role override. ## Настройка в Keycloak diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 49c7117..33171ec 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -33,19 +33,42 @@ import { useAuth } from 'react-oidc-context' export const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer' export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher' export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer' +export const ROLE_ADMIN = 'ordinis:admin' -/** Достаёт realm roles из JWT (Keycloak claim path: realm_access.roles). */ -function realmRoles(profile: unknown): string[] { +/** + * Достаёт roles из JWT. Поддерживает оба claim path: + * - {@code realm_access.roles} — Keycloak realm-роли + * - {@code resource_access.ordinis.roles} — Keycloak client-роли + * + *

Backend читает один из двух через {@code OrdinisAuthProperties.rolesClaim} + * (default {@code realm_access.roles}, на prod/staging + * {@code resource_access.ordinis.roles}). Frontend читает оба и объединяет + * — UI gate должен работать независимо от config'а. + */ +function tokenRoles(profile: unknown): string[] { if (!profile || typeof profile !== 'object') return [] - const realmAccess = (profile as { realm_access?: { roles?: unknown } }).realm_access - if (!realmAccess || typeof realmAccess !== 'object') return [] - const roles = (realmAccess as { roles?: unknown }).roles - if (!Array.isArray(roles)) return [] - return roles.filter((r): r is string => typeof r === 'string') + const p = profile as { + realm_access?: { roles?: unknown } + resource_access?: { ordinis?: { roles?: unknown } } + } + const fromRealm = Array.isArray(p.realm_access?.roles) + ? (p.realm_access.roles as unknown[]).filter((r): r is string => typeof r === 'string') + : [] + const fromClient = Array.isArray(p.resource_access?.ordinis?.roles) + ? (p.resource_access.ordinis.roles as unknown[]).filter( + (r): r is string => typeof r === 'string', + ) + : [] + return [...fromRealm, ...fromClient] } +/** + * True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}. + * Admin grants full workflow access — convenience override для small teams. + */ function hasRole(profile: unknown, role: string): boolean { - return realmRoles(profile).includes(role) + const roles = tokenRoles(profile) + return roles.includes(role) || roles.includes(ROLE_ADMIN) } /** Может ли актор approve / request_changes / reject schema draft. */ diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java index b67ab06..9d82e83 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java @@ -1,5 +1,6 @@ package cloud.nstart.terravault.ordinis.restapi.auth; +import cloud.nstart.terravault.ordinis.auth.OrdinisAuthProperties; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,6 +11,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut import org.springframework.stereotype.Component; import java.util.Collection; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -35,8 +37,12 @@ import java.util.Set; * {@code self_approve_forbidden} (maker != reviewer) как primary defence * для maker-checker invariant. * - *

JWT источник: {@code realm_access.roles} claim (Keycloak realm roles). - * Совместимо с {@code ScopeContext} JWT parsing pattern. + *

JWT источник: configurable claim path через + * {@link OrdinisAuthProperties#rolesClaim()} — тот же что у + * {@code ScopeContext}. Default {@code realm_access.roles} (Keycloak realm + * roles). Можно настроить на {@code resource_access.ordinis.roles} если + * Keycloak realm использует client-scoped roles (mapped к {@code ordinis} + * client). * *

Migration note (Phase 1d enforcement): Keycloak realm должен * иметь созданные роли, и users должны их получить. Иначе все decide / @@ -59,18 +65,44 @@ public class WorkflowRoles { /** Approve / reject record draft (Approval Workflow v2). */ public static final String RECORD_REVIEWER = "ordinis:record:reviewer"; - /** Read realm_access.roles из current request JWT. Anonymous → empty. */ + /** + * Super-role: holder автоматически проходит любой role check без явного + * назначения отдельных workflow roles. Convenience для small teams где + * один-два user'а имеют full access — assign one role вместо трёх. + * + *

Composite role в Keycloak — лучше; super-role override — fallback + * для cases где composite ещё не настроен. + */ + public static final String ADMIN = "ordinis:admin"; + + private final OrdinisAuthProperties props; + + public WorkflowRoles(OrdinisAuthProperties props) { + this.props = props; + } + + /** Test ctor — без OrdinisAuthProperties (defaults to realm_access.roles). */ + WorkflowRoles() { + this.props = null; + } + + /** Reads roles из configured claim path. Anonymous → empty. */ public Set currentRoles() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (!(auth instanceof JwtAuthenticationToken jwt)) { return Set.of(); } - return extractRoles(jwt.getToken()); + return extractRoles(jwt.getToken(), rolesClaimPath()); } - /** True если у actor'а есть указанная realm role. */ + /** + * True если у actor'а есть указанная workflow role ИЛИ super-role + * {@link #ADMIN}. Admin грантит full workflow access без явного + * назначения отдельных reviewer/publisher ролей. + */ public boolean hasRole(String role) { - return currentRoles().contains(role); + Set roles = currentRoles(); + return roles.contains(role) || roles.contains(ADMIN); } /** @@ -79,23 +111,26 @@ public class WorkflowRoles { */ public void requireRole(String role) { if (!hasRole(role)) { - log.debug("Access denied: missing required realm role '{}'", role); + log.debug("Access denied: missing required workflow role '{}' at claim path '{}'", + role, rolesClaimPath()); throw OrdinisException.forbidden( "forbidden_role", "Требуется realm role: " + role); } } - static Set extractRoles(Jwt token) { - Object realmAccessObj = token.getClaim("realm_access"); - if (!(realmAccessObj instanceof Map realmAccess)) { + private String rolesClaimPath() { + return props != null ? props.rolesClaim() : "realm_access.roles"; + } + + /** Reads JWT claim по nested path (e.g. "realm_access.roles" or + * "resource_access.ordinis.roles") и возвращает roles как Set. */ + static Set extractRoles(Jwt token, String path) { + Object current = readClaimByPath(token, path); + if (!(current instanceof Collection rolesCol)) { return Set.of(); } - Object rolesObj = realmAccess.get("roles"); - if (!(rolesObj instanceof Collection rolesCol)) { - return Set.of(); - } - java.util.LinkedHashSet out = new java.util.LinkedHashSet<>(); + LinkedHashSet out = new LinkedHashSet<>(); for (Object o : rolesCol) { if (o instanceof String s) { out.add(s); @@ -103,4 +138,23 @@ public class WorkflowRoles { } return out; } + + /** Default-path overload для тестов и backward compat. */ + static Set extractRoles(Jwt token) { + return extractRoles(token, "realm_access.roles"); + } + + /** Nested JWT claim resolver. Mirror {@code ScopeContext.readClaimByPath}. */ + private static Object readClaimByPath(Jwt token, String path) { + String[] parts = path.split("\\."); + Object current = token.getClaim(parts[0]); + for (int i = 1; i < parts.length && current != null; i++) { + if (current instanceof Map m) { + current = m.get(parts[i]); + } else { + return null; + } + } + return current; + } }