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