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:
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+69
-15
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user