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