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.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…',
|
||||||
|
|||||||
+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
|
* Регистрирует 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);
|
||||||
|
|||||||
+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
|
// 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user