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 c7df0bc2..1f1c89c7 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 @@ -6,8 +6,11 @@ import com.cameleer3.server.app.dto.OidcPublicConfigResponse; 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.ClaimMappingRepository; +import com.cameleer3.server.core.rbac.ClaimMappingRule; +import com.cameleer3.server.core.rbac.ClaimMappingService; +import com.cameleer3.server.core.rbac.GroupRepository; 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; @@ -33,13 +36,10 @@ 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. @@ -60,19 +60,28 @@ public class OidcAuthController { private final UserRepository userRepository; private final AuditService auditService; private final RbacService rbacService; + private final ClaimMappingService claimMappingService; + private final ClaimMappingRepository claimMappingRepository; + private final GroupRepository groupRepository; public OidcAuthController(OidcTokenExchanger tokenExchanger, OidcConfigRepository configRepository, JwtService jwtService, UserRepository userRepository, AuditService auditService, - RbacService rbacService) { + RbacService rbacService, + ClaimMappingService claimMappingService, + ClaimMappingRepository claimMappingRepository, + GroupRepository groupRepository) { this.tokenExchanger = tokenExchanger; this.configRepository = configRepository; this.jwtService = jwtService; this.userRepository = userRepository; this.auditService = auditService; this.rbacService = rbacService; + this.claimMappingService = claimMappingService; + this.claimMappingRepository = claimMappingRepository; + this.groupRepository = groupRepository; } /** @@ -146,10 +155,8 @@ public class OidcAuthController { userRepository.upsert(new UserInfo( userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); - // 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()); + // Apply claim mapping rules to assign managed roles/groups from JWT claims + applyClaimMappings(userId, oidcUser.allClaims()); List roles = rbacService.getSystemRoleNames(userId); @@ -173,36 +180,38 @@ public class OidcAuthController { } } - private void syncOidcRoles(String userId, List oidcRoles, OidcConfig config) { - List roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles(); - log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}", - userId, oidcRoles, config.defaultRoles(), roleNames); - - // Resolve desired role IDs from OIDC scopes - Set desired = new HashSet<>(); - for (String roleName : roleNames) { - UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(roleName)); - if (roleId != null) { - desired.add(roleId); - } + private void applyClaimMappings(String userId, Map claims) { + List rules = claimMappingRepository.findAll(); + if (rules.isEmpty()) { + log.debug("No claim mapping rules configured, skipping for user {}", userId); + return; } - // Only compare against directly-assigned roles (not group-inherited) - Set current = rbacService.getDirectRolesForUser(userId).stream() - .filter(r -> SystemRole.isSystem(r.id())) - .map(RoleSummary::id) - .collect(Collectors.toSet()); + rbacService.clearManagedAssignments(userId); - // 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); + List results = claimMappingService.evaluate(rules, claims); + for (var result : results) { + ClaimMappingRule rule = result.rule(); + switch (rule.action()) { + case "assignRole" -> { + UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(rule.target())); + if (roleId == null) { + log.warn("Claim mapping target role '{}' not found, skipping", rule.target()); + continue; + } + rbacService.assignManagedRole(userId, roleId, rule.id()); + log.debug("Managed role {} assigned to {} via mapping {}", rule.target(), userId, rule.id()); + } + case "addToGroup" -> { + var groups = groupRepository.findAll(); + var group = groups.stream().filter(g -> g.name().equalsIgnoreCase(rule.target())).findFirst(); + if (group.isEmpty()) { + log.warn("Claim mapping target group '{}' not found, skipping", rule.target()); + continue; + } + rbacService.addUserToManagedGroup(userId, group.get().id(), rule.id()); + log.debug("Managed group {} assigned to {} via mapping {}", rule.target(), userId, rule.id()); + } } } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java index bb4c8855..0646da42 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcTokenExchanger.java @@ -53,7 +53,8 @@ public class OidcTokenExchanger { this.securityProperties = securityProperties; } - public record OidcUserInfo(String subject, String email, String name, List roles, String idToken) {} + public record OidcUserInfo(String subject, String email, String name, List roles, String idToken, + Map allClaims) {} /** * Exchanges an authorization code for validated user info. @@ -142,9 +143,24 @@ public class OidcTokenExchanger { roles = extractRoles(claims, config.rolesClaim()); } + // Merge id_token and access_token claims for claim mapping evaluation + Map allClaims = new java.util.HashMap<>(claims.getClaims()); + if (accessTokenStr != null && accessTokenStr.contains(".")) { + try { + String audience = config.audience() != null ? config.audience() : ""; + JWTClaimsSet atClaims2 = decodeAccessToken(accessTokenStr, config.issuerUri(), audience); + if (atClaims2 != null) { + // Access token claims take precedence (they have scopes, roles) + atClaims2.getClaims().forEach(allClaims::putIfAbsent); + } + } catch (Exception e) { + log.debug("Could not merge access_token claims: {}", e.getMessage()); + } + } + log.info("OIDC user authenticated: id={}, email={}, rolesClaim='{}', extractedRoles={}, idTokenClaims={}, hasAccessToken={}", subject, email, config.rolesClaim(), roles, claims.getClaims().keySet(), accessTokenStr != null); - return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr); + return new OidcUserInfo(subject, email != null ? email : "", name != null ? name : "", roles, idTokenStr, allClaims); } /**