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
@@ -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
* вызывает {@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);
@@ -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(