Merge branch 'feat/phase-1d-configurable-claim-path-admin-override' into 'main'

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

See merge request 2-6/2-6-4/terravault/ordinis!163
This commit is contained in:
Александр Зимин
2026-05-13 10:20:59 +00:00
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:reviewer` | Approve / Запросить правки / Отклонить **schema draft** |
| `ordinis:schema:publisher` | Publish approved schema draft → live | | `ordinis:schema:publisher` | Publish approved schema draft → live |
| `ordinis:record:reviewer` | Approve / Reject **record draft** (Approval Workflow v2) | | `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 дополнительно скрывает кнопки, для которых нет роли. 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). > **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. Финальный gatekeeper для production публикации схем. Обычно более узкая группа чем reviewers.
### Admin (полный доступ) ### Admin (полный доступ)
**Простой путь** — single role `ordinis:admin` (super-role): backend и frontend оба recognize её как override для любого workflow check'а. Достаточно одного назначения.
**Канонический путь (composite в Keycloak)**`ordinis:admin` как composite, агрегирующий:
- `ordinis:client:restricted` - `ordinis:client:restricted`
- `ordinis:schema:reviewer` - `ordinis:schema:reviewer`
- `ordinis:schema:publisher` - `ordinis:schema:publisher`
- `ordinis:record:reviewer` - `ordinis:record:reviewer`
Или **composite role** `ordinis:admin`, которая агрегирует все четыре. Composite-роль в Keycloak автоматически expands в JWT — token содержит все включённые роли. Это работает и без super-role override.
## Настройка в Keycloak ## Настройка в 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_REVIEWER = 'ordinis:schema:reviewer'
export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher' export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher'
export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer' 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 [] if (!profile || typeof profile !== 'object') return []
const realmAccess = (profile as { realm_access?: { roles?: unknown } }).realm_access const p = profile as {
if (!realmAccess || typeof realmAccess !== 'object') return [] realm_access?: { roles?: unknown }
const roles = (realmAccess as { roles?: unknown }).roles resource_access?: { ordinis?: { roles?: unknown } }
if (!Array.isArray(roles)) return [] }
return roles.filter((r): r is string => typeof r === 'string') 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 { 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. */ /** Может ли актор approve / request_changes / reject schema draft. */
@@ -1,5 +1,6 @@
package cloud.nstart.terravault.ordinis.restapi.auth; package cloud.nstart.terravault.ordinis.restapi.auth;
import cloud.nstart.terravault.ordinis.auth.OrdinisAuthProperties;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -10,6 +11,7 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAut
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.Collection; import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -35,8 +37,12 @@ import java.util.Set;
* {@code self_approve_forbidden} (maker != reviewer) как primary defence * {@code self_approve_forbidden} (maker != reviewer) как primary defence
* для maker-checker invariant. * для maker-checker invariant.
* *
* <p>JWT источник: {@code realm_access.roles} claim (Keycloak realm roles). * <p>JWT источник: configurable claim path через
* Совместимо с {@code ScopeContext} JWT parsing pattern. * {@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 должен * <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm должен
* иметь созданные роли, и users должны их получить. Иначе все decide / * иметь созданные роли, и users должны их получить. Иначе все decide /
@@ -59,18 +65,44 @@ public class WorkflowRoles {
/** Approve / reject record draft (Approval Workflow v2). */ /** Approve / reject record draft (Approval Workflow v2). */
public static final String RECORD_REVIEWER = "ordinis:record:reviewer"; 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() { public Set<String> currentRoles() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) { if (!(auth instanceof JwtAuthenticationToken jwt)) {
return Set.of(); 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) { 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) { public void requireRole(String role) {
if (!hasRole(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( throw OrdinisException.forbidden(
"forbidden_role", "forbidden_role",
"Требуется realm role: " + role); "Требуется realm role: " + role);
} }
} }
static Set<String> extractRoles(Jwt token) { private String rolesClaimPath() {
Object realmAccessObj = token.getClaim("realm_access"); return props != null ? props.rolesClaim() : "realm_access.roles";
if (!(realmAccessObj instanceof Map<?, ?> realmAccess)) { }
/** 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(); return Set.of();
} }
Object rolesObj = realmAccess.get("roles"); LinkedHashSet<String> out = new LinkedHashSet<>();
if (!(rolesObj instanceof Collection<?> rolesCol)) {
return Set.of();
}
java.util.LinkedHashSet<String> out = new java.util.LinkedHashSet<>();
for (Object o : rolesCol) { for (Object o : rolesCol) {
if (o instanceof String s) { if (o instanceof String s) {
out.add(s); out.add(s);
@@ -103,4 +138,23 @@ public class WorkflowRoles {
} }
return out; 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;
}
} }