docs: add architecture review spec and implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
986
docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md
Normal file
986
docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md
Normal file
@@ -0,0 +1,986 @@
|
||||
# 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"
|
||||
```
|
||||
Reference in New Issue
Block a user