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.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()));
|
||||||
// Remove revoked (skip AGENT — never managed by OIDC)
|
if (roleId == null) {
|
||||||
for (UUID id : current) {
|
log.warn("Claim mapping target role '{}' not found, skipping", rule.target());
|
||||||
if (!desired.contains(id) && !id.equals(SystemRole.AGENT_ID)) {
|
continue;
|
||||||
rbacService.removeRoleFromUser(userId, id);
|
}
|
||||||
|
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;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user