docs: add architecture review spec and implementation plans
Some checks failed
CI / build (push) Failing after 25s
CI / docker (push) Has been skipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-08 08:53:22 +02:00
parent 5938643632
commit 3fa062b92c
5 changed files with 3563 additions and 0 deletions

View 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"
```

View File

@@ -0,0 +1,615 @@
# Plan 2: Server-Side License Validation
> **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 Ed25519-signed license JWT validation to the server, enabling feature gating for MOAT features (debugger, lineage, correlation) by tier.
**Architecture:** The SaaS generates Ed25519-signed license JWTs containing tier, features, limits, and expiry. The server validates the license on startup (from env var or file) or at runtime (via admin API). A `LicenseGate` service checks whether a feature is enabled before serving gated endpoints. The server's existing Ed25519 infrastructure (JDK 17 `java.security`) is reused for verification. In standalone mode without a license, all features are available (open/dev mode).
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
---
## File Map
### New Files
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
### Modified Files
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
---
### Task 1: Core Domain — LicenseInfo, Feature Enum
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
- [ ] **Step 1: Create Feature enum**
```java
package com.cameleer3.server.core.license;
public enum Feature {
topology,
lineage,
correlation,
debugger,
replay
}
```
- [ ] **Step 2: Create LicenseInfo record**
```java
package com.cameleer3.server.core.license;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
public record LicenseInfo(
String tier,
Set<Feature> features,
Map<String, Integer> limits,
Instant issuedAt,
Instant expiresAt
) {
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public boolean hasFeature(Feature feature) {
return features.contains(feature);
}
public int getLimit(String key, int defaultValue) {
return limits.getOrDefault(key, defaultValue);
}
/** Open license — all features enabled, no limits. Used when no license is configured. */
public static LicenseInfo open() {
return new LicenseInfo("open", Set.of(Feature.values()), Map.of(), Instant.now(), null);
}
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
git commit -m "feat: add LicenseInfo and Feature domain model"
```
---
### Task 2: LicenseValidator — Ed25519 JWT Verification
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
- [ ] **Step 1: Write tests**
```java
package com.cameleer3.server.core.license;
import org.junit.jupiter.api.Test;
import java.security.*;
import java.security.spec.NamedParameterSpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseValidatorTest {
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
return kpg.generateKeyPair();
}
private String sign(PrivateKey key, String payload) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(payload.getBytes());
return Base64.getEncoder().encodeToString(signer.sign());
}
@Test
void validate_validLicense_returnsLicenseInfo() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
String payload = """
{"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d}
""".formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
LicenseInfo info = validator.validate(token);
assertThat(info.tier()).isEqualTo("HIGH");
assertThat(info.hasFeature(Feature.debugger)).isTrue();
assertThat(info.hasFeature(Feature.replay)).isFalse();
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse();
}
@Test
void validate_expiredLicense_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d}
""".formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expired");
}
@Test
void validate_tamperedPayload_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999}
""".trim();
String signature = sign(kp.getPrivate(), payload);
// Tamper with payload
String tampered = payload.replace("LOW", "BUSINESS");
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("signature");
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
Expected: Compilation error — LicenseValidator does not exist.
- [ ] **Step 3: Implement LicenseValidator**
```java
package com.cameleer3.server.core.license;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
public class LicenseValidator {
private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
private final PublicKey publicKey;
public LicenseValidator(String publicKeyBase64) {
try {
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory kf = KeyFactory.getInstance("Ed25519");
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));
} catch (Exception e) {
throw new IllegalStateException("Failed to load license public key", e);
}
}
public LicenseInfo validate(String token) {
String[] parts = token.split("\\.", 2);
if (parts.length != 2) {
throw new IllegalArgumentException("Invalid license token format: expected payload.signature");
}
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
// Verify signature
try {
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payloadBytes);
if (!verifier.verify(signatureBytes)) {
throw new SecurityException("License signature verification failed");
}
} catch (SecurityException e) {
throw e;
} catch (Exception e) {
throw new SecurityException("License signature verification failed", e);
}
// Parse payload
try {
JsonNode root = objectMapper.readTree(payloadBytes);
String tier = root.get("tier").asText();
Set<Feature> features = new HashSet<>();
if (root.has("features")) {
for (JsonNode f : root.get("features")) {
try {
features.add(Feature.valueOf(f.asText()));
} catch (IllegalArgumentException e) {
log.warn("Unknown feature in license: {}", f.asText());
}
}
}
Map<String, Integer> limits = new HashMap<>();
if (root.has("limits")) {
root.get("limits").fields().forEachRemaining(entry ->
limits.put(entry.getKey(), entry.getValue().asInt()));
}
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null;
LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt);
if (info.isExpired()) {
throw new IllegalArgumentException("License expired at " + expiresAt);
}
return info;
} catch (IllegalArgumentException e) {
throw e;
} catch (Exception e) {
throw new IllegalArgumentException("Failed to parse license payload", e);
}
}
}
```
- [ ] **Step 4: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
Expected: All 3 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
```
---
### Task 3: LicenseGate — Feature Check Service
**Files:**
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
- [ ] **Step 1: Write tests**
```java
package com.cameleer3.server.core.license;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseGateTest {
@Test
void noLicense_allFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
// No license loaded → open mode
assertThat(gate.isEnabled(Feature.debugger)).isTrue();
assertThat(gate.isEnabled(Feature.replay)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.getTier()).isEqualTo("open");
}
@Test
void withLicense_onlyLicensedFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
LicenseInfo license = new LicenseInfo("MID",
Set.of(Feature.topology, Feature.lineage, Feature.correlation),
Map.of("max_agents", 10, "retention_days", 30),
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS));
gate.load(license);
assertThat(gate.isEnabled(Feature.topology)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.isEnabled(Feature.debugger)).isFalse();
assertThat(gate.isEnabled(Feature.replay)).isFalse();
assertThat(gate.getTier()).isEqualTo("MID");
assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10);
}
}
```
- [ ] **Step 2: Implement LicenseGate**
```java
package com.cameleer3.server.core.license;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.atomic.AtomicReference;
public class LicenseGate {
private static final Logger log = LoggerFactory.getLogger(LicenseGate.class);
private final AtomicReference<LicenseInfo> current = new AtomicReference<>(LicenseInfo.open());
public void load(LicenseInfo license) {
current.set(license);
log.info("License loaded: tier={}, features={}, expires={}",
license.tier(), license.features(), license.expiresAt());
}
public boolean isEnabled(Feature feature) {
return current.get().hasFeature(feature);
}
public String getTier() {
return current.get().tier();
}
public int getLimit(String key, int defaultValue) {
return current.get().getLimit(key, defaultValue);
}
public LicenseInfo getCurrent() {
return current.get();
}
}
```
- [ ] **Step 3: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
git commit -m "feat: implement LicenseGate for feature checking"
```
---
### Task 4: License Loading — Bean Config and Startup
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
- [ ] **Step 1: Add license config properties to application.yml**
```yaml
license:
token: ${CAMELEER_LICENSE_TOKEN:}
file: ${CAMELEER_LICENSE_FILE:}
public-key: ${CAMELEER_LICENSE_PUBLIC_KEY:}
```
- [ ] **Step 2: Implement LicenseBeanConfig**
```java
package com.cameleer3.server.app.config;
import com.cameleer3.server.core.license.LicenseGate;
import com.cameleer3.server.core.license.LicenseInfo;
import com.cameleer3.server.core.license.LicenseValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class LicenseBeanConfig {
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
@Value("${license.token:}")
private String licenseToken;
@Value("${license.file:}")
private String licenseFile;
@Value("${license.public-key:}")
private String licensePublicKey;
@Bean
public LicenseGate licenseGate() {
LicenseGate gate = new LicenseGate();
String token = resolveLicenseToken();
if (token == null || token.isBlank()) {
log.info("No license configured — running in open mode (all features enabled)");
return gate;
}
if (licensePublicKey == null || licensePublicKey.isBlank()) {
log.warn("License token provided but no public key configured (CAMELEER_LICENSE_PUBLIC_KEY). Running in open mode.");
return gate;
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(token);
gate.load(info);
} catch (Exception e) {
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
}
return gate;
}
private String resolveLicenseToken() {
if (licenseToken != null && !licenseToken.isBlank()) {
return licenseToken;
}
if (licenseFile != null && !licenseFile.isBlank()) {
try {
return Files.readString(Path.of(licenseFile)).trim();
} catch (Exception e) {
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
}
}
return null;
}
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
git add cameleer3-server-app/src/main/resources/application.yml
git commit -m "feat: add license loading at startup from env var or file"
```
---
### Task 5: License Admin API — Runtime License Update
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
- [ ] **Step 1: Implement controller**
```java
package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.license.LicenseGate;
import com.cameleer3.server.core.license.LicenseInfo;
import com.cameleer3.server.core.license.LicenseValidator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/admin/license")
@PreAuthorize("hasRole('ADMIN')")
@Tag(name = "License Admin", description = "License management")
public class LicenseAdminController {
private final LicenseGate licenseGate;
private final String licensePublicKey;
public LicenseAdminController(LicenseGate licenseGate,
@Value("${license.public-key:}") String licensePublicKey) {
this.licenseGate = licenseGate;
this.licensePublicKey = licensePublicKey;
}
@GetMapping
@Operation(summary = "Get current license info")
public ResponseEntity<LicenseInfo> getCurrent() {
return ResponseEntity.ok(licenseGate.getCurrent());
}
record UpdateLicenseRequest(String token) {}
@PostMapping
@Operation(summary = "Update license token at runtime")
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
if (licensePublicKey == null || licensePublicKey.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
}
try {
LicenseValidator validator = new LicenseValidator(licensePublicKey);
LicenseInfo info = validator.validate(request.token());
licenseGate.load(info);
return ResponseEntity.ok(info);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
}
```
- [ ] **Step 2: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java
git commit -m "feat: add license admin API for runtime license updates"
```
---
### Task 6: Feature Gating — Wire LicenseGate Into Endpoints
This task is a placeholder — MOAT feature endpoints don't exist yet. When they're added (debugger, lineage, correlation), they should inject `LicenseGate` and check `isEnabled(Feature.xxx)` before serving:
```java
@GetMapping("/api/v1/debug/sessions")
public ResponseEntity<?> listDebugSessions() {
if (!licenseGate.isEnabled(Feature.debugger)) {
return ResponseEntity.status(403).body(Map.of("error", "Feature 'debugger' requires a HIGH or BUSINESS tier license"));
}
// ... serve debug sessions
}
```
- [ ] **Step 1: No code changes needed now — document the pattern for MOAT feature implementation**
- [ ] **Step 2: Final verification**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: All tests PASS.

View File

@@ -0,0 +1,991 @@
# Plan 3: Runtime Management in the Server
> **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:** Move environment management, app lifecycle, JAR upload, and Docker container orchestration from the SaaS layer into the server, so the server is a self-sufficient product that can deploy and manage Camel applications.
**Architecture:** The server gains Environment/App/AppVersion/Deployment entities stored in its PostgreSQL. A `RuntimeOrchestrator` interface abstracts Docker/K8s/disabled modes, auto-detected at startup. The Docker implementation uses a shared base image + volume-mounted JARs (no per-deployment image builds). Apps are promoted between environments by creating new Deployments pointing to the same AppVersion. Routing supports both path-based and subdomain-based modes via Traefik labels.
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
---
## File Map
### New Files — Core Module (`cameleer3-server-core`)
```
src/main/java/com/cameleer3/server/core/runtime/
├── Environment.java Record: id, slug, displayName, status, createdAt
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
├── EnvironmentRepository.java Interface: CRUD + findBySlug
├── EnvironmentService.java Business logic: create, list, delete, enforce limits
├── App.java Record: id, environmentId, slug, displayName, createdAt
├── AppVersion.java Record: id, appId, version, jarPath, sha256, uploadedAt
├── AppRepository.java Interface: CRUD + findByEnvironmentId
├── AppVersionRepository.java Interface: CRUD + findByAppId
├── AppService.java Business logic: create, upload JAR, list, delete
├── Deployment.java Record: id, appId, appVersionId, environmentId, status, containerId
├── DeploymentStatus.java Enum: STARTING, RUNNING, FAILED, STOPPED
├── DeploymentRepository.java Interface: CRUD + findByAppId + findByEnvironmentId
├── DeploymentService.java Business logic: deploy, stop, restart, promote
├── RuntimeOrchestrator.java Interface: startContainer, stopContainer, getStatus, getLogs
├── RuntimeConfig.java Record: jarStoragePath, baseImage, dockerNetwork, routing, etc.
├── ContainerRequest.java Record: containerName, jarPath, envVars, memoryLimit, cpuShares
├── ContainerStatus.java Record: state, running, exitCode, error
└── RoutingMode.java Enum: path, subdomain
```
### New Files — App Module (`cameleer3-server-app`)
```
src/main/java/com/cameleer3/server/app/runtime/
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
├── DeploymentExecutor.java @Service: async deployment pipeline
├── JarStorageService.java File-system JAR storage with versioning
└── ContainerLogCollector.java Collects Docker container stdout/stderr
src/main/java/com/cameleer3/server/app/storage/
├── PostgresEnvironmentRepository.java
├── PostgresAppRepository.java
├── PostgresAppVersionRepository.java
└── PostgresDeploymentRepository.java
src/main/java/com/cameleer3/server/app/controller/
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
├── AppController.java App + version CRUD + JAR upload
└── DeploymentController.java Deploy, stop, restart, promote, logs
src/main/resources/db/migration/
└── V3__runtime_management.sql Environments, apps, app_versions, deployments tables
```
### Modified Files
- `pom.xml` (parent) — add docker-java dependency
- `cameleer3-server-app/pom.xml` — add docker-java dependency
- `application.yml` — add runtime config properties
---
### Task 1: Add docker-java Dependency
**Files:**
- Modify: `cameleer3-server-app/pom.xml`
- [ ] **Step 1: Add docker-java dependency**
```xml
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
<version>3.4.1</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
<version>3.4.1</version>
</dependency>
```
- [ ] **Step 2: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/pom.xml
git commit -m "chore: add docker-java dependency for runtime orchestration"
```
---
### Task 2: Database Migration — Runtime Management Tables
**Files:**
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
- [ ] **Step 1: Write migration**
```sql
-- V3__runtime_management.sql
-- Runtime management: environments, apps, app versions, deployments
CREATE TABLE environments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
slug VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(environment_id, slug)
);
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
CREATE TABLE app_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
jar_path VARCHAR(500) NOT NULL,
jar_checksum VARCHAR(64) NOT NULL,
jar_filename VARCHAR(255),
jar_size_bytes BIGINT,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(app_id, version)
);
CREATE INDEX idx_app_versions_app_id ON app_versions(app_id);
CREATE TABLE deployments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
app_version_id UUID NOT NULL REFERENCES app_versions(id),
environment_id UUID NOT NULL REFERENCES environments(id),
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
container_id VARCHAR(100),
container_name VARCHAR(255),
error_message TEXT,
deployed_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
CREATE INDEX idx_deployments_env_id ON deployments(environment_id);
-- Default environment (standalone mode always has at least one)
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
```
- [ ] **Step 2: Commit**
```bash
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
```
---
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
**Files:**
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
- [ ] **Step 1: Create all domain records**
```java
// Environment.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
// EnvironmentStatus.java
package com.cameleer3.server.core.runtime;
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
// App.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
// AppVersion.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
// Deployment.java
package com.cameleer3.server.core.runtime;
import java.time.Instant;
import java.util.UUID;
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
DeploymentStatus status, String containerId, String containerName,
String errorMessage, Instant deployedAt, Instant stoppedAt, Instant createdAt) {
public Deployment withStatus(DeploymentStatus newStatus) {
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
}
}
// DeploymentStatus.java
package com.cameleer3.server.core.runtime;
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
// RoutingMode.java
package com.cameleer3.server.core.runtime;
public enum RoutingMode { path, subdomain }
```
- [ ] **Step 2: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add runtime management domain records"
```
---
### Task 4: Core — Repository Interfaces and RuntimeOrchestrator
**Files:**
- Create repository interfaces and RuntimeOrchestrator in `core/runtime/`
- [ ] **Step 1: Create repository interfaces**
```java
// EnvironmentRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface EnvironmentRepository {
List<Environment> findAll();
Optional<Environment> findById(UUID id);
Optional<Environment> findBySlug(String slug);
UUID create(String slug, String displayName);
void updateDisplayName(UUID id, String displayName);
void updateStatus(UUID id, EnvironmentStatus status);
void delete(UUID id);
}
// AppRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface AppRepository {
List<App> findByEnvironmentId(UUID environmentId);
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
UUID create(UUID environmentId, String slug, String displayName);
void delete(UUID id);
}
// AppVersionRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface AppVersionRepository {
List<AppVersion> findByAppId(UUID appId);
Optional<AppVersion> findById(UUID id);
int findMaxVersion(UUID appId);
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
}
// DeploymentRepository.java
package com.cameleer3.server.core.runtime;
import java.util.*;
public interface DeploymentRepository {
List<Deployment> findByAppId(UUID appId);
List<Deployment> findByEnvironmentId(UUID environmentId);
Optional<Deployment> findById(UUID id);
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
void markDeployed(UUID id);
void markStopped(UUID id);
}
```
- [ ] **Step 2: Create RuntimeOrchestrator interface**
```java
// RuntimeOrchestrator.java
package com.cameleer3.server.core.runtime;
import java.util.stream.Stream;
public interface RuntimeOrchestrator {
boolean isEnabled();
String startContainer(ContainerRequest request);
void stopContainer(String containerId);
void removeContainer(String containerId);
ContainerStatus getContainerStatus(String containerId);
Stream<String> getLogs(String containerId, int tailLines);
}
// ContainerRequest.java
package com.cameleer3.server.core.runtime;
import java.util.Map;
public record ContainerRequest(
String containerName,
String baseImage,
String jarPath,
String network,
Map<String, String> envVars,
Map<String, String> labels,
long memoryLimitBytes,
int cpuShares,
int healthCheckPort
) {}
// ContainerStatus.java
package com.cameleer3.server.core.runtime;
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
public static ContainerStatus notFound() {
return new ContainerStatus("not_found", false, -1, "Container not found");
}
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
```
---
### Task 5: Core — EnvironmentService, AppService, DeploymentService
**Files:**
- Create service classes in `core/runtime/`
- [ ] **Step 1: Create EnvironmentService**
```java
package com.cameleer3.server.core.runtime;
import java.util.List;
import java.util.UUID;
public class EnvironmentService {
private final EnvironmentRepository repo;
public EnvironmentService(EnvironmentRepository repo) {
this.repo = repo;
}
public List<Environment> listAll() { return repo.findAll(); }
public Environment getById(UUID id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + id)); }
public Environment getBySlug(String slug) { return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug)); }
public UUID create(String slug, String displayName) {
if (repo.findBySlug(slug).isPresent()) {
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
}
return repo.create(slug, displayName);
}
public void delete(UUID id) {
Environment env = getById(id);
if ("default".equals(env.slug())) {
throw new IllegalArgumentException("Cannot delete the default environment");
}
repo.delete(id);
}
}
```
- [ ] **Step 2: Create AppService**
```java
package com.cameleer3.server.core.runtime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
public class AppService {
private static final Logger log = LoggerFactory.getLogger(AppService.class);
private final AppRepository appRepo;
private final AppVersionRepository versionRepo;
private final String jarStoragePath;
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
this.appRepo = appRepo;
this.versionRepo = versionRepo;
this.jarStoragePath = jarStoragePath;
}
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public UUID createApp(UUID environmentId, String slug, String displayName) {
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
}
return appRepo.create(environmentId, slug, displayName);
}
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
App app = getById(appId);
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
Files.createDirectories(versionDir);
Path jarFile = versionDir.resolve("app.jar");
MessageDigest digest;
try { digest = MessageDigest.getInstance("SHA-256"); }
catch (Exception e) { throw new RuntimeException(e); }
try (InputStream in = jarData) {
byte[] buffer = new byte[8192];
int bytesRead;
try (var out = Files.newOutputStream(jarFile)) {
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
digest.update(buffer, 0, bytesRead);
}
}
}
String checksum = HexFormat.of().formatHex(digest.digest());
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}", appId, nextVersion, size, checksum);
return versionRepo.findById(versionId).orElseThrow();
}
public String resolveJarPath(UUID appVersionId) {
AppVersion version = versionRepo.findById(appVersionId)
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
return version.jarPath();
}
public void deleteApp(UUID id) {
appRepo.delete(id);
}
}
```
- [ ] **Step 3: Create DeploymentService**
```java
package com.cameleer3.server.core.runtime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.UUID;
public class DeploymentService {
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
private final DeploymentRepository deployRepo;
private final AppService appService;
private final EnvironmentService envService;
public DeploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
this.deployRepo = deployRepo;
this.appService = appService;
this.envService = envService;
}
public List<Deployment> listByApp(UUID appId) { return deployRepo.findByAppId(appId); }
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) {
App app = appService.getById(appId);
Environment env = envService.getById(environmentId);
String containerName = env.slug() + "-" + app.slug();
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
return deployRepo.findById(deploymentId).orElseThrow();
}
/** Promote: deploy the same app version to a different environment. */
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) {
return createDeployment(appId, appVersionId, targetEnvironmentId);
}
public void markRunning(UUID deploymentId, String containerId) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.RUNNING, containerId, null);
deployRepo.markDeployed(deploymentId);
}
public void markFailed(UUID deploymentId, String errorMessage) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.FAILED, null, errorMessage);
}
public void markStopped(UUID deploymentId) {
deployRepo.updateStatus(deploymentId, DeploymentStatus.STOPPED, null, null);
deployRepo.markStopped(deploymentId);
}
}
```
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
```
---
### Task 6: App Module — PostgreSQL Repositories
**Files:**
- Create all Postgres repositories in `app/storage/`
- [ ] **Step 1: Implement all four repositories**
Follow the pattern from `PostgresUserRepository.java``JdbcTemplate` with row mappers. Each repository implements its core interface with standard SQL (INSERT, SELECT, UPDATE, DELETE).
Key patterns to follow:
- Constructor injection of `JdbcTemplate`
- RowMapper lambdas returning records
- `UUID.randomUUID()` for ID generation
- `Timestamp.from(Instant)` for timestamp parameters
- [ ] **Step 2: Wire beans**
Create `RuntimeBeanConfig.java` in `app/config/`:
```java
@Configuration
public class RuntimeBeanConfig {
@Bean
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) {
return new PostgresEnvironmentRepository(jdbc);
}
@Bean
public AppRepository appRepository(JdbcTemplate jdbc) {
return new PostgresAppRepository(jdbc);
}
@Bean
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
return new PostgresAppVersionRepository(jdbc);
}
@Bean
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc) {
return new PostgresDeploymentRepository(jdbc);
}
@Bean
public EnvironmentService environmentService(EnvironmentRepository repo) {
return new EnvironmentService(repo);
}
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
}
@Bean
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
return new DeploymentService(deployRepo, appService, envService);
}
}
```
- [ ] **Step 3: Run tests**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
Expected: PASS (Flyway applies V3 migration, context loads).
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
git commit -m "feat: implement PostgreSQL repositories for runtime management"
```
---
### Task 7: Docker Runtime Orchestrator
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
- [ ] **Step 1: Implement DisabledRuntimeOrchestrator**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.*;
import java.util.stream.Stream;
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
@Override public boolean isEnabled() { return false; }
@Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
@Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); }
@Override public Stream<String> getLogs(String id, int tail) { return Stream.empty(); }
}
```
- [ ] **Step 2: Implement DockerRuntimeOrchestrator**
Port from SaaS `DockerRuntimeOrchestrator.java`, adapted:
- Uses docker-java `DockerClientImpl` with zerodep transport
- `startContainer()`: creates container from base image with volume mount for JAR (instead of image build), sets env vars, Traefik labels, health check, resource limits
- `stopContainer()`: stops with 30s timeout
- `removeContainer()`: force remove
- `getContainerStatus()`: inspect container state
- `getLogs()`: tail container logs
Key difference from SaaS version: **no image build**. The base image is pre-built. JAR is volume-mounted:
```java
@Override
public String startContainer(ContainerRequest request) {
List<String> envList = request.envVars().entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue()).toList();
// Volume bind: mount JAR into container
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
HostConfig hostConfig = HostConfig.newHostConfig()
.withMemory(request.memoryLimitBytes())
.withMemorySwap(request.memoryLimitBytes())
.withCpuShares(request.cpuShares())
.withNetworkMode(request.network())
.withBinds(jarBind);
CreateContainerResponse container = dockerClient.createContainerCmd(request.baseImage())
.withName(request.containerName())
.withEnv(envList)
.withLabels(request.labels())
.withHostConfig(hostConfig)
.withHealthcheck(new HealthCheck()
.withTest(List.of("CMD-SHELL", "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
.withInterval(10_000_000_000L)
.withTimeout(5_000_000_000L)
.withRetries(3)
.withStartPeriod(30_000_000_000L))
.exec();
dockerClient.startContainerCmd(container.getId()).exec();
return container.getId();
}
```
- [ ] **Step 3: Implement RuntimeOrchestratorAutoConfig**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Files;
import java.nio.file.Path;
@Configuration
public class RuntimeOrchestratorAutoConfig {
private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class);
@Bean
public RuntimeOrchestrator runtimeOrchestrator() {
// Auto-detect: Docker socket available?
if (Files.exists(Path.of("/var/run/docker.sock"))) {
log.info("Docker socket detected — enabling Docker runtime orchestrator");
return new DockerRuntimeOrchestrator();
}
// TODO: K8s detection (check for service account token)
log.info("No Docker socket or K8s detected — runtime management disabled (observability-only mode)");
return new DisabledRuntimeOrchestrator();
}
}
```
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
```
---
### Task 8: DeploymentExecutor — Async Deployment Pipeline
**Files:**
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
- [ ] **Step 1: Implement async deployment pipeline**
```java
package com.cameleer3.server.app.runtime;
import com.cameleer3.server.core.runtime.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class DeploymentExecutor {
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
private final RuntimeOrchestrator orchestrator;
private final DeploymentService deploymentService;
private final AppService appService;
private final EnvironmentService envService;
// Inject runtime config values
public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService,
AppService appService, EnvironmentService envService) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
this.envService = envService;
}
@Async("deploymentExecutor")
public void executeAsync(Deployment deployment) {
try {
// Stop existing deployment in same environment for same app
// ... (find active deployment, stop container)
String jarPath = appService.resolveJarPath(deployment.appVersionId());
App app = appService.getById(deployment.appId());
Environment env = envService.getById(deployment.environmentId());
Map<String, String> envVars = new HashMap<>();
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
envVars.put("CAMELEER_EXPORT_ENDPOINT", /* server endpoint */);
envVars.put("CAMELEER_AUTH_TOKEN", /* bootstrap token */);
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName());
Map<String, String> labels = buildTraefikLabels(app, env, deployment);
ContainerRequest request = new ContainerRequest(
deployment.containerName(),
/* baseImage */, jarPath, /* network */,
envVars, labels, /* memoryLimit */, /* cpuShares */, 9464);
String containerId = orchestrator.startContainer(request);
waitForHealthy(containerId, 60);
deploymentService.markRunning(deployment.id(), containerId);
log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId);
} catch (Exception e) {
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
deploymentService.markFailed(deployment.id(), e.getMessage());
}
}
private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
ContainerStatus status = orchestrator.getContainerStatus(containerId);
if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) {
return;
}
if (!status.running()) {
throw new RuntimeException("Container stopped unexpectedly: " + status.error());
}
Thread.sleep(2000);
}
throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s");
}
private Map<String, String> buildTraefikLabels(App app, Environment env, Deployment deployment) {
// TODO: implement path-based and subdomain-based Traefik labels based on routing config
return Map.of("traefik.enable", "true");
}
}
```
- [ ] **Step 2: Add async config**
Add to `RuntimeBeanConfig.java` or create `AsyncConfig.java`:
```java
@Bean(name = "deploymentExecutor")
public TaskExecutor deploymentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("deploy-");
executor.initialize();
return executor;
}
```
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
git commit -m "feat: implement async DeploymentExecutor pipeline"
```
---
### Task 9: REST Controllers — Environment, App, Deployment
**Files:**
- Create: `EnvironmentAdminController.java` (under `/api/v1/admin/environments`, ADMIN role)
- Create: `AppController.java` (under `/api/v1/apps`, OPERATOR role)
- Create: `DeploymentController.java` (under `/api/v1/apps/{appId}/deployments`, OPERATOR role)
- [ ] **Step 1: Implement EnvironmentAdminController**
CRUD for environments. Path: `/api/v1/admin/environments`. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).
- [ ] **Step 2: Implement AppController**
App CRUD + JAR upload. Path: `/api/v1/apps`. Requires OPERATOR role. JAR upload via `multipart/form-data`. Returns app versions.
Key endpoint for JAR upload:
```java
@PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AppVersion> uploadJar(@PathVariable UUID appId,
@RequestParam("file") MultipartFile file) throws IOException {
AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version);
}
```
- [ ] **Step 3: Implement DeploymentController**
Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments`. Requires OPERATOR role.
Key endpoints:
```java
@PostMapping
public ResponseEntity<Deployment> deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) {
// request contains: appVersionId, environmentId
Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
}
@PostMapping("/{deploymentId}/promote")
public ResponseEntity<Deployment> promote(@PathVariable UUID appId, @PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
Deployment source = deploymentService.getById(deploymentId);
Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId());
deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted);
}
```
- [ ] **Step 4: Add security rules to SecurityConfig**
Add to `SecurityConfig.filterChain()`:
```java
// Runtime management (OPERATOR+)
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
```
- [ ] **Step 5: Commit**
```bash
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
git commit -m "feat: add REST controllers for environment, app, and deployment management"
```
---
### Task 10: Configuration and Application Properties
**Files:**
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
- [ ] **Step 1: Add runtime config properties**
```yaml
cameleer:
runtime:
enabled: ${CAMELEER_RUNTIME_ENABLED:true}
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest}
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
agent-health-port: 9464
health-check-timeout: 60
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
routing-mode: ${CAMELEER_ROUTING_MODE:path}
routing-domain: ${CAMELEER_ROUTING_DOMAIN:localhost}
```
- [ ] **Step 2: Run full test suite**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add cameleer3-server-app/src/main/resources/application.yml
git commit -m "feat: add runtime management configuration properties"
```
---
### Task 11: Integration Tests
- [ ] **Step 1: Write EnvironmentAdminController integration test**
Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT`.
- [ ] **Step 2: Write AppController integration test**
Test app creation, JAR upload, version listing.
- [ ] **Step 3: Write DeploymentController integration test**
Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the deployment record is created even if Docker is unavailable). Full Docker tests require Docker-in-Docker and are out of scope for CI.
- [ ] **Step 4: Commit**
```bash
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
git commit -m "test: add integration tests for runtime management API"
```
---
### Task 12: Final Verification
- [ ] **Step 1: Run full build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
Expected: All tests PASS.
- [ ] **Step 2: Verify schema applies cleanly**
Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.
- [ ] **Step 3: Commit any remaining fixes**
```bash
git add -A
git commit -m "chore: finalize runtime management — all tests passing"
```

View File

@@ -0,0 +1,377 @@
# Plan 4: SaaS Cleanup — Strip to Vendor Management Plane
> **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:** Remove all migrated code from the SaaS layer (environments, apps, deployments, ClickHouse access) and strip it down to a thin vendor management plane: tenant lifecycle, license generation, billing, and Logto organization management.
**Architecture:** The SaaS retains only vendor-level concerns. All runtime management, observability, and user management is now in the server. The SaaS communicates with server instances exclusively via REST API (ServerApiClient). ClickHouse dependency is removed entirely.
**Tech Stack:** Java 21, Spring Boot 3.4.3, PostgreSQL 16
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
**Prerequisite:** Plans 1-3 must be implemented in cameleer3-server first.
---
## Summary of Changes
### Files to DELETE (migrated to server or no longer needed)
```
src/main/java/net/siegeln/cameleer/saas/environment/
├── EnvironmentEntity.java
├── EnvironmentService.java
├── EnvironmentController.java
├── EnvironmentRepository.java
├── EnvironmentStatus.java
└── dto/
├── CreateEnvironmentRequest.java
├── UpdateEnvironmentRequest.java
└── EnvironmentResponse.java
src/main/java/net/siegeln/cameleer/saas/app/
├── AppEntity.java
├── AppService.java
├── AppController.java
├── AppRepository.java
└── dto/
├── CreateAppRequest.java
└── AppResponse.java
src/main/java/net/siegeln/cameleer/saas/deployment/
├── DeploymentEntity.java
├── DeploymentService.java
├── DeploymentController.java
├── DeploymentRepository.java
├── DeploymentExecutor.java
├── DesiredStatus.java
├── ObservedStatus.java
└── dto/
└── DeploymentResponse.java
src/main/java/net/siegeln/cameleer/saas/runtime/
├── RuntimeOrchestrator.java
├── DockerRuntimeOrchestrator.java
├── RuntimeConfig.java
├── BuildImageRequest.java
├── StartContainerRequest.java
├── ContainerStatus.java
└── LogConsumer.java
src/main/java/net/siegeln/cameleer/saas/log/
├── ClickHouseConfig.java
├── ClickHouseProperties.java
├── ContainerLogService.java
├── LogController.java
└── dto/
└── LogEntry.java
src/main/java/net/siegeln/cameleer/saas/observability/
├── AgentStatusService.java
├── AgentStatusController.java
└── dto/
├── AgentStatusResponse.java
└── ObservabilityStatusResponse.java
```
### Files to MODIFY
```
src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java — remove deploymentExecutor bean
src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java — remove createDefaultForTenant() call
src/main/resources/application.yml — remove clickhouse + runtime config sections
docker-compose.yml — remove Docker socket mount from SaaS, update routing
```
### Files to KEEP (vendor management plane)
```
src/main/java/net/siegeln/cameleer/saas/tenant/ — Tenant CRUD, lifecycle
src/main/java/net/siegeln/cameleer/saas/license/ — License generation
src/main/java/net/siegeln/cameleer/saas/identity/ — Logto org management, ServerApiClient
src/main/java/net/siegeln/cameleer/saas/config/ — SecurityConfig, SpaController
src/main/java/net/siegeln/cameleer/saas/audit/ — Vendor audit logging
src/main/java/net/siegeln/cameleer/saas/apikey/ — API key management (if used)
ui/ — Vendor management dashboard
```
### Flyway Migrations to KEEP
The existing migrations (V001-V009) can remain since they're already applied. Add a new cleanup migration:
```
src/main/resources/db/migration/V010__drop_migrated_tables.sql
```
---
### Task 1: Remove ClickHouse Dependency
- [ ] **Step 1: Delete ClickHouse files**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseProperties.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ContainerLogService.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/LogController.java
rm -rf src/main/java/net/siegeln/cameleer/saas/log/dto/
```
- [ ] **Step 2: Remove ClickHouse from AgentStatusService**
Delete `AgentStatusService.java` and `AgentStatusController.java` entirely (agent status is now a server concern).
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/observability/
```
- [ ] **Step 3: Remove ClickHouse config from application.yml**
Remove the entire `cameleer.clickhouse:` section.
- [ ] **Step 4: Remove ClickHouse JDBC dependency from pom.xml**
Remove:
```xml
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
</dependency>
```
- [ ] **Step 5: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
Expected: BUILD SUCCESS. Fix any remaining import errors.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: remove all ClickHouse dependencies from SaaS layer"
```
---
### Task 2: Remove Environment/App/Deployment Code
- [ ] **Step 1: Delete environment package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/environment/
```
- [ ] **Step 2: Delete app package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/app/
```
- [ ] **Step 3: Delete deployment package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/deployment/
```
- [ ] **Step 4: Delete runtime package**
```bash
rm -rf src/main/java/net/siegeln/cameleer/saas/runtime/
```
- [ ] **Step 5: Remove AsyncConfig deploymentExecutor bean**
In `AsyncConfig.java`, remove the `deploymentExecutor` bean (or delete AsyncConfig if it only had that bean).
- [ ] **Step 6: Update TenantService**
Remove any calls to `EnvironmentService.createDefaultForTenant()` from `TenantService.java`. The server now handles default environment creation.
- [ ] **Step 7: Remove runtime config from application.yml**
Remove the entire `cameleer.runtime:` section.
- [ ] **Step 8: Verify build**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
Expected: BUILD SUCCESS. Fix any remaining import errors.
- [ ] **Step 9: Commit**
```bash
git add -A
git commit -m "feat: remove migrated environment/app/deployment/runtime code from SaaS"
```
---
### Task 3: Database Cleanup Migration
- [ ] **Step 1: Create cleanup migration**
```sql
-- V010__drop_migrated_tables.sql
-- Drop tables that have been migrated to cameleer3-server
DROP TABLE IF EXISTS deployments CASCADE;
DROP TABLE IF EXISTS apps CASCADE;
DROP TABLE IF EXISTS environments CASCADE;
DROP TABLE IF EXISTS api_keys CASCADE;
```
- [ ] **Step 2: Commit**
```bash
git add src/main/resources/db/migration/V010__drop_migrated_tables.sql
git commit -m "feat: drop migrated tables from SaaS database"
```
---
### Task 4: Remove Docker Socket Dependency
- [ ] **Step 1: Update docker-compose.yml**
Remove from `cameleer-saas` service:
```yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- jardata:/data/jars
group_add:
- "0"
```
The Docker socket mount now belongs to the `cameleer3-server` service instead.
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
Remove:
```xml
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-core</artifactId>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-zerodep</artifactId>
</dependency>
```
- [ ] **Step 3: Commit**
```bash
git add docker-compose.yml pom.xml
git commit -m "feat: remove Docker socket dependency from SaaS layer"
```
---
### Task 5: Update SaaS UI
- [ ] **Step 1: Remove environment/app/deployment pages from SaaS frontend**
Remove pages that now live in the server UI:
- `EnvironmentsPage`
- `EnvironmentDetailPage`
- `AppDetailPage`
The SaaS UI retains:
- `DashboardPage` — vendor overview (tenant list, status)
- `AdminTenantsPage` — tenant management
- `LicensePage` — license management
- [ ] **Step 2: Update navigation**
Remove links to environments/apps/deployments. The SaaS UI should link to the tenant's server instance for those features (e.g., "Open Dashboard" link to `https://{tenant-slug}.cameleer.example.com/server/`).
- [ ] **Step 3: Commit**
```bash
git add ui/
git commit -m "feat: strip SaaS UI to vendor management dashboard"
```
---
### Task 6: Expand ServerApiClient
- [ ] **Step 1: Add provisioning-related API calls**
The `ServerApiClient` should gain methods for tenant provisioning:
```java
public void pushLicense(String serverEndpoint, String licenseToken) {
post(serverEndpoint + "/api/v1/admin/license")
.body(Map.of("token", licenseToken))
.retrieve()
.toBodilessEntity();
}
public Map<String, Object> getHealth(String serverEndpoint) {
return get(serverEndpoint + "/api/v1/health")
.retrieve()
.body(Map.class);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java
git commit -m "feat: expand ServerApiClient with license push and health check methods"
```
---
### Task 7: Write SAAS-INTEGRATION.md
- [ ] **Step 1: Create integration contract document**
Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
- Which server API endpoints the SaaS calls
- Required auth (M2M token with `server:admin` scope)
- License injection mechanism (`POST /api/v1/admin/license`)
- Health check endpoint (`GET /api/v1/health`)
- What the server exposes vs what the SaaS must never access directly
- Env vars the SaaS sets when provisioning a server instance
- [ ] **Step 2: Commit**
```bash
cd /c/Users/Hendrik/Documents/projects/cameleer3-server
git add docs/SAAS-INTEGRATION.md
git commit -m "docs: add SaaS integration contract documentation"
```
---
### Task 8: Final Verification
- [ ] **Step 1: Build SaaS**
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn clean verify`
Expected: BUILD SUCCESS with reduced dependency footprint.
- [ ] **Step 2: Verify SaaS starts without ClickHouse**
The SaaS should start with only PostgreSQL (and Logto). No ClickHouse required.
- [ ] **Step 3: Verify remaining code footprint**
The SaaS source should now contain approximately:
- `tenant/` — ~4 files
- `license/` — ~5 files
- `identity/` — ~3 files (LogtoConfig, ServerApiClient, M2M token)
- `config/` — ~3 files (SecurityConfig, SpaController, TLS)
- `audit/` — ~3 files
- `ui/` — stripped dashboard
Total: ~20 Java files (down from ~75).
- [ ] **Step 4: Final commit**
```bash
git add -A
git commit -m "chore: finalize SaaS cleanup — vendor management plane only"
```