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 5519caa..60bc7a8 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 @@ -93,7 +93,33 @@ public class ScopeContext { processRole(result, part); } } - return result.isEmpty() ? EnumSet.of(DataScope.PUBLIC) : result; + if (result.isEmpty()) return EnumSet.of(DataScope.PUBLIC); + return expandHierarchy(result); + } + + /** + * Иерархическое расширение scope set: RESTRICTED видит INTERNAL+PUBLIC, + * INTERNAL видит PUBLIC. {@link DataScope#visibleTo} уже описывает эту + * семантику для одиночного scope сравнения; здесь применяем её к set + * чтобы все downstream API ({@code RecordReadService.allowedScopes.contains}) + * работали с уже расширенным набором. + * + *

Без этого юзер с client-ролью только {@code admin} (→ RESTRICTED) + * не мог читать PUBLIC справочники: {@code [RESTRICTED].contains(PUBLIC) + * = false} → 403 scope_access_denied. Контекст: altum realm присваивает + * единственную роль {@code admin} на client {@code ordinis}, в отличие + * от nstart realm где исторически назначались 4 роли + * (public/internal/restricted/admin) явно. + */ + private static EnumSet expandHierarchy(EnumSet base) { + EnumSet expanded = EnumSet.copyOf(base); + if (base.contains(DataScope.RESTRICTED)) { + expanded.add(DataScope.INTERNAL); + expanded.add(DataScope.PUBLIC); + } else if (base.contains(DataScope.INTERNAL)) { + expanded.add(DataScope.PUBLIC); + } + return expanded; } /** 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 47ae1f8..69260f9 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 @@ -39,9 +39,13 @@ class ScopeContextTest { @Test void mapsUnderscoreUppercaseRolesToScopes() { + // RESTRICTED-яльная роль теперь иерархически расширяется в + // PUBLIC+INTERNAL+RESTRICTED (см. expandHierarchy в ScopeContext). + // Иначе RESTRICTED user не мог бы читать PUBLIC справочники + // (RecordReadService использует List.contains, не visibleTo). setJwtWithRealmRoles(List.of("ORDINIS_RESTRICTED")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactly(DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test @@ -71,9 +75,10 @@ class ScopeContextTest { @Test void mapsMixedColonAndDashRoles() { + // RESTRICTED → expand добавляет INTERNAL+PUBLIC. PUBLIC уже был. setJwtWithRealmRoles(List.of("ordinis:client:public", "ordinis-restricted")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test @@ -85,36 +90,55 @@ class ScopeContextTest { } @Test - void adminRoleGetsRestricted() { + void adminRoleGetsAllScopes() { // Любая роль с tail'ом ADMIN (admin / ordinis-admin / ADMIN_FOO с - // суффиксом ADMIN) даёт RESTRICTED — RESTRICTED visibleTo всем уровням. - // Раньше admin молча проваливался в normalizeRoleToScope → PUBLIC → - // админ получал 403 на /admin/* endpoint'ах с requireInternal(). + // суффиксом ADMIN) даёт RESTRICTED — а через expandHierarchy и + // INTERNAL + PUBLIC. Раньше admin молча проваливался в + // normalizeRoleToScope → PUBLIC → админ получал 403 на /admin/* + // endpoint'ах с requireInternal(). Сейчас admin получает все три + // scope'а, может читать PUBLIC/INTERNAL/RESTRICTED справочники. setJwtWithRealmRoles(List.of("admin")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactly(DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test - void ordinisDashAdminGetsRestricted() { + void ordinisDashAdminGetsAllScopes() { setJwtWithRealmRoles(List.of("ordinis-admin")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactly(DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test - void adminColonSeparatedGetsRestricted() { + void adminColonSeparatedGetsAllScopes() { setJwtWithRealmRoles(List.of("ordinis:client:admin")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactly(DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test void adminAlongsideExplicitScopesUnionizes() { - // admin + ordinis-public → RESTRICTED (admin) + PUBLIC (ordinis-public). + // admin → RESTRICTED → expand до всех трёх. Дополнительный + // ordinis-public ничего не добавляет (уже в наборе). setJwtWithRealmRoles(List.of("admin", "ordinis-public")); assertThat(new ScopeContext(propsNoQuery).currentScopes()) - .containsExactlyInAnyOrder(DataScope.RESTRICTED, DataScope.PUBLIC); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); + } + + @Test + void internalOnlyExpandsToPublic() { + // INTERNAL role → INTERNAL + auto PUBLIC. PUBLIC справочники видны. + setJwtWithRealmRoles(List.of("ordinis-internal")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL); + } + + @Test + void publicOnlyStaysPublic() { + // PUBLIC role не расширяется — нечего расширять (он самый широкий снизу). + setJwtWithRealmRoles(List.of("ordinis-public")); + assertThat(new ScopeContext(propsNoQuery).currentScopes()) + .containsExactly(DataScope.PUBLIC); } // ============= JWT vs query ============= @@ -151,10 +175,11 @@ class ScopeContextTest { @Test void supportsCustomClaimPath() { + // RESTRICTED → expand добавляет INTERNAL (PUBLIC уже был). var props = new OrdinisAuthProperties(null, null, null, "scopes", false, false); setJwtWithFlatScopes(List.of("PUBLIC", "RESTRICTED")); assertThat(new ScopeContext(props).currentScopes()) - .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED); + .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED); } @Test