From c5adbdf39c3e44b76028b37173ef97d0123fb7be Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 18:51:43 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(auth):=20=D1=80=D0=BE=D0=BB=D1=8C=20adm?= =?UTF-8?q?in=20=D0=B4=D0=B0=D1=91=D1=82=20RESTRICTED=20scope=20(=D0=B2?= =?UTF-8?q?=D0=B8=D0=B4=D0=B8=D1=82=20=D0=B2=D1=81=D1=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScopeContext.normalizeRoleToScope ищет суффикс PUBLIC|INTERNAL|RESTRICTED после `-`/`_`/`:`. Роль `admin` (и `ordinis-admin`, `ordinis:client:admin`) не match'илась — нет суффикса с known scope name → DataScope.valueOf("ADMIN") бросал IllegalArgumentException → ignored → юзер с одной только admin ролью получал PUBLIC scope → 403 на любом /admin/* endpoint'е с requireInternal(). Симптом на проде/staging: админ открывает /audit, видит 403 на /admin/audit и /admin/users несмотря на JWT с admin ролью. Fix: processRole() сначала проверяет isAdminRole (tail == "ADMIN" после prefix-stripping и separator-split) → даёт RESTRICTED (RESTRICTED visibleTo PUBLIC + INTERNAL + RESTRICTED). Иначе идёт обычный normalizeRoleToScope. Поддерживаются: admin, ADMIN, ordinis-admin, ordinis_admin, ordinis:client:admin, ANYTHING-admin, ANYTHING_ADMIN — короче, любая роль с tail'ом ADMIN. Tests: +4 новых кейса (admin alone, ordinis-dash-admin, colon-separated admin, admin + explicit scope unionize). Старый unrecognizedRolesGivePublicFallback оставил только "viewer" — admin теперь recognized. 17/17 ScopeContextTest passed. --- .../terravault/ordinis/auth/ScopeContext.java | 33 +++++++++++++++-- .../ordinis/auth/ScopeContextTest.java | 36 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java index 2c26e73..5519caa 100644 --- a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java @@ -87,15 +87,44 @@ public class ScopeContext { if (raw == null) return EnumSet.of(DataScope.PUBLIC); EnumSet result = EnumSet.noneOf(DataScope.class); if (raw instanceof Collection col) { - for (Object o : col) addScope(result, normalizeRoleToScope(String.valueOf(o))); + for (Object o : col) processRole(result, String.valueOf(o)); } else { for (String part : String.valueOf(raw).split("[,\\s]+")) { - addScope(result, normalizeRoleToScope(part)); + processRole(result, part); } } return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; } + /** + * Single-role handler. Сначала проверяет admin-aliases (admin / + * ordinis-admin / ADMIN с любым префиксом) → дают RESTRICTED (видит всё). + * Иначе идёт через normalizeRoleToScope (PUBLIC/INTERNAL/RESTRICTED суффикс). + * + *

Раньше admin-роль молча проваливалась в normalizeRoleToScope (нет + * матча на PUBLIC/INTERNAL/RESTRICTED suffix) → user получал только PUBLIC + * → 403 на любом /admin/* endpoint'е с requireInternal(). UX чинит: + * админу не нужно явно иметь ordinis-internal в Keycloak, роль admin + * сама по себе даёт максимальный доступ. + */ + private static void processRole(EnumSet target, String role) { + if (isAdminRole(role)) { + target.add(DataScope.RESTRICTED); + return; + } + addScope(target, normalizeRoleToScope(role)); + } + + /** True если role tail (после `-`/`_`/`:` или целиком) равен "ADMIN". */ + private static boolean isAdminRole(String role) { + if (role == null) return false; + String s = role.trim().toUpperCase(); + if (s.isEmpty()) return false; + int sep = Math.max(Math.max(s.lastIndexOf('-'), s.lastIndexOf('_')), s.lastIndexOf(':')); + String tail = sep >= 0 ? s.substring(sep + 1) : s; + return "ADMIN".equals(tail); + } + /** Поддерживает nested путь "realm_access.roles" или просто "scopes". */ private static Object readClaimByPath(Jwt token, String path) { String[] parts = path.split("\\."); diff --git a/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java b/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java index e21b17d..47ae1f8 100644 --- a/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java +++ b/ordinis-auth/src/test/java/cloud/nstart/terravault/ordinis/auth/ScopeContextTest.java @@ -78,11 +78,45 @@ class ScopeContextTest { @Test void unrecognizedRolesGivePublicFallback() { - setJwtWithRealmRoles(List.of("admin", "viewer")); + // "viewer" — unrecognized; "admin" больше не unrecognized (см. тест ниже). + setJwtWithRealmRoles(List.of("viewer")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) .containsExactly(DataScope.PUBLIC); } + @Test + void adminRoleGetsRestricted() { + // Любая роль с tail'ом ADMIN (admin / ordinis-admin / ADMIN_FOO с + // суффиксом ADMIN) даёт RESTRICTED — RESTRICTED visibleTo всем уровням. + // Раньше admin молча проваливался в normalizeRoleToScope → PUBLIC → + // админ получал 403 на /admin/* endpoint'ах с requireInternal(). + setJwtWithRealmRoles(List.of("admin")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactly(DataScope.RESTRICTED); + } + + @Test + void ordinisDashAdminGetsRestricted() { + setJwtWithRealmRoles(List.of("ordinis-admin")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactly(DataScope.RESTRICTED); + } + + @Test + void adminColonSeparatedGetsRestricted() { + setJwtWithRealmRoles(List.of("ordinis:client:admin")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactly(DataScope.RESTRICTED); + } + + @Test + void adminAlongsideExplicitScopesUnionizes() { + // admin + ordinis-public → RESTRICTED (admin) + PUBLIC (ordinis-public). + setJwtWithRealmRoles(List.of("admin", "ordinis-public")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactlyInAnyOrder(DataScope.RESTRICTED, DataScope.PUBLIC); + } + // ============= JWT vs query ============= @Test From 4cd92aeb60fcdd509e9aeedd3024cb3638949559 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Mon, 25 May 2026 18:54:37 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20TokenSync=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?re-auth=20=D0=BF=D1=80=D0=B8=20expired=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Симптом: после ~30 мин неактивности пользователь видит 403 на /admin/audit и /admin/users. Перелогин решает. Корень. automaticSilentRenew: true в oidcConfig должен обновлять access_token, но падает молча когда: - refresh_token истёк (Keycloak SSO Session Idle = 30 мин) - silent renew iframe заблокирован 3rd-party cookies - silentRenewError не подцеплен глобально В этих случаях user.expired=true, accessToken=null. Дальнейшие запросы идут БЕЗ Authorization header → backend с ORDINIS_AUTH_REQUIRED=false пропускает как anonymous → requireInternal() видит PUBLIC scope → 403 (не 401). 401-handler в interceptor'е не срабатывает (статус не тот) → юзер залипает. Fix. Новый useEffect в TokenSync watches auth.user?.expired. На transition в true: signinSilent (вдруг refresh ещё жив), на failure signinRedirect. Anti-loop ref предотвращает повторные попытки пока expired=true. Бонусом — за компанию с MR !267 (admin → RESTRICTED) — после v2.42.4 деплоя админ может работать сессией дольше 30 мин без 403'ов. --- ordinis-admin-ui/src/auth/TokenSync.tsx | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/ordinis-admin-ui/src/auth/TokenSync.tsx b/ordinis-admin-ui/src/auth/TokenSync.tsx index f836685..f178c9e 100644 --- a/ordinis-admin-ui/src/auth/TokenSync.tsx +++ b/ordinis-admin-ui/src/auth/TokenSync.tsx @@ -23,6 +23,9 @@ import { LEGACY_TOKEN_STORAGE_KEY } from './oidcConfig' export function TokenSync() { const auth = useAuth() const redirecting = useRef(false) + // Anti-loop guard для проактивной re-auth при expiry — без него + // useEffect срабатывал бы повторно на каждый render пока expired=true. + const reauthing = useRef(false) // 1) Token sync — single update path к accessToken holder в client.ts. // ВАЖНО: пропускаем токен ТОЛЬКО когда user реально authenticated AND token @@ -93,5 +96,41 @@ export function TokenSync() { return () => setUnauthorizedHandler(null) }, [auth]) + // 3) Проактивная re-auth при expiry. automaticSilentRenew: true должен + // обновлять access_token автоматически, но молча падает если: + // - refresh_token истёк (Keycloak SSO Session Idle ~30 мин) + // - silent renew iframe заблокирован 3rd-party cookie policy + // - silentRenewError не подцеплен глобально + // + // В этих случаях user.expired=true, accessToken=null, requests идут + // как anonymous → backend с requireInternal() отдаёт 403 (не 401), + // 401-handler не срабатывает → юзер видит «403 forbidden» вместо + // привычного relogin redirect. Симптом который пользователь репортит + // как «перелогин решает». + // + // Логика: если user был authenticated и стал expired — пытаемся + // signinSilent (вдруг refresh ещё жив), на failure full redirect. + useEffect(() => { + if (!auth.user?.expired) { + reauthing.current = false + return + } + if (reauthing.current || auth.isLoading || auth.activeNavigator) return + if (typeof window !== 'undefined' && window.location.search.includes('error=')) { + return + } + reauthing.current = true + auth + .signinSilent() + .then(() => { + reauthing.current = false + }) + .catch(() => { + auth.signinRedirect().catch(() => { + reauthing.current = false + }) + }) + }, [auth, auth.user?.expired]) + return null }