Files
cameleer-saas/docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

36 KiB

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

-- 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
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

package com.cameleer.server.core.rbac;

public enum AssignmentOrigin {
    direct, managed
}
  • Step 2: Create ClaimMappingRule record
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
package com.cameleer.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
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

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<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/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
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<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/cameleer-server && mvn test -pl cameleer-server-app -Dtest=ClaimMappingServiceTest Expected: All 7 tests PASS.

  • Step 5: Commit
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

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<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 cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java (or create a new RbacBeanConfig.java):

@Bean
public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) {
    return new PostgresClaimMappingRepository(jdbcTemplate);
}

@Bean
public ClaimMappingService claimMappingService() {
    return new ClaimMappingService();
}
  • Step 3: Commit
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:

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:

@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':

@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'
@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/cameleer-server && mvn test -pl cameleer-server-app Expected: All existing tests still pass (migration adds columns with defaults).

  • Step 6: Commit
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:

private final ClaimMappingService claimMappingService;
private final ClaimMappingRepository claimMappingRepository;
  • Step 2: Replace syncOidcRoles with applyClaimMappings

Replace the syncOidcRoles method (lines 176-208) with:

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:

// 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/cameleer-server && mvn test -pl cameleer-server-app Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).

  • Step 5: Commit
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

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:

// 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:

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:

@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
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

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<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/cameleer-server && mvn test -pl cameleer-server-app Expected: PASS.

  • Step 4: Commit
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

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
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
git add -A
git commit -m "chore: finalize auth & RBAC overhaul — all tests passing"