Merge branch 'feat/admin-self-approve' into 'main'

feat(workflow): admin self-approve override (D3 relaxation для small teams)

See merge request 2-6/2-6-4/terravault/ordinis!280
This commit is contained in:
Александр Зимин
2026-06-01 18:46:15 +00:00
3 changed files with 53 additions and 9 deletions
@@ -104,12 +104,41 @@ public class WorkflowRoles {
/**
* True если у actor'а есть указанная workflow role ИЛИ super-role
* {@link #ADMIN}. Admin грантит full workflow access без явного
* назначения отдельных reviewer/publisher ролей.
* (любая admin-эквивалентная — см. {@link #isAdminRole}).
* Admin грантит full workflow access без явного назначения отдельных
* reviewer/publisher ролей.
*/
public boolean hasRole(String role) {
Set<String> roles = currentRoles();
return roles.contains(role) || roles.contains(ADMIN);
if (roles.contains(role)) return true;
return isAdmin();
}
/**
* True если у actor'а есть admin-эквивалентная роль. Match'ит:
* <ul>
* <li>Literal {@code ordinis:admin} (canonical).</li>
* <li>Plain {@code admin} (Keycloak client-role на client {@code ordinis}
* без префикса — Альтум realm convention).</li>
* <li>Любая роль с tail'ом ADMIN после {@code -}/{@code _}/{@code :}
* (e.g. {@code ordinis-admin}, {@code global:admin}, {@code org:admin}).</li>
* </ul>
*
* <p>Зеркало backend {@code ScopeContext.isAdminRole} — что считаем admin
* для scope hierarchy expansion, то же считаем admin для workflow override.
*/
public boolean isAdmin() {
return currentRoles().stream().anyMatch(WorkflowRoles::isAdminRole);
}
/** Tail-match: true если role литерально admin или tail после
* {@code -}/{@code _}/{@code :} равен ADMIN. */
static boolean isAdminRole(String role) {
if (role == null || role.isBlank()) return false;
String s = role.trim().toUpperCase();
int sep = Math.max(Math.max(s.lastIndexOf('-'), s.lastIndexOf('_')), s.lastIndexOf(':'));
String tail = sep >= 0 ? s.substring(sep + 1) : s;
return "ADMIN".equals(tail);
}
/**
@@ -265,9 +265,17 @@ public class DraftService {
+ " — approve possible только PENDING.");
}
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
throw OrdinisException.conflict(
"self_approve_forbidden",
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
// Admin override — D3 maker-checker relaxation для small teams.
// Admin'ы (ordinis:admin / admin / *:admin) могут approve свой draft
// — solo-founder + small-team scenario. Audit log записывается с
// явной пометкой что reviewer == maker, compliance trail сохраняется.
if (workflowRoles == null || !workflowRoles.isAdmin()) {
throw OrdinisException.conflict(
"self_approve_forbidden",
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
}
log.info("Admin self-approve override: draft={} maker={} reviewer={} (same)",
draftId, draft.getMakerId(), reviewerId);
}
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
// Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
@@ -285,9 +285,16 @@ public class SchemaDraftService {
+ " — decision требует review_pending.");
}
if (draft.getMakerId().equals(reviewerId)) {
throw OrdinisException.conflict(
"self_approve_forbidden",
"Maker '" + reviewerId + "' не может decide свой draft (maker-checker).");
// Admin override — см. DraftService.approve rationale.
// Solo-founder + small-team могут decide свои schema drafts если
// у них admin role. Audit log записывает self-decision явно.
if (workflowRoles == null || !workflowRoles.isAdmin()) {
throw OrdinisException.conflict(
"self_approve_forbidden",
"Maker '" + reviewerId + "' не может decide свой draft (maker-checker).");
}
log.info("Admin self-decide override: draft={} maker={} reviewer={} decision={}",
draftId, draft.getMakerId(), reviewerId, req.decision());
}
// Phase 2: dict-level минимальная роль ревьюера. Resolveим def early,
// чтобы вернуть 403 forbidden_role до того как меняем status.