} (как 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:
+ *
+ * - {@link #APPROVER} {@value APPROVER} — gate на approve / reject /
+ * request_changes для record AND schema drafts. Single role для
+ * всего decide layer'а — easier to assign в Keycloak (one role
+ * grants both review workflows).
+ * - {@link #PUBLISHER} {@value PUBLISHER} — gate на publish approved
+ * schema draft. У records publish auto-выполняется при approve,
+ * отдельной publish step нет.
+ *
+ *
+ * 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);
+ }
+}