diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 33171ec..2ed4bca 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -2,7 +2,8 @@ import { useAuth } from 'react-oidc-context' /** * Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**: - * читает {@code realm_access.roles} из JWT и гейтит actions. + * декодирует access_token, читает {@code realm_access.roles} + + * {@code resource_access.ordinis.roles} и гейтит UI actions. * *

Naming convention: colon-separated {@code ordinis::} * pattern (consistent с scope ACL {@code ordinis:client:public}). Backend @@ -36,18 +37,59 @@ export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer' export const ROLE_ADMIN = 'ordinis:admin' /** - * Достаёт roles из JWT. Поддерживает оба claim path: - * - {@code realm_access.roles} — Keycloak realm-роли - * - {@code resource_access.ordinis.roles} — Keycloak client-роли + * Декодирует payload JWT без верификации подписи (для чтения claims). * - *

Backend читает один из двух через {@code OrdinisAuthProperties.rolesClaim} - * (default {@code realm_access.roles}, на prod/staging - * {@code resource_access.ordinis.roles}). Frontend читает оба и объединяет - * — UI gate должен работать независимо от config'а. + *

Безопасно для UI gate'ов: подпись проверяет Keycloak при выдаче, backend + * — при каждом API вызове через {@code spring-security-oauth2-resource-server}. + * Frontend читает claims только чтобы решить «показать кнопку или нет»; + * primary defence остаётся на backend. + * + *

JWT формат: {@code header.payload.signature}, все три части + * base64url-encoded. Берём payload, normalize base64url → base64, decode, + * parse JSON. */ -function tokenRoles(profile: unknown): string[] { - if (!profile || typeof profile !== 'object') return [] - const p = profile as { +function decodeJwtPayload(token: string | undefined | null): Record | null { + if (!token || typeof token !== 'string') return null + const parts = token.split('.') + if (parts.length !== 3) return null + try { + // base64url → base64: + / padding + const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/') + const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4)) + const json = atob(b64 + pad) + // UTF-8 декодирование (atob возвращает binary string, нужен TextDecoder + // если есть non-ASCII в claims — например русское имя в name claim). + const bytes = new Uint8Array(json.length) + for (let i = 0; i < json.length; i++) bytes[i] = json.charCodeAt(i) + const decoded = new TextDecoder('utf-8').decode(bytes) + return JSON.parse(decoded) as Record + } catch { + return null + } +} + +/** + * Достаёт roles из access_token. Поддерживает оба claim path: + * - {@code realm_access.roles} — Keycloak realm-роли + * - {@code resource_access.ordinis.roles} — Keycloak client-роли (scoped к + * {@code ordinis} client) + * + *

Важно — почему access_token, не id_token: Keycloak по дефолту + * mapper'ит {@code realm_access} / {@code resource_access} только в + * access_token. ID token (который {@code auth.user.profile} в oidc-client-ts) + * содержит только стандартные OIDC claims (sub, email, name, preferred_username). + * Раньше код читал {@code profile} → roles всегда пустой → "Согласования" + * скрыт даже у admin'а. Fix: decode access_token напрямую. + * + *

Backend читает один из двух path через + * {@code OrdinisAuthProperties.rolesClaim} (default {@code realm_access.roles}, + * на prod/staging {@code resource_access.ordinis.roles}). Frontend читает + * оба и объединяет — UI gate должен работать независимо от config'а. + */ +function tokenRoles(accessToken: string | undefined | null): string[] { + const payload = decodeJwtPayload(accessToken) + if (!payload) return [] + const p = payload as { realm_access?: { roles?: unknown } resource_access?: { ordinis?: { roles?: unknown } } } @@ -66,27 +108,27 @@ function tokenRoles(profile: unknown): string[] { * True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}. * Admin grants full workflow access — convenience override для small teams. */ -function hasRole(profile: unknown, role: string): boolean { - const roles = tokenRoles(profile) +function hasRole(accessToken: string | undefined | null, role: string): boolean { + const roles = tokenRoles(accessToken) return roles.includes(role) || roles.includes(ROLE_ADMIN) } /** Может ли актор approve / request_changes / reject schema draft. */ export function useCanReviewSchema(): boolean { const auth = useAuth() - return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_REVIEWER) + return auth.isAuthenticated && hasRole(auth.user?.access_token, ROLE_SCHEMA_REVIEWER) } /** Может ли актор approve / reject record draft. */ export function useCanReviewRecord(): boolean { const auth = useAuth() - return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_RECORD_REVIEWER) + return auth.isAuthenticated && hasRole(auth.user?.access_token, ROLE_RECORD_REVIEWER) } /** Может ли актор publish approved schema draft (bump live version). */ export function useCanPublishSchema(): boolean { const auth = useAuth() - return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_PUBLISHER) + return auth.isAuthenticated && hasRole(auth.user?.access_token, ROLE_SCHEMA_PUBLISHER) } /**