From f4eafd9a0fa92e42c78eae24f48b4c11fe240579 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:11:06 +0200 Subject: [PATCH] feat: sync OIDC roles on every login, not just first Roles from the id_token's rolesClaim are now diffed against stored system roles on each OIDC login. Missing roles are added, revoked roles are removed. Group memberships (manually assigned) are never touched. This propagates scope revocations from the OIDC provider on next user login. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/security/OidcAuthController.java | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java index ec4e162b..9cb0ca95 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java @@ -7,6 +7,7 @@ import com.cameleer3.server.core.admin.AuditCategory; import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.rbac.RbacService; +import com.cameleer3.server.core.rbac.RoleSummary; import com.cameleer3.server.core.rbac.SystemRole; import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.OidcConfig; @@ -32,10 +33,13 @@ import org.springframework.web.server.ResponseStatusException; import java.net.URI; import java.time.Instant; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; /** * OIDC authentication endpoints for the UI. @@ -140,10 +144,10 @@ public class OidcAuthController { userRepository.upsert(new UserInfo( userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); - // Assign roles if new user - if (existingUser.isEmpty()) { - assignRolesForNewUser(userId, oidcUser.roles(), config.get()); - } + // Sync system roles from OIDC scopes on every login (not just first). + // This propagates scope revocations from the provider. Group memberships + // (manually assigned) are not touched. + syncOidcRoles(userId, oidcUser.roles(), config.get()); List roles = rbacService.getSystemRoleNames(userId); @@ -167,8 +171,11 @@ public class OidcAuthController { } } - private void assignRolesForNewUser(String userId, List oidcRoles, OidcConfig config) { + private void syncOidcRoles(String userId, List oidcRoles, OidcConfig config) { List roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles(); + + // Resolve desired role IDs from OIDC scopes + Set desired = new HashSet<>(); for (String roleName : roleNames) { String normalized = roleName.toUpperCase(); if (normalized.startsWith("SERVER:")) { @@ -176,7 +183,26 @@ public class OidcAuthController { } UUID roleId = SystemRole.BY_NAME.get(normalized); if (roleId != null) { - rbacService.assignRoleToUser(userId, roleId); + desired.add(roleId); + } + } + + // Current system roles (excludes group-inherited roles) + Set current = rbacService.getEffectiveRolesForUser(userId).stream() + .filter(r -> SystemRole.isSystem(r.id())) + .map(RoleSummary::id) + .collect(Collectors.toSet()); + + // Add missing + for (UUID id : desired) { + if (!current.contains(id)) { + rbacService.assignRoleToUser(userId, id); + } + } + // Remove revoked (skip AGENT — never managed by OIDC) + for (UUID id : current) { + if (!desired.contains(id) && !id.equals(SystemRole.AGENT_ID)) { + rbacService.removeRoleFromUser(userId, id); } } }