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
+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. */