docs: add architecture review spec and implementation plans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
986
docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md
Normal file
986
docs/superpowers/plans/2026-04-07-plan1-auth-rbac-overhaul.md
Normal file
@@ -0,0 +1,986 @@
|
||||
# Plan 1: Auth & RBAC Overhaul
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add claim-based RBAC with managed/direct assignment origins, and make the server operate as a pure OAuth2 resource server when OIDC is configured.
|
||||
|
||||
**Architecture:** Extend the existing RBAC schema with an `origin` column (direct vs managed) on assignment tables, add a `claim_mapping_rules` table, and implement a ClaimMappingService that evaluates JWT claims against mapping rules on every OIDC login. When OIDC is configured, the server becomes a pure resource server — no local login, no JWT generation for users. Agents always use server-issued tokens regardless of auth mode.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, PostgreSQL 16, Flyway, JUnit 5, Testcontainers, AssertJ
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files
|
||||
- `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcOnlyModeIT.java`
|
||||
|
||||
### Modified Files
|
||||
- `cameleer3-server-app/src/main/resources/db/migration/V1__init.sql` — no changes (immutable)
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java` — add origin-aware query methods
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java` — add origin-aware queries
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java` — replace syncOidcRoles with claim mapping
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java` — disable internal token path in OIDC-only mode
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java` — conditional endpoint registration
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` — disable in OIDC-only mode
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` — wire ClaimMappingService
|
||||
- `cameleer3-server-app/src/main/resources/application.yml` — no new properties needed (OIDC config already exists)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Database Migration — Add Origin Tracking and Claim Mapping Rules
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql`
|
||||
|
||||
- [ ] **Step 1: Write the migration**
|
||||
|
||||
```sql
|
||||
-- V2__claim_mapping.sql
|
||||
-- Add origin tracking to assignment tables
|
||||
|
||||
ALTER TABLE user_roles ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
|
||||
ALTER TABLE user_roles ADD COLUMN mapping_id UUID;
|
||||
|
||||
ALTER TABLE user_groups ADD COLUMN origin TEXT NOT NULL DEFAULT 'direct';
|
||||
ALTER TABLE user_groups ADD COLUMN mapping_id UUID;
|
||||
|
||||
-- Drop old primary keys (they don't include origin)
|
||||
ALTER TABLE user_roles DROP CONSTRAINT user_roles_pkey;
|
||||
ALTER TABLE user_roles ADD PRIMARY KEY (user_id, role_id, origin);
|
||||
|
||||
ALTER TABLE user_groups DROP CONSTRAINT user_groups_pkey;
|
||||
ALTER TABLE user_groups ADD PRIMARY KEY (user_id, group_id, origin);
|
||||
|
||||
-- Claim mapping rules table
|
||||
CREATE TABLE claim_mapping_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
claim TEXT NOT NULL,
|
||||
match_type TEXT NOT NULL,
|
||||
match_value TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chk_match_type CHECK (match_type IN ('equals', 'contains', 'regex')),
|
||||
CONSTRAINT chk_action CHECK (action IN ('assignRole', 'addToGroup'))
|
||||
);
|
||||
|
||||
-- Foreign key from assignments to mapping rules
|
||||
ALTER TABLE user_roles ADD CONSTRAINT fk_user_roles_mapping
|
||||
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
|
||||
ALTER TABLE user_groups ADD CONSTRAINT fk_user_groups_mapping
|
||||
FOREIGN KEY (mapping_id) REFERENCES claim_mapping_rules(id) ON DELETE CASCADE;
|
||||
|
||||
-- Index for fast managed assignment cleanup
|
||||
CREATE INDEX idx_user_roles_origin ON user_roles(user_id, origin);
|
||||
CREATE INDEX idx_user_groups_origin ON user_groups(user_id, origin);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run migration to verify**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn flyway:migrate -pl cameleer3-server-app -Dflyway.url=jdbc:postgresql://localhost:5432/cameleer3 -Dflyway.user=cameleer -Dflyway.password=cameleer_dev`
|
||||
|
||||
If no local PostgreSQL, verify syntax by running the existing test suite which uses Testcontainers.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/db/migration/V2__claim_mapping.sql
|
||||
git commit -m "feat: add claim mapping rules table and origin tracking to RBAC assignments"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Core Domain — ClaimMappingRule, AssignmentOrigin, Repository Interface
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java`
|
||||
|
||||
- [ ] **Step 1: Create AssignmentOrigin enum**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
public enum AssignmentOrigin {
|
||||
direct, managed
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create ClaimMappingRule record**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ClaimMappingRule(
|
||||
UUID id,
|
||||
String claim,
|
||||
String matchType,
|
||||
String matchValue,
|
||||
String action,
|
||||
String target,
|
||||
int priority,
|
||||
Instant createdAt
|
||||
) {
|
||||
public enum MatchType { equals, contains, regex }
|
||||
public enum Action { assignRole, addToGroup }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create ClaimMappingRepository interface**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface ClaimMappingRepository {
|
||||
List<ClaimMappingRule> findAll();
|
||||
Optional<ClaimMappingRule> findById(UUID id);
|
||||
UUID create(String claim, String matchType, String matchValue, String action, String target, int priority);
|
||||
void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority);
|
||||
void delete(UUID id);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/AssignmentOrigin.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRule.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingRepository.java
|
||||
git commit -m "feat: add ClaimMappingRule domain model and repository interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Core Domain — ClaimMappingService
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests for ClaimMappingService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ClaimMappingServiceTest {
|
||||
|
||||
private ClaimMappingService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ClaimMappingService();
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_containsMatch_onStringArrayClaim() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
|
||||
"assignRole", "ADMIN", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("groups", List.of("eng", "cameleer-admins", "devops"));
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).rule()).isEqualTo(rule);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_equalsMatch_onStringClaim() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "department", "equals", "platform",
|
||||
"assignRole", "OPERATOR", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("department", "platform");
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_regexMatch() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "email", "regex", ".*@example\\.com$",
|
||||
"addToGroup", "Example Corp", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("email", "john@example.com");
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_noMatch_returnsEmpty() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
|
||||
"assignRole", "ADMIN", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("groups", List.of("eng", "devops"));
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_missingClaim_returnsEmpty() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "groups", "contains", "admins",
|
||||
"assignRole", "ADMIN", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("department", "eng");
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_rulesOrderedByPriority() {
|
||||
var lowPriority = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "role", "equals", "dev",
|
||||
"assignRole", "VIEWER", 0, null);
|
||||
var highPriority = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "role", "equals", "dev",
|
||||
"assignRole", "OPERATOR", 10, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("role", "dev");
|
||||
|
||||
var results = service.evaluate(List.of(highPriority, lowPriority), claims);
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.get(0).rule().priority()).isEqualTo(0);
|
||||
assertThat(results.get(1).rule().priority()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluate_containsMatch_onSpaceSeparatedString() {
|
||||
var rule = new ClaimMappingRule(
|
||||
UUID.randomUUID(), "scope", "contains", "server:admin",
|
||||
"assignRole", "ADMIN", 0, null);
|
||||
|
||||
Map<String, Object> claims = Map.of("scope", "openid profile server:admin");
|
||||
|
||||
var results = service.evaluate(List.of(rule), claims);
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Expected: Compilation error — ClaimMappingService does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement ClaimMappingService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.rbac;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ClaimMappingService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ClaimMappingService.class);
|
||||
|
||||
public record MappingResult(ClaimMappingRule rule) {}
|
||||
|
||||
public List<MappingResult> evaluate(List<ClaimMappingRule> rules, Map<String, Object> claims) {
|
||||
return rules.stream()
|
||||
.sorted(Comparator.comparingInt(ClaimMappingRule::priority))
|
||||
.filter(rule -> matches(rule, claims))
|
||||
.map(MappingResult::new)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean matches(ClaimMappingRule rule, Map<String, Object> claims) {
|
||||
Object claimValue = claims.get(rule.claim());
|
||||
if (claimValue == null) return false;
|
||||
|
||||
return switch (rule.matchType()) {
|
||||
case "equals" -> equalsMatch(claimValue, rule.matchValue());
|
||||
case "contains" -> containsMatch(claimValue, rule.matchValue());
|
||||
case "regex" -> regexMatch(claimValue, rule.matchValue());
|
||||
default -> {
|
||||
log.warn("Unknown match type: {}", rule.matchType());
|
||||
yield false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private boolean equalsMatch(Object claimValue, String matchValue) {
|
||||
if (claimValue instanceof String s) {
|
||||
return s.equalsIgnoreCase(matchValue);
|
||||
}
|
||||
return String.valueOf(claimValue).equalsIgnoreCase(matchValue);
|
||||
}
|
||||
|
||||
private boolean containsMatch(Object claimValue, String matchValue) {
|
||||
if (claimValue instanceof List<?> list) {
|
||||
return list.stream().anyMatch(item -> String.valueOf(item).equalsIgnoreCase(matchValue));
|
||||
}
|
||||
if (claimValue instanceof String s) {
|
||||
// Space-separated string (e.g., OAuth2 scope claim)
|
||||
return Arrays.stream(s.split("\\s+"))
|
||||
.anyMatch(part -> part.equalsIgnoreCase(matchValue));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean regexMatch(Object claimValue, String matchValue) {
|
||||
String s = String.valueOf(claimValue);
|
||||
try {
|
||||
return Pattern.matches(matchValue, s);
|
||||
} catch (Exception e) {
|
||||
log.warn("Invalid regex in claim mapping rule: {}", matchValue, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingServiceTest`
|
||||
Expected: All 7 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/ClaimMappingService.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/rbac/ClaimMappingServiceTest.java
|
||||
git commit -m "feat: implement ClaimMappingService with equals/contains/regex matching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: PostgreSQL Repository — ClaimMappingRepository Implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java`
|
||||
|
||||
- [ ] **Step 1: Implement PostgresClaimMappingRepository**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.storage;
|
||||
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public class PostgresClaimMappingRepository implements ClaimMappingRepository {
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
|
||||
public PostgresClaimMappingRepository(JdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ClaimMappingRule> findAll() {
|
||||
return jdbc.query("""
|
||||
SELECT id, claim, match_type, match_value, action, target, priority, created_at
|
||||
FROM claim_mapping_rules ORDER BY priority, created_at
|
||||
""", (rs, i) -> new ClaimMappingRule(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("claim"),
|
||||
rs.getString("match_type"),
|
||||
rs.getString("match_value"),
|
||||
rs.getString("action"),
|
||||
rs.getString("target"),
|
||||
rs.getInt("priority"),
|
||||
rs.getTimestamp("created_at").toInstant()
|
||||
));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ClaimMappingRule> findById(UUID id) {
|
||||
var results = jdbc.query("""
|
||||
SELECT id, claim, match_type, match_value, action, target, priority, created_at
|
||||
FROM claim_mapping_rules WHERE id = ?
|
||||
""", (rs, i) -> new ClaimMappingRule(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("claim"),
|
||||
rs.getString("match_type"),
|
||||
rs.getString("match_value"),
|
||||
rs.getString("action"),
|
||||
rs.getString("target"),
|
||||
rs.getInt("priority"),
|
||||
rs.getTimestamp("created_at").toInstant()
|
||||
), id);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID create(String claim, String matchType, String matchValue, String action, String target, int priority) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("""
|
||||
INSERT INTO claim_mapping_rules (id, claim, match_type, match_value, action, target, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", id, claim, matchType, matchValue, action, target, priority);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(UUID id, String claim, String matchType, String matchValue, String action, String target, int priority) {
|
||||
jdbc.update("""
|
||||
UPDATE claim_mapping_rules
|
||||
SET claim = ?, match_type = ?, match_value = ?, action = ?, target = ?, priority = ?
|
||||
WHERE id = ?
|
||||
""", claim, matchType, matchValue, action, target, priority, id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(UUID id) {
|
||||
jdbc.update("DELETE FROM claim_mapping_rules WHERE id = ?", id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Wire the bean in AgentRegistryBeanConfig (or a new RbacBeanConfig)**
|
||||
|
||||
Add to `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java` (or create a new `RbacBeanConfig.java`):
|
||||
|
||||
```java
|
||||
@Bean
|
||||
public ClaimMappingRepository claimMappingRepository(JdbcTemplate jdbcTemplate) {
|
||||
return new PostgresClaimMappingRepository(jdbcTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ClaimMappingService claimMappingService() {
|
||||
return new ClaimMappingService();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresClaimMappingRepository.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java
|
||||
git commit -m "feat: implement PostgresClaimMappingRepository and wire beans"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Modify RbacServiceImpl — Origin-Aware Assignments
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java`
|
||||
|
||||
- [ ] **Step 1: Add managed assignment methods to RbacService interface**
|
||||
|
||||
In `cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java`, add:
|
||||
|
||||
```java
|
||||
void clearManagedAssignments(String userId);
|
||||
void assignManagedRole(String userId, UUID roleId, UUID mappingId);
|
||||
void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement in RbacServiceImpl**
|
||||
|
||||
Add these methods to `RbacServiceImpl.java`:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public void clearManagedAssignments(String userId) {
|
||||
jdbc.update("DELETE FROM user_roles WHERE user_id = ? AND origin = 'managed'", userId);
|
||||
jdbc.update("DELETE FROM user_groups WHERE user_id = ? AND origin = 'managed'", userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void assignManagedRole(String userId, UUID roleId, UUID mappingId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO user_roles (user_id, role_id, origin, mapping_id)
|
||||
VALUES (?, ?, 'managed', ?)
|
||||
ON CONFLICT (user_id, role_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id
|
||||
""", userId, roleId, mappingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUserToManagedGroup(String userId, UUID groupId, UUID mappingId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO user_groups (user_id, group_id, origin, mapping_id)
|
||||
VALUES (?, ?, 'managed', ?)
|
||||
ON CONFLICT (user_id, group_id, origin) DO UPDATE SET mapping_id = EXCLUDED.mapping_id
|
||||
""", userId, groupId, mappingId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update existing assignRoleToUser to specify origin='direct'**
|
||||
|
||||
Modify the existing `assignRoleToUser` and `addUserToGroup` methods to explicitly set `origin = 'direct'`:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public void assignRoleToUser(String userId, UUID roleId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO user_roles (user_id, role_id, origin)
|
||||
VALUES (?, ?, 'direct')
|
||||
ON CONFLICT (user_id, role_id, origin) DO NOTHING
|
||||
""", userId, roleId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addUserToGroup(String userId, UUID groupId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO user_groups (user_id, group_id, origin)
|
||||
VALUES (?, ?, 'direct')
|
||||
ON CONFLICT (user_id, group_id, origin) DO NOTHING
|
||||
""", userId, groupId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update getDirectRolesForUser to filter by origin='direct'**
|
||||
|
||||
```java
|
||||
@Override
|
||||
public List<RoleSummary> getDirectRolesForUser(String userId) {
|
||||
return jdbc.query("""
|
||||
SELECT r.id, r.name, r.system FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ? AND ur.origin = 'direct'
|
||||
""", (rs, i) -> new RoleSummary(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getBoolean("system"),
|
||||
"direct"
|
||||
), userId);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run existing tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Expected: All existing tests still pass (migration adds columns with defaults).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/rbac/RbacService.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/rbac/RbacServiceImpl.java
|
||||
git commit -m "feat: add origin-aware managed/direct assignment methods to RbacService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Modify OidcAuthController — Replace syncOidcRoles with Claim Mapping
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java`
|
||||
|
||||
- [ ] **Step 1: Inject ClaimMappingService and ClaimMappingRepository**
|
||||
|
||||
Add to constructor:
|
||||
|
||||
```java
|
||||
private final ClaimMappingService claimMappingService;
|
||||
private final ClaimMappingRepository claimMappingRepository;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace syncOidcRoles with applyClaimMappings**
|
||||
|
||||
Replace the `syncOidcRoles` method (lines 176-208) with:
|
||||
|
||||
```java
|
||||
private void applyClaimMappings(String userId, Map<String, Object> claims) {
|
||||
List<ClaimMappingRule> rules = claimMappingRepository.findAll();
|
||||
if (rules.isEmpty()) {
|
||||
log.debug("No claim mapping rules configured, skipping for user {}", userId);
|
||||
return;
|
||||
}
|
||||
|
||||
rbacService.clearManagedAssignments(userId);
|
||||
|
||||
List<ClaimMappingService.MappingResult> results = claimMappingService.evaluate(rules, claims);
|
||||
for (var result : results) {
|
||||
ClaimMappingRule rule = result.rule();
|
||||
switch (rule.action()) {
|
||||
case "assignRole" -> {
|
||||
UUID roleId = SystemRole.BY_NAME.get(SystemRole.normalizeScope(rule.target()));
|
||||
if (roleId == null) {
|
||||
log.warn("Claim mapping target role '{}' not found, skipping", rule.target());
|
||||
continue;
|
||||
}
|
||||
rbacService.assignManagedRole(userId, roleId, rule.id());
|
||||
log.debug("Managed role {} assigned to {} via mapping {}", rule.target(), userId, rule.id());
|
||||
}
|
||||
case "addToGroup" -> {
|
||||
// Look up group by name
|
||||
var groups = groupRepository.findAll();
|
||||
var group = groups.stream().filter(g -> g.name().equalsIgnoreCase(rule.target())).findFirst();
|
||||
if (group.isEmpty()) {
|
||||
log.warn("Claim mapping target group '{}' not found, skipping", rule.target());
|
||||
continue;
|
||||
}
|
||||
rbacService.addUserToManagedGroup(userId, group.get().id(), rule.id());
|
||||
log.debug("Managed group {} assigned to {} via mapping {}", rule.target(), userId, rule.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update callback() to call applyClaimMappings**
|
||||
|
||||
In the `callback()` method, replace the `syncOidcRoles(userId, oidcRoles, config)` call with:
|
||||
|
||||
```java
|
||||
// Extract all claims from the access token for claim mapping
|
||||
Map<String, Object> claims = tokenExchanger.extractAllClaims(oidcUser);
|
||||
applyClaimMappings(userId, claims);
|
||||
```
|
||||
|
||||
Note: `extractAllClaims` needs to be added to `OidcTokenExchanger` — it returns the raw JWT claims map from the access token.
|
||||
|
||||
- [ ] **Step 4: Run existing tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Expected: PASS (OIDC tests may need adjustment if they test syncOidcRoles directly).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/OidcAuthController.java
|
||||
git commit -m "feat: replace syncOidcRoles with claim mapping evaluation on OIDC login"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: OIDC-Only Mode — Disable Local Auth When OIDC Configured
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java`
|
||||
- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java`
|
||||
|
||||
- [ ] **Step 1: Add isOidcEnabled() helper to SecurityConfig**
|
||||
|
||||
```java
|
||||
private boolean isOidcEnabled() {
|
||||
return oidcIssuerUri != null && !oidcIssuerUri.isBlank();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Conditionally disable local login endpoints**
|
||||
|
||||
In `SecurityConfig.filterChain()`, when OIDC is enabled, remove `/api/v1/auth/login` and `/api/v1/auth/refresh` from public endpoints (or let them return 404). The simplest approach: add a condition in `UiAuthController`:
|
||||
|
||||
```java
|
||||
// In UiAuthController
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
if (oidcEnabled) {
|
||||
return ResponseEntity.status(404).body(Map.of("error", "Local login disabled when OIDC is configured"));
|
||||
}
|
||||
// ... existing logic
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Modify JwtAuthenticationFilter to skip internal token path for user tokens in OIDC mode**
|
||||
|
||||
In `JwtAuthenticationFilter`, when OIDC is enabled, only accept internal (HMAC) tokens for agent subjects (starting with no `user:` prefix or explicitly agent subjects). User-facing tokens must come from the OIDC decoder:
|
||||
|
||||
```java
|
||||
private void tryInternalToken(String token, HttpServletRequest request) {
|
||||
try {
|
||||
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||
// In OIDC mode, only accept agent tokens via internal validation
|
||||
if (oidcDecoder != null && result.subject() != null && result.subject().startsWith("user:")) {
|
||||
return; // User tokens must go through OIDC path
|
||||
}
|
||||
setAuthentication(result, request);
|
||||
} catch (Exception e) {
|
||||
// Not a valid internal token, will try OIDC next
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Disable user admin endpoints in OIDC mode**
|
||||
|
||||
In `UserAdminController`, add a guard for user creation and password reset:
|
||||
|
||||
```java
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
|
||||
if (oidcEnabled) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", "User creation disabled when OIDC is configured. Users are auto-provisioned on OIDC login."));
|
||||
}
|
||||
// ... existing logic
|
||||
}
|
||||
|
||||
@PostMapping("/{userId}/password")
|
||||
public ResponseEntity<?> resetPassword(@PathVariable String userId, @RequestBody PasswordRequest request) {
|
||||
if (oidcEnabled) {
|
||||
return ResponseEntity.status(400).body(Map.of("error", "Password management disabled when OIDC is configured"));
|
||||
}
|
||||
// ... existing logic
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java
|
||||
git commit -m "feat: disable local auth when OIDC is configured (resource server mode)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Claim Mapping Admin Controller
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java`
|
||||
|
||||
- [ ] **Step 1: Implement the controller**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRepository;
|
||||
import com.cameleer3.server.core.rbac.ClaimMappingRule;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/claim-mappings")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Claim Mapping Admin", description = "Manage OIDC claim-to-role/group mapping rules")
|
||||
public class ClaimMappingAdminController {
|
||||
|
||||
private final ClaimMappingRepository repository;
|
||||
|
||||
public ClaimMappingAdminController(ClaimMappingRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all claim mapping rules")
|
||||
public List<ClaimMappingRule> list() {
|
||||
return repository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
@Operation(summary = "Get a claim mapping rule by ID")
|
||||
public ResponseEntity<ClaimMappingRule> get(@PathVariable UUID id) {
|
||||
return repository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
record CreateRuleRequest(String claim, String matchType, String matchValue,
|
||||
String action, String target, int priority) {}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create a claim mapping rule")
|
||||
public ResponseEntity<ClaimMappingRule> create(@RequestBody CreateRuleRequest request) {
|
||||
UUID id = repository.create(
|
||||
request.claim(), request.matchType(), request.matchValue(),
|
||||
request.action(), request.target(), request.priority());
|
||||
return repository.findById(id)
|
||||
.map(rule -> ResponseEntity.created(URI.create("/api/v1/admin/claim-mappings/" + id)).body(rule))
|
||||
.orElse(ResponseEntity.internalServerError().build());
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "Update a claim mapping rule")
|
||||
public ResponseEntity<ClaimMappingRule> update(@PathVariable UUID id, @RequestBody CreateRuleRequest request) {
|
||||
if (repository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
repository.update(id, request.claim(), request.matchType(), request.matchValue(),
|
||||
request.action(), request.target(), request.priority());
|
||||
return repository.findById(id)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.internalServerError().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "Delete a claim mapping rule")
|
||||
public ResponseEntity<Void> delete(@PathVariable UUID id) {
|
||||
if (repository.findById(id).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
repository.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add endpoint to SecurityConfig**
|
||||
|
||||
In `SecurityConfig.filterChain()`, the `/api/v1/admin/**` path already requires ADMIN role. No changes needed.
|
||||
|
||||
- [ ] **Step 3: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ClaimMappingAdminController.java
|
||||
git commit -m "feat: add ClaimMappingAdminController for CRUD on mapping rules"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Integration Test — Claim Mapping End-to-End
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java`
|
||||
|
||||
- [ ] **Step 1: Write integration test**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ClaimMappingAdminControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired private TestRestTemplate restTemplate;
|
||||
@Autowired private ObjectMapper objectMapper;
|
||||
@Autowired private TestSecurityHelper securityHelper;
|
||||
|
||||
private HttpHeaders adminHeaders;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminHeaders = securityHelper.adminHeaders();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createAndListRules() throws Exception {
|
||||
String body = """
|
||||
{"claim":"groups","matchType":"contains","matchValue":"admins","action":"assignRole","target":"ADMIN","priority":0}
|
||||
""";
|
||||
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
|
||||
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
|
||||
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
var listResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
|
||||
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
|
||||
assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
JsonNode rules = objectMapper.readTree(listResponse.getBody());
|
||||
assertThat(rules.isArray()).isTrue();
|
||||
assertThat(rules.size()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRule() throws Exception {
|
||||
String body = """
|
||||
{"claim":"dept","matchType":"equals","matchValue":"eng","action":"assignRole","target":"VIEWER","priority":0}
|
||||
""";
|
||||
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
|
||||
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
|
||||
JsonNode created = objectMapper.readTree(createResponse.getBody());
|
||||
String id = created.get("id").asText();
|
||||
|
||||
var deleteResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
|
||||
HttpMethod.DELETE, new HttpEntity<>(adminHeaders), Void.class);
|
||||
assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
|
||||
var getResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
|
||||
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
|
||||
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run integration tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=ClaimMappingAdminControllerIT`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ClaimMappingAdminControllerIT.java
|
||||
git commit -m "test: add integration tests for claim mapping admin API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Run Full Test Suite and Final Verification
|
||||
|
||||
- [ ] **Step 1: Run all tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Expected: All tests PASS. Build succeeds.
|
||||
|
||||
- [ ] **Step 2: Verify migration applies cleanly on fresh database**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=AbstractPostgresIT`
|
||||
Expected: Testcontainers starts fresh PostgreSQL, Flyway applies V1 + V2, context loads.
|
||||
|
||||
- [ ] **Step 3: Commit any remaining fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: finalize auth & RBAC overhaul — all tests passing"
|
||||
```
|
||||
615
docs/superpowers/plans/2026-04-07-plan2-license-validation.md
Normal file
615
docs/superpowers/plans/2026-04-07-plan2-license-validation.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# Plan 2: Server-Side License Validation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Ed25519-signed license JWT validation to the server, enabling feature gating for MOAT features (debugger, lineage, correlation) by tier.
|
||||
|
||||
**Architecture:** The SaaS generates Ed25519-signed license JWTs containing tier, features, limits, and expiry. The server validates the license on startup (from env var or file) or at runtime (via admin API). A `LicenseGate` service checks whether a feature is enabled before serving gated endpoints. The server's existing Ed25519 infrastructure (JDK 17 `java.security`) is reused for verification. In standalone mode without a license, all features are available (open/dev mode).
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, Ed25519 (JDK built-in), Nimbus JOSE JWT, JUnit 5, AssertJ
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
||||
- `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
||||
- `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
||||
- `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
||||
|
||||
### Modified Files
|
||||
- `cameleer3-server-app/src/main/resources/application.yml` — add license config properties
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Core Domain — LicenseInfo, Feature Enum
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java`
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java`
|
||||
|
||||
- [ ] **Step 1: Create Feature enum**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
public enum Feature {
|
||||
topology,
|
||||
lineage,
|
||||
correlation,
|
||||
debugger,
|
||||
replay
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create LicenseInfo record**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public record LicenseInfo(
|
||||
String tier,
|
||||
Set<Feature> features,
|
||||
Map<String, Integer> limits,
|
||||
Instant issuedAt,
|
||||
Instant expiresAt
|
||||
) {
|
||||
public boolean isExpired() {
|
||||
return expiresAt != null && Instant.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
public boolean hasFeature(Feature feature) {
|
||||
return features.contains(feature);
|
||||
}
|
||||
|
||||
public int getLimit(String key, int defaultValue) {
|
||||
return limits.getOrDefault(key, defaultValue);
|
||||
}
|
||||
|
||||
/** Open license — all features enabled, no limits. Used when no license is configured. */
|
||||
public static LicenseInfo open() {
|
||||
return new LicenseInfo("open", Set.of(Feature.values()), Map.of(), Instant.now(), null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/Feature.java
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseInfo.java
|
||||
git commit -m "feat: add LicenseInfo and Feature domain model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: LicenseValidator — Ed25519 JWT Verification
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.security.*;
|
||||
import java.security.spec.NamedParameterSpec;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class LicenseValidatorTest {
|
||||
|
||||
private KeyPair generateKeyPair() throws Exception {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
|
||||
return kpg.generateKeyPair();
|
||||
}
|
||||
|
||||
private String sign(PrivateKey key, String payload) throws Exception {
|
||||
Signature signer = Signature.getInstance("Ed25519");
|
||||
signer.initSign(key);
|
||||
signer.update(payload.getBytes());
|
||||
return Base64.getEncoder().encodeToString(signer.sign());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_validLicense_returnsLicenseInfo() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
|
||||
|
||||
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d}
|
||||
""".formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||
|
||||
LicenseInfo info = validator.validate(token);
|
||||
|
||||
assertThat(info.tier()).isEqualTo("HIGH");
|
||||
assertThat(info.hasFeature(Feature.debugger)).isTrue();
|
||||
assertThat(info.hasFeature(Feature.replay)).isFalse();
|
||||
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
|
||||
assertThat(info.isExpired()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_expiredLicense_throwsException() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
|
||||
|
||||
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
|
||||
String payload = """
|
||||
{"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d}
|
||||
""".formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("expired");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validate_tamperedPayload_throwsException() throws Exception {
|
||||
KeyPair kp = generateKeyPair();
|
||||
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
|
||||
|
||||
String payload = """
|
||||
{"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999}
|
||||
""".trim();
|
||||
String signature = sign(kp.getPrivate(), payload);
|
||||
|
||||
// Tamper with payload
|
||||
String tampered = payload.replace("LOW", "BUSINESS");
|
||||
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
|
||||
|
||||
assertThatThrownBy(() -> validator.validate(token))
|
||||
.isInstanceOf(SecurityException.class)
|
||||
.hasMessageContaining("signature");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest -Dsurefire.failIfNoSpecifiedTests=false`
|
||||
Expected: Compilation error — LicenseValidator does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement LicenseValidator**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.security.*;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
public class LicenseValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseValidator.class);
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final PublicKey publicKey;
|
||||
|
||||
public LicenseValidator(String publicKeyBase64) {
|
||||
try {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(publicKeyBase64);
|
||||
KeyFactory kf = KeyFactory.getInstance("Ed25519");
|
||||
this.publicKey = kf.generatePublic(new X509EncodedKeySpec(keyBytes));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to load license public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public LicenseInfo validate(String token) {
|
||||
String[] parts = token.split("\\.", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new IllegalArgumentException("Invalid license token format: expected payload.signature");
|
||||
}
|
||||
|
||||
byte[] payloadBytes = Base64.getDecoder().decode(parts[0]);
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(parts[1]);
|
||||
|
||||
// Verify signature
|
||||
try {
|
||||
Signature verifier = Signature.getInstance("Ed25519");
|
||||
verifier.initVerify(publicKey);
|
||||
verifier.update(payloadBytes);
|
||||
if (!verifier.verify(signatureBytes)) {
|
||||
throw new SecurityException("License signature verification failed");
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new SecurityException("License signature verification failed", e);
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(payloadBytes);
|
||||
|
||||
String tier = root.get("tier").asText();
|
||||
|
||||
Set<Feature> features = new HashSet<>();
|
||||
if (root.has("features")) {
|
||||
for (JsonNode f : root.get("features")) {
|
||||
try {
|
||||
features.add(Feature.valueOf(f.asText()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Unknown feature in license: {}", f.asText());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Integer> limits = new HashMap<>();
|
||||
if (root.has("limits")) {
|
||||
root.get("limits").fields().forEachRemaining(entry ->
|
||||
limits.put(entry.getKey(), entry.getValue().asInt()));
|
||||
}
|
||||
|
||||
Instant issuedAt = root.has("iat") ? Instant.ofEpochSecond(root.get("iat").asLong()) : Instant.now();
|
||||
Instant expiresAt = root.has("exp") ? Instant.ofEpochSecond(root.get("exp").asLong()) : null;
|
||||
|
||||
LicenseInfo info = new LicenseInfo(tier, features, limits, issuedAt, expiresAt);
|
||||
|
||||
if (info.isExpired()) {
|
||||
throw new IllegalArgumentException("License expired at " + expiresAt);
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to parse license payload", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseValidatorTest`
|
||||
Expected: All 3 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseValidator.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseValidatorTest.java
|
||||
git commit -m "feat: implement LicenseValidator with Ed25519 signature verification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: LicenseGate — Feature Check Service
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java`
|
||||
- Create: `cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java`
|
||||
|
||||
- [ ] **Step 1: Write tests**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LicenseGateTest {
|
||||
|
||||
@Test
|
||||
void noLicense_allFeaturesEnabled() {
|
||||
LicenseGate gate = new LicenseGate();
|
||||
// No license loaded → open mode
|
||||
|
||||
assertThat(gate.isEnabled(Feature.debugger)).isTrue();
|
||||
assertThat(gate.isEnabled(Feature.replay)).isTrue();
|
||||
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
|
||||
assertThat(gate.getTier()).isEqualTo("open");
|
||||
}
|
||||
|
||||
@Test
|
||||
void withLicense_onlyLicensedFeaturesEnabled() {
|
||||
LicenseGate gate = new LicenseGate();
|
||||
LicenseInfo license = new LicenseInfo("MID",
|
||||
Set.of(Feature.topology, Feature.lineage, Feature.correlation),
|
||||
Map.of("max_agents", 10, "retention_days", 30),
|
||||
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS));
|
||||
gate.load(license);
|
||||
|
||||
assertThat(gate.isEnabled(Feature.topology)).isTrue();
|
||||
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
|
||||
assertThat(gate.isEnabled(Feature.debugger)).isFalse();
|
||||
assertThat(gate.isEnabled(Feature.replay)).isFalse();
|
||||
assertThat(gate.getTier()).isEqualTo("MID");
|
||||
assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement LicenseGate**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.license;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public class LicenseGate {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseGate.class);
|
||||
|
||||
private final AtomicReference<LicenseInfo> current = new AtomicReference<>(LicenseInfo.open());
|
||||
|
||||
public void load(LicenseInfo license) {
|
||||
current.set(license);
|
||||
log.info("License loaded: tier={}, features={}, expires={}",
|
||||
license.tier(), license.features(), license.expiresAt());
|
||||
}
|
||||
|
||||
public boolean isEnabled(Feature feature) {
|
||||
return current.get().hasFeature(feature);
|
||||
}
|
||||
|
||||
public String getTier() {
|
||||
return current.get().tier();
|
||||
}
|
||||
|
||||
public int getLimit(String key, int defaultValue) {
|
||||
return current.get().getLimit(key, defaultValue);
|
||||
}
|
||||
|
||||
public LicenseInfo getCurrent() {
|
||||
return current.get();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app -Dtest=LicenseGateTest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/license/LicenseGate.java
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/core/license/LicenseGateTest.java
|
||||
git commit -m "feat: implement LicenseGate for feature checking"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: License Loading — Bean Config and Startup
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java`
|
||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||||
|
||||
- [ ] **Step 1: Add license config properties to application.yml**
|
||||
|
||||
```yaml
|
||||
license:
|
||||
token: ${CAMELEER_LICENSE_TOKEN:}
|
||||
file: ${CAMELEER_LICENSE_FILE:}
|
||||
public-key: ${CAMELEER_LICENSE_PUBLIC_KEY:}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement LicenseBeanConfig**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.config;
|
||||
|
||||
import com.cameleer3.server.core.license.LicenseGate;
|
||||
import com.cameleer3.server.core.license.LicenseInfo;
|
||||
import com.cameleer3.server.core.license.LicenseValidator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Configuration
|
||||
public class LicenseBeanConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LicenseBeanConfig.class);
|
||||
|
||||
@Value("${license.token:}")
|
||||
private String licenseToken;
|
||||
|
||||
@Value("${license.file:}")
|
||||
private String licenseFile;
|
||||
|
||||
@Value("${license.public-key:}")
|
||||
private String licensePublicKey;
|
||||
|
||||
@Bean
|
||||
public LicenseGate licenseGate() {
|
||||
LicenseGate gate = new LicenseGate();
|
||||
|
||||
String token = resolveLicenseToken();
|
||||
if (token == null || token.isBlank()) {
|
||||
log.info("No license configured — running in open mode (all features enabled)");
|
||||
return gate;
|
||||
}
|
||||
|
||||
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||
log.warn("License token provided but no public key configured (CAMELEER_LICENSE_PUBLIC_KEY). Running in open mode.");
|
||||
return gate;
|
||||
}
|
||||
|
||||
try {
|
||||
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
||||
LicenseInfo info = validator.validate(token);
|
||||
gate.load(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to validate license: {}. Running in open mode.", e.getMessage());
|
||||
}
|
||||
|
||||
return gate;
|
||||
}
|
||||
|
||||
private String resolveLicenseToken() {
|
||||
if (licenseToken != null && !licenseToken.isBlank()) {
|
||||
return licenseToken;
|
||||
}
|
||||
if (licenseFile != null && !licenseFile.isBlank()) {
|
||||
try {
|
||||
return Files.readString(Path.of(licenseFile)).trim();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to read license file {}: {}", licenseFile, e.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/LicenseBeanConfig.java
|
||||
git add cameleer3-server-app/src/main/resources/application.yml
|
||||
git commit -m "feat: add license loading at startup from env var or file"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: License Admin API — Runtime License Update
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java`
|
||||
|
||||
- [ ] **Step 1: Implement controller**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.license.LicenseGate;
|
||||
import com.cameleer3.server.core.license.LicenseInfo;
|
||||
import com.cameleer3.server.core.license.LicenseValidator;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/license")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "License Admin", description = "License management")
|
||||
public class LicenseAdminController {
|
||||
|
||||
private final LicenseGate licenseGate;
|
||||
private final String licensePublicKey;
|
||||
|
||||
public LicenseAdminController(LicenseGate licenseGate,
|
||||
@Value("${license.public-key:}") String licensePublicKey) {
|
||||
this.licenseGate = licenseGate;
|
||||
this.licensePublicKey = licensePublicKey;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Get current license info")
|
||||
public ResponseEntity<LicenseInfo> getCurrent() {
|
||||
return ResponseEntity.ok(licenseGate.getCurrent());
|
||||
}
|
||||
|
||||
record UpdateLicenseRequest(String token) {}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Update license token at runtime")
|
||||
public ResponseEntity<?> update(@RequestBody UpdateLicenseRequest request) {
|
||||
if (licensePublicKey == null || licensePublicKey.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "No license public key configured"));
|
||||
}
|
||||
try {
|
||||
LicenseValidator validator = new LicenseValidator(licensePublicKey);
|
||||
LicenseInfo info = validator.validate(request.token());
|
||||
licenseGate.load(info);
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/LicenseAdminController.java
|
||||
git commit -m "feat: add license admin API for runtime license updates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Feature Gating — Wire LicenseGate Into Endpoints
|
||||
|
||||
This task is a placeholder — MOAT feature endpoints don't exist yet. When they're added (debugger, lineage, correlation), they should inject `LicenseGate` and check `isEnabled(Feature.xxx)` before serving:
|
||||
|
||||
```java
|
||||
@GetMapping("/api/v1/debug/sessions")
|
||||
public ResponseEntity<?> listDebugSessions() {
|
||||
if (!licenseGate.isEnabled(Feature.debugger)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Feature 'debugger' requires a HIGH or BUSINESS tier license"));
|
||||
}
|
||||
// ... serve debug sessions
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 1: No code changes needed now — document the pattern for MOAT feature implementation**
|
||||
|
||||
- [ ] **Step 2: Final verification**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Expected: All tests PASS.
|
||||
991
docs/superpowers/plans/2026-04-07-plan3-runtime-management.md
Normal file
991
docs/superpowers/plans/2026-04-07-plan3-runtime-management.md
Normal file
@@ -0,0 +1,991 @@
|
||||
# Plan 3: Runtime Management in the Server
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move environment management, app lifecycle, JAR upload, and Docker container orchestration from the SaaS layer into the server, so the server is a self-sufficient product that can deploy and manage Camel applications.
|
||||
|
||||
**Architecture:** The server gains Environment/App/AppVersion/Deployment entities stored in its PostgreSQL. A `RuntimeOrchestrator` interface abstracts Docker/K8s/disabled modes, auto-detected at startup. The Docker implementation uses a shared base image + volume-mounted JARs (no per-deployment image builds). Apps are promoted between environments by creating new Deployments pointing to the same AppVersion. Routing supports both path-based and subdomain-based modes via Traefik labels.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.4.3, docker-java (zerodep transport), PostgreSQL 16, Flyway, JUnit 5, Testcontainers
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer3-server`
|
||||
|
||||
**Source reference:** Code ported from `C:\Users\Hendrik\Documents\projects\cameleer-saas` (environment, app, deployment, runtime packages)
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### New Files — Core Module (`cameleer3-server-core`)
|
||||
|
||||
```
|
||||
src/main/java/com/cameleer3/server/core/runtime/
|
||||
├── Environment.java Record: id, slug, displayName, status, createdAt
|
||||
├── EnvironmentStatus.java Enum: ACTIVE, SUSPENDED
|
||||
├── EnvironmentRepository.java Interface: CRUD + findBySlug
|
||||
├── EnvironmentService.java Business logic: create, list, delete, enforce limits
|
||||
├── App.java Record: id, environmentId, slug, displayName, createdAt
|
||||
├── AppVersion.java Record: id, appId, version, jarPath, sha256, uploadedAt
|
||||
├── AppRepository.java Interface: CRUD + findByEnvironmentId
|
||||
├── AppVersionRepository.java Interface: CRUD + findByAppId
|
||||
├── AppService.java Business logic: create, upload JAR, list, delete
|
||||
├── Deployment.java Record: id, appId, appVersionId, environmentId, status, containerId
|
||||
├── DeploymentStatus.java Enum: STARTING, RUNNING, FAILED, STOPPED
|
||||
├── DeploymentRepository.java Interface: CRUD + findByAppId + findByEnvironmentId
|
||||
├── DeploymentService.java Business logic: deploy, stop, restart, promote
|
||||
├── RuntimeOrchestrator.java Interface: startContainer, stopContainer, getStatus, getLogs
|
||||
├── RuntimeConfig.java Record: jarStoragePath, baseImage, dockerNetwork, routing, etc.
|
||||
├── ContainerRequest.java Record: containerName, jarPath, envVars, memoryLimit, cpuShares
|
||||
├── ContainerStatus.java Record: state, running, exitCode, error
|
||||
└── RoutingMode.java Enum: path, subdomain
|
||||
```
|
||||
|
||||
### New Files — App Module (`cameleer3-server-app`)
|
||||
|
||||
```
|
||||
src/main/java/com/cameleer3/server/app/runtime/
|
||||
├── DockerRuntimeOrchestrator.java Docker implementation using docker-java
|
||||
├── DisabledRuntimeOrchestrator.java No-op implementation (observability-only mode)
|
||||
├── RuntimeOrchestratorAutoConfig.java @Configuration: auto-detects Docker vs K8s vs disabled
|
||||
├── DeploymentExecutor.java @Service: async deployment pipeline
|
||||
├── JarStorageService.java File-system JAR storage with versioning
|
||||
└── ContainerLogCollector.java Collects Docker container stdout/stderr
|
||||
|
||||
src/main/java/com/cameleer3/server/app/storage/
|
||||
├── PostgresEnvironmentRepository.java
|
||||
├── PostgresAppRepository.java
|
||||
├── PostgresAppVersionRepository.java
|
||||
└── PostgresDeploymentRepository.java
|
||||
|
||||
src/main/java/com/cameleer3/server/app/controller/
|
||||
├── EnvironmentAdminController.java CRUD endpoints under /api/v1/admin/environments
|
||||
├── AppController.java App + version CRUD + JAR upload
|
||||
└── DeploymentController.java Deploy, stop, restart, promote, logs
|
||||
|
||||
src/main/resources/db/migration/
|
||||
└── V3__runtime_management.sql Environments, apps, app_versions, deployments tables
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
- `pom.xml` (parent) — add docker-java dependency
|
||||
- `cameleer3-server-app/pom.xml` — add docker-java dependency
|
||||
- `application.yml` — add runtime config properties
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add docker-java Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/pom.xml`
|
||||
|
||||
- [ ] **Step 1: Add docker-java dependency**
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.github.docker-java</groupId>
|
||||
<artifactId>docker-java-core</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.docker-java</groupId>
|
||||
<artifactId>docker-java-transport-zerodep</artifactId>
|
||||
<version>3.4.1</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn compile -pl cameleer3-server-app`
|
||||
Expected: BUILD SUCCESS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/pom.xml
|
||||
git commit -m "chore: add docker-java dependency for runtime orchestration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Database Migration — Runtime Management Tables
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql`
|
||||
|
||||
- [ ] **Step 1: Write migration**
|
||||
|
||||
```sql
|
||||
-- V3__runtime_management.sql
|
||||
-- Runtime management: environments, apps, app versions, deployments
|
||||
|
||||
CREATE TABLE environments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(environment_id, slug)
|
||||
);
|
||||
CREATE INDEX idx_apps_environment_id ON apps(environment_id);
|
||||
|
||||
CREATE TABLE app_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
jar_path VARCHAR(500) NOT NULL,
|
||||
jar_checksum VARCHAR(64) NOT NULL,
|
||||
jar_filename VARCHAR(255),
|
||||
jar_size_bytes BIGINT,
|
||||
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(app_id, version)
|
||||
);
|
||||
CREATE INDEX idx_app_versions_app_id ON app_versions(app_id);
|
||||
|
||||
CREATE TABLE deployments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
app_version_id UUID NOT NULL REFERENCES app_versions(id),
|
||||
environment_id UUID NOT NULL REFERENCES environments(id),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'STARTING',
|
||||
container_id VARCHAR(100),
|
||||
container_name VARCHAR(255),
|
||||
error_message TEXT,
|
||||
deployed_at TIMESTAMPTZ,
|
||||
stopped_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_deployments_app_id ON deployments(app_id);
|
||||
CREATE INDEX idx_deployments_env_id ON deployments(environment_id);
|
||||
|
||||
-- Default environment (standalone mode always has at least one)
|
||||
INSERT INTO environments (slug, display_name) VALUES ('default', 'Default');
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/db/migration/V3__runtime_management.sql
|
||||
git commit -m "feat: add runtime management database schema (environments, apps, versions, deployments)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Core Domain — Environment, App, AppVersion, Deployment Records
|
||||
|
||||
**Files:**
|
||||
- Create all records in `cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/`
|
||||
|
||||
- [ ] **Step 1: Create all domain records**
|
||||
|
||||
```java
|
||||
// Environment.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record Environment(UUID id, String slug, String displayName, EnvironmentStatus status, Instant createdAt) {}
|
||||
|
||||
// EnvironmentStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
public enum EnvironmentStatus { ACTIVE, SUSPENDED }
|
||||
|
||||
// App.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record App(UUID id, UUID environmentId, String slug, String displayName, Instant createdAt) {}
|
||||
|
||||
// AppVersion.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record AppVersion(UUID id, UUID appId, int version, String jarPath, String jarChecksum,
|
||||
String jarFilename, Long jarSizeBytes, Instant uploadedAt) {}
|
||||
|
||||
// Deployment.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
public record Deployment(UUID id, UUID appId, UUID appVersionId, UUID environmentId,
|
||||
DeploymentStatus status, String containerId, String containerName,
|
||||
String errorMessage, Instant deployedAt, Instant stoppedAt, Instant createdAt) {
|
||||
public Deployment withStatus(DeploymentStatus newStatus) {
|
||||
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
||||
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
// DeploymentStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
public enum DeploymentStatus { STARTING, RUNNING, FAILED, STOPPED }
|
||||
|
||||
// RoutingMode.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
public enum RoutingMode { path, subdomain }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git commit -m "feat: add runtime management domain records"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Core — Repository Interfaces and RuntimeOrchestrator
|
||||
|
||||
**Files:**
|
||||
- Create repository interfaces and RuntimeOrchestrator in `core/runtime/`
|
||||
|
||||
- [ ] **Step 1: Create repository interfaces**
|
||||
|
||||
```java
|
||||
// EnvironmentRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface EnvironmentRepository {
|
||||
List<Environment> findAll();
|
||||
Optional<Environment> findById(UUID id);
|
||||
Optional<Environment> findBySlug(String slug);
|
||||
UUID create(String slug, String displayName);
|
||||
void updateDisplayName(UUID id, String displayName);
|
||||
void updateStatus(UUID id, EnvironmentStatus status);
|
||||
void delete(UUID id);
|
||||
}
|
||||
|
||||
// AppRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface AppRepository {
|
||||
List<App> findByEnvironmentId(UUID environmentId);
|
||||
Optional<App> findById(UUID id);
|
||||
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
|
||||
UUID create(UUID environmentId, String slug, String displayName);
|
||||
void delete(UUID id);
|
||||
}
|
||||
|
||||
// AppVersionRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface AppVersionRepository {
|
||||
List<AppVersion> findByAppId(UUID appId);
|
||||
Optional<AppVersion> findById(UUID id);
|
||||
int findMaxVersion(UUID appId);
|
||||
UUID create(UUID appId, int version, String jarPath, String jarChecksum, String jarFilename, Long jarSizeBytes);
|
||||
}
|
||||
|
||||
// DeploymentRepository.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.util.*;
|
||||
public interface DeploymentRepository {
|
||||
List<Deployment> findByAppId(UUID appId);
|
||||
List<Deployment> findByEnvironmentId(UUID environmentId);
|
||||
Optional<Deployment> findById(UUID id);
|
||||
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
|
||||
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
|
||||
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
||||
void markDeployed(UUID id);
|
||||
void markStopped(UUID id);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create RuntimeOrchestrator interface**
|
||||
|
||||
```java
|
||||
// RuntimeOrchestrator.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public interface RuntimeOrchestrator {
|
||||
boolean isEnabled();
|
||||
String startContainer(ContainerRequest request);
|
||||
void stopContainer(String containerId);
|
||||
void removeContainer(String containerId);
|
||||
ContainerStatus getContainerStatus(String containerId);
|
||||
Stream<String> getLogs(String containerId, int tailLines);
|
||||
}
|
||||
|
||||
// ContainerRequest.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
import java.util.Map;
|
||||
public record ContainerRequest(
|
||||
String containerName,
|
||||
String baseImage,
|
||||
String jarPath,
|
||||
String network,
|
||||
Map<String, String> envVars,
|
||||
Map<String, String> labels,
|
||||
long memoryLimitBytes,
|
||||
int cpuShares,
|
||||
int healthCheckPort
|
||||
) {}
|
||||
|
||||
// ContainerStatus.java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
public record ContainerStatus(String state, boolean running, int exitCode, String error) {
|
||||
public static ContainerStatus notFound() {
|
||||
return new ContainerStatus("not_found", false, -1, "Container not found");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git commit -m "feat: add runtime repository interfaces and RuntimeOrchestrator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Core — EnvironmentService, AppService, DeploymentService
|
||||
|
||||
**Files:**
|
||||
- Create service classes in `core/runtime/`
|
||||
|
||||
- [ ] **Step 1: Create EnvironmentService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class EnvironmentService {
|
||||
private final EnvironmentRepository repo;
|
||||
|
||||
public EnvironmentService(EnvironmentRepository repo) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
public List<Environment> listAll() { return repo.findAll(); }
|
||||
public Environment getById(UUID id) { return repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + id)); }
|
||||
public Environment getBySlug(String slug) { return repo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("Environment not found: " + slug)); }
|
||||
|
||||
public UUID create(String slug, String displayName) {
|
||||
if (repo.findBySlug(slug).isPresent()) {
|
||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||
}
|
||||
return repo.create(slug, displayName);
|
||||
}
|
||||
|
||||
public void delete(UUID id) {
|
||||
Environment env = getById(id);
|
||||
if ("default".equals(env.slug())) {
|
||||
throw new IllegalArgumentException("Cannot delete the default environment");
|
||||
}
|
||||
repo.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create AppService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class AppService {
|
||||
private static final Logger log = LoggerFactory.getLogger(AppService.class);
|
||||
|
||||
private final AppRepository appRepo;
|
||||
private final AppVersionRepository versionRepo;
|
||||
private final String jarStoragePath;
|
||||
|
||||
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
|
||||
this.appRepo = appRepo;
|
||||
this.versionRepo = versionRepo;
|
||||
this.jarStoragePath = jarStoragePath;
|
||||
}
|
||||
|
||||
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
|
||||
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
|
||||
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
|
||||
|
||||
public UUID createApp(UUID environmentId, String slug, String displayName) {
|
||||
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
|
||||
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
|
||||
}
|
||||
return appRepo.create(environmentId, slug, displayName);
|
||||
}
|
||||
|
||||
public AppVersion uploadJar(UUID appId, String filename, InputStream jarData, long size) throws IOException {
|
||||
App app = getById(appId);
|
||||
int nextVersion = versionRepo.findMaxVersion(appId) + 1;
|
||||
|
||||
// Store JAR: {jarStoragePath}/{appId}/v{version}/app.jar
|
||||
Path versionDir = Path.of(jarStoragePath, appId.toString(), "v" + nextVersion);
|
||||
Files.createDirectories(versionDir);
|
||||
Path jarFile = versionDir.resolve("app.jar");
|
||||
|
||||
MessageDigest digest;
|
||||
try { digest = MessageDigest.getInstance("SHA-256"); }
|
||||
catch (Exception e) { throw new RuntimeException(e); }
|
||||
|
||||
try (InputStream in = jarData) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
try (var out = Files.newOutputStream(jarFile)) {
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||
UUID versionId = versionRepo.create(appId, nextVersion, jarFile.toString(), checksum, filename, size);
|
||||
|
||||
log.info("Uploaded JAR for app {}: version={}, size={}, sha256={}", appId, nextVersion, size, checksum);
|
||||
return versionRepo.findById(versionId).orElseThrow();
|
||||
}
|
||||
|
||||
public String resolveJarPath(UUID appVersionId) {
|
||||
AppVersion version = versionRepo.findById(appVersionId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + appVersionId));
|
||||
return version.jarPath();
|
||||
}
|
||||
|
||||
public void deleteApp(UUID id) {
|
||||
appRepo.delete(id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create DeploymentService**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.core.runtime;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public class DeploymentService {
|
||||
private static final Logger log = LoggerFactory.getLogger(DeploymentService.class);
|
||||
|
||||
private final DeploymentRepository deployRepo;
|
||||
private final AppService appService;
|
||||
private final EnvironmentService envService;
|
||||
|
||||
public DeploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
|
||||
this.deployRepo = deployRepo;
|
||||
this.appService = appService;
|
||||
this.envService = envService;
|
||||
}
|
||||
|
||||
public List<Deployment> listByApp(UUID appId) { return deployRepo.findByAppId(appId); }
|
||||
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
|
||||
|
||||
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
|
||||
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) {
|
||||
App app = appService.getById(appId);
|
||||
Environment env = envService.getById(environmentId);
|
||||
String containerName = env.slug() + "-" + app.slug();
|
||||
|
||||
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
|
||||
return deployRepo.findById(deploymentId).orElseThrow();
|
||||
}
|
||||
|
||||
/** Promote: deploy the same app version to a different environment. */
|
||||
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) {
|
||||
return createDeployment(appId, appVersionId, targetEnvironmentId);
|
||||
}
|
||||
|
||||
public void markRunning(UUID deploymentId, String containerId) {
|
||||
deployRepo.updateStatus(deploymentId, DeploymentStatus.RUNNING, containerId, null);
|
||||
deployRepo.markDeployed(deploymentId);
|
||||
}
|
||||
|
||||
public void markFailed(UUID deploymentId, String errorMessage) {
|
||||
deployRepo.updateStatus(deploymentId, DeploymentStatus.FAILED, null, errorMessage);
|
||||
}
|
||||
|
||||
public void markStopped(UUID deploymentId) {
|
||||
deployRepo.updateStatus(deploymentId, DeploymentStatus.STOPPED, null, null);
|
||||
deployRepo.markStopped(deploymentId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/
|
||||
git commit -m "feat: add EnvironmentService, AppService, DeploymentService"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: App Module — PostgreSQL Repositories
|
||||
|
||||
**Files:**
|
||||
- Create all Postgres repositories in `app/storage/`
|
||||
|
||||
- [ ] **Step 1: Implement all four repositories**
|
||||
|
||||
Follow the pattern from `PostgresUserRepository.java` — `JdbcTemplate` with row mappers. Each repository implements its core interface with standard SQL (INSERT, SELECT, UPDATE, DELETE).
|
||||
|
||||
Key patterns to follow:
|
||||
- Constructor injection of `JdbcTemplate`
|
||||
- RowMapper lambdas returning records
|
||||
- `UUID.randomUUID()` for ID generation
|
||||
- `Timestamp.from(Instant)` for timestamp parameters
|
||||
|
||||
- [ ] **Step 2: Wire beans**
|
||||
|
||||
Create `RuntimeBeanConfig.java` in `app/config/`:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
public class RuntimeBeanConfig {
|
||||
@Bean
|
||||
public EnvironmentRepository environmentRepository(JdbcTemplate jdbc) {
|
||||
return new PostgresEnvironmentRepository(jdbc);
|
||||
}
|
||||
@Bean
|
||||
public AppRepository appRepository(JdbcTemplate jdbc) {
|
||||
return new PostgresAppRepository(jdbc);
|
||||
}
|
||||
@Bean
|
||||
public AppVersionRepository appVersionRepository(JdbcTemplate jdbc) {
|
||||
return new PostgresAppVersionRepository(jdbc);
|
||||
}
|
||||
@Bean
|
||||
public DeploymentRepository deploymentRepository(JdbcTemplate jdbc) {
|
||||
return new PostgresDeploymentRepository(jdbc);
|
||||
}
|
||||
@Bean
|
||||
public EnvironmentService environmentService(EnvironmentRepository repo) {
|
||||
return new EnvironmentService(repo);
|
||||
}
|
||||
@Bean
|
||||
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
|
||||
@Value("${cameleer.runtime.jar-storage-path:/data/jars}") String jarStoragePath) {
|
||||
return new AppService(appRepo, versionRepo, jarStoragePath);
|
||||
}
|
||||
@Bean
|
||||
public DeploymentService deploymentService(DeploymentRepository deployRepo, AppService appService, EnvironmentService envService) {
|
||||
return new DeploymentService(deployRepo, appService, envService);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run tests**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn test -pl cameleer3-server-app`
|
||||
Expected: PASS (Flyway applies V3 migration, context loads).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/Postgres*Repository.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java
|
||||
git commit -m "feat: implement PostgreSQL repositories for runtime management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Docker Runtime Orchestrator
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DockerRuntimeOrchestrator.java`
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DisabledRuntimeOrchestrator.java`
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/RuntimeOrchestratorAutoConfig.java`
|
||||
|
||||
- [ ] **Step 1: Implement DisabledRuntimeOrchestrator**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.*;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class DisabledRuntimeOrchestrator implements RuntimeOrchestrator {
|
||||
@Override public boolean isEnabled() { return false; }
|
||||
@Override public String startContainer(ContainerRequest r) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||||
@Override public void stopContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||||
@Override public void removeContainer(String id) { throw new UnsupportedOperationException("Runtime management disabled"); }
|
||||
@Override public ContainerStatus getContainerStatus(String id) { return ContainerStatus.notFound(); }
|
||||
@Override public Stream<String> getLogs(String id, int tail) { return Stream.empty(); }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement DockerRuntimeOrchestrator**
|
||||
|
||||
Port from SaaS `DockerRuntimeOrchestrator.java`, adapted:
|
||||
- Uses docker-java `DockerClientImpl` with zerodep transport
|
||||
- `startContainer()`: creates container from base image with volume mount for JAR (instead of image build), sets env vars, Traefik labels, health check, resource limits
|
||||
- `stopContainer()`: stops with 30s timeout
|
||||
- `removeContainer()`: force remove
|
||||
- `getContainerStatus()`: inspect container state
|
||||
- `getLogs()`: tail container logs
|
||||
|
||||
Key difference from SaaS version: **no image build**. The base image is pre-built. JAR is volume-mounted:
|
||||
|
||||
```java
|
||||
@Override
|
||||
public String startContainer(ContainerRequest request) {
|
||||
List<String> envList = request.envVars().entrySet().stream()
|
||||
.map(e -> e.getKey() + "=" + e.getValue()).toList();
|
||||
|
||||
// Volume bind: mount JAR into container
|
||||
Bind jarBind = new Bind(request.jarPath(), new Volume("/app/app.jar"), AccessMode.ro);
|
||||
|
||||
HostConfig hostConfig = HostConfig.newHostConfig()
|
||||
.withMemory(request.memoryLimitBytes())
|
||||
.withMemorySwap(request.memoryLimitBytes())
|
||||
.withCpuShares(request.cpuShares())
|
||||
.withNetworkMode(request.network())
|
||||
.withBinds(jarBind);
|
||||
|
||||
CreateContainerResponse container = dockerClient.createContainerCmd(request.baseImage())
|
||||
.withName(request.containerName())
|
||||
.withEnv(envList)
|
||||
.withLabels(request.labels())
|
||||
.withHostConfig(hostConfig)
|
||||
.withHealthcheck(new HealthCheck()
|
||||
.withTest(List.of("CMD-SHELL", "wget -qO- http://localhost:" + request.healthCheckPort() + "/cameleer/health || exit 1"))
|
||||
.withInterval(10_000_000_000L)
|
||||
.withTimeout(5_000_000_000L)
|
||||
.withRetries(3)
|
||||
.withStartPeriod(30_000_000_000L))
|
||||
.exec();
|
||||
|
||||
dockerClient.startContainerCmd(container.getId()).exec();
|
||||
return container.getId();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement RuntimeOrchestratorAutoConfig**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.RuntimeOrchestrator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Configuration
|
||||
public class RuntimeOrchestratorAutoConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RuntimeOrchestratorAutoConfig.class);
|
||||
|
||||
@Bean
|
||||
public RuntimeOrchestrator runtimeOrchestrator() {
|
||||
// Auto-detect: Docker socket available?
|
||||
if (Files.exists(Path.of("/var/run/docker.sock"))) {
|
||||
log.info("Docker socket detected — enabling Docker runtime orchestrator");
|
||||
return new DockerRuntimeOrchestrator();
|
||||
}
|
||||
// TODO: K8s detection (check for service account token)
|
||||
log.info("No Docker socket or K8s detected — runtime management disabled (observability-only mode)");
|
||||
return new DisabledRuntimeOrchestrator();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/
|
||||
git commit -m "feat: implement DockerRuntimeOrchestrator with volume-mount JAR deployment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: DeploymentExecutor — Async Deployment Pipeline
|
||||
|
||||
**Files:**
|
||||
- Create: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java`
|
||||
|
||||
- [ ] **Step 1: Implement async deployment pipeline**
|
||||
|
||||
```java
|
||||
package com.cameleer3.server.app.runtime;
|
||||
|
||||
import com.cameleer3.server.core.runtime.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class DeploymentExecutor {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeploymentExecutor.class);
|
||||
|
||||
private final RuntimeOrchestrator orchestrator;
|
||||
private final DeploymentService deploymentService;
|
||||
private final AppService appService;
|
||||
private final EnvironmentService envService;
|
||||
// Inject runtime config values
|
||||
|
||||
public DeploymentExecutor(RuntimeOrchestrator orchestrator, DeploymentService deploymentService,
|
||||
AppService appService, EnvironmentService envService) {
|
||||
this.orchestrator = orchestrator;
|
||||
this.deploymentService = deploymentService;
|
||||
this.appService = appService;
|
||||
this.envService = envService;
|
||||
}
|
||||
|
||||
@Async("deploymentExecutor")
|
||||
public void executeAsync(Deployment deployment) {
|
||||
try {
|
||||
// Stop existing deployment in same environment for same app
|
||||
// ... (find active deployment, stop container)
|
||||
|
||||
String jarPath = appService.resolveJarPath(deployment.appVersionId());
|
||||
App app = appService.getById(deployment.appId());
|
||||
Environment env = envService.getById(deployment.environmentId());
|
||||
|
||||
Map<String, String> envVars = new HashMap<>();
|
||||
envVars.put("CAMELEER_EXPORT_TYPE", "HTTP");
|
||||
envVars.put("CAMELEER_EXPORT_ENDPOINT", /* server endpoint */);
|
||||
envVars.put("CAMELEER_AUTH_TOKEN", /* bootstrap token */);
|
||||
envVars.put("CAMELEER_APPLICATION_ID", app.slug());
|
||||
envVars.put("CAMELEER_ENVIRONMENT_ID", env.slug());
|
||||
envVars.put("CAMELEER_DISPLAY_NAME", deployment.containerName());
|
||||
|
||||
Map<String, String> labels = buildTraefikLabels(app, env, deployment);
|
||||
|
||||
ContainerRequest request = new ContainerRequest(
|
||||
deployment.containerName(),
|
||||
/* baseImage */, jarPath, /* network */,
|
||||
envVars, labels, /* memoryLimit */, /* cpuShares */, 9464);
|
||||
|
||||
String containerId = orchestrator.startContainer(request);
|
||||
waitForHealthy(containerId, 60);
|
||||
|
||||
deploymentService.markRunning(deployment.id(), containerId);
|
||||
log.info("Deployment {} is RUNNING (container={})", deployment.id(), containerId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Deployment {} FAILED: {}", deployment.id(), e.getMessage(), e);
|
||||
deploymentService.markFailed(deployment.id(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void waitForHealthy(String containerId, int timeoutSeconds) throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
ContainerStatus status = orchestrator.getContainerStatus(containerId);
|
||||
if ("healthy".equalsIgnoreCase(status.state()) || (status.running() && "running".equalsIgnoreCase(status.state()))) {
|
||||
return;
|
||||
}
|
||||
if (!status.running()) {
|
||||
throw new RuntimeException("Container stopped unexpectedly: " + status.error());
|
||||
}
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
throw new RuntimeException("Container health check timed out after " + timeoutSeconds + "s");
|
||||
}
|
||||
|
||||
private Map<String, String> buildTraefikLabels(App app, Environment env, Deployment deployment) {
|
||||
// TODO: implement path-based and subdomain-based Traefik labels based on routing config
|
||||
return Map.of("traefik.enable", "true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add async config**
|
||||
|
||||
Add to `RuntimeBeanConfig.java` or create `AsyncConfig.java`:
|
||||
|
||||
```java
|
||||
@Bean(name = "deploymentExecutor")
|
||||
public TaskExecutor deploymentTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
executor.setMaxPoolSize(4);
|
||||
executor.setQueueCapacity(25);
|
||||
executor.setThreadNamePrefix("deploy-");
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java
|
||||
git commit -m "feat: implement async DeploymentExecutor pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: REST Controllers — Environment, App, Deployment
|
||||
|
||||
**Files:**
|
||||
- Create: `EnvironmentAdminController.java` (under `/api/v1/admin/environments`, ADMIN role)
|
||||
- Create: `AppController.java` (under `/api/v1/apps`, OPERATOR role)
|
||||
- Create: `DeploymentController.java` (under `/api/v1/apps/{appId}/deployments`, OPERATOR role)
|
||||
|
||||
- [ ] **Step 1: Implement EnvironmentAdminController**
|
||||
|
||||
CRUD for environments. Path: `/api/v1/admin/environments`. Requires ADMIN role. Follows existing controller patterns (OpenAPI annotations, ResponseEntity).
|
||||
|
||||
- [ ] **Step 2: Implement AppController**
|
||||
|
||||
App CRUD + JAR upload. Path: `/api/v1/apps`. Requires OPERATOR role. JAR upload via `multipart/form-data`. Returns app versions.
|
||||
|
||||
Key endpoint for JAR upload:
|
||||
```java
|
||||
@PostMapping(value = "/{appId}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<AppVersion> uploadJar(@PathVariable UUID appId,
|
||||
@RequestParam("file") MultipartFile file) throws IOException {
|
||||
AppVersion version = appService.uploadJar(appId, file.getOriginalFilename(), file.getInputStream(), file.getSize());
|
||||
return ResponseEntity.status(201).body(version);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement DeploymentController**
|
||||
|
||||
Deploy, stop, restart, promote, logs. Path: `/api/v1/apps/{appId}/deployments`. Requires OPERATOR role.
|
||||
|
||||
Key endpoints:
|
||||
```java
|
||||
@PostMapping
|
||||
public ResponseEntity<Deployment> deploy(@PathVariable UUID appId, @RequestBody DeployRequest request) {
|
||||
// request contains: appVersionId, environmentId
|
||||
Deployment deployment = deploymentService.createDeployment(appId, request.appVersionId(), request.environmentId());
|
||||
deploymentExecutor.executeAsync(deployment);
|
||||
return ResponseEntity.accepted().body(deployment);
|
||||
}
|
||||
|
||||
@PostMapping("/{deploymentId}/promote")
|
||||
public ResponseEntity<Deployment> promote(@PathVariable UUID appId, @PathVariable UUID deploymentId,
|
||||
@RequestBody PromoteRequest request) {
|
||||
Deployment source = deploymentService.getById(deploymentId);
|
||||
Deployment promoted = deploymentService.promote(appId, source.appVersionId(), request.targetEnvironmentId());
|
||||
deploymentExecutor.executeAsync(promoted);
|
||||
return ResponseEntity.accepted().body(promoted);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add security rules to SecurityConfig**
|
||||
|
||||
Add to `SecurityConfig.filterChain()`:
|
||||
```java
|
||||
// Runtime management (OPERATOR+)
|
||||
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/EnvironmentAdminController.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AppController.java
|
||||
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DeploymentController.java
|
||||
git commit -m "feat: add REST controllers for environment, app, and deployment management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Configuration and Application Properties
|
||||
|
||||
**Files:**
|
||||
- Modify: `cameleer3-server-app/src/main/resources/application.yml`
|
||||
|
||||
- [ ] **Step 1: Add runtime config properties**
|
||||
|
||||
```yaml
|
||||
cameleer:
|
||||
runtime:
|
||||
enabled: ${CAMELEER_RUNTIME_ENABLED:true}
|
||||
jar-storage-path: ${CAMELEER_JAR_STORAGE_PATH:/data/jars}
|
||||
base-image: ${CAMELEER_RUNTIME_BASE_IMAGE:cameleer-runtime-base:latest}
|
||||
docker-network: ${CAMELEER_DOCKER_NETWORK:cameleer}
|
||||
agent-health-port: 9464
|
||||
health-check-timeout: 60
|
||||
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
|
||||
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
|
||||
routing-mode: ${CAMELEER_ROUTING_MODE:path}
|
||||
routing-domain: ${CAMELEER_ROUTING_DOMAIN:localhost}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run full test suite**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/main/resources/application.yml
|
||||
git commit -m "feat: add runtime management configuration properties"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Integration Tests
|
||||
|
||||
- [ ] **Step 1: Write EnvironmentAdminController integration test**
|
||||
|
||||
Test CRUD operations for environments. Follows existing pattern from `AgentRegistrationControllerIT`.
|
||||
|
||||
- [ ] **Step 2: Write AppController integration test**
|
||||
|
||||
Test app creation, JAR upload, version listing.
|
||||
|
||||
- [ ] **Step 3: Write DeploymentController integration test**
|
||||
|
||||
Test deployment creation (with `DisabledRuntimeOrchestrator` — verifies the deployment record is created even if Docker is unavailable). Full Docker tests require Docker-in-Docker and are out of scope for CI.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/
|
||||
git commit -m "test: add integration tests for runtime management API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Final Verification
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer3-server && mvn clean verify`
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 2: Verify schema applies cleanly**
|
||||
|
||||
Fresh Testcontainers PostgreSQL should apply V1 + V2 + V3 without errors.
|
||||
|
||||
- [ ] **Step 3: Commit any remaining fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: finalize runtime management — all tests passing"
|
||||
```
|
||||
377
docs/superpowers/plans/2026-04-07-plan4-saas-cleanup.md
Normal file
377
docs/superpowers/plans/2026-04-07-plan4-saas-cleanup.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Plan 4: SaaS Cleanup — Strip to Vendor Management Plane
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remove all migrated code from the SaaS layer (environments, apps, deployments, ClickHouse access) and strip it down to a thin vendor management plane: tenant lifecycle, license generation, billing, and Logto organization management.
|
||||
|
||||
**Architecture:** The SaaS retains only vendor-level concerns. All runtime management, observability, and user management is now in the server. The SaaS communicates with server instances exclusively via REST API (ServerApiClient). ClickHouse dependency is removed entirely.
|
||||
|
||||
**Tech Stack:** Java 21, Spring Boot 3.4.3, PostgreSQL 16
|
||||
|
||||
**Repo:** `C:\Users\Hendrik\Documents\projects\cameleer-saas`
|
||||
|
||||
**Prerequisite:** Plans 1-3 must be implemented in cameleer3-server first.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
### Files to DELETE (migrated to server or no longer needed)
|
||||
|
||||
```
|
||||
src/main/java/net/siegeln/cameleer/saas/environment/
|
||||
├── EnvironmentEntity.java
|
||||
├── EnvironmentService.java
|
||||
├── EnvironmentController.java
|
||||
├── EnvironmentRepository.java
|
||||
├── EnvironmentStatus.java
|
||||
└── dto/
|
||||
├── CreateEnvironmentRequest.java
|
||||
├── UpdateEnvironmentRequest.java
|
||||
└── EnvironmentResponse.java
|
||||
|
||||
src/main/java/net/siegeln/cameleer/saas/app/
|
||||
├── AppEntity.java
|
||||
├── AppService.java
|
||||
├── AppController.java
|
||||
├── AppRepository.java
|
||||
└── dto/
|
||||
├── CreateAppRequest.java
|
||||
└── AppResponse.java
|
||||
|
||||
src/main/java/net/siegeln/cameleer/saas/deployment/
|
||||
├── DeploymentEntity.java
|
||||
├── DeploymentService.java
|
||||
├── DeploymentController.java
|
||||
├── DeploymentRepository.java
|
||||
├── DeploymentExecutor.java
|
||||
├── DesiredStatus.java
|
||||
├── ObservedStatus.java
|
||||
└── dto/
|
||||
└── DeploymentResponse.java
|
||||
|
||||
src/main/java/net/siegeln/cameleer/saas/runtime/
|
||||
├── RuntimeOrchestrator.java
|
||||
├── DockerRuntimeOrchestrator.java
|
||||
├── RuntimeConfig.java
|
||||
├── BuildImageRequest.java
|
||||
├── StartContainerRequest.java
|
||||
├── ContainerStatus.java
|
||||
└── LogConsumer.java
|
||||
|
||||
src/main/java/net/siegeln/cameleer/saas/log/
|
||||
├── ClickHouseConfig.java
|
||||
├── ClickHouseProperties.java
|
||||
├── ContainerLogService.java
|
||||
├── LogController.java
|
||||
└── dto/
|
||||
└── LogEntry.java
|
||||
|
||||
src/main/java/net/siegeln/cameleer/saas/observability/
|
||||
├── AgentStatusService.java
|
||||
├── AgentStatusController.java
|
||||
└── dto/
|
||||
├── AgentStatusResponse.java
|
||||
└── ObservabilityStatusResponse.java
|
||||
```
|
||||
|
||||
### Files to MODIFY
|
||||
|
||||
```
|
||||
src/main/java/net/siegeln/cameleer/saas/config/AsyncConfig.java — remove deploymentExecutor bean
|
||||
src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java — remove createDefaultForTenant() call
|
||||
src/main/resources/application.yml — remove clickhouse + runtime config sections
|
||||
docker-compose.yml — remove Docker socket mount from SaaS, update routing
|
||||
```
|
||||
|
||||
### Files to KEEP (vendor management plane)
|
||||
|
||||
```
|
||||
src/main/java/net/siegeln/cameleer/saas/tenant/ — Tenant CRUD, lifecycle
|
||||
src/main/java/net/siegeln/cameleer/saas/license/ — License generation
|
||||
src/main/java/net/siegeln/cameleer/saas/identity/ — Logto org management, ServerApiClient
|
||||
src/main/java/net/siegeln/cameleer/saas/config/ — SecurityConfig, SpaController
|
||||
src/main/java/net/siegeln/cameleer/saas/audit/ — Vendor audit logging
|
||||
src/main/java/net/siegeln/cameleer/saas/apikey/ — API key management (if used)
|
||||
ui/ — Vendor management dashboard
|
||||
```
|
||||
|
||||
### Flyway Migrations to KEEP
|
||||
|
||||
The existing migrations (V001-V009) can remain since they're already applied. Add a new cleanup migration:
|
||||
|
||||
```
|
||||
src/main/resources/db/migration/V010__drop_migrated_tables.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remove ClickHouse Dependency
|
||||
|
||||
- [ ] **Step 1: Delete ClickHouse files**
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ClickHouseProperties.java
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/log/ContainerLogService.java
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/log/LogController.java
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/log/dto/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove ClickHouse from AgentStatusService**
|
||||
|
||||
Delete `AgentStatusService.java` and `AgentStatusController.java` entirely (agent status is now a server concern).
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/observability/
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove ClickHouse config from application.yml**
|
||||
|
||||
Remove the entire `cameleer.clickhouse:` section.
|
||||
|
||||
- [ ] **Step 4: Remove ClickHouse JDBC dependency from pom.xml**
|
||||
|
||||
Remove:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.clickhouse</groupId>
|
||||
<artifactId>clickhouse-jdbc</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
|
||||
Expected: BUILD SUCCESS. Fix any remaining import errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: remove all ClickHouse dependencies from SaaS layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Remove Environment/App/Deployment Code
|
||||
|
||||
- [ ] **Step 1: Delete environment package**
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/environment/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Delete app package**
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/app/
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Delete deployment package**
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/deployment/
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Delete runtime package**
|
||||
|
||||
```bash
|
||||
rm -rf src/main/java/net/siegeln/cameleer/saas/runtime/
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove AsyncConfig deploymentExecutor bean**
|
||||
|
||||
In `AsyncConfig.java`, remove the `deploymentExecutor` bean (or delete AsyncConfig if it only had that bean).
|
||||
|
||||
- [ ] **Step 6: Update TenantService**
|
||||
|
||||
Remove any calls to `EnvironmentService.createDefaultForTenant()` from `TenantService.java`. The server now handles default environment creation.
|
||||
|
||||
- [ ] **Step 7: Remove runtime config from application.yml**
|
||||
|
||||
Remove the entire `cameleer.runtime:` section.
|
||||
|
||||
- [ ] **Step 8: Verify build**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn compile`
|
||||
Expected: BUILD SUCCESS. Fix any remaining import errors.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: remove migrated environment/app/deployment/runtime code from SaaS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Database Cleanup Migration
|
||||
|
||||
- [ ] **Step 1: Create cleanup migration**
|
||||
|
||||
```sql
|
||||
-- V010__drop_migrated_tables.sql
|
||||
-- Drop tables that have been migrated to cameleer3-server
|
||||
|
||||
DROP TABLE IF EXISTS deployments CASCADE;
|
||||
DROP TABLE IF EXISTS apps CASCADE;
|
||||
DROP TABLE IF EXISTS environments CASCADE;
|
||||
DROP TABLE IF EXISTS api_keys CASCADE;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/resources/db/migration/V010__drop_migrated_tables.sql
|
||||
git commit -m "feat: drop migrated tables from SaaS database"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Remove Docker Socket Dependency
|
||||
|
||||
- [ ] **Step 1: Update docker-compose.yml**
|
||||
|
||||
Remove from `cameleer-saas` service:
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- jardata:/data/jars
|
||||
group_add:
|
||||
- "0"
|
||||
```
|
||||
|
||||
The Docker socket mount now belongs to the `cameleer3-server` service instead.
|
||||
|
||||
- [ ] **Step 2: Remove docker-java dependency from pom.xml**
|
||||
|
||||
Remove:
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.github.docker-java</groupId>
|
||||
<artifactId>docker-java-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.docker-java</groupId>
|
||||
<artifactId>docker-java-transport-zerodep</artifactId>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.yml pom.xml
|
||||
git commit -m "feat: remove Docker socket dependency from SaaS layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update SaaS UI
|
||||
|
||||
- [ ] **Step 1: Remove environment/app/deployment pages from SaaS frontend**
|
||||
|
||||
Remove pages that now live in the server UI:
|
||||
- `EnvironmentsPage`
|
||||
- `EnvironmentDetailPage`
|
||||
- `AppDetailPage`
|
||||
|
||||
The SaaS UI retains:
|
||||
- `DashboardPage` — vendor overview (tenant list, status)
|
||||
- `AdminTenantsPage` — tenant management
|
||||
- `LicensePage` — license management
|
||||
|
||||
- [ ] **Step 2: Update navigation**
|
||||
|
||||
Remove links to environments/apps/deployments. The SaaS UI should link to the tenant's server instance for those features (e.g., "Open Dashboard" link to `https://{tenant-slug}.cameleer.example.com/server/`).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add ui/
|
||||
git commit -m "feat: strip SaaS UI to vendor management dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Expand ServerApiClient
|
||||
|
||||
- [ ] **Step 1: Add provisioning-related API calls**
|
||||
|
||||
The `ServerApiClient` should gain methods for tenant provisioning:
|
||||
|
||||
```java
|
||||
public void pushLicense(String serverEndpoint, String licenseToken) {
|
||||
post(serverEndpoint + "/api/v1/admin/license")
|
||||
.body(Map.of("token", licenseToken))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
public Map<String, Object> getHealth(String serverEndpoint) {
|
||||
return get(serverEndpoint + "/api/v1/health")
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/java/net/siegeln/cameleer/saas/identity/ServerApiClient.java
|
||||
git commit -m "feat: expand ServerApiClient with license push and health check methods"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Write SAAS-INTEGRATION.md
|
||||
|
||||
- [ ] **Step 1: Create integration contract document**
|
||||
|
||||
Create `docs/SAAS-INTEGRATION.md` in the cameleer3-server repo documenting:
|
||||
- Which server API endpoints the SaaS calls
|
||||
- Required auth (M2M token with `server:admin` scope)
|
||||
- License injection mechanism (`POST /api/v1/admin/license`)
|
||||
- Health check endpoint (`GET /api/v1/health`)
|
||||
- What the server exposes vs what the SaaS must never access directly
|
||||
- Env vars the SaaS sets when provisioning a server instance
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd /c/Users/Hendrik/Documents/projects/cameleer3-server
|
||||
git add docs/SAAS-INTEGRATION.md
|
||||
git commit -m "docs: add SaaS integration contract documentation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final Verification
|
||||
|
||||
- [ ] **Step 1: Build SaaS**
|
||||
|
||||
Run: `cd /c/Users/Hendrik/Documents/projects/cameleer-saas && mvn clean verify`
|
||||
Expected: BUILD SUCCESS with reduced dependency footprint.
|
||||
|
||||
- [ ] **Step 2: Verify SaaS starts without ClickHouse**
|
||||
|
||||
The SaaS should start with only PostgreSQL (and Logto). No ClickHouse required.
|
||||
|
||||
- [ ] **Step 3: Verify remaining code footprint**
|
||||
|
||||
The SaaS source should now contain approximately:
|
||||
- `tenant/` — ~4 files
|
||||
- `license/` — ~5 files
|
||||
- `identity/` — ~3 files (LogtoConfig, ServerApiClient, M2M token)
|
||||
- `config/` — ~3 files (SecurityConfig, SpaController, TLS)
|
||||
- `audit/` — ~3 files
|
||||
- `ui/` — stripped dashboard
|
||||
|
||||
Total: ~20 Java files (down from ~75).
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore: finalize SaaS cleanup — vendor management plane only"
|
||||
```
|
||||
Reference in New Issue
Block a user