feat(workflow): admin self-approve override (D3 relaxation для small teams)
This commit is contained in:
+32
-3
@@ -104,12 +104,41 @@ public class WorkflowRoles {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* True если у actor'а есть указанная workflow role ИЛИ super-role
|
* True если у actor'а есть указанная workflow role ИЛИ super-role
|
||||||
* {@link #ADMIN}. Admin грантит full workflow access без явного
|
* (любая admin-эквивалентная — см. {@link #isAdminRole}).
|
||||||
* назначения отдельных reviewer/publisher ролей.
|
* Admin грантит full workflow access без явного назначения отдельных
|
||||||
|
* reviewer/publisher ролей.
|
||||||
*/
|
*/
|
||||||
public boolean hasRole(String role) {
|
public boolean hasRole(String role) {
|
||||||
Set<String> roles = currentRoles();
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+11
-3
@@ -265,9 +265,17 @@ public class DraftService {
|
|||||||
+ " — approve possible только PENDING.");
|
+ " — approve possible только PENDING.");
|
||||||
}
|
}
|
||||||
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
|
if (draft.getMakerId() != null && draft.getMakerId().equals(reviewerId)) {
|
||||||
throw OrdinisException.conflict(
|
// Admin override — D3 maker-checker relaxation для small teams.
|
||||||
"self_approve_forbidden",
|
// Admin'ы (ordinis:admin / admin / *:admin) могут approve свой draft
|
||||||
"Maker '" + reviewerId + "' не может approve свой draft (D3 maker-checker).");
|
// — 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());
|
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
|
||||||
// Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
|
// Phase 2: dict-level минимальная роль ревьюера (e.g. "ordinis:restricted").
|
||||||
|
|||||||
+10
-3
@@ -285,9 +285,16 @@ public class SchemaDraftService {
|
|||||||
+ " — decision требует review_pending.");
|
+ " — decision требует review_pending.");
|
||||||
}
|
}
|
||||||
if (draft.getMakerId().equals(reviewerId)) {
|
if (draft.getMakerId().equals(reviewerId)) {
|
||||||
throw OrdinisException.conflict(
|
// Admin override — см. DraftService.approve rationale.
|
||||||
"self_approve_forbidden",
|
// Solo-founder + small-team могут decide свои schema drafts если
|
||||||
"Maker '" + reviewerId + "' не может decide свой draft (maker-checker).");
|
// у них 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,
|
// Phase 2: dict-level минимальная роль ревьюера. Resolveим def early,
|
||||||
// чтобы вернуть 403 forbidden_role до того как меняем status.
|
// чтобы вернуть 403 forbidden_role до того как меняем status.
|
||||||
|
|||||||
Reference in New Issue
Block a user