diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 3a358f9..af13827 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -129,7 +129,7 @@ i18n 'schema.approvalRequired.label': 'Требовать approval (maker-checker)', 'schema.approvalRequired.hint': 'Approval Workflow v2. Все CRUD операции пойдут через draft → review → approve. Защита от operator error на критичных справочниках. Существующие маршруты (POST/PUT/DELETE) вернут 409 draft_required с pointer на /drafts API.', 'schema.approvalMinRole.label': 'Минимальная роль ревьюера (опционально)', - 'schema.approvalMinRole.hint': 'Например ordinis:internal или ordinis:restricted. Пусто = любой пользователь с ролью ordinis:approver. (Phase 2 — enforcement TBD).', + 'schema.approvalMinRole.hint': 'Например ordinis:internal или ordinis:restricted. Если задано — backend требует чтобы ревьюер имел эту роль (поверх стандартных ordinis:record:reviewer / ordinis:schema:reviewer) для approve / reject / publish. Пусто = достаточно стандартной reviewer role. ordinis:admin проходит всегда.', 'search.title': 'Smart search', 'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).', 'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…', @@ -844,7 +844,7 @@ i18n 'schema.approvalRequired.label': 'Require approval (maker-checker)', 'schema.approvalRequired.hint': 'Approval Workflow v2. All CRUD goes through draft → review → approve. Protects critical dictionaries from operator error. Existing endpoints (POST/PUT/DELETE) return 409 draft_required pointing to /drafts API.', 'schema.approvalMinRole.label': 'Minimum reviewer role (optional)', - 'schema.approvalMinRole.hint': 'e.g. ordinis:internal or ordinis:restricted. Empty = any user with ordinis:approver. (Phase 2 — enforcement TBD).', + 'schema.approvalMinRole.hint': 'e.g. ordinis:internal or ordinis:restricted. When set, backend requires reviewer to hold this role (on top of standard ordinis:record:reviewer / ordinis:schema:reviewer) for approve / reject / publish. Empty = standard reviewer role suffices. ordinis:admin always passes.', 'search.title': 'Smart search', 'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).', 'search.placeholder': 'Type 3+ chars: code, name, identifier…', 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 5e65ad1..98ad3e9 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 @@ -105,6 +105,27 @@ public class DraftService { } } + /** + * Phase 2 (per-dictionary fine-grained role gate): если у словаря задан + * {@code approval_min_role}, дополнительно требуем чтобы reviewer имел + * именно эту role (поверх стандартного {@code ordinis:record:reviewer}). + * + *
Use case: критичный справочник помечен как требующий повышенного + * доступа — например, {@code approvalMinRole = "ordinis:restricted"}, + * тогда approve может только user с этой role'ю даже если у него есть + * record:reviewer на других dict'ах. Pure UI gate был до Phase 2 — + * чистый no-op, теперь enforced. + * + *
Admin super-role {@code ordinis:admin} проходит автоматически + * (через {@link WorkflowRoles#hasRole}). + */ + private void requireDictMinRoleIfSet(DictionaryDefinition def) { + if (workflowRoles == null) return; + String minRole = def.getApprovalMinRole(); + if (minRole == null || minRole.isBlank()) return; + workflowRoles.requireRole(minRole); + } + /** * Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer * вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick. @@ -239,6 +260,8 @@ public class DraftService { "Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker)."); } DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + // Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted"). + requireDictMinRoleIfSet(def); // Apply через recordService — validate-on-approve + outbox + audit + COPY → live. var req = toCreateRecordRequest(draft); @@ -292,6 +315,9 @@ public class DraftService { "self_reject_forbidden", "Maker '" + reviewerId + "' не может reject свой draft."); } + // Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted"). + DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + requireDictMinRoleIfSet(def); OffsetDateTime now = OffsetDateTime.now(); draft.setStatus(DraftStatus.REJECTED); @@ -299,7 +325,7 @@ public class DraftService { draft.setReviewedAt(now); draft.setReviewComment(reviewComment); RecordDraft saved = draftRepo.saveAndFlush(draft); - DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + // def уже resolved выше для min-role check — reuse. recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now); log.info("Draft rejected: id={} reviewer={} reason='{}'", draftId, reviewerId, reviewComment); 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 098db4b..80132ea 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 @@ -102,6 +102,20 @@ public class SchemaDraftService { } } + /** + * Phase 2: dict-level минимальная роль ревьюера (поле + * {@code DictionaryDefinition.approvalMinRole}). Если задано — + * требуем чтобы reviewer имел эту роль вдобавок к стандартной + * {@code SCHEMA_REVIEWER}. Admin super-role проходит автоматически. + * Зеркало {@code DraftService.requireDictMinRoleIfSet} для записей. + */ + private void requireDictMinRoleIfSet(DictionaryDefinition def) { + if (workflowRoles == null) return; + String minRole = def.getApprovalMinRole(); + if (minRole == null || minRole.isBlank()) return; + workflowRoles.requireRole(minRole); + } + // ───────────────────────────────────────────────────────────────────── // Reads // ───────────────────────────────────────────────────────────────────── @@ -275,6 +289,10 @@ public class SchemaDraftService { "self_approve_forbidden", "Maker '" + reviewerId + "' не может decide свой draft (maker-checker)."); } + // Phase 2: dict-level минимальная роль ревьюера. Resolveим def early, + // чтобы вернуть 403 forbidden_role до того как меняем status. + DictionaryDefinition decideDef = definitionService.findById(draft.getDictionaryId()); + requireDictMinRoleIfSet(decideDef); SchemaDraftStatus nextStatus = switch (req.decision()) { case APPROVE -> SchemaDraftStatus.APPROVED; @@ -288,7 +306,8 @@ public class SchemaDraftService { draft.setDecisionComment(req.comment()); DictionarySchemaDraft saved = repository.save(draft); - DictionaryDefinition def = definitionService.findById(saved.getDictionaryId()); + // decideDef уже resolved выше для min-role check — reuse. + DictionaryDefinition def = decideDef; emitEvent("SchemaDraftDecided", saved, def, Map.of( "decision", req.decision().name().toLowerCase(), "reviewerId", reviewerId, @@ -327,6 +346,10 @@ public class SchemaDraftService { requireRoleIfEnforced(WorkflowRoles.SCHEMA_PUBLISHER); DictionarySchemaDraft draft = findById(draftId); DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); + // Phase 2: dict-level минимальная роль (e.g. "ordinis:restricted"). Publish + // — самое чувствительное действие (меняет live schema), требуем тот же + // gate что у decide. Если поле пусто — no-op. + requireDictMinRoleIfSet(def); if (draft.getStatus() != SchemaDraftStatus.APPROVED) { throw OrdinisException.conflict(