diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java new file mode 100644 index 00000000..7f82915b --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java @@ -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 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 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 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 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 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 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 claims = Map.of("scope", "openid profile server:admin"); + + var results = service.evaluate(List.of(rule), claims); + + assertThat(results).hasSize(1); + } +} diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java new file mode 100644 index 00000000..a3d12a0a --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java @@ -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 evaluate(List rules, Map claims) { + return rules.stream() + .sorted(Comparator.comparingInt(ClaimMappingRule::priority)) + .filter(rule -> matches(rule, claims)) + .map(MappingResult::new) + .toList(); + } + + private boolean matches(ClaimMappingRule rule, Map 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; + } + } +}