feat(auth): WorkflowRoles configurable claim path + ordinis:admin super-role

User report: token has roles в resource_access.ordinis.roles (client-scoped),
backend WorkflowRoles читал hardcoded realm_access.roles → forbidden_role
для всех reviewer actions.

Backend:
- WorkflowRoles: depends OrdinisAuthProperties → reads via rolesClaim path
  (же что ScopeContext). Default realm_access.roles, на staging/prod env
  var ORDINIS_AUTH_ROLES_CLAIM=resource_access.ordinis.roles (уже wired
  в helm helper).
- Nested claim path resolver — mirror ScopeContext.readClaimByPath
- New super-role ADMIN ('ordinis:admin') — holder автоматически проходит
  любой workflow check без явного назначения отдельных ролей. Convenience
  для small teams.

Frontend (usePermissions.ts):
- tokenRoles() читает оба claim path (realm_access.roles +
  resource_access.ordinis.roles) и объединяет — UI gate работает
  независимо от backend config
- hasRole() recognizes 'ordinis:admin' как super-role override
- ROLE_ADMIN exported

Docs:
- docs/operator/rbac.md: добавлен ordinis:admin row, JWT claim path note,
  Admin profile updated (super-role vs composite explanation)
This commit is contained in:
Zimin A.N.
2026-05-13 13:17:54 +03:00
parent 031a582167
commit 36c06ae1da
3 changed files with 108 additions and 24 deletions
+8 -1
View File
@@ -28,9 +28,12 @@ Ordinis использует **Keycloak realm roles** (claim `realm_access.roles
| `ordinis:schema:reviewer` | Approve / Запросить правки / Отклонить **schema draft** |
| `ordinis:schema:publisher` | Publish approved schema draft → live |
| `ordinis:record:reviewer` | Approve / Reject **record draft** (Approval Workflow v2) |
| `ordinis:admin` | **Super-role** — автоматически разрешает любой workflow action. Convenience для small teams: assign one role вместо трёх. |
Backend проверяет роль перед каждым действием. Без роли — `403 forbidden_role` с сообщением о требуемой роли. Frontend дополнительно скрывает кнопки, для которых нет роли.
> **JWT claim path:** на staging/prod backend читает роли из `resource_access.ordinis.roles` (client-scoped roles в Keycloak `ordinis` client). Default fallback — `realm_access.roles`. Настраивается через `ORDINIS_AUTH_ROLES_CLAIM` env var.
> **Maker side** (создание / submit / withdraw draft'а) специальной ролью не gated — любой authenticated user с required scope может создать draft. Backend защищает invariant maker-checker через `403 self_approve_forbidden` (один и тот же user не может approve свой draft).
## Полная матрица действий
@@ -78,12 +81,16 @@ Backend проверяет роль перед каждым действием.
Финальный gatekeeper для production публикации схем. Обычно более узкая группа чем reviewers.
### Admin (полный доступ)
**Простой путь** — single role `ordinis:admin` (super-role): backend и frontend оба recognize её как override для любого workflow check'а. Достаточно одного назначения.
**Канонический путь (composite в Keycloak)**`ordinis:admin` как composite, агрегирующий:
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:schema:publisher`
- `ordinis:record:reviewer`
Или **composite role** `ordinis:admin`, которая агрегирует все четыре.
Composite-роль в Keycloak автоматически expands в JWT — token содержит все включённые роли. Это работает и без super-role override.
## Настройка в Keycloak
+31 -8
View File
@@ -33,19 +33,42 @@ import { useAuth } from 'react-oidc-context'
export const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer'
export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher'
export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer'
export const ROLE_ADMIN = 'ordinis:admin'
/** Достаёт realm roles из JWT (Keycloak claim path: realm_access.roles). */
function realmRoles(profile: unknown): string[] {
/**
* Достаёт roles из JWT. Поддерживает оба claim path:
* - {@code realm_access.roles} — Keycloak realm-роли
* - {@code resource_access.ordinis.roles} — Keycloak client-роли
*
* <p>Backend читает один из двух через {@code OrdinisAuthProperties.rolesClaim}
* (default {@code realm_access.roles}, на prod/staging
* {@code resource_access.ordinis.roles}). Frontend читает оба и объединяет
* — UI gate должен работать независимо от config'а.
*/
function tokenRoles(profile: unknown): string[] {
if (!profile || typeof profile !== 'object') return []
const realmAccess = (profile as { realm_access?: { roles?: unknown } }).realm_access
if (!realmAccess || typeof realmAccess !== 'object') return []
const roles = (realmAccess as { roles?: unknown }).roles
if (!Array.isArray(roles)) return []
return roles.filter((r): r is string => typeof r === 'string')
const p = profile as {
realm_access?: { roles?: unknown }
resource_access?: { ordinis?: { roles?: unknown } }
}
const fromRealm = Array.isArray(p.realm_access?.roles)
? (p.realm_access.roles as unknown[]).filter((r): r is string => typeof r === 'string')
: []
const fromClient = Array.isArray(p.resource_access?.ordinis?.roles)
? (p.resource_access.ordinis.roles as unknown[]).filter(
(r): r is string => typeof r === 'string',
)
: []
return [...fromRealm, ...fromClient]
}
/**
* True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}.
* Admin grants full workflow access — convenience override для small teams.
*/
function hasRole(profile: unknown, role: string): boolean {
return realmRoles(profile).includes(role)
const roles = tokenRoles(profile)
return roles.includes(role) || roles.includes(ROLE_ADMIN)
}
/** Может ли актор approve / request_changes / reject schema draft. */
@@ -1,5 +1,6 @@
package cloud.nstart.terravault.ordinis.restapi.auth;
import cloud.nstart.terravault.ordinis.auth.OrdinisAuthProperties;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -10,6 +11,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
@@ -35,8 +37,12 @@ import java.util.Set;
* {@code self_approve_forbidden} (maker != reviewer) как primary defence
* для maker-checker invariant.
*
* <p>JWT источник: {@code realm_access.roles} claim (Keycloak realm roles).
* Совместимо с {@code ScopeContext} JWT parsing pattern.
* <p>JWT источник: configurable claim path через
* {@link OrdinisAuthProperties#rolesClaim()} — тот же что у
* {@code ScopeContext}. Default {@code realm_access.roles} (Keycloak realm
* roles). Можно настроить на {@code resource_access.ordinis.roles} если
* Keycloak realm использует client-scoped roles (mapped к {@code ordinis}
* client).
*
* <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm должен
* иметь созданные роли, и users должны их получить. Иначе все decide /
@@ -59,18 +65,44 @@ public class WorkflowRoles {
/** Approve / reject record draft (Approval Workflow v2). */
public static final String RECORD_REVIEWER = "ordinis:record:reviewer";
/** Read realm_access.roles из current request JWT. Anonymous → empty. */
/**
* Super-role: holder автоматически проходит любой role check без явного
* назначения отдельных workflow roles. Convenience для small teams где
* один-два user'а имеют full access — assign one role вместо трёх.
*
* <p>Composite role в Keycloak — лучше; super-role override — fallback
* для cases где composite ещё не настроен.
*/
public static final String ADMIN = "ordinis:admin";
private final OrdinisAuthProperties props;
public WorkflowRoles(OrdinisAuthProperties props) {
this.props = props;
}
/** Test ctor — без OrdinisAuthProperties (defaults to realm_access.roles). */
WorkflowRoles() {
this.props = null;
}
/** Reads roles из configured claim path. Anonymous → empty. */
public Set<String> currentRoles() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) {
return Set.of();
}
return extractRoles(jwt.getToken());
return extractRoles(jwt.getToken(), rolesClaimPath());
}
/** True если у actor'а есть указанная realm role. */
/**
* True если у actor'а есть указанная workflow role ИЛИ super-role
* {@link #ADMIN}. Admin грантит full workflow access без явного
* назначения отдельных reviewer/publisher ролей.
*/
public boolean hasRole(String role) {
return currentRoles().contains(role);
Set<String> roles = currentRoles();
return roles.contains(role) || roles.contains(ADMIN);
}
/**
@@ -79,23 +111,26 @@ public class WorkflowRoles {
*/
public void requireRole(String role) {
if (!hasRole(role)) {
log.debug("Access denied: missing required realm role '{}'", role);
log.debug("Access denied: missing required workflow role '{}' at claim path '{}'",
role, rolesClaimPath());
throw OrdinisException.forbidden(
"forbidden_role",
"Требуется realm role: " + role);
}
}
static Set<String> extractRoles(Jwt token) {
Object realmAccessObj = token.getClaim("realm_access");
if (!(realmAccessObj instanceof Map<?, ?> realmAccess)) {
private String rolesClaimPath() {
return props != null ? props.rolesClaim() : "realm_access.roles";
}
/** Reads JWT claim по nested path (e.g. "realm_access.roles" or
* "resource_access.ordinis.roles") и возвращает roles как Set<String>. */
static Set<String> extractRoles(Jwt token, String path) {
Object current = readClaimByPath(token, path);
if (!(current instanceof Collection<?> rolesCol)) {
return Set.of();
}
Object rolesObj = realmAccess.get("roles");
if (!(rolesObj instanceof Collection<?> rolesCol)) {
return Set.of();
}
java.util.LinkedHashSet<String> out = new java.util.LinkedHashSet<>();
LinkedHashSet<String> out = new LinkedHashSet<>();
for (Object o : rolesCol) {
if (o instanceof String s) {
out.add(s);
@@ -103,4 +138,23 @@ public class WorkflowRoles {
}
return out;
}
/** Default-path overload для тестов и backward compat. */
static Set<String> extractRoles(Jwt token) {
return extractRoles(token, "realm_access.roles");
}
/** Nested JWT claim resolver. Mirror {@code ScopeContext.readClaimByPath}. */
private static Object readClaimByPath(Jwt token, String path) {
String[] parts = path.split("\\.");
Object current = token.getClaim(parts[0]);
for (int i = 1; i < parts.length && current != null; i++) {
if (current instanceof Map<?, ?> m) {
current = m.get(parts[i]);
} else {
return null;
}
}
return current;
}
}