feat(approval): enforce per-dictionary approvalMinRole (Phase 2)

This commit is contained in:
Александр Зимин
2026-05-13 16:42:09 +00:00
parent 5629b85af7
commit 5187845047
3 changed files with 53 additions and 4 deletions
+2 -2
View File
@@ -129,7 +129,7 @@ i18n
'schema.approvalRequired.label': 'Требовать approval (maker-checker)', '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.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.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.title': 'Smart search',
'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).', 'search.description': 'Поиск по содержимому всех справочников. Активные записи, текущий момент. Min 3 символа (trigram index).',
'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…', 'search.placeholder': 'Введите 3+ символа: код, название, идентификатор…',
@@ -844,7 +844,7 @@ i18n
'schema.approvalRequired.label': 'Require approval (maker-checker)', '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.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.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.title': 'Smart search',
'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).', 'search.description': 'Search across all dictionary content. Active records, current moment. Min 3 chars (trigram index).',
'search.placeholder': 'Type 3+ chars: code, name, identifier…', 'search.placeholder': 'Type 3+ chars: code, name, identifier…',
@@ -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}).
*
* <p>Use case: критичный справочник помечен как требующий повышенного
* доступа — например, {@code approvalMinRole = "ordinis:restricted"},
* тогда approve может только user с этой role'ю даже если у него есть
* record:reviewer на других dict'ах. Pure UI gate был до Phase 2 —
* чистый no-op, теперь enforced.
*
* <p>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 * Регистрирует Gauge для pending queue depth. Polled на scrape — Micrometer
* вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick. * вызывает {@link RecordDraftRepository#countPending()} раз в Prometheus tick.
@@ -239,6 +260,8 @@ public class DraftService {
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker)."); "Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
} }
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); 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. // Apply через recordService — validate-on-approve + outbox + audit + COPY → live.
var req = toCreateRecordRequest(draft); var req = toCreateRecordRequest(draft);
@@ -292,6 +315,9 @@ public class DraftService {
"self_reject_forbidden", "self_reject_forbidden",
"Maker '" + reviewerId + "' не может reject свой draft."); "Maker '" + reviewerId + "' не может reject свой draft.");
} }
// Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
requireDictMinRoleIfSet(def);
OffsetDateTime now = OffsetDateTime.now(); OffsetDateTime now = OffsetDateTime.now();
draft.setStatus(DraftStatus.REJECTED); draft.setStatus(DraftStatus.REJECTED);
@@ -299,7 +325,7 @@ public class DraftService {
draft.setReviewedAt(now); draft.setReviewedAt(now);
draft.setReviewComment(reviewComment); draft.setReviewComment(reviewComment);
RecordDraft saved = draftRepo.saveAndFlush(draft); 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); recordDecision(def.getName(), draft.getOperation(), "rejected", draft.getSubmittedAt(), now);
log.info("Draft rejected: id={} reviewer={} reason='{}'", log.info("Draft rejected: id={} reviewer={} reason='{}'",
draftId, reviewerId, reviewComment); draftId, reviewerId, reviewComment);
@@ -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 // Reads
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -275,6 +289,10 @@ public class SchemaDraftService {
"self_approve_forbidden", "self_approve_forbidden",
"Maker '" + reviewerId + "' не может decide свой draft (maker-checker)."); "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()) { SchemaDraftStatus nextStatus = switch (req.decision()) {
case APPROVE -> SchemaDraftStatus.APPROVED; case APPROVE -> SchemaDraftStatus.APPROVED;
@@ -288,7 +306,8 @@ public class SchemaDraftService {
draft.setDecisionComment(req.comment()); draft.setDecisionComment(req.comment());
DictionarySchemaDraft saved = repository.save(draft); 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( emitEvent("SchemaDraftDecided", saved, def, Map.of(
"decision", req.decision().name().toLowerCase(), "decision", req.decision().name().toLowerCase(),
"reviewerId", reviewerId, "reviewerId", reviewerId,
@@ -327,6 +346,10 @@ public class SchemaDraftService {
requireRoleIfEnforced(WorkflowRoles.SCHEMA_PUBLISHER); requireRoleIfEnforced(WorkflowRoles.SCHEMA_PUBLISHER);
DictionarySchemaDraft draft = findById(draftId); DictionarySchemaDraft draft = findById(draftId);
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId()); 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) { if (draft.getStatus() != SchemaDraftStatus.APPROVED) {
throw OrdinisException.conflict( throw OrdinisException.conflict(