From ba49854a612aae10e9a1dacb1f020dfe36eefed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 1 Jun 2026 18:46:15 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20admin=20self-approve=20overri?= =?UTF-8?q?de=20(D3=20relaxation=20=D0=B4=D0=BB=D1=8F=20small=20teams)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ordinis/restapi/auth/WorkflowRoles.java | 35 +++++++++++++++++-- .../restapi/service/draft/DraftService.java | 14 ++++++-- .../schemaworkflow/SchemaDraftService.java | 13 +++++-- 3 files changed, 53 insertions(+), 9 deletions(-) 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.