feat: replace syncOidcRoles with claim mapping evaluation on OIDC login

- OidcUserInfo now includes allClaims map from id_token + access_token
- OidcAuthController.callback() calls applyClaimMappings instead of syncOidcRoles
- applyClaimMappings evaluates rules, clears managed assignments, applies new ones
- Supports both assignRole and addToGroup actions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-07 23:13:52 +02:00
parent 7904a18f67
commit b5e85162f8
2 changed files with 63 additions and 38 deletions

View File

@@ -6,8 +6,11 @@ import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
import com.cameleer3.server.core.admin.AuditCategory; import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService; 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.RbacService;
import com.cameleer3.server.core.rbac.RoleSummary;
import com.cameleer3.server.core.rbac.SystemRole; import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfig;
@@ -33,13 +36,10 @@ import org.springframework.web.server.ResponseStatusException;
import java.net.URI; import java.net.URI;
import java.time.Instant; import java.time.Instant;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Collectors;
/** /**
* OIDC authentication endpoints for the UI. * OIDC authentication endpoints for the UI.
@@ -60,19 +60,28 @@ public class OidcAuthController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AuditService auditService; private final AuditService auditService;
private final RbacService rbacService; private final RbacService rbacService;
private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
private final GroupRepository groupRepository;
public OidcAuthController(OidcTokenExchanger tokenExchanger, public OidcAuthController(OidcTokenExchanger tokenExchanger,
OidcConfigRepository configRepository, OidcConfigRepository configRepository,
JwtService jwtService, JwtService jwtService,
UserRepository userRepository, UserRepository userRepository,
AuditService auditService, AuditService auditService,
RbacService rbacService) { RbacService rbacService,
ClaimMappingService claimMappingService,
ClaimMappingRepository claimMappingRepository,
GroupRepository groupRepository) {
this.tokenExchanger = tokenExchanger; this.tokenExchanger = tokenExchanger;
this.configRepository = configRepository; this.configRepository = configRepository;
this.jwtService = jwtService; this.jwtService = jwtService;
this.userRepository = userRepository; this.userRepository = userRepository;
this.auditService = auditService; this.auditService = auditService;
this.rbacService = rbacService; this.rbacService = rbacService;
this.claimMappingService = claimMappingService;
this.claimMappingRepository = claimMappingRepository;
this.groupRepository = groupRepository;
} }
/** /**
@@ -146,10 +155,8 @@ public class OidcAuthController {
userRepository.upsert(new UserInfo( userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), Instant.now())); userId, provider, oidcUser.email(), oidcUser.name(), Instant.now()));
// Sync system roles from OIDC scopes on every login (not just first). // Apply claim mapping rules to assign managed roles/groups from JWT claims
// This propagates scope revocations from the provider. Group memberships applyClaimMappings(userId, oidcUser.allClaims());
// (manually assigned) are not touched.
syncOidcRoles(userId, oidcUser.roles(), config.get());
List<String> roles = rbacService.getSystemRoleNames(userId); List<String> roles = rbacService.getSystemRoleNames(userId);
@@ -173,36 +180,38 @@ public class OidcAuthController {
} }
} }
private void syncOidcRoles(String userId, List<String> oidcRoles, OidcConfig config) { private void applyClaimMappings(String userId, Map<String, Object> claims) {
List<String> roleNames = !oidcRoles.isEmpty() ? oidcRoles : config.defaultRoles(); List<ClaimMappingRule> rules = claimMappingRepository.findAll();
log.info("syncOidcRoles: userId={}, oidcRoles={}, defaultRoles={}, using={}", if (rules.isEmpty()) {
userId, oidcRoles, config.defaultRoles(), roleNames); log.debug("No claim mapping rules configured, skipping for user {}", userId);
return;
// Resolve desired role IDs from OIDC scopes
Set<UUID> desired = new HashSet<>();
for (String roleName : roleNames) {
UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(roleName));
if (roleId != null) {
desired.add(roleId);
}
} }
// Only compare against directly-assigned roles (not group-inherited) rbacService.clearManagedAssignments(userId);
Set<UUID> current = rbacService.getDirectRolesForUser(userId).stream()
.filter(r -> SystemRole.isSystem(r.id()))
.map(RoleSummary::id)
.collect(Collectors.toSet());
// Add missing List<ClaimMappingService.MappingResult> results = claimMappingService.evaluate(rules, claims);
for (UUID id : desired) { for (var result : results) {
if (!current.contains(id)) { ClaimMappingRule rule = result.rule();
rbacService.assignRoleToUser(userId, id); 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());
} }
// 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);
} }
} }
} }

View File

@@ -53,7 +53,8 @@ public class OidcTokenExchanger {
this.securityProperties = securityProperties; this.securityProperties = securityProperties;
} }
public record OidcUserInfo(String subject, String email, String name, List<String> roles, String idToken) {} public record OidcUserInfo(String subject, String email, String name, List<String> roles, String idToken,
Map<String, Object> allClaims) {}
/** /**
* Exchanges an authorization code for validated user info. * Exchanges an authorization code for validated user info.
@@ -142,9 +143,24 @@ public class OidcTokenExchanger {
roles = extractRoles(claims, config.rolesClaim()); roles = extractRoles(claims, config.rolesClaim());
} }
// Merge id_token and access_token claims for claim mapping evaluation
Map<String, Object> 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={}", log.info("OIDC user authenticated: id={}, email={}, rolesClaim='{}', extractedRoles={}, idTokenClaims={}, hasAccessToken={}",
subject, email, config.rolesClaim(), roles, claims.getClaims().keySet(), accessTokenStr != null); 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);
} }
/** /**