fix(auth): read roles из access_token, не id_token (sidebar reviews hidden bug)
This commit is contained in:
@@ -2,7 +2,8 @@ import { useAuth } from 'react-oidc-context'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**:
|
* 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.
|
||||||
*
|
*
|
||||||
* <p>Naming convention: colon-separated {@code ordinis:<domain>:<action>}
|
* <p>Naming convention: colon-separated {@code ordinis:<domain>:<action>}
|
||||||
* pattern (consistent с scope ACL {@code ordinis:client:public}). Backend
|
* 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'
|
export const ROLE_ADMIN = 'ordinis:admin'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Достаёт roles из JWT. Поддерживает оба claim path:
|
* Декодирует payload JWT без верификации подписи (для чтения claims).
|
||||||
* - {@code realm_access.roles} — Keycloak realm-роли
|
|
||||||
* - {@code resource_access.ordinis.roles} — Keycloak client-роли
|
|
||||||
*
|
*
|
||||||
* <p>Backend читает один из двух через {@code OrdinisAuthProperties.rolesClaim}
|
* <p>Безопасно для UI gate'ов: подпись проверяет Keycloak при выдаче, backend
|
||||||
* (default {@code realm_access.roles}, на prod/staging
|
* — при каждом API вызове через {@code spring-security-oauth2-resource-server}.
|
||||||
* {@code resource_access.ordinis.roles}). Frontend читает оба и объединяет
|
* Frontend читает claims только чтобы решить «показать кнопку или нет»;
|
||||||
* — UI gate должен работать независимо от config'а.
|
* primary defence остаётся на backend.
|
||||||
|
*
|
||||||
|
* <p>JWT формат: {@code header.payload.signature}, все три части
|
||||||
|
* base64url-encoded. Берём payload, normalize base64url → base64, decode,
|
||||||
|
* parse JSON.
|
||||||
*/
|
*/
|
||||||
function tokenRoles(profile: unknown): string[] {
|
function decodeJwtPayload(token: string | undefined | null): Record<string, unknown> | null {
|
||||||
if (!profile || typeof profile !== 'object') return []
|
if (!token || typeof token !== 'string') return null
|
||||||
const p = profile as {
|
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<string, unknown>
|
||||||
|
} 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)
|
||||||
|
*
|
||||||
|
* <p><b>Важно — почему access_token, не id_token:</b> 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 напрямую.
|
||||||
|
*
|
||||||
|
* <p>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 }
|
realm_access?: { roles?: unknown }
|
||||||
resource_access?: { ordinis?: { roles?: unknown } }
|
resource_access?: { ordinis?: { roles?: unknown } }
|
||||||
}
|
}
|
||||||
@@ -66,27 +108,27 @@ function tokenRoles(profile: unknown): string[] {
|
|||||||
* True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}.
|
* True если у actor'а есть указанная role или super-role {@link ROLE_ADMIN}.
|
||||||
* Admin grants full workflow access — convenience override для small teams.
|
* Admin grants full workflow access — convenience override для small teams.
|
||||||
*/
|
*/
|
||||||
function hasRole(profile: unknown, role: string): boolean {
|
function hasRole(accessToken: string | undefined | null, role: string): boolean {
|
||||||
const roles = tokenRoles(profile)
|
const roles = tokenRoles(accessToken)
|
||||||
return roles.includes(role) || roles.includes(ROLE_ADMIN)
|
return roles.includes(role) || roles.includes(ROLE_ADMIN)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Может ли актор approve / request_changes / reject schema draft. */
|
/** Может ли актор approve / request_changes / reject schema draft. */
|
||||||
export function useCanReviewSchema(): boolean {
|
export function useCanReviewSchema(): boolean {
|
||||||
const auth = useAuth()
|
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. */
|
/** Может ли актор approve / reject record draft. */
|
||||||
export function useCanReviewRecord(): boolean {
|
export function useCanReviewRecord(): boolean {
|
||||||
const auth = useAuth()
|
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). */
|
/** Может ли актор publish approved schema draft (bump live version). */
|
||||||
export function useCanPublishSchema(): boolean {
|
export function useCanPublishSchema(): boolean {
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_PUBLISHER)
|
return auth.isAuthenticated && hasRole(auth.user?.access_token, ROLE_SCHEMA_PUBLISHER)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user