diff --git a/docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md b/docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md new file mode 100644 index 0000000..8ab6b48 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md @@ -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 findAll(); + Optional 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 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 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 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 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 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 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 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 evaluate(List rules, Map claims) { + return rules.stream() + .sorted(Comparator.comparingInt(ClaimMappingRule::priority)) + .filter(rule -> matches(rule, claims)) + .map(MappingResult::new) + .toList(); + } + + private boolean matches(ClaimMappingRule rule, Map 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 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 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 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 claims) { + List rules = claimMappingRepository.findAll(); + if (rules.isEmpty()) { + log.debug("No claim mapping rules configured, skipping for user {}", userId); + return; + } + + rbacService.clearManagedAssignments(userId); + + List 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 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 list() { + return repository.findAll(); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a claim mapping rule by ID") + public ResponseEntity 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 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 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 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" +``` diff --git a/docs/superpowers/plans/2026-04-07-plan2-license-validation.md b/docs/superpowers/plans/2026-04-07-plan2-license-validation.md new file mode 100644 index 0000000..16a898f --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plan2-license-validation.md @@ -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 features, + Map 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 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 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 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 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. diff --git a/docs/superpowers/plans/2026-04-07-plan3-runtime-management.md b/docs/superpowers/plans/2026-04-07-plan3-runtime-management.md new file mode 100644 index 0000000..333bce3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plan3-runtime-management.md @@ -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 + + com.github.docker-java + docker-java-core + 3.4.1 + + + com.github.docker-java + docker-java-transport-zerodep + 3.4.1 + +``` + +- [ ] **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 findAll(); + Optional findById(UUID id); + Optional 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 findByEnvironmentId(UUID environmentId); + Optional findById(UUID id); + Optional 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 findByAppId(UUID appId); + Optional 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 findByAppId(UUID appId); + List findByEnvironmentId(UUID environmentId); + Optional findById(UUID id); + Optional 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 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 envVars, + Map 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 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 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 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 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 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 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 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 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 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 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 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 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" +``` diff --git a/docs/superpowers/plans/2026-04-07-plan4-saas-cleanup.md b/docs/superpowers/plans/2026-04-07-plan4-saas-cleanup.md new file mode 100644 index 0000000..fee3d70 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-plan4-saas-cleanup.md @@ -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 + + com.clickhouse + clickhouse-jdbc + +``` + +- [ ] **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 + + com.github.docker-java + docker-java-core + + + com.github.docker-java + docker-java-transport-zerodep + +``` + +- [ ] **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 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" +``` diff --git a/docs/superpowers/specs/2026-04-07-architecture-review.md b/docs/superpowers/specs/2026-04-07-architecture-review.md new file mode 100644 index 0000000..ea8ab9c --- /dev/null +++ b/docs/superpowers/specs/2026-04-07-architecture-review.md @@ -0,0 +1,594 @@ +# Cameleer Ecosystem Architecture Review + +**Date:** 2026-04-07 +**Status:** Ready for review +**Scope:** cameleer3 (agent), cameleer3-server, cameleer-saas +**Focus:** Responsibility boundaries, architectural fitness, simplification opportunities +**Not in scope:** Security hardening, code quality, performance + +--- + +## Executive Summary + +The cameleer ecosystem has a clear vision: a standalone observability and runtime platform for Apache Camel, optionally managed by a thin SaaS vendor layer. Both deployment modes must be first-class. + +The agent (cameleer3) is architecturally clean. Single job, well-defined protocol. + +The server (cameleer3-server) is solid for observability but currently lacks runtime management capabilities (deploying and managing Camel application containers). These capabilities exist in the SaaS layer today but belong in the server, since standalone customers also need them. + +The SaaS layer (cameleer-saas) has taken on too many responsibilities: environment management, app lifecycle, container orchestration, direct ClickHouse access, and partial auth duplication. It should be a thin vendor management plane: onboard tenants, provision server instances, manage billing. Nothing more. + +**The revised direction:** + +- **Server layer** = the product. Observability + runtime management + auth/RBAC. Self-sufficient standalone, or managed by SaaS. +- **SaaS layer** = vendor management plane. Owns tenant lifecycle (onboard, offboard, bill), provisions server instances, communicates exclusively via server REST APIs. +- **Strong data separation.** Each layer has its own dedicated PostgreSQL and ClickHouse. No cross-layer database access. +- **Logto as federation hub.** In SaaS mode, Logto handles all user authentication. Customers bring their own OIDC providers via Logto Enterprise SSO connectors. + +--- + +## What's Working Well + +### Agent (cameleer3) +- Clean separation: core logic in `cameleer3-core`, protocol models in `cameleer3-common`, delivery mechanisms (agent/extension) as thin wrappers +- Well-defined agent-server protocol (PROTOCOL.md) with versioning +- Dual-mode design (Java agent + Quarkus extension) is elegant +- Compatibility matrix across 40 Camel versions demonstrates maturity +- No changes needed + +### Server (cameleer3-server) +- Two-database pattern (PostgreSQL control plane, ClickHouse observability data) is correct +- In-memory agent registry with heartbeat-based auto-recovery is operationally sound +- `cameleer3-server-core` / `cameleer3-server-app` split keeps domain logic framework-free +- SSE command push with Ed25519 signing is well-designed +- The UI is competitive-grade (per UX audit #100) +- Independent user/group/role management works for standalone deployments + +### SaaS (cameleer-saas) +- Logto as identity provider was a good buy-vs-build decision +- The async deployment pipeline (DeploymentExecutor) is well-implemented (will migrate to server) +- Tenant isolation interceptor is a solid pattern +- M2M token infrastructure (ServerApiClient) is the right integration pattern +- The dual-deployment architecture document shows strong strategic thinking + +### Cross-cutting +- MOAT features (debugger, lineage, correlation) correctly planned as agent+server features +- Design doc discipline provides good decision traceability +- Gitea issues show clear product thinking and prioritization + +--- + +## Current Architectural Problems + +### Problem 1: Environment and app management lives in the wrong layer + +**What happens today:** +- The SaaS has `EnvironmentEntity`, `AppEntity`, `DeploymentEntity` — full environment/app lifecycle management +- The server treats `applicationId` and `environmentId` as opaque strings from agent heartbeats +- The server has no concept of "deploy an app" or "create an environment" + +**Why this is wrong:** +- Standalone customers need environment and app management too. Without the SaaS, they have no way to deploy Camel JARs through a UI. +- The server is the product. Runtime management is a core product capability, not a SaaS add-on. +- The SaaS should provision a server instance for a tenant and then get out of the way. The tenant interacts with their server instance directly. + +**What should happen:** +- Environment CRUD, app CRUD, JAR upload, deployment lifecycle — all move to the server. +- The server gains a `RuntimeOrchestrator` interface with auto-detected implementations: + - Docker socket available → Docker mode (image build, container lifecycle) + - K8s service account available → K8s mode (Deployments, Kaniko builds) + - Neither → observability-only mode (agents connect externally, no managed runtime) +- One deployable. Adapts to its environment. Standalone customer mounts Docker socket and gets full runtime management. + +--- + +### Problem 2: SaaS bypasses the server to access its databases + +**What happens today:** +- `AgentStatusService` queries the server's ClickHouse directly (`SELECT count(), max(start_time) FROM executions`) +- `ContainerLogService` creates and manages its own `container_logs` table in the server's ClickHouse +- The SaaS has its own ClickHouse connection pool (HikariCP, 10 connections) + +**Why this is wrong:** +- Violates the exclusive data ownership principle. The server owns its ClickHouse schema. +- Schema changes in the server silently break the SaaS. +- Creates tight coupling where there should be a clean API boundary. +- Two connection pools to ClickHouse from different services adds unnecessary operational complexity. + +**What should happen:** +- The SaaS has zero access to the server's databases. All data flows through the server's REST API. +- Container logs (a runtime concern) move to the server along with runtime management. +- The SaaS has its own PostgreSQL for vendor concerns (tenants, billing, provisioning records). No ClickHouse needed. + +--- + +### Problem 3: Auth architecture doesn't support per-tenant OIDC + +**What happens today:** +- The server has one global OIDC configuration +- In SaaS mode, it validates Logto tokens. All tenants use the same Logto instance. +- Customers cannot bring their own OIDC providers (Okta, Azure AD, etc.) +- The server generates its own JWTs after OIDC callback, creating dual-issuer problems (#38) +- `syncOidcRoles` writes shadow copies of roles to PostgreSQL on every login + +**Why this matters:** +- Enterprise customers require SSO with their own identity provider. This is table-stakes for B2B SaaS. +- The dual-issuer pattern (server JWTs + Logto JWTs) causes the session synchronization problem (#38). +- Per-tenant OIDC needs a federation hub, not per-server OIDC config changes. + +**What should happen:** +- **Standalone mode:** Server manages users/groups/roles independently. Optional OIDC integration pointing to customer's IdP directly. Works exactly as today. +- **SaaS mode:** Logto acts as federation hub via Enterprise SSO connectors. Each tenant/organization configures their own SSO connector (SAML or OIDC). Logto handles federation and issues a single token type. The server validates Logto tokens (one OIDC config). Single token issuer eliminates #38. +- **Server auth behavior (inferred from config, no explicit mode flag):** + - No OIDC configured: Full local auth. Server generates JWTs, manages users/groups/roles. + - OIDC configured: Local + OIDC coexist. Claim mapping available. + - OIDC configured + `cameleer.auth.local.enabled=false`: Pure resource server. No local login, no JWT generation, no shadow role sync. SaaS provisioner sets this. + +--- + +### Problem 4: License validation has no server-side implementation + +**What exists today:** +- The SaaS generates Ed25519-signed license JWTs with tier/features/limits +- The server has zero license awareness — no validation, no feature gating, no tier concept +- MOAT features cannot be gated at the server level + +**What should happen:** +- Server validates Ed25519-signed license JWTs +- License loaded from: env var, file path, or API endpoint +- MOAT feature endpoints check license before serving data +- In standalone: license file at `/etc/cameleer/license.jwt` +- In SaaS: license injected during tenant provisioning + +--- + +## Revised Architecture + +### Layer Separation + +``` +SAAS LAYER (vendor management plane) + Owns: tenants, billing, provisioning, onboarding/offboarding + Storage: own PostgreSQL (vendor data only) + Auth: Logto (SaaS vendor UI, tenant SSO federation hub) + Communicates with server layer: exclusively via REST API + +SERVER LAYER (the product) + Owns: observability, runtime management, environments, apps, + deployments, users, groups, roles, agent protocol, licenses + Storage: own PostgreSQL (RBAC, config, app metadata) + own ClickHouse (traces, metrics, logs) + Auth: standalone (local + optional OIDC) or oidc-only (validates external tokens) + Multi-tenancy: tenant_id-scoped data access in shared PG + CH + +AGENT LAYER + Owns: instrumentation, data collection, command execution + Communicates with server: via PROTOCOL.md (HTTP + SSE) +``` + +### Deployment Models + +**Standalone (single tenant):** +``` +Customer runs one server instance. +Server manages its own users, apps, environments, deployments. +Server connects to its own PG + CH. +No SaaS involvement. No Logto. +Optional: customer configures OIDC to their corporate IdP. +License: file-based or env var. +``` + +**SaaS (multi-tenant):** +``` +Vendor runs the SaaS layer + Logto + shared PG (vendor) + shared PG (server) + shared CH. +SaaS provisions one server instance per tenant. +Each server instance is scoped to one tenant_id. +All server instances share PG + CH (tenant_id partitioning). +Auth: Logto federation hub. Per-tenant Enterprise SSO connectors. +Server runs in oidc-only mode, validates Logto tokens. +SaaS communicates with each server instance via REST API (M2M token). +License: injected by SaaS during provisioning, pushed via server API. +``` + +### SaaS Tenant Onboarding Flow + +``` +1. Vendor creates tenant in SaaS layer (name, slug, tier, billing) +2. SaaS creates Logto organization (maps 1:1 to tenant) +3. SaaS generates Ed25519-signed license JWT (tier, features, limits, expiry) +4. SaaS provisions server instance: + - Docker mode: start container with tenant_id + license + OIDC config + - K8s mode: create Deployment in tenant namespace +5. SaaS calls server API to verify health +6. Tenant admin logs in via Logto (federated to their SSO if configured) +7. Tenant admin uploads Camel JARs, manages environments, deploys apps — all via server UI +8. SaaS only re-engages for: billing, license renewal, tier changes, offboarding +``` + +### Runtime Orchestration in the Server + +The server gains runtime management, auto-detected by environment: + +```java +RuntimeOrchestrator (interface) + + createEnvironment(name, config) -> Environment + + deployApp(envId, jar, config) -> Deployment + + stopApp(appId) -> void + + restartApp(appId) -> void + + getAppLogs(appId, since) -> Stream + + getAppStatus(appId) -> AppStatus + +DockerRuntimeOrchestrator + - Activated when /var/run/docker.sock is accessible + - docker-java for image build + container lifecycle + - Traefik labels for HTTP routing + +KubernetesRuntimeOrchestrator + - Activated when K8s service account is available + - fabric8 for Deployments, Services, ConfigMaps + - Kaniko for image builds + +DisabledRuntimeOrchestrator + - Activated when neither Docker nor K8s is available + - Observability-only mode: agents connect externally + - Runtime management endpoints return 404 +``` + +### Auth Architecture + +Auth mode is inferred from configuration — no explicit mode flag. + +**No OIDC configured → standalone:** +- Server manages users, groups, roles in its own PostgreSQL +- Local login via username/password +- Server generates JWTs (HMAC-SHA256) +- Agent auth: bootstrap token → JWT exchange + +**OIDC configured → standalone + OIDC:** +- Local auth still available alongside OIDC +- OIDC users auto-signup on first login +- Claim mapping available for automated role/group assignment +- Server generates JWTs for both local and OIDC users +- Agent auth: bootstrap token → JWT exchange + +**OIDC configured + local auth disabled (`cameleer.auth.local.enabled=false`) → OIDC-only:** +- Server is a pure OAuth2 resource server +- Validates external JWTs (Logto or any OIDC provider) +- Reads roles from configurable JWT claim (`roles`, `scope`, etc.) +- No local login, no JWT generation, no user table writes on login +- Agent auth: bootstrap token → JWT exchange (agents always use server-issued tokens) +- In SaaS mode, the SaaS provisioner sets `cameleer.auth.local.enabled=false` + +**Per-tenant OIDC in SaaS (via Logto Enterprise SSO):** +``` +Tenant A (uses Okta): + User → Logto → detects email domain → redirects to Okta → authenticates + → Okta returns assertion → Logto issues JWT with org context + → Server validates Logto JWT (one OIDC config for all tenants) + +Tenant B (uses Azure AD): + User → Logto → detects email domain → redirects to Azure AD → authenticates + → Azure AD returns assertion → Logto issues JWT with org context + → Server validates same Logto JWT + +Tenant C (no enterprise SSO): + User → Logto → authenticates with Logto credentials directly + → Logto issues JWT with org context + → Server validates same Logto JWT +``` + +Single token issuer (Logto). Single server OIDC config. Per-tenant SSO handled entirely in Logto. Eliminates dual-issuer problem (#38). + +--- + +## What Moves Where + +### From SaaS to Server + +| Component | Current Location | New Home | Notes | +|-----------|-----------------|----------|-------| +| `EnvironmentEntity` + CRUD | SaaS | Server | First-class server concept | +| `AppEntity` + CRUD | SaaS | Server | First-class server concept | +| `DeploymentEntity` + lifecycle | SaaS | Server | First-class server concept | +| `DeploymentExecutor` | SaaS | Server | Async deployment pipeline | +| `DockerRuntimeOrchestrator` | SaaS | Server | Docker mode runtime | +| JAR upload + image build | SaaS | Server | Runtime management | +| Container log collection | SaaS | Server | Part of runtime management | +| `AgentStatusService` | SaaS | Removed | Server already has this natively | +| `ContainerLogService` | SaaS | Server | Logs stored in server's ClickHouse | + +### Stays in SaaS + +| Component | Why | +|-----------|-----| +| `TenantEntity` + lifecycle | Vendor concern: onboarding, offboarding | +| `LicenseService` (generation) | Vendor signs licenses | +| Billing integration (Stripe) | Vendor concern | +| Logto bootstrap + org management | Vendor concern | +| `ServerApiClient` | SaaS → server communication (grows in importance) | +| Audit logging (vendor actions) | Vendor concern | + +### Stays in Server + +| Component | Why | +|-----------|-----| +| Agent protocol (registration, heartbeat, SSE) | Core product | +| Observability pipeline (ingestion, storage, querying) | Core product | +| User/group/role management | Must work standalone | +| OIDC integration | Must work standalone | +| Dashboard, route diagrams, execution detail | Core product | +| Ed25519 config signing | Agent security | +| License validation (new) | Feature gating | + +### Removed Entirely from SaaS + +| Component | Why | +|-----------|-----| +| `ClickHouseConfig` + connection pool | SaaS must not access server's CH | +| `ClickHouseProperties` | No ClickHouse in SaaS | +| `AgentStatusService.getObservabilityStatus()` | Server API replaces this | +| `container_logs` table in CH | Moves to server with runtime management | +| Environment/App/Deployment entities | Move to server | + +--- + +## What the SaaS Becomes + +After migration, the SaaS layer is small and focused: + +``` +cameleer-saas/ +├── tenant/ Tenant CRUD, lifecycle (PROVISIONING → ACTIVE → SUSPENDED → DELETED) +├── license/ License generation (Ed25519-signed JWTs) +├── billing/ Stripe integration (subscriptions, webhooks, tier changes) +├── identity/ Logto org management, Enterprise SSO configuration +├── provisioning/ Server instance provisioning (Docker / K8s) +├── config/ Security, SPA routing +├── audit/ Vendor action audit log +└── ui/ Vendor management dashboard (tenant list, billing, provisioning status) +``` + +**SaaS API surface shrinks to:** +- `POST/GET /api/tenants` — tenant CRUD (vendor admin) +- `POST/GET /api/tenants/{id}/license` — license management +- `POST /api/tenants/{id}/provision` — provision server instance +- `POST /api/tenants/{id}/suspend` — suspend tenant +- `DELETE /api/tenants/{id}` — offboard tenant +- `GET /api/tenants/{id}/status` — server instance health (via server API) +- `GET /api/config` — public config (Logto endpoint, scopes) +- Billing webhooks (Stripe) + +Everything else (environments, apps, deployments, observability, user management) is the server's UI and API, accessed directly by the tenant. + +--- + +## Migration Path + +| Order | Action | Effort | Notes | +|-------|--------|--------|-------| +| 1 | Add Environment/App/Deployment entities + CRUD to server | Medium | Port from SaaS, adapt to server's patterns | +| 2 | Add RuntimeOrchestrator interface + DockerRuntimeOrchestrator to server | Medium | Port from SaaS, add auto-detection | +| 3 | Add JAR upload + image build pipeline to server | Medium | Port DeploymentExecutor | +| 4 | Add container log collection to server | Small | Part of runtime management | +| 5 | Add server API endpoints for app/env management | Medium | REST controllers + UI pages | +| 6 | Add `oidc-only` auth mode to server | Medium | Resource server mode | +| 7 | Implement server-side license validation | Medium | Ed25519 JWT validation + feature gating | +| 8 | Strip SaaS down to vendor management plane | Medium | Remove migrated code, simplify | +| 9 | Remove ClickHouse dependency from SaaS entirely | Small | Delete config, connection pool, queries | +| 10 | Write SAAS-INTEGRATION.md | Small | Document server API contract for SaaS | + +Steps 1-5 can be developed as a "runtime management" feature in the server. +Steps 6-7 are independent server features. +Step 8 is the SaaS cleanup after server capabilities are in place. + +--- + +## Issue Triage Notes + +### Issues resolved by this architecture: +- **saas#38 (session management):** Eliminated — single token issuer in SaaS mode +- **server#100 (UX audit):** Server UI gains full runtime management, richer experience +- **server#122 (ClickHouse scaling):** SaaS no longer a ClickHouse client +- **saas#7 (license & feature gating):** Server-side license validation +- **saas#37 (admin tenant creation UI):** SaaS UI becomes vendor-focused, simpler + +### Issues that become more important: +- **agent#33 (version cameleer3-common independently):** Critical before server API contract stabilizes +- **server#46 (OIDC PKCE for SPA):** Required for server-ui in oidc-only mode +- **server#101 (onboarding experience):** Server UI needs guided setup for standalone users + +### Issues unaffected: +- **MOAT epics (#57-#72):** Correctly scoped as agent+server. License gating is the prerequisite. +- **UX audit P0s (#101-#103):** PMF-critical. Independent of architectural changes. +- **Agent transport/security (#13-#15, #52-#54):** Agent concerns, unrelated. + +--- + +## User Management Model + +### RBAC Structure + +The server has a classical RBAC model with users, groups, and roles. All stored in the server's PostgreSQL. + +**Entities:** +- **Users** — identity records (local or OIDC-sourced) +- **Groups** — organizational units; can nest (parent_group_id). Users belong to groups. +- **Roles** — permission sets. Attached to users (directly) or groups (inherited by members). +- **Permissions** — what a role allows (view executions, send commands, manage config, admin, deploy apps, etc.) + +**Built-in roles** (system roles, cannot be deleted): +- `VIEWER` — read-only access to observability data and runtime status +- `OPERATOR` — VIEWER + send commands, edit config, deploy/manage apps +- `ADMIN` — full access including user/group/role management, server settings, license + +Custom roles may be defined by the server admin for finer-grained control. + +### Assignment Types + +Every user-role and user-group assignment has an **origin**: + +| Origin | Set by | Lifecycle | On OIDC login | +|--------|--------|-----------|---------------| +| `direct` | Admin manually assigns via UI/API | Persisted until admin removes it | Untouched | +| `managed` | Claim mapping rules evaluate JWT | Recalculated on every OIDC login | Cleared and re-evaluated | + +Effective permissions = union of direct roles + managed roles + roles inherited from groups (both direct and managed group memberships). + +**Schema:** +```sql +-- user_roles +user_id UUID NOT NULL +role_id UUID NOT NULL +origin VARCHAR NOT NULL -- 'direct' or 'managed' +mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed + +-- user_groups (same pattern) +user_id UUID NOT NULL +group_id UUID NOT NULL +origin VARCHAR NOT NULL -- 'direct' or 'managed' +mapping_id UUID -- NULL for direct; FK to claim_mapping_rules for managed +``` + +### Claim Mapping + +When OIDC is configured, the server admin can define **claim mapping rules** that automatically assign roles or group memberships based on JWT claims. Rules are server-level config (one set per server instance = effectively per-tenant in SaaS mode). + +**Rule structure:** +```sql +-- claim_mapping_rules +id UUID PRIMARY KEY +claim VARCHAR NOT NULL -- JWT claim to read (e.g., 'groups', 'roles', 'department') +match_type VARCHAR NOT NULL -- 'equals', 'contains', 'regex' +match_value VARCHAR NOT NULL -- value to match against +action VARCHAR NOT NULL -- 'assignRole' or 'addToGroup' +target VARCHAR NOT NULL -- role name or group name +priority INT DEFAULT 0 -- evaluation order (higher = later, for conflict resolution) +``` + +**Examples:** +```json +[ + { "claim": "groups", "match": "contains", "value": "cameleer-admins", "action": "assignRole", "target": "ADMIN" }, + { "claim": "groups", "match": "contains", "value": "integration-team", "action": "addToGroup", "target": "Integration Developers" }, + { "claim": "department", "match": "equals", "value": "ops", "action": "assignRole", "target": "OPERATOR" } +] +``` + +**Login flow with claim mapping:** +``` +1. User authenticates via OIDC +2. Server receives JWT with claims +3. Auto-signup: create user record if not exists (with configurable default role) +4. Clear all MANAGED assignments for this user: + DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed' + DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed' +5. Evaluate claim mapping rules against JWT claims: + For each rule (ordered by priority): + Read claim value from JWT + If match_type matches match_value: + Insert MANAGED assignment (role or group) +6. User's effective permissions are now: + direct roles + managed roles + group-inherited roles +``` + +### How It Scales + +| Size | OIDC | Claim mapping | Admin work | +|------|------|---------------|------------| +| Solo / small (1-10) | No, local auth | N/A | Create users, assign roles manually | +| Small + OIDC (5-20) | Yes | Not needed | Auto-signup with default VIEWER. Admin promotes key people (direct assignments) | +| Medium org (20-100) | Yes | 3-5 claim-to-role rules | One-time setup. Most users get roles automatically. Manual overrides for exceptions | +| Large org (100+) | Yes | Claim-to-group mappings | One-time setup. IdP groups → server groups → roles. Fully automated, self-maintaining | +| SaaS (Logto) | Enforced | Default mapping: `roles` claim → server roles | Vendor sets defaults. Customer configures SSO claim structure, then adds mapping rules | + +### Standalone vs SaaS Behavior + +**Standalone (no OIDC configured):** +- Full local user management: create users, set passwords, assign roles/groups +- Admin manages everything via server UI + +**Standalone + OIDC (OIDC configured and enabled):** +- Local user management still available +- OIDC users auto-signup on first login (configurable: on/off, default role) +- Claim mapping available for automated role/group assignment +- Both local and OIDC users coexist + +**SaaS / OIDC-only (OIDC configured and enabled, local auth disabled):** +- Inferred: when OIDC is configured and enabled, the server operates as a pure resource server +- No local user creation or password management +- Users exist only after first OIDC login (auto-signup always on) +- Claim mapping is the primary role assignment mechanism +- Admin can still make direct assignments via UI (for overrides) +- User/password management UI sections hidden +- Logto org roles → JWT `roles` claim → server claim mapping → server roles + +**Note:** There is no explicit `cameleer.auth.mode` flag. The server infers its auth behavior from whether OIDC is configured and enabled. If OIDC is present, the server acts as a resource server for user-facing auth (agents always use server-issued tokens regardless). + +### SaaS with Enterprise SSO (per-tenant customer IdPs) + +``` +Customer uses Okta: + Okta JWT contains: { "groups": ["eng", "cameleer-admins"], "department": "platform" } + → Logto Enterprise SSO federates, forwards claims into Logto JWT + → Server evaluates claim mapping rules: + "groups contains cameleer-admins" → ADMIN role (managed) + "department equals platform" → add to "Platform Team" group (managed) + → User gets: ADMIN + Platform Team's inherited roles + +Customer uses Azure AD: + Azure AD JWT contains: { "roles": ["CameleerOperator"], "jobTitle": "Integration Developer" } + → Same flow, different mapping rules configured by that tenant's admin: + "roles contains CameleerOperator" → OPERATOR role (managed) +``` + +Each tenant configures their own mapping rules in their server instance. The server doesn't care which IdP issued the claims — it just evaluates rules against whatever JWT it receives. + +--- + +## Resolved Design Questions + +### Server Instance Topology + +- `CAMELEER_TENANT_ID` env var (already exists) scopes all data access in PG and CH +- Standalone: defaults to `"default"`, customer never thinks about it +- SaaS: the SaaS provisioner sets it when starting the server container +- Auth behavior is inferred from OIDC configuration (no explicit mode flag) +- The server doesn't need to "know" it's in SaaS mode — tenant_id + OIDC config is sufficient + +### Runtime Orchestrator Scope (Routing) + +The server owns routing as part of the RuntimeOrchestrator abstraction. Two routing strategies, configured at the server level: + +``` +cameleer.routing.mode=path → api.example.com/apps/{env}/{app} (default, works everywhere) +cameleer.routing.mode=subdomain → {app}.{env}.apps.example.com (requires wildcard DNS + TLS) +``` + +- **Path-based** is the default — no wildcard DNS or TLS required, works in every environment +- **Subdomain-based** is opt-in for customers who prefer it and can provide wildcard infrastructure +- Docker mode: Traefik labels on deployed containers +- K8s mode: Service + Ingress resources +- The routing mechanism is an implementation detail of each RuntimeOrchestrator, not a separate concern + +### UI Navigation + +The current server navigation structure is preserved. Runtime management integrates as follows: + +- **Environment management** → Admin section (high-privilege task, not daily workflow) +- **Applications** → New top-level nav item (app list, deploy, JAR upload, container status, deployment history) +- **Observability pages** (Exchanges, Dashboard, Routes, Logs) → unchanged + +Applications page design requires a UI mock before finalizing — to be explored in the frontend design phase. + +### Data Migration + +Not applicable — greenfield. No existing installations to migrate. + +--- + +## Summary + +The cameleer ecosystem is well-conceived but the current SaaS-server boundary is in the wrong place. The SaaS has grown into a second product rather than a thin vendor layer. + +The fix is architectural: move runtime management (environments, apps, deployments) into the server, make the SaaS a pure vendor management plane, enforce strict data separation, and use Logto Enterprise SSO as the federation hub for per-tenant OIDC. + +The result: **the server is the complete product (observability + runtime + auth). The SaaS is how the vendor manages tenants of that product. Both standalone and SaaS are first-class because the server doesn't depend on the SaaS for any of its capabilities.**