From 24aa94d13007b7d2d9716a6afb7fdbc54a32d8ba Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Wed, 13 May 2026 12:36:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20Phase=201d=20=E2=80=94=20backend?= =?UTF-8?q?=20role=20enforcement=20=D0=B4=D0=BB=D1=8F=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User explicitly requested 'Phase 1d пора'. До этого MR'а backend гейтил только maker-checker (self_approve_forbidden), frontend hooks возвращали auth.isAuthenticated. Реализация: Backend WorkflowRoles (ordinis-rest-api/auth/WorkflowRoles.java): - Component reads realm_access.roles из JWT claim - Constants: ordinis:approver (decide/approve/reject), ordinis:publisher (publish) - requireRole(role) throws 403 forbidden_role если missing - Defensive parse: malformed/missing realm_access → empty roles - 9 unit tests cover authenticated + anonymous + malformed JWT Applied gates: - SchemaDraftService.decide() — require ordinis:approver - SchemaDraftService.publish() — require ordinis:publisher - DraftService.approve() — require ordinis:approver - DraftService.reject() — require ordinis:approver WorkflowRoles is Optional в test ctors → unit tests без auth setup не ломаются. Все existing tests должны проходить (legacy ctor overloads сохранены). Frontend usePermissions.ts: - Stub return auth.isAuthenticated заменён на real role decode из auth.user.profile.realm_access.roles - ROLE_APPROVER + ROLE_PUBLISHER constants exported - Defensive parsing — graceful с missing/malformed claims - useCanCreateSchemaDraft остаётся permissive (любой auth — maker'ом может быть anyone, scope ACL заведует видимостью) Naming convention — colon-separated (consistent с ordinis:client:* scope ролями), aligned с MR !161. MIGRATION (BREAKING без правильной Keycloak setup): 1. Keycloak realm 'nstart' → Realm Roles → Create: - ordinis:approver - ordinis:publisher 2. Users → assign roles: - reviewer/approver users → ordinis:approver - publisher users → ordinis:approver + ordinis:publisher 3. После refresh JWT (logout/login) роли появляются в realm_access.roles Без этого все decide/publish операции вернут 403 forbidden_role. --- ordinis-admin-ui/src/auth/usePermissions.ts | 79 +++++++----- .../ordinis/restapi/auth/WorkflowRoles.java | 100 +++++++++++++++ .../restapi/service/draft/DraftService.java | 21 +++- .../schemaworkflow/SchemaDraftService.java | 25 +++- .../restapi/auth/WorkflowRolesTest.java | 119 ++++++++++++++++++ 5 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRolesTest.java diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 961e7bc..10ecc4b 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -1,56 +1,69 @@ import { useAuth } from 'react-oidc-context' /** - * Permission helpers для schema workflow. Phase 1c — stub: проверяем - * только что пользователь authenticated (любой logged-in юзер может - * approve/publish). Backend всё равно отрежет: + * Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**: + * читает {@code realm_access.roles} из JWT и гейтит actions. + * + *

Naming convention: colon-separated namespace + * {@code ordinis::} consistent с scope ACL ролями + * ({@code ordinis:client:public}). Backend parser в + * {@code ScopeContext.java} / {@code WorkflowRoles.java}. + * + *

Backend enforcement (см. {@code WorkflowRoles}): *

* - *

Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и - * начнут возвращать false для users без {@code ordinis:schema:reviewer} - * / {@code ordinis:schema:publisher} ролей. UI hide'ит соответствующие - * кнопки. + *

Frontend hide'ит соответствующие кнопки на UI level для UX, backend + * гейт остаётся primary defence. Если user имеет роль в Keycloak — + * кнопки видны и действия пройдут backend; если не имеет — кнопки скрыты, + * backend всё равно 403'нет на прямой API call. * - *

Naming convention: roles используют colon-separated namespace - * pattern {@code ordinis::} (как Altum-style scope roles - * вида {@code ordinis:client:restricted}). Совпадает с realm role parser - * в {@code ScopeContext.java}. Dash-форма ({@code ordinis-schema-reviewer}) - * НЕ используется в этом проекте — colon-стиль consistent с уже-existing - * scope ролями ({@code ordinis:client:public|internal|restricted}). - * - *

До тех пор все logged-in юзеры могут жать любые workflow buttons — - * backend гейт остаётся primary defence. + *

Migration note (Phase 1d enforcement): Keycloak realm `nstart` + * должен иметь созданные роли {@code ordinis:approver} и + * {@code ordinis:publisher}, и actual reviewer-users должны их получить. + * До этого все decide / publish операции вернут {@code 403 forbidden_role}. */ -const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer' -const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher' -const ROLE_SCHEMA_MAKER = 'ordinis:schema:maker' +export const ROLE_APPROVER = 'ordinis:approver' +export const ROLE_PUBLISHER = 'ordinis:publisher' -/** Может ли актор approve / request_changes / reject schema draft. */ +/** Достаёт realm roles из JWT (Keycloak claim path: realm_access.roles). */ +function realmRoles(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') +} + +function hasRole(profile: unknown, role: string): boolean { + return realmRoles(profile).includes(role) +} + +/** Может ли актор approve / request_changes / reject schema OR record draft. */ export function useCanReviewSchema(): boolean { const auth = useAuth() - // TODO Phase 1d: const roles = (auth.user?.profile as { realm_access?: { roles?: string[] } })?.realm_access?.roles ?? [] - // return roles.includes(ROLE_SCHEMA_REVIEWER) - void ROLE_SCHEMA_REVIEWER - return auth.isAuthenticated + return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_APPROVER) } +/** Universal alias — то же что {@link useCanReviewSchema}. */ +export const useCanReviewDraft = useCanReviewSchema + /** Может ли актор publish approved schema draft (bump live version). */ export function useCanPublishSchema(): boolean { const auth = useAuth() - // TODO Phase 1d: roles.includes(ROLE_SCHEMA_PUBLISHER) - void ROLE_SCHEMA_PUBLISHER - return auth.isAuthenticated + return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_PUBLISHER) } -/** Может ли актор create schema draft (maker side). */ +/** + * Может ли актор create schema draft (maker side). Maker не gated + * специальной ролью — любой authenticated с required scope. Backend + * проверяет scope ACL для конкретного словаря. + */ export function useCanCreateSchemaDraft(): boolean { const auth = useAuth() - // TODO Phase 1d: roles.includes(ROLE_SCHEMA_MAKER) OR any authenticated - void ROLE_SCHEMA_MAKER return auth.isAuthenticated } 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 new file mode 100644 index 0000000..7cf33ea --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java @@ -0,0 +1,100 @@ +package cloud.nstart.terravault.ordinis.restapi.auth; + +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; + +/** + * Realm-role enforcement для workflow actions (approve/reject/publish + * как record draft, так и schema draft). + * + *

Naming convention: colon-separated namespace, тот же что у scope ACL + * ({@code ordinis:client:public}). Constants: + *

+ * + *

Maker side (create / submit / withdraw) пока не role-gated — любой + * authenticated user может создавать drafts. Backend гейтит + * {@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. + * + *

Migration note (Phase 1d enforcement): до deploy этой версии + * Keycloak realm должен иметь созданные роли {@code ordinis:approver} и + * {@code ordinis:publisher}, и actual reviewer-users должны их получить. + * Иначе все decide / publish операции вернут {@code 403 forbidden_role}. + */ +@Component +public class WorkflowRoles { + + private static final Logger log = LoggerFactory.getLogger(WorkflowRoles.class); + + /** Universal approver роль — gate на decide / approve / reject. */ + public static final String APPROVER = "ordinis:approver"; + + /** Publish approved schema draft → live (bump dictionary live version). */ + public static final String PUBLISHER = "ordinis:publisher"; + + /** Read realm_access.roles из current request JWT. Anonymous → empty. */ + public Set currentRoles() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (!(auth instanceof JwtAuthenticationToken jwt)) { + return Set.of(); + } + return extractRoles(jwt.getToken()); + } + + /** True если у actor'а есть указанная realm role. */ + public boolean hasRole(String role) { + return currentRoles().contains(role); + } + + /** + * Throw {@code 403 forbidden_role} если у actor'а нет role. + * Service-layer fail-fast guard. + */ + public void requireRole(String role) { + if (!hasRole(role)) { + log.debug("Access denied: missing required realm role '{}'", role); + throw OrdinisException.forbidden( + "forbidden_role", + "Требуется realm role: " + role); + } + } + + static Set extractRoles(Jwt token) { + Object realmAccessObj = token.getClaim("realm_access"); + if (!(realmAccessObj instanceof Map realmAccess)) { + return Set.of(); + } + Object rolesObj = realmAccess.get("roles"); + if (!(rolesObj instanceof Collection rolesCol)) { + return Set.of(); + } + java.util.LinkedHashSet out = new java.util.LinkedHashSet<>(); + for (Object o : rolesCol) { + if (o instanceof String s) { + out.add(s); + } + } + return out; + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java index fdf60d7..bb7bbf8 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -62,6 +62,8 @@ public class DraftService { private final ScopeContext scopeContext; /** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */ private final Optional meterRegistry; + /** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */ + private final cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles; @org.springframework.beans.factory.annotation.Autowired public DraftService( @@ -69,12 +71,14 @@ public class DraftService { DictionaryDefinitionService definitionService, DictionaryRecordService recordService, ScopeContext scopeContext, - Optional meterRegistry) { + Optional meterRegistry, + cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles) { this.draftRepo = draftRepo; this.definitionService = definitionService; this.recordService = recordService; this.scopeContext = scopeContext; this.meterRegistry = meterRegistry; + this.workflowRoles = workflowRoles; } /** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */ @@ -82,7 +86,7 @@ public class DraftService { RecordDraftRepository draftRepo, DictionaryDefinitionService definitionService, ScopeContext scopeContext) { - this(draftRepo, definitionService, null, scopeContext, Optional.empty()); + this(draftRepo, definitionService, null, scopeContext, Optional.empty(), null); } /** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */ @@ -91,7 +95,14 @@ public class DraftService { DictionaryDefinitionService definitionService, DictionaryRecordService recordService, ScopeContext scopeContext) { - this(draftRepo, definitionService, recordService, scopeContext, Optional.empty()); + this(draftRepo, definitionService, recordService, scopeContext, Optional.empty(), null); + } + + /** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */ + private void requireRoleIfEnforced(String role) { + if (workflowRoles != null) { + workflowRoles.requireRole(role); + } } /** @@ -212,6 +223,8 @@ public class DraftService { if (recordService == null) { throw new IllegalStateException("DictionaryRecordService not wired (test ctor used)."); } + // Phase 1d: realm role gate (universal 'ordinis:approver'). + requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.APPROVER); String reviewerId = currentUserId(); RecordDraft draft = findById(draftId); if (draft.getStatus() != DraftStatus.PENDING) { @@ -259,6 +272,8 @@ public class DraftService { */ @Transactional public RecordDraft reject(UUID draftId, String reviewComment) { + // Phase 1d: same role как approve — reviewer'ы могут оба действия. + requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.APPROVER); if (reviewComment == null || reviewComment.isBlank()) { throw OrdinisException.badRequest( "reject_reason_required", diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java index 3795670..1700f26 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java @@ -7,6 +7,7 @@ import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus; import cloud.nstart.terravault.ordinis.events.EventEnvelope; import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder; import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger; +import cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest; import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest; @@ -66,6 +67,8 @@ public class SchemaDraftService { private final ObjectMapper objectMapper; /** Optional — NULL в test ctor'е (без audit_log writes). */ private final AuditLogger auditLogger; + /** Optional — NULL в test ctor'е (skip role enforcement в unit tests). */ + private final WorkflowRoles workflowRoles; @org.springframework.beans.factory.annotation.Autowired public SchemaDraftService( @@ -73,21 +76,30 @@ public class SchemaDraftService { DictionaryDefinitionService definitionService, OutboxRecorder outboxRecorder, ObjectMapper objectMapper, - AuditLogger auditLogger) { + AuditLogger auditLogger, + WorkflowRoles workflowRoles) { this.repository = repository; this.definitionService = definitionService; this.outboxRecorder = outboxRecorder; this.objectMapper = objectMapper; this.auditLogger = auditLogger; + this.workflowRoles = workflowRoles; } - /** Test ctor — без audit_log. */ + /** Test ctor — без audit_log + без role enforcement. */ public SchemaDraftService( SchemaDraftRepository repository, DictionaryDefinitionService definitionService, OutboxRecorder outboxRecorder, ObjectMapper objectMapper) { - this(repository, definitionService, outboxRecorder, objectMapper, null); + this(repository, definitionService, outboxRecorder, objectMapper, null, null); + } + + /** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */ + private void requireRoleIfEnforced(String role) { + if (workflowRoles != null) { + workflowRoles.requireRole(role); + } } // ───────────────────────────────────────────────────────────────────── @@ -232,6 +244,9 @@ public class SchemaDraftService { */ @Transactional public DictionarySchemaDraft decide(UUID draftId, DecisionRequest req) { + // Phase 1d: realm role gate. Без 'ordinis:approver' actor получит + // 403 forbidden_role до даже status checks — fail-fast. + requireRoleIfEnforced(WorkflowRoles.APPROVER); DictionarySchemaDraft draft = findById(draftId); String reviewerId = currentUserId(); @@ -293,6 +308,10 @@ public class SchemaDraftService { */ @Transactional(isolation = Isolation.SERIALIZABLE) public DictionarySchemaDraft publish(UUID draftId, PublishDraftRequest req) { + // Phase 1d: publish — отдельная role от approver. У publisher могут + // быть более узкие пользователи (production gatekeepers), тогда как + // approvers более wide (любой senior reviewer). + requireRoleIfEnforced(WorkflowRoles.PUBLISHER); DictionarySchemaDraft draft = findById(draftId); DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRolesTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRolesTest.java new file mode 100644 index 0000000..e24718f --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRolesTest.java @@ -0,0 +1,119 @@ +package cloud.nstart.terravault.ordinis.restapi.auth; + +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests для {@link WorkflowRoles} — realm role extraction из JWT + * claim {@code realm_access.roles} + enforcement через requireRole(). + */ +class WorkflowRolesTest { + + private final WorkflowRoles roles = new WorkflowRoles(); + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void anonymous_currentRoles_empty() { + SecurityContextHolder.clearContext(); + assertThat(roles.currentRoles()).isEmpty(); + } + + @Test + void anonymous_hasRole_false() { + SecurityContextHolder.clearContext(); + assertThat(roles.hasRole(WorkflowRoles.APPROVER)).isFalse(); + } + + @Test + void jwtWithApprover_hasRole_true() { + setJwtWithRealmRoles(List.of(WorkflowRoles.APPROVER)); + assertThat(roles.hasRole(WorkflowRoles.APPROVER)).isTrue(); + assertThat(roles.hasRole(WorkflowRoles.PUBLISHER)).isFalse(); + } + + @Test + void jwtWithMultipleRoles_currentRoles_contains() { + setJwtWithRealmRoles(List.of( + WorkflowRoles.APPROVER, + WorkflowRoles.PUBLISHER, + "ordinis:client:restricted", + "some-other-role")); + assertThat(roles.currentRoles()) + .containsExactlyInAnyOrder( + WorkflowRoles.APPROVER, + WorkflowRoles.PUBLISHER, + "ordinis:client:restricted", + "some-other-role"); + } + + @Test + void requireRole_present_noThrow() { + setJwtWithRealmRoles(List.of(WorkflowRoles.APPROVER)); + roles.requireRole(WorkflowRoles.APPROVER); + // no exception — passes + } + + @Test + void requireRole_missing_throws403() { + setJwtWithRealmRoles(List.of("some-other-role")); + assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.PUBLISHER)) + .isInstanceOf(OrdinisException.class) + .hasMessageContaining(WorkflowRoles.PUBLISHER); + } + + @Test + void requireRole_anonymous_throws() { + SecurityContextHolder.clearContext(); + assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.APPROVER)) + .isInstanceOf(OrdinisException.class); + } + + @Test + void jwtWithoutRealmAccess_emptyRoles() { + setJwtWithClaims(Map.of("sub", "user-1")); // no realm_access claim + assertThat(roles.currentRoles()).isEmpty(); + } + + @Test + void jwtWithMalformedRealmAccess_emptyRoles() { + setJwtWithClaims(Map.of( + "sub", "user-1", + "realm_access", "not-a-map")); // bad type — defensive parse + assertThat(roles.currentRoles()).isEmpty(); + } + + // ─── helpers ───────────────────────────────────────────────────────── + + private void setJwtWithRealmRoles(List realmRoles) { + setJwtWithClaims(Map.of( + "sub", "test-user", + "realm_access", Map.of("roles", realmRoles))); + } + + private void setJwtWithClaims(Map claims) { + Jwt jwt = new Jwt( + "test-token", + Instant.now(), + Instant.now().plusSeconds(600), + Map.of("alg", "RS256"), + claims); + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + auth.setAuthenticated(true); + SecurityContextHolder.getContext().setAuthentication(auth); + } +}