fix(auth): иерархическое расширение scope set в ScopeContext

This commit is contained in:
Александр Зимин
2026-05-26 12:25:14 +00:00
parent 90a6c1185e
commit 6977ba106d
2 changed files with 66 additions and 15 deletions
@@ -93,7 +93,33 @@ public class ScopeContext {
processRole(result, part); 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})
* работали с уже расширенным набором.
*
* <p>Без этого юзер с 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<DataScope> expandHierarchy(EnumSet<DataScope> base) {
EnumSet<DataScope> 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;
} }
/** /**
@@ -39,9 +39,13 @@ class ScopeContextTest {
@Test @Test
void mapsUnderscoreUppercaseRolesToScopes() { void mapsUnderscoreUppercaseRolesToScopes() {
// RESTRICTED-яльная роль теперь иерархически расширяется в
// PUBLIC+INTERNAL+RESTRICTED (см. expandHierarchy в ScopeContext).
// Иначе RESTRICTED user не мог бы читать PUBLIC справочники
// (RecordReadService использует List.contains, не visibleTo).
setJwtWithRealmRoles(List.of("ORDINIS_RESTRICTED")); setJwtWithRealmRoles(List.of("ORDINIS_RESTRICTED"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test
@@ -71,9 +75,10 @@ class ScopeContextTest {
@Test @Test
void mapsMixedColonAndDashRoles() { void mapsMixedColonAndDashRoles() {
// RESTRICTED → expand добавляет INTERNAL+PUBLIC. PUBLIC уже был.
setJwtWithRealmRoles(List.of("ordinis:client:public", "ordinis-restricted")); setJwtWithRealmRoles(List.of("ordinis:client:public", "ordinis-restricted"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test
@@ -85,36 +90,55 @@ class ScopeContextTest {
} }
@Test @Test
void adminRoleGetsRestricted() { void adminRoleGetsAllScopes() {
// Любая роль с tail'ом ADMIN (admin / ordinis-admin / ADMIN_FOO с // Любая роль с tail'ом ADMIN (admin / ordinis-admin / ADMIN_FOO с
// суффиксом ADMIN) даёт RESTRICTED — RESTRICTED visibleTo всем уровням. // суффиксом ADMIN) даёт RESTRICTED — а через expandHierarchy и
// Раньше admin молча проваливался в normalizeRoleToScope → PUBLIC → // INTERNAL + PUBLIC. Раньше admin молча проваливался в
// админ получал 403 на /admin/* endpoint'ах с requireInternal(). // normalizeRoleToScope → PUBLIC → админ получал 403 на /admin/*
// endpoint'ах с requireInternal(). Сейчас admin получает все три
// scope'а, может читать PUBLIC/INTERNAL/RESTRICTED справочники.
setJwtWithRealmRoles(List.of("admin")); setJwtWithRealmRoles(List.of("admin"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test
void ordinisDashAdminGetsRestricted() { void ordinisDashAdminGetsAllScopes() {
setJwtWithRealmRoles(List.of("ordinis-admin")); setJwtWithRealmRoles(List.of("ordinis-admin"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test
void adminColonSeparatedGetsRestricted() { void adminColonSeparatedGetsAllScopes() {
setJwtWithRealmRoles(List.of("ordinis:client:admin")); setJwtWithRealmRoles(List.of("ordinis:client:admin"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) assertThat(new ScopeContext(propsNoQuery).currentScopes())
.containsExactly(DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test
void adminAlongsideExplicitScopesUnionizes() { void adminAlongsideExplicitScopesUnionizes() {
// admin + ordinis-public → RESTRICTED (admin) + PUBLIC (ordinis-public). // admin → RESTRICTED → expand до всех трёх. Дополнительный
// ordinis-public ничего не добавляет (уже в наборе).
setJwtWithRealmRoles(List.of("admin", "ordinis-public")); setJwtWithRealmRoles(List.of("admin", "ordinis-public"));
assertThat(new ScopeContext(propsNoQuery).currentScopes()) 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 ============= // ============= JWT vs query =============
@@ -151,10 +175,11 @@ class ScopeContextTest {
@Test @Test
void supportsCustomClaimPath() { void supportsCustomClaimPath() {
// RESTRICTED → expand добавляет INTERNAL (PUBLIC уже был).
var props = new OrdinisAuthProperties(null, null, null, "scopes", false, false); var props = new OrdinisAuthProperties(null, null, null, "scopes", false, false);
setJwtWithFlatScopes(List.of("PUBLIC", "RESTRICTED")); setJwtWithFlatScopes(List.of("PUBLIC", "RESTRICTED"));
assertThat(new ScopeContext(props).currentScopes()) assertThat(new ScopeContext(props).currentScopes())
.containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.RESTRICTED); .containsExactlyInAnyOrder(DataScope.PUBLIC, DataScope.INTERNAL, DataScope.RESTRICTED);
} }
@Test @Test