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: 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 Важно — почему 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)
}
/**