fix(auth): роль admin даёт RESTRICTED scope (видит всё)
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.
This commit is contained in:
@@ -87,15 +87,44 @@ public class ScopeContext {
|
||||
if (raw == null) return EnumSet.of(DataScope.PUBLIC);
|
||||
EnumSet<DataScope> 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 суффикс).
|
||||
*
|
||||
* <p>Раньше admin-роль молча проваливалась в normalizeRoleToScope (нет
|
||||
* матча на PUBLIC/INTERNAL/RESTRICTED suffix) → user получал только PUBLIC
|
||||
* → 403 на любом /admin/* endpoint'е с requireInternal(). UX чинит:
|
||||
* админу не нужно явно иметь ordinis-internal в Keycloak, роль admin
|
||||
* сама по себе даёт максимальный доступ.
|
||||
*/
|
||||
private static void processRole(EnumSet<DataScope> 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("\\.");
|
||||
|
||||
+35
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user