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 c81f27f..252c4c2 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 @@ -104,12 +104,41 @@ public class WorkflowRoles { /** * True если у actor'а есть указанная workflow role ИЛИ super-role - * {@link #ADMIN}. Admin грантит full workflow access без явного - * назначения отдельных reviewer/publisher ролей. + * (любая admin-эквивалентная — см. {@link #isAdminRole}). + * Admin грантит full workflow access без явного назначения отдельных + * reviewer/publisher ролей. */ public boolean hasRole(String role) { Set roles = currentRoles(); - return roles.contains(role) || roles.contains(ADMIN); + if (roles.contains(role)) return true; + return isAdmin(); + } + + /** + * True если у actor'а есть admin-эквивалентная роль. Match'ит: + * + * + *

Зеркало backend {@code ScopeContext.isAdminRole} — что считаем admin + * для scope hierarchy expansion, то же считаем admin для workflow override. + */ + public boolean isAdmin() { + return currentRoles().stream().anyMatch(WorkflowRoles::isAdminRole); + } + + /** Tail-match: true если role литерально admin или tail после + * {@code -}/{@code _}/{@code :} равен ADMIN. */ + static boolean isAdminRole(String role) { + if (role == null || role.isBlank()) return false; + String s = role.trim().toUpperCase(); + int sep = Math.max(Math.max(s.lastIndexOf('-'), s.lastIndexOf('_')), s.lastIndexOf(':')); + String tail = sep >= 0 ? s.substring(sep + 1) : s; + return "ADMIN".equals(tail); } /** 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 5917332..53f765d 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 @@ -265,9 +265,17 @@ public class DraftService { + " — approve possible только PENDING."); } if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) { - throw OrdinisException.conflict( - "self_approve_forbidden", - "Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker)."); + // Admin override — D3 maker-checker relaxation для small teams. + // Admin'ы (ordinis:admin / admin / *:admin) могут approve свой draft + // — solo-founder + small-team scenario. Audit log записывается с + // явной пометкой что reviewer == maker, compliance trail сохраняется. + if (workflowRoles == null || !workflowRoles.isAdmin()) { + throw OrdinisException.conflict( + "self_approve_forbidden", + "Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker)."); + } + log.info("Admin self-approve override: draft={} maker={} reviewer={} (same)", + draftId, draft.getMakerId(), reviewerId); } DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); // Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted"). 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 80132ea..f696846 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 @@ -285,9 +285,16 @@ public class SchemaDraftService { + " — decision требует review_pending."); } if (draft.getMakerId().equals(reviewerId)) { - throw OrdinisException.conflict( - "self_approve_forbidden", - "Maker '" + reviewerId + "' не может decide свой draft (maker-checker)."); + // Admin override — см. DraftService.approve rationale. + // Solo-founder + small-team могут decide свои schema drafts если + // у них admin role. Audit log записывает self-decision явно. + if (workflowRoles == null || !workflowRoles.isAdmin()) { + throw OrdinisException.conflict( + "self_approve_forbidden", + "Maker '" + reviewerId + "' не может decide свой draft (maker-checker)."); + } + log.info("Admin self-decide override: draft={} maker={} reviewer={} decision={}", + draftId, draft.getMakerId(), reviewerId, req.decision()); } // Phase 2: dict-level минимальная роль ревьюера. Resolveим def early, // чтобы вернуть 403 forbidden_role до того как меняем status.