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.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<String> roles = rbacService.getSystemRoleNames(userId);
@@ -173,36 +180,38 @@ public class OidcAuthController {
}
}
private void syncOidcRoles(String userId, List<String> oidcRoles, OidcConfig config) {
List<String> 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<UUID> 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<String, Object> claims) {
List<ClaimMappingRule> 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<UUID> 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<ClaimMappingService.MappingResult> 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());
}
}
}
}

View File

@@ -53,7 +53,8 @@ public class OidcTokenExchanger {
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.
@@ -142,9 +143,24 @@ public class OidcTokenExchanger {
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={}",
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);
}
/**