feat: implement ClaimMappingService with equals/contains/regex matching
- Evaluates JWT claims against mapping rules - Supports equals, contains (list + space-separated), regex match types - Results sorted by priority - 7 unit tests covering all match types and edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class ClaimMappingServiceTest {
|
||||||
|
|
||||||
|
private ClaimMappingService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new ClaimMappingService();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_containsMatch_onStringArrayClaim() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
|
||||||
|
"assignRole", "ADMIN", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("groups", List.of("eng", "cameleer-admins", "devops"));
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).rule()).isEqualTo(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_equalsMatch_onStringClaim() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "department", "equals", "platform",
|
||||||
|
"assignRole", "OPERATOR", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("department", "platform");
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_regexMatch() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "email", "regex", ".*@example\\.com$",
|
||||||
|
"addToGroup", "Example Corp", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("email", "john@example.com");
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_noMatch_returnsEmpty() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
|
||||||
|
"assignRole", "ADMIN", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("groups", List.of("eng", "devops"));
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_missingClaim_returnsEmpty() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "groups", "contains", "admins",
|
||||||
|
"assignRole", "ADMIN", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("department", "eng");
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_rulesOrderedByPriority() {
|
||||||
|
var lowPriority = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "role", "equals", "dev",
|
||||||
|
"assignRole", "VIEWER", 0, null);
|
||||||
|
var highPriority = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "role", "equals", "dev",
|
||||||
|
"assignRole", "OPERATOR", 10, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("role", "dev");
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(highPriority, lowPriority), claims);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(2);
|
||||||
|
assertThat(results.get(0).rule().priority()).isEqualTo(0);
|
||||||
|
assertThat(results.get(1).rule().priority()).isEqualTo(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void evaluate_containsMatch_onSpaceSeparatedString() {
|
||||||
|
var rule = new ClaimMappingRule(
|
||||||
|
UUID.randomUUID(), "scope", "contains", "server:admin",
|
||||||
|
"assignRole", "ADMIN", 0, null);
|
||||||
|
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid profile server:admin");
|
||||||
|
|
||||||
|
var results = service.evaluate(List.of(rule), claims);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.cameleer3.server.core.rbac;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public class ClaimMappingService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ClaimMappingService.class);
|
||||||
|
|
||||||
|
public record MappingResult(ClaimMappingRule rule) {}
|
||||||
|
|
||||||
|
public List<MappingResult> evaluate(List<ClaimMappingRule> rules, Map<String, Object> claims) {
|
||||||
|
return rules.stream()
|
||||||
|
.sorted(Comparator.comparingInt(ClaimMappingRule::priority))
|
||||||
|
.filter(rule -> matches(rule, claims))
|
||||||
|
.map(MappingResult::new)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matches(ClaimMappingRule rule, Map<String, Object> claims) {
|
||||||
|
Object claimValue = claims.get(rule.claim());
|
||||||
|
if (claimValue == null) return false;
|
||||||
|
|
||||||
|
return switch (rule.matchType()) {
|
||||||
|
case "equals" -> equalsMatch(claimValue, rule.matchValue());
|
||||||
|
case "contains" -> containsMatch(claimValue, rule.matchValue());
|
||||||
|
case "regex" -> regexMatch(claimValue, rule.matchValue());
|
||||||
|
default -> {
|
||||||
|
log.warn("Unknown match type: {}", rule.matchType());
|
||||||
|
yield false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean equalsMatch(Object claimValue, String matchValue) {
|
||||||
|
if (claimValue instanceof String s) {
|
||||||
|
return s.equalsIgnoreCase(matchValue);
|
||||||
|
}
|
||||||
|
return String.valueOf(claimValue).equalsIgnoreCase(matchValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean containsMatch(Object claimValue, String matchValue) {
|
||||||
|
if (claimValue instanceof List<?> list) {
|
||||||
|
return list.stream().anyMatch(item -> String.valueOf(item).equalsIgnoreCase(matchValue));
|
||||||
|
}
|
||||||
|
if (claimValue instanceof String s) {
|
||||||
|
// Space-separated string (e.g., OAuth2 scope claim)
|
||||||
|
return Arrays.stream(s.split("\\s+"))
|
||||||
|
.anyMatch(part -> part.equalsIgnoreCase(matchValue));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean regexMatch(Object claimValue, String matchValue) {
|
||||||
|
String s = String.valueOf(claimValue);
|
||||||
|
try {
|
||||||
|
return Pattern.matches(matchValue, s);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Invalid regex in claim mapping rule: {}", matchValue, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user