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