feat(approval): enforce per-dictionary approvalMinRole (Phase 2)
This commit is contained in:
@@ -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…',
|
||||
|
||||
+27
-1
@@ -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);
|
||||
|
||||
+24
-1
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user