# 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\cameleer3-server`

---

## File Map

### New Files
- `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcOnlyModeIT.java`

### Modified Files
- `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — conditional endpoint registration
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
- `cameleer3-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: `cameleer3-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/cameleer3-server && mvn flyway:migrate -pl cameleer3-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer3 -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 cameleer3-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: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`

- [ ] **Step 1: Create AssignmentOrigin enum**

```java
package com.cameleer3.server.core.rbac;

public enum AssignmentOrigin {
    direct, managed
}
```

- [ ] **Step 2: Create ClaimMappingRule record**

```java
package com.cameleer3.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.cameleer3.server.core.rbac;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface ClaimMappingRepository {
    List<ClaimMappingRule> findAll();
    Optional<ClaimMappingRule> 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 cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
```

---

### Task 3: Core Domain — ClaimMappingService

**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`

- [ ] **Step 1: Write tests for ClaimMappingService**

```java
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);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: Compilation error — ClaimMappingService does not exist yet.

- [ ] **Step 3: Implement ClaimMappingService**

```java
package com.cameleer3.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<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;
        }
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest`
Expected: All 7 tests PASS.

- [ ] **Step 5: Commit**

```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
```

---

### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation

**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`

- [ ] **Step 1: Implement PostgresClaimMappingRepository**

```java
package com.cameleer3.server.app.storage;

import com.cameleer3.server.core.rbac.ClaimMappingRepository;
import com.cameleer3.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<ClaimMappingRule> 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<ClaimMappingRule> 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 `cameleer3-server-app/src/main/java/com/cameleer3/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 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
```

---

### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments

**Files:**
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java`

- [ ] **Step 1: Add managed assignment methods to RbacService interface**

In `cameleer3-server-core/src/main/java/com/cameleer3/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<RoleSummary> 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/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: All existing tests still pass (migration adds columns with defaults).

- [ ] **Step 6: Commit**

```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/java/com/cameleer3/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<String, Object> claims) {
    List<ClaimMappingRule> rules = claimMappingRepository.findAll();
    if (rules.isEmpty()) {
        log.debug("No claim mapping rules configured, skipping for user {}", userId);
        return;
    }

    rbacService.clearManagedAssignments(userId);

    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" -> {
                // 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<String, Object> 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/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).

- [ ] **Step 5: Commit**

```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/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/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`

- [ ] **Step 1: Implement the controller**

```java
package com.cameleer3.server.app.controller;

import com.cameleer3.server.core.rbac.ClaimMappingRepository;
import com.cameleer3.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<ClaimMappingRule> list() {
        return repository.findAll();
    }

    @GetMapping("/{id}")
    @Operation(summary = "Get a claim mapping rule by ID")
    public ResponseEntity<ClaimMappingRule> 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<ClaimMappingRule> 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<ClaimMappingRule> 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<Void> 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/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/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: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`

- [ ] **Step 1: Write integration test**

```java
package com.cameleer3.server.app.controller;

import com.cameleer3.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/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingAdminControllerIT`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add cameleer3-server-app/src/test/java/com/cameleer3/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/cameleer3-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/cameleer3-server && mvn test -pl cameleer3-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"
```
