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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user