diff --git a/.gitignore b/.gitignore
index 2bdc03d..ec6bf16 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@ HELP.md
# Sensitive
*.pem
*.key
+.gstack/
diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts
index 10ecc4b..49c7117 100644
--- a/ordinis-admin-ui/src/auth/usePermissions.ts
+++ b/ordinis-admin-ui/src/auth/usePermissions.ts
@@ -4,10 +4,9 @@ import { useAuth } from 'react-oidc-context'
* 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}.
+ * Naming convention: colon-separated {@code ordinis::}
+ * pattern (consistent с scope ACL {@code ordinis:client:public}). Backend
+ * parser в {@code WorkflowRoles.java}.
*
* Backend enforcement (см. {@code WorkflowRoles}):
*
@@ -21,13 +20,19 @@ import { useAuth } from 'react-oidc-context'
* backend всё равно 403'нет на прямой API call.
*
* Migration note (Phase 1d enforcement): Keycloak realm `nstart`
- * должен иметь созданные роли {@code ordinis:approver} и
- * {@code ordinis:publisher}, и actual reviewer-users должны их получить.
- * До этого все decide / publish операции вернут {@code 403 forbidden_role}.
+ * должен иметь созданные роли:
+ *
+ * - {@code ordinis:schema:reviewer} — schema decide
+ * - {@code ordinis:schema:publisher} — schema publish
+ * - {@code ordinis:record:reviewer} — record approve/reject
+ *
+ * и actual reviewer-users должны их получить. До этого все decide /
+ * publish операции вернут {@code 403 forbidden_role}.
*/
-export const ROLE_APPROVER = 'ordinis:approver'
-export const ROLE_PUBLISHER = 'ordinis:publisher'
+export const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer'
+export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher'
+export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer'
/** Достаёт realm roles из JWT (Keycloak claim path: realm_access.roles). */
function realmRoles(profile: unknown): string[] {
@@ -43,19 +48,22 @@ function hasRole(profile: unknown, role: string): boolean {
return realmRoles(profile).includes(role)
}
-/** Может ли актор approve / request_changes / reject schema OR record draft. */
+/** Может ли актор approve / request_changes / reject schema draft. */
export function useCanReviewSchema(): boolean {
const auth = useAuth()
- return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_APPROVER)
+ return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_REVIEWER)
}
-/** Universal alias — то же что {@link useCanReviewSchema}. */
-export const useCanReviewDraft = useCanReviewSchema
+/** Может ли актор approve / reject record draft. */
+export function useCanReviewRecord(): boolean {
+ const auth = useAuth()
+ return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_RECORD_REVIEWER)
+}
/** Может ли актор publish approved schema draft (bump live version). */
export function useCanPublishSchema(): boolean {
const auth = useAuth()
- return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_PUBLISHER)
+ return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_PUBLISHER)
}
/**
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 7cf33ea..b67ab06 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
@@ -17,16 +17,17 @@ 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:
+ *
Naming convention: colon-separated {@code ordinis::}
+ * pattern (consistent с scope ACL {@code ordinis:client:public}).
+ * Domain-specific роли для clear separation между record и schema workflows:
*
- * - {@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 нет.
+ * - {@link #SCHEMA_REVIEWER} {@value SCHEMA_REVIEWER} — gate на
+ * schema draft decide (approve / request_changes / reject).
+ * - {@link #SCHEMA_PUBLISHER} {@value SCHEMA_PUBLISHER} — gate на
+ * publish approved schema draft → live. У records publish
+ * auto-выполняется при approve, отдельной step'и нет.
+ * - {@link #RECORD_REVIEWER} {@value RECORD_REVIEWER} — gate на
+ * record draft approve / reject (Approval Workflow v2).
*
*
* Maker side (create / submit / withdraw) пока не role-gated — любой
@@ -37,21 +38,26 @@ import java.util.Set;
*
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}.
+ *
Migration note (Phase 1d enforcement): Keycloak realm должен
+ * иметь созданные роли, и users должны их получить. Иначе все decide /
+ * publish операции вернут {@code 403 forbidden_role}.
+ *
+ *
Composite role idea: {@code ordinis:admin} = combined из всех трёх
+ * + scope role'ов — single assignment grants full workflow access.
*/
@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";
+ /** Approve / reject / request_changes schema draft (Phase 1c workflow). */
+ public static final String SCHEMA_REVIEWER = "ordinis:schema:reviewer";
- /** Publish approved schema draft → live (bump dictionary live version). */
- public static final String PUBLISHER = "ordinis:publisher";
+ /** Publish approved schema draft → live (bump dictionary schema version). */
+ public static final String SCHEMA_PUBLISHER = "ordinis:schema:publisher";
+
+ /** 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. */
public Set currentRoles() {
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 bb7bbf8..5e65ad1 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
@@ -223,8 +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);
+ // Phase 1d: realm role gate (record-specific 'ordinis:record:reviewer').
+ requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
String reviewerId = currentUserId();
RecordDraft draft = findById(draftId);
if (draft.getStatus() != DraftStatus.PENDING) {
@@ -273,7 +273,7 @@ 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);
+ requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
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 1700f26..deb3602 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
@@ -244,9 +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);
+ // Phase 1d: realm role gate. Без 'ordinis:schema:reviewer' actor
+ // получит 403 forbidden_role до даже status checks — fail-fast.
+ requireRoleIfEnforced(WorkflowRoles.SCHEMA_REVIEWER);
DictionarySchemaDraft draft = findById(draftId);
String reviewerId = currentUserId();
@@ -308,10 +308,9 @@ 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);
+ // Phase 1d: publish — отдельная role от reviewer'а. Publishers
+ // обычно более узкая группа (production gatekeepers) чем reviewers.
+ requireRoleIfEnforced(WorkflowRoles.SCHEMA_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
index e24718f..d2ae2f8 100644
--- 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
@@ -36,50 +36,50 @@ class WorkflowRolesTest {
@Test
void anonymous_hasRole_false() {
SecurityContextHolder.clearContext();
- assertThat(roles.hasRole(WorkflowRoles.APPROVER)).isFalse();
+ assertThat(roles.hasRole(WorkflowRoles.SCHEMA_REVIEWER)).isFalse();
}
@Test
void jwtWithApprover_hasRole_true() {
- setJwtWithRealmRoles(List.of(WorkflowRoles.APPROVER));
- assertThat(roles.hasRole(WorkflowRoles.APPROVER)).isTrue();
- assertThat(roles.hasRole(WorkflowRoles.PUBLISHER)).isFalse();
+ setJwtWithRealmRoles(List.of(WorkflowRoles.SCHEMA_REVIEWER));
+ assertThat(roles.hasRole(WorkflowRoles.SCHEMA_REVIEWER)).isTrue();
+ assertThat(roles.hasRole(WorkflowRoles.SCHEMA_PUBLISHER)).isFalse();
}
@Test
void jwtWithMultipleRoles_currentRoles_contains() {
setJwtWithRealmRoles(List.of(
- WorkflowRoles.APPROVER,
- WorkflowRoles.PUBLISHER,
+ WorkflowRoles.SCHEMA_REVIEWER,
+ WorkflowRoles.SCHEMA_PUBLISHER,
"ordinis:client:restricted",
"some-other-role"));
assertThat(roles.currentRoles())
.containsExactlyInAnyOrder(
- WorkflowRoles.APPROVER,
- WorkflowRoles.PUBLISHER,
+ WorkflowRoles.SCHEMA_REVIEWER,
+ WorkflowRoles.SCHEMA_PUBLISHER,
"ordinis:client:restricted",
"some-other-role");
}
@Test
void requireRole_present_noThrow() {
- setJwtWithRealmRoles(List.of(WorkflowRoles.APPROVER));
- roles.requireRole(WorkflowRoles.APPROVER);
+ setJwtWithRealmRoles(List.of(WorkflowRoles.SCHEMA_REVIEWER));
+ roles.requireRole(WorkflowRoles.SCHEMA_REVIEWER);
// no exception — passes
}
@Test
void requireRole_missing_throws403() {
setJwtWithRealmRoles(List.of("some-other-role"));
- assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.PUBLISHER))
+ assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.SCHEMA_PUBLISHER))
.isInstanceOf(OrdinisException.class)
- .hasMessageContaining(WorkflowRoles.PUBLISHER);
+ .hasMessageContaining(WorkflowRoles.SCHEMA_PUBLISHER);
}
@Test
void requireRole_anonymous_throws() {
SecurityContextHolder.clearContext();
- assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.APPROVER))
+ assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.SCHEMA_REVIEWER))
.isInstanceOf(OrdinisException.class);
}