# Plan 1: Auth & RBAC Overhaul > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add claim-based RBAC with managed/direct assignment origins, and make the server operate as a pure OAuth2 resource server when OIDC is configured. **Architecture:** Extend the existing RBAC schema with an `origin` column (direct vs managed) on assignment tables, add a `claim_mapping_rules` table, and implement a ClaimMappingService that evaluates JWT claims against mapping rules on every OIDC login. When OIDC is configured, the server becomes a pure resource server — no local login, no JWT generation for users. Agents always use server-issued tokens regardless of auth mode. **Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ **Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-server` --- ## File Map ### New Files - `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql` - `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java` - `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java` - `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java` - `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java` - `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java` - `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcOnlyModeIT.java` ### Modified Files - `cameleer-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable) - `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods - `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresUserRepository.java` — add origin-aware queries - `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping - `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode - `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` — conditional endpoint registration - `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java` — disable in OIDC-only mode - `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService - `cameleer-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists) --- ### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules **Files:** - Create: `cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql` - [ ] **Step 1: Write the migration** ```sql -- V2__claim_mapping.sql -- Add origin tracking to assignment tables ALTER TABLE user_roles ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct'; ALTER TABLE user_roles ADD COLUMN mapping_id UUID; ALTER TABLE user_groups ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct'; ALTER TABLE user_groups ADD COLUMN mapping_id UUID; -- Drop old primary keys (they don't include origin) ALTER TABLE user_roles DROP CONSTRAINT user_roles_pkey; ALTER TABLE user_roles ADD PRIMARY KEY (user_id, role_id, origin); ALTER TABLE user_groups DROP CONSTRAINT user_groups_pkey; ALTER TABLE user_groups ADD PRIMARY KEY (user_id, group_id, origin); -- Claim mapping rules table CREATE TABLE claim_mapping_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), claim TEXT NOT NULL, match_type TEXT NOT NULL, match_value TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL, priority INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT chk_match_type CHECK (match_type IN ('equals', 'contains', 'regex')), CONSTRAINT chk_action CHECK (action IN ('assignRole', 'addToGroup')) ); -- Foreign key from assignments to mapping rules ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_mapping FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE; ALTER TABLE user_groups ADD CONSTRAINT fk_user_groups_mapping FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE; -- Index for fast managed assignment cleanup CREATE INDEX idx_user_roles_origin ON user_roles(user_id, origin); CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin); ``` - [ ] **Step 2: Run migration to verify** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn flyway:migrate -pl cameleer-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer -Dflyway.user=cameleer -Dflyway.password=cameleer_dev` If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers. - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/resources/db/migration/V2__claim_mapping.sql git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments" ``` --- ### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java` - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java` - [ ] **Step 1: Create AssignmentOrigin enum** ```java package com.cameleer.server.core.rbac; public enum AssignmentOrigin { direct, managed } ``` - [ ] **Step 2: Create ClaimMappingRule record** ```java package com.cameleer.server.core.rbac; import java.time.Instant; import java.util.UUID; public record ClaimMappingRule( UUID id, String claim, String matchType, String matchValue, String action, String target, int priority, Instant createdAt ) { public enum MatchType { equals, contains, regex } public enum Action { assignRole, addToGroup } } ``` - [ ] **Step 3: Create ClaimMappingRepository interface** ```java package com.cameleer.server.core.rbac; import java.util.List; import java.util.Optional; import java.util.UUID; public interface ClaimMappingRepository { List findAll(); Optional findById(UUID id); UUID create(String claim, String matchType, String matchValue, String action, String target, int priority); void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority); void delete(UUID id); } ``` - [ ] **Step 4: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/AssignmentOrigin.java git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRule.java git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingRepository.java git commit -m "feat: add ClaimMappingRule domain model and repository interface" ``` --- ### Task 3: Core Domain — ClaimMappingService **Files:** - Create: `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java` - Create: `cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java` - [ ] **Step 1: Write tests for ClaimMappingService** ```java package com.cameleer.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); } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false` Expected: Compilation error — ClaimMappingService does not exist yet. - [ ] **Step 3: Implement ClaimMappingService** ```java package com.cameleer.server.core.rbac; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; 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; } } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest` Expected: All 7 tests PASS. - [ ] **Step 5: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/ClaimMappingService.java git add cameleer-server-app/src/test/java/com/cameleer/server/core/rbac/ClaimMappingServiceTest.java git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching" ``` --- ### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java` - [ ] **Step 1: Implement PostgresClaimMappingRepository** ```java package com.cameleer.server.app.storage; import com.cameleer.server.core.rbac.ClaimMappingRepository; import com.cameleer.server.core.rbac.ClaimMappingRule; import org.springframework.jdbc.core.JdbcTemplate; import java.util.List; import java.util.Optional; import java.util.UUID; public class PostgresClaimMappingRepository implements ClaimMappingRepository { private final JdbcTemplate jdbc; public PostgresClaimMappingRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } @Override public List findAll() { return jdbc.query(""" SELECT id, claim, match_type, match_value, action, target, priority, created_at FROM claim_mapping_rules ORDER BY priority, created_at """, (rs, i) -> new ClaimMappingRule( rs.getObject("id", UUID.class), rs.getString("claim"), rs.getString("match_type"), rs.getString("match_value"), rs.getString("action"), rs.getString("target"), rs.getInt("priority"), rs.getTimestamp("created_at").toInstant() )); } @Override public Optional findById(UUID id) { var results = jdbc.query(""" SELECT id, claim, match_type, match_value, action, target, priority, created_at FROM claim_mapping_rules WHERE id = ? """, (rs, i) -> new ClaimMappingRule( rs.getObject("id", UUID.class), rs.getString("claim"), rs.getString("match_type"), rs.getString("match_value"), rs.getString("action"), rs.getString("target"), rs.getInt("priority"), rs.getTimestamp("created_at").toInstant() ), id); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @Override public UUID create(String claim, String matchType, String matchValue, String action, String target, int priority) { UUID id = UUID.randomUUID(); jdbc.update(""" INSERT INTO claim_mapping_rules (id, claim, match_type, match_value, action, target, priority) VALUES (?, ?, ?, ?, ?, ?, ?) """, id, claim, matchType, matchValue, action, target, priority); return id; } @Override public void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority) { jdbc.update(""" UPDATE claim_mapping_rules SET claim = ?, match_type = ?, match_value = ?, action = ?, target = ?, priority = ? WHERE id = ? """, claim, matchType, matchValue, action, target, priority, id); } @Override public void delete(UUID id) { jdbc.update("DELETE FROM claim_mapping_rules WHERE id = ?", id); } } ``` - [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)** Add to `cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`): ```java @Bean public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) { return new PostgresClaimMappingRepository(jdbcTemplate); } @Bean public ClaimMappingService claimMappingService() { return new ClaimMappingService(); } ``` - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresClaimMappingRepository.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java git commit -m "feat: implement PostgresClaimMappingRepository and wire beans" ``` --- ### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java` - [ ] **Step 1: Add managed assignment methods to RbacService interface** In `cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java`, add: ```java void clearManagedAssignments(String userId); void assignManagedRole(String userId, UUID roleId, UUID mappingId); void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId); ``` - [ ] **Step 2: Implement in RbacServiceImpl** Add these methods to `RbacServiceImpl.java`: ```java @Override public void clearManagedAssignments(String userId) { jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed'", userId); jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed'", userId); } @Override public void assignManagedRole(String userId, UUID roleId, UUID mappingId) { jdbc.update(""" INSERT INTO user_roles (user_id, role_id, origin, mapping_id) VALUES (?, ?, 'managed', ?) ON CONFLICT (user_id, role_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id """, userId, roleId, mappingId); } @Override public void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId) { jdbc.update(""" INSERT INTO user_groups (user_id, group_id, origin, mapping_id) VALUES (?, ?, 'managed', ?) ON CONFLICT (user_id, group_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id """, userId, groupId, mappingId); } ``` - [ ] **Step 3: Update existing assignRoleToUser to specify origin='direct'** Modify the existing `assignRoleToUser` and `addUserToGroup` methods to explicitly set `origin = 'direct'`: ```java @Override public void assignRoleToUser(String userId, UUID roleId) { jdbc.update(""" INSERT INTO user_roles (user_id, role_id, origin) VALUES (?, ?, 'direct') ON CONFLICT (user_id, role_id, origin) DO NOTHING """, userId, roleId); } @Override public void addUserToGroup(String userId, UUID groupId) { jdbc.update(""" INSERT INTO user_groups (user_id, group_id, origin) VALUES (?, ?, 'direct') ON CONFLICT (user_id, group_id, origin) DO NOTHING """, userId, groupId); } ``` - [ ] **Step 4: Update getDirectRolesForUser to filter by origin='direct'** ```java @Override public List getDirectRolesForUser(String userId) { return jdbc.query(""" SELECT r.id, r.name, r.system FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ? AND ur.origin = 'direct' """, (rs, i) -> new RoleSummary( rs.getObject("id", UUID.class), rs.getString("name"), rs.getBoolean("system"), "direct" ), userId); } ``` - [ ] **Step 5: Run existing tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app` Expected: All existing tests still pass (migration adds columns with defaults). - [ ] **Step 6: Commit** ```bash git add cameleer-server-core/src/main/java/com/cameleer/server/core/rbac/RbacService.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/rbac/RbacServiceImpl.java git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService" ``` --- ### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java` - [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository** Add to constructor: ```java private final ClaimMappingService claimMappingService; private final ClaimMappingRepository claimMappingRepository; ``` - [ ] **Step 2: Replace syncOidcRoles with applyClaimMappings** Replace the `syncOidcRoles` method (lines 176-208) with: ```java private void applyClaimMappings(String userId, Map claims) { List rules = claimMappingRepository.findAll(); if (rules.isEmpty()) { log.debug("No claim mapping rules configured, skipping for user {}", userId); return; } rbacService.clearManagedAssignments(userId); List 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" -> { // Look up group by name 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()); } } } } ``` - [ ] **Step 3: Update callback() to call applyClaimMappings** In the `callback()` method, replace the `syncOidcRoles(userId, oidcRoles, config)` call with: ```java // Extract all claims from the access token for claim mapping Map claims = tokenExchanger.extractAllClaims(oidcUser); applyClaimMappings(userId, claims); ``` Note: `extractAllClaims` needs to be added to `OidcTokenExchanger` — it returns the raw JWT claims map from the access token. - [ ] **Step 4: Run existing tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app` Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly). - [ ] **Step 5: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/OidcAuthController.java git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login" ``` --- ### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured **Files:** - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java` - Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` - [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig** ```java private boolean isOidcEnabled() { return oidcIssuerUri != null && !oidcIssuerUri.isBlank(); } ``` - [ ] **Step 2: Conditionally disable local login endpoints** In `SecurityConfig.filterChain()`, when OIDC is enabled, remove `/api/v1/auth/login` and `/api/v1/auth/refresh` from public endpoints (or let them return 404). The simplest approach: add a condition in `UiAuthController`: ```java // In UiAuthController @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request) { if (oidcEnabled) { return ResponseEntity.status(404).body(Map.of("error", "Local login disabled when OIDC is configured")); } // ... existing logic } ``` - [ ] **Step 3: Modify JwtAuthenticationFilter to skip internal token path for user tokens in OIDC mode** In `JwtAuthenticationFilter`, when OIDC is enabled, only accept internal (HMAC) tokens for agent subjects (starting with no `user:` prefix or explicitly agent subjects). User-facing tokens must come from the OIDC decoder: ```java private void tryInternalToken(String token, HttpServletRequest request) { try { JwtService.JwtValidationResult result = jwtService.validateAccessToken(token); // In OIDC mode, only accept agent tokens via internal validation if (oidcDecoder != null && result.subject() != null && result.subject().startsWith("user:")) { return; // User tokens must go through OIDC path } setAuthentication(result, request); } catch (Exception e) { // Not a valid internal token, will try OIDC next } } ``` - [ ] **Step 4: Disable user admin endpoints in OIDC mode** In `UserAdminController`, add a guard for user creation and password reset: ```java @PostMapping public ResponseEntity createUser(@RequestBody CreateUserRequest request) { if (oidcEnabled) { return ResponseEntity.status(400).body(Map.of("error", "User creation disabled when OIDC is configured. Users are auto-provisioned on OIDC login.")); } // ... existing logic } @PostMapping("/{userId}/password") public ResponseEntity resetPassword(@PathVariable String userId, @RequestBody PasswordRequest request) { if (oidcEnabled) { return ResponseEntity.status(400).body(Map.of("error", "Password management disabled when OIDC is configured")); } // ... existing logic } ``` - [ ] **Step 5: Run full test suite** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/UserAdminController.java git commit -m "feat: disable local auth when OIDC is configured (resource server mode)" ``` --- ### Task 8: Claim Mapping Admin Controller **Files:** - Create: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java` - [ ] **Step 1: Implement the controller** ```java package com.cameleer.server.app.controller; import com.cameleer.server.core.rbac.ClaimMappingRepository; import com.cameleer.server.core.rbac.ClaimMappingRule; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.net.URI; import java.util.List; import java.util.UUID; @RestController @RequestMapping("/api/v1/admin/claim-mappings") @PreAuthorize("hasRole('ADMIN')") @Tag(name = "Claim Mapping Admin", description = "Manage OIDC claim-to-role/group mapping rules") public class ClaimMappingAdminController { private final ClaimMappingRepository repository; public ClaimMappingAdminController(ClaimMappingRepository repository) { this.repository = repository; } @GetMapping @Operation(summary = "List all claim mapping rules") public List list() { return repository.findAll(); } @GetMapping("/{id}") @Operation(summary = "Get a claim mapping rule by ID") public ResponseEntity get(@PathVariable UUID id) { return repository.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } record CreateRuleRequest(String claim, String matchType, String matchValue, String action, String target, int priority) {} @PostMapping @Operation(summary = "Create a claim mapping rule") public ResponseEntity create(@RequestBody CreateRuleRequest request) { UUID id = repository.create( request.claim(), request.matchType(), request.matchValue(), request.action(), request.target(), request.priority()); return repository.findById(id) .map(rule -> ResponseEntity.created(URI.create("/api/v1/admin/claim-mappings/" + id)).body(rule)) .orElse(ResponseEntity.internalServerError().build()); } @PutMapping("/{id}") @Operation(summary = "Update a claim mapping rule") public ResponseEntity update(@PathVariable UUID id, @RequestBody CreateRuleRequest request) { if (repository.findById(id).isEmpty()) { return ResponseEntity.notFound().build(); } repository.update(id, request.claim(), request.matchType(), request.matchValue(), request.action(), request.target(), request.priority()); return repository.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.internalServerError().build()); } @DeleteMapping("/{id}") @Operation(summary = "Delete a claim mapping rule") public ResponseEntity delete(@PathVariable UUID id) { if (repository.findById(id).isEmpty()) { return ResponseEntity.notFound().build(); } repository.delete(id); return ResponseEntity.noContent().build(); } } ``` - [ ] **Step 2: Add endpoint to SecurityConfig** In `SecurityConfig.filterChain()`, the `/api/v1/admin/**` path already requires ADMIN role. No changes needed. - [ ] **Step 3: Run full test suite** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ClaimMappingAdminController.java git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules" ``` --- ### Task 9: Integration Test — Claim Mapping End-to-End **Files:** - Create: `cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java` - [ ] **Step 1: Write integration test** ```java package com.cameleer.server.app.controller; import com.cameleer.server.app.AbstractPostgresIT; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.http.*; import static org.assertj.core.api.Assertions.assertThat; class ClaimMappingAdminControllerIT extends AbstractPostgresIT { @Autowired private TestRestTemplate restTemplate; @Autowired private ObjectMapper objectMapper; @Autowired private TestSecurityHelper securityHelper; private HttpHeaders adminHeaders; @BeforeEach void setUp() { adminHeaders = securityHelper.adminHeaders(); } @Test void createAndListRules() throws Exception { String body = """ {"claim":"groups","matchType":"contains","matchValue":"admins","action":"assignRole","target":"ADMIN","priority":0} """; var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings", HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class); assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); var listResponse = restTemplate.exchange("/api/v1/admin/claim-mappings", HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class); assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); JsonNode rules = objectMapper.readTree(listResponse.getBody()); assertThat(rules.isArray()).isTrue(); assertThat(rules.size()).isGreaterThanOrEqualTo(1); } @Test void deleteRule() throws Exception { String body = """ {"claim":"dept","matchType":"equals","matchValue":"eng","action":"assignRole","target":"VIEWER","priority":0} """; var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings", HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class); JsonNode created = objectMapper.readTree(createResponse.getBody()); String id = created.get("id").asText(); var deleteResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id, HttpMethod.DELETE, new HttpEntity<>(adminHeaders), Void.class); assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); var getResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id, HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class); assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } } ``` - [ ] **Step 2: Run integration tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingAdminControllerIT` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ClaimMappingAdminControllerIT.java git commit -m "test: add integration tests for claim mapping admin API" ``` --- ### Task 10: Run Full Test Suite and Final Verification - [ ] **Step 1: Run all tests** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify` Expected: All tests PASS. Build succeeds. - [ ] **Step 2: Verify migration applies cleanly on fresh database** Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn test -pl cameleer-server-app -Dtest=AbstractPostgresIT` Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads. - [ ] **Step 3: Commit any remaining fixes** ```bash git add -A git commit -m "chore: finalize auth & RBAC overhaul — all tests passing" ```