diff --git a/docs/superpowers/plans/2026-03-29-phase-1-foundation-auth.md b/docs/superpowers/plans/2026-03-29-phase-1-foundation-auth.md new file mode 100644 index 0000000..6e43ded --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-phase-1-foundation-auth.md @@ -0,0 +1,2213 @@ +# Phase 1: Foundation + Auth — Implementation Plan + +> **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:** Running Spring Boot 3 application with PostgreSQL, user registration, login, Ed25519 JWT issuance, RBAC, and immutable audit logging. + +**Architecture:** Modular monolith — single Spring Boot app with clean package boundaries per module (auth, audit, tenant, common). Maven build matching sibling repo conventions. PostgreSQL for platform data. Flyway for schema migrations. + +**Tech Stack:** Java 21, Spring Boot 3.4.3, Spring Security 6, Maven, PostgreSQL 16, Flyway, JUnit 5, TestContainers, Docker Compose, Ed25519 (java.security), bcrypt (Spring Security Crypto) + +--- + +## File Structure + +``` +cameleer-saas/ +├── pom.xml # Maven parent POM +├── docker-compose.yml # Local dev: PostgreSQL +├── Dockerfile # Production container build +├── .gitea/workflows/ci.yml # Gitea Actions CI +├── src/ +│ ├── main/ +│ │ ├── java/net/siegeln/cameleer/saas/ +│ │ │ ├── CameleerSaasApplication.java # Spring Boot entry point +│ │ │ ├── config/ +│ │ │ │ ├── SecurityConfig.java # Spring Security filter chain +│ │ │ │ ├── JwtConfig.java # Ed25519 key management +│ │ │ │ └── HealthController.java # Secured health endpoint +│ │ │ ├── auth/ +│ │ │ │ ├── AuthController.java # POST /api/auth/register, /login +│ │ │ │ ├── AuthService.java # Registration + login logic +│ │ │ │ ├── JwtService.java # Ed25519 JWT sign + verify +│ │ │ │ ├── JwtAuthenticationFilter.java # OncePerRequestFilter for JWT +│ │ │ │ ├── UserEntity.java # JPA entity +│ │ │ │ ├── UserRepository.java # Spring Data JPA +│ │ │ │ ├── RoleEntity.java # JPA entity +│ │ │ │ ├── RoleRepository.java # Spring Data JPA +│ │ │ │ ├── PermissionEntity.java # JPA entity +│ │ │ │ └── dto/ +│ │ │ │ ├── RegisterRequest.java # Registration DTO +│ │ │ │ ├── LoginRequest.java # Login DTO +│ │ │ │ └── AuthResponse.java # JWT response DTO +│ │ │ └── audit/ +│ │ │ ├── AuditService.java # Append-only audit logging +│ │ │ ├── AuditEntity.java # JPA entity +│ │ │ ├── AuditRepository.java # Spring Data JPA (no delete/update) +│ │ │ └── AuditAction.java # Enum of audit actions +│ │ └── resources/ +│ │ ├── application.yml # Spring config +│ │ ├── application-dev.yml # Dev profile (Docker Compose DB) +│ │ ├── application-test.yml # Test profile (TestContainers) +│ │ └── db/migration/ +│ │ ├── V001__create_users_table.sql +│ │ ├── V002__create_roles_and_permissions.sql +│ │ ├── V003__seed_default_roles.sql +│ │ └── V004__create_audit_log.sql +│ └── test/ +│ └── java/net/siegeln/cameleer/saas/ +│ ├── CameleerSaasApplicationTest.java # Context loads test +│ ├── auth/ +│ │ ├── AuthControllerTest.java # Integration tests +│ │ ├── AuthServiceTest.java # Unit tests +│ │ └── JwtServiceTest.java # Unit tests +│ └── audit/ +│ ├── AuditServiceTest.java # Unit tests +│ └── AuditRepositoryTest.java # Integration tests (TestContainers) +``` + +--- + +### Task 1: Maven Project Setup + +**Files:** +- Create: `pom.xml` +- Create: `src/main/java/net/siegeln/cameleer/saas/CameleerSaasApplication.java` +- Create: `src/main/resources/application.yml` + +- [ ] **Step 1: Create root POM** + +```xml + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.4.3 + + + + net.siegeln.cameleer + cameleer-saas + 0.1.0-SNAPSHOT + Cameleer SaaS Platform + Multi-tenant SaaS platform for Apache Camel applications + + + 21 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + postgresql + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + +``` + +- [ ] **Step 2: Create Spring Boot application class** + +```java +// src/main/java/net/siegeln/cameleer/saas/CameleerSaasApplication.java +package net.siegeln.cameleer.saas; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CameleerSaasApplication { + public static void main(String[] args) { + SpringApplication.run(CameleerSaasApplication.class, args); + } +} +``` + +- [ ] **Step 3: Create application.yml** + +```yaml +# src/main/resources/application.yml +spring: + application: + name: cameleer-saas + jpa: + open-in-view: false + hibernate: + ddl-auto: validate + flyway: + enabled: true + locations: classpath:db/migration + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when-authorized + +cameleer: + jwt: + expiration: 86400 # 24 hours in seconds +``` + +- [ ] **Step 4: Create application-dev.yml** + +```yaml +# src/main/resources/application-dev.yml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/cameleer_saas + username: cameleer + password: cameleer_dev + jpa: + show-sql: true +``` + +- [ ] **Step 5: Create application-test.yml** + +```yaml +# src/main/resources/application-test.yml +spring: + jpa: + show-sql: false + flyway: + clean-disabled: false +``` + +- [ ] **Step 6: Verify the project compiles** + +Run: `./mvnw compile` +Expected: BUILD SUCCESS (no source to compile yet beyond Application class, but validates POM) + +- [ ] **Step 7: Commit** + +```bash +git add pom.xml src/main/java/net/siegeln/cameleer/saas/CameleerSaasApplication.java src/main/resources/application.yml src/main/resources/application-dev.yml src/main/resources/application-test.yml +git commit -m "feat: initialize Maven project with Spring Boot 3.4.3 + Java 21" +``` + +--- + +### Task 2: Docker Compose + TestContainers Base + +**Files:** +- Create: `docker-compose.yml` +- Create: `src/test/java/net/siegeln/cameleer/saas/CameleerSaasApplicationTest.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/TestcontainersConfig.java` + +- [ ] **Step 1: Create docker-compose.yml for local dev** + +```yaml +# docker-compose.yml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: cameleer_saas + POSTGRES_USER: cameleer + POSTGRES_PASSWORD: cameleer_dev + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: +``` + +- [ ] **Step 2: Create TestContainers configuration for tests** + +```java +// src/test/java/net/siegeln/cameleer/saas/TestcontainersConfig.java +package net.siegeln.cameleer.saas; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfig { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>("postgres:16-alpine"); + } +} +``` + +- [ ] **Step 3: Create context loads test** + +```java +// src/test/java/net/siegeln/cameleer/saas/CameleerSaasApplicationTest.java +package net.siegeln.cameleer.saas; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@Import(TestcontainersConfig.class) +@ActiveProfiles("test") +class CameleerSaasApplicationTest { + + @Test + void contextLoads() { + // Verifies the application context starts successfully + } +} +``` + +- [ ] **Step 4: Start Docker Compose PostgreSQL** + +Run: `docker compose up -d` +Expected: PostgreSQL container running on port 5432 + +- [ ] **Step 5: Run the context loads test (it will fail — no migrations yet, but context should start)** + +Run: `./mvnw test -Dtest=CameleerSaasApplicationTest -pl .` +Expected: FAIL — Flyway will complain about no migrations (this confirms Flyway is active). We fix this in Task 3. + +- [ ] **Step 6: Commit** + +```bash +git add docker-compose.yml src/test/java/net/siegeln/cameleer/saas/TestcontainersConfig.java src/test/java/net/siegeln/cameleer/saas/CameleerSaasApplicationTest.java +git commit -m "feat: add Docker Compose + TestContainers for PostgreSQL" +``` + +--- + +### Task 3: Flyway Migrations — Users, Roles, Permissions + +**Files:** +- Create: `src/main/resources/db/migration/V001__create_users_table.sql` +- Create: `src/main/resources/db/migration/V002__create_roles_and_permissions.sql` +- Create: `src/main/resources/db/migration/V003__seed_default_roles.sql` + +- [ ] **Step 1: Create users table migration** + +```sql +-- src/main/resources/db/migration/V001__create_users_table.sql +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + 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 INDEX idx_users_email ON users (email); +CREATE INDEX idx_users_status ON users (status); +``` + +- [ ] **Step 2: Create roles and permissions tables** + +```sql +-- src/main/resources/db/migration/V002__create_roles_and_permissions.sql +CREATE TABLE roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) NOT NULL UNIQUE, + description VARCHAR(255), + built_in BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(255) +); + +CREATE TABLE role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) +); + +CREATE TABLE user_roles ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); +``` + +- [ ] **Step 3: Seed default roles and permissions** + +```sql +-- src/main/resources/db/migration/V003__seed_default_roles.sql + +-- Permissions +INSERT INTO permissions (id, name, description) VALUES + ('00000000-0000-0000-0000-000000000001', 'tenant:manage', 'Full tenant administration'), + ('00000000-0000-0000-0000-000000000002', 'billing:manage', 'Manage billing and subscriptions'), + ('00000000-0000-0000-0000-000000000003', 'team:manage', 'Manage team members and roles'), + ('00000000-0000-0000-0000-000000000004', 'apps:manage', 'Deploy, configure, and manage applications'), + ('00000000-0000-0000-0000-000000000005', 'apps:deploy', 'Deploy and promote applications'), + ('00000000-0000-0000-0000-000000000006', 'secrets:manage', 'Create and rotate secrets'), + ('00000000-0000-0000-0000-000000000007', 'observe:read', 'View traces, topology, dashboards'), + ('00000000-0000-0000-0000-000000000008', 'observe:debug', 'Use debugger and replay'), + ('00000000-0000-0000-0000-000000000009', 'settings:manage', 'Manage tenant settings'); + +-- Roles +INSERT INTO roles (id, name, description, built_in) VALUES + ('10000000-0000-0000-0000-000000000001', 'OWNER', 'Full tenant admin including billing and deletion', true), + ('10000000-0000-0000-0000-000000000002', 'ADMIN', 'Manage apps, secrets, team. No billing.', true), + ('10000000-0000-0000-0000-000000000003', 'DEVELOPER', 'Deploy apps, view traces, use debugger.', true), + ('10000000-0000-0000-0000-000000000004', 'VIEWER', 'Read-only access to dashboards and traces.', true); + +-- Owner: all permissions +INSERT INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000001', id FROM permissions; + +-- Admin: everything except billing and tenant management +INSERT INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000002', id FROM permissions +WHERE name NOT IN ('tenant:manage', 'billing:manage'); + +-- Developer: apps, secrets, observe (including debug) +INSERT INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000003', id FROM permissions +WHERE name IN ('apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug'); + +-- Viewer: observe read-only +INSERT INTO role_permissions (role_id, permission_id) +SELECT '10000000-0000-0000-0000-000000000004', id FROM permissions +WHERE name = 'observe:read'; +``` + +- [ ] **Step 4: Run the context loads test** + +Run: `./mvnw test -Dtest=CameleerSaasApplicationTest` +Expected: PASS — Flyway runs migrations, Spring context starts, Hibernate validates schema + +- [ ] **Step 5: Commit** + +```bash +git add src/main/resources/db/migration/ +git commit -m "feat: add Flyway migrations for users, roles, and permissions" +``` + +--- + +### Task 4: Audit Logging Framework + +**Files:** +- Create: `src/main/resources/db/migration/V004__create_audit_log.sql` +- Create: `src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java` + +- [ ] **Step 1: Create audit_log migration (append-only: no UPDATE/DELETE grants)** + +```sql +-- src/main/resources/db/migration/V004__create_audit_log.sql +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id UUID, + actor_email VARCHAR(255), + tenant_id UUID, + action VARCHAR(100) NOT NULL, + resource VARCHAR(500), + environment VARCHAR(50), + source_ip VARCHAR(45), + result VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC); +CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC); +CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC); + +-- SOC 2: Prevent modifications to audit records. +-- The application user should only have INSERT + SELECT on this table. +-- In production, enforce via a restricted DB role: +-- REVOKE UPDATE, DELETE ON audit_log FROM cameleer_app; +-- For dev/test, we rely on application-level enforcement. +COMMENT ON TABLE audit_log IS 'Immutable audit trail. No UPDATE or DELETE allowed.'; +``` + +- [ ] **Step 2: Write failing tests for AuditService** + +```java +// src/test/java/net/siegeln/cameleer/saas/audit/AuditServiceTest.java +package net.siegeln.cameleer.saas.audit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuditServiceTest { + + @Mock + private AuditRepository auditRepository; + + @InjectMocks + private AuditService auditService; + + @Test + void log_createsAuditEntryWithAllFields() { + var actorId = UUID.randomUUID(); + var tenantId = UUID.randomUUID(); + + auditService.log( + actorId, "user@example.com", tenantId, + AuditAction.AUTH_LOGIN, "user:" + actorId, + null, "192.168.1.1", "SUCCESS", + Map.of("method", "password") + ); + + var captor = ArgumentCaptor.forClass(AuditEntity.class); + verify(auditRepository).save(captor.capture()); + + var entry = captor.getValue(); + assertThat(entry.getActorId()).isEqualTo(actorId); + assertThat(entry.getActorEmail()).isEqualTo("user@example.com"); + assertThat(entry.getTenantId()).isEqualTo(tenantId); + assertThat(entry.getAction()).isEqualTo("AUTH_LOGIN"); + assertThat(entry.getResource()).isEqualTo("user:" + actorId); + assertThat(entry.getSourceIp()).isEqualTo("192.168.1.1"); + assertThat(entry.getResult()).isEqualTo("SUCCESS"); + } + + @Test + void log_worksWithNullOptionalFields() { + auditService.log( + null, null, null, + AuditAction.AUTH_REGISTER, "user:new", + null, "10.0.0.1", "SUCCESS", null + ); + + var captor = ArgumentCaptor.forClass(AuditEntity.class); + verify(auditRepository).save(captor.capture()); + + var entry = captor.getValue(); + assertThat(entry.getActorId()).isNull(); + assertThat(entry.getTenantId()).isNull(); + assertThat(entry.getMetadata()).isNull(); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `./mvnw test -Dtest=AuditServiceTest` +Expected: FAIL — AuditAction, AuditEntity, AuditRepository, AuditService don't exist yet + +- [ ] **Step 4: Implement AuditAction enum** + +```java +// src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java +package net.siegeln.cameleer.saas.audit; + +public enum AuditAction { + // Auth + AUTH_REGISTER, + AUTH_LOGIN, + AUTH_LOGIN_FAILED, + AUTH_LOGOUT, + + // Tenant (Phase 2+) + TENANT_CREATE, + TENANT_UPDATE, + TENANT_SUSPEND, + TENANT_REACTIVATE, + TENANT_DELETE, + + // Application (Phase 4+) + APP_CREATE, + APP_DEPLOY, + APP_PROMOTE, + APP_ROLLBACK, + APP_SCALE, + APP_STOP, + APP_DELETE, + + // Secrets (Phase 5+) + SECRET_CREATE, + SECRET_READ, + SECRET_UPDATE, + SECRET_DELETE, + SECRET_ROTATE, + + // Config (Phase 5+) + CONFIG_UPDATE, + + // Team (Phase 2+) + TEAM_INVITE, + TEAM_REMOVE, + TEAM_ROLE_CHANGE +} +``` + +- [ ] **Step 5: Implement AuditEntity** + +```java +// src/main/java/net/siegeln/cameleer/saas/audit/AuditEntity.java +package net.siegeln.cameleer.saas.audit; + +import jakarta.persistence.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "audit_log") +public class AuditEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "actor_id") + private UUID actorId; + + @Column(name = "actor_email") + private String actorEmail; + + @Column(name = "tenant_id") + private UUID tenantId; + + @Column(name = "action", nullable = false) + private String action; + + @Column(name = "resource") + private String resource; + + @Column(name = "environment") + private String environment; + + @Column(name = "source_ip") + private String sourceIp; + + @Column(name = "result", nullable = false) + private String result; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "metadata", columnDefinition = "jsonb") + private Map metadata; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(); + } + + // Getters and setters + public UUID getId() { return id; } + public UUID getActorId() { return actorId; } + public void setActorId(UUID actorId) { this.actorId = actorId; } + public String getActorEmail() { return actorEmail; } + public void setActorEmail(String actorEmail) { this.actorEmail = actorEmail; } + public UUID getTenantId() { return tenantId; } + public void setTenantId(UUID tenantId) { this.tenantId = tenantId; } + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + public String getResource() { return resource; } + public void setResource(String resource) { this.resource = resource; } + public String getEnvironment() { return environment; } + public void setEnvironment(String environment) { this.environment = environment; } + public String getSourceIp() { return sourceIp; } + public void setSourceIp(String sourceIp) { this.sourceIp = sourceIp; } + public String getResult() { return result; } + public void setResult(String result) { this.result = result; } + public Map getMetadata() { return metadata; } + public void setMetadata(Map metadata) { this.metadata = metadata; } + public Instant getCreatedAt() { return createdAt; } +} +``` + +- [ ] **Step 6: Implement AuditRepository (no delete methods exposed)** + +```java +// src/main/java/net/siegeln/cameleer/saas/audit/AuditRepository.java +package net.siegeln.cameleer.saas.audit; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.UUID; + +public interface AuditRepository extends JpaRepository { + + Page findByTenantIdAndCreatedAtBetween( + UUID tenantId, Instant from, Instant to, Pageable pageable + ); + + Page findByActorId(UUID actorId, Pageable pageable); +} +``` + +- [ ] **Step 7: Implement AuditService** + +```java +// src/main/java/net/siegeln/cameleer/saas/audit/AuditService.java +package net.siegeln.cameleer.saas.audit; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.UUID; + +@Service +public class AuditService { + + private final AuditRepository auditRepository; + + public AuditService(AuditRepository auditRepository) { + this.auditRepository = auditRepository; + } + + public void log(UUID actorId, String actorEmail, UUID tenantId, + AuditAction action, String resource, + String environment, String sourceIp, + String result, Map metadata) { + var entry = new AuditEntity(); + entry.setActorId(actorId); + entry.setActorEmail(actorEmail); + entry.setTenantId(tenantId); + entry.setAction(action.name()); + entry.setResource(resource); + entry.setEnvironment(environment); + entry.setSourceIp(sourceIp); + entry.setResult(result); + entry.setMetadata(metadata); + auditRepository.save(entry); + } +} +``` + +- [ ] **Step 8: Run unit tests** + +Run: `./mvnw test -Dtest=AuditServiceTest` +Expected: PASS — both tests green + +- [ ] **Step 9: Write integration test for audit persistence** + +```java +// src/test/java/net/siegeln/cameleer/saas/audit/AuditRepositoryTest.java +package net.siegeln.cameleer.saas.audit; + +import net.siegeln.cameleer.saas.TestcontainersConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@Import(TestcontainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +class AuditRepositoryTest { + + @Autowired + private AuditRepository auditRepository; + + @Test + void save_persistsAuditEntry() { + var entry = new AuditEntity(); + entry.setAction("AUTH_LOGIN"); + entry.setActorEmail("test@example.com"); + entry.setResult("SUCCESS"); + entry.setSourceIp("127.0.0.1"); + + var saved = auditRepository.save(entry); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCreatedAt()).isNotNull(); + } + + @Test + void findByTenantId_returnsFilteredResults() { + var tenantId = UUID.randomUUID(); + var otherTenantId = UUID.randomUUID(); + + saveEntry(tenantId, "AUTH_LOGIN"); + saveEntry(tenantId, "AUTH_LOGOUT"); + saveEntry(otherTenantId, "AUTH_LOGIN"); + + var results = auditRepository.findByTenantIdAndCreatedAtBetween( + tenantId, + Instant.now().minusSeconds(60), + Instant.now().plusSeconds(60), + PageRequest.of(0, 10) + ); + + assertThat(results.getContent()).hasSize(2); + assertThat(results.getContent()).allMatch(e -> e.getTenantId().equals(tenantId)); + } + + @Test + void save_persistsJsonMetadata() { + var entry = new AuditEntity(); + entry.setAction("AUTH_LOGIN"); + entry.setResult("SUCCESS"); + entry.setMetadata(Map.of("method", "password", "mfa", false)); + + var saved = auditRepository.save(entry); + var loaded = auditRepository.findById(saved.getId()).orElseThrow(); + + assertThat(loaded.getMetadata()).containsEntry("method", "password"); + } + + private void saveEntry(UUID tenantId, String action) { + var entry = new AuditEntity(); + entry.setTenantId(tenantId); + entry.setAction(action); + entry.setResult("SUCCESS"); + auditRepository.save(entry); + } +} +``` + +- [ ] **Step 10: Run integration tests** + +Run: `./mvnw test -Dtest=AuditRepositoryTest` +Expected: PASS — TestContainers spins up PostgreSQL, Flyway migrates, tests pass + +- [ ] **Step 11: Commit** + +```bash +git add src/main/resources/db/migration/V004__create_audit_log.sql src/main/java/net/siegeln/cameleer/saas/audit/ src/test/java/net/siegeln/cameleer/saas/audit/ +git commit -m "feat: add audit logging framework with immutable append-only log" +``` + +--- + +### Task 5: User Entity + Registration + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java` + +- [ ] **Step 1: Write failing tests for AuthService.register** + +```java +// src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock private UserRepository userRepository; + @Mock private RoleRepository roleRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private JwtService jwtService; + @Mock private AuditService auditService; + + @InjectMocks + private AuthService authService; + + @Test + void register_createsUserAndReturnsToken() { + var request = new RegisterRequest("user@example.com", "Test User", "SecurePass123!"); + var role = new RoleEntity(); + role.setName("OWNER"); + + when(userRepository.existsByEmail("user@example.com")).thenReturn(false); + when(passwordEncoder.encode("SecurePass123!")).thenReturn("$2a$hashed"); + when(roleRepository.findByName("OWNER")).thenReturn(Optional.of(role)); + when(userRepository.save(any(UserEntity.class))).thenAnswer(inv -> inv.getArgument(0)); + when(jwtService.generateToken(any(UserEntity.class))).thenReturn("jwt.token.here"); + + var response = authService.register(request, "192.168.1.1"); + + assertThat(response.token()).isEqualTo("jwt.token.here"); + assertThat(response.email()).isEqualTo("user@example.com"); + verify(userRepository).save(any(UserEntity.class)); + verify(auditService).log(any(), eq("user@example.com"), any(), any(), anyString(), any(), eq("192.168.1.1"), eq("SUCCESS"), any()); + } + + @Test + void register_rejectsDuplicateEmail() { + var request = new RegisterRequest("existing@example.com", "Test User", "SecurePass123!"); + when(userRepository.existsByEmail("existing@example.com")).thenReturn(true); + + assertThatThrownBy(() -> authService.register(request, "10.0.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Email already registered"); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./mvnw test -Dtest=AuthServiceTest` +Expected: FAIL — classes don't exist yet + +- [ ] **Step 3: Implement JPA entities** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.*; +import java.util.UUID; + +@Entity +@Table(name = "permissions") +public class PermissionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + public UUID getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } +} +``` + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "roles") +public class RoleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + @Column(name = "built_in", nullable = false) + private boolean builtIn; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "role_permissions", + joinColumns = @JoinColumn(name = "role_id"), + inverseJoinColumns = @JoinColumn(name = "permission_id") + ) + private Set permissions = new HashSet<>(); + + @PrePersist + void prePersist() { this.createdAt = Instant.now(); } + + public UUID getId() { return id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public boolean isBuiltIn() { return builtIn; } + public Set getPermissions() { return permissions; } +} +``` + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java +package net.siegeln.cameleer.saas.auth; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "users") +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String status = "ACTIVE"; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + private Set roles = new HashSet<>(); + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + @PreUpdate + void preUpdate() { this.updatedAt = Instant.now(); } + + public UUID getId() { return id; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public Set getRoles() { return roles; } + public Instant getCreatedAt() { return createdAt; } +} +``` + +- [ ] **Step 4: Implement repositories** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java +package net.siegeln.cameleer.saas.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} +``` + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java +package net.siegeln.cameleer.saas.auth; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import java.util.UUID; + +public interface RoleRepository extends JpaRepository { + Optional findByName(String name); +} +``` + +- [ ] **Step 5: Implement DTOs** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java +package net.siegeln.cameleer.saas.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RegisterRequest( + @NotBlank @Email String email, + @NotBlank String name, + @NotBlank @Size(min = 8, max = 128) String password +) {} +``` + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java +package net.siegeln.cameleer.saas.auth.dto; + +public record AuthResponse( + String token, + String email, + String name +) {} +``` + +- [ ] **Step 6: Create a stub JwtService (real implementation in Task 6)** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java +package net.siegeln.cameleer.saas.auth; + +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + public String generateToken(UserEntity user) { + // Stub — replaced with Ed25519 implementation in Task 6 + return "stub-token"; + } + + public String extractEmail(String token) { + return null; + } + + public boolean isTokenValid(String token) { + return false; + } +} +``` + +- [ ] **Step 7: Implement AuthService** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.audit.AuditAction; +import net.siegeln.cameleer.saas.audit.AuditService; +import net.siegeln.cameleer.saas.auth.dto.AuthResponse; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuditService auditService; + + public AuthService(UserRepository userRepository, RoleRepository roleRepository, + PasswordEncoder passwordEncoder, JwtService jwtService, + AuditService auditService) { + this.userRepository = userRepository; + this.roleRepository = roleRepository; + this.passwordEncoder = passwordEncoder; + this.jwtService = jwtService; + this.auditService = auditService; + } + + @Transactional + public AuthResponse register(RegisterRequest request, String sourceIp) { + if (userRepository.existsByEmail(request.email())) { + throw new IllegalArgumentException("Email already registered"); + } + + var user = new UserEntity(); + user.setEmail(request.email()); + user.setName(request.name()); + user.setPassword(passwordEncoder.encode(request.password())); + + var ownerRole = roleRepository.findByName("OWNER") + .orElseThrow(() -> new IllegalStateException("OWNER role not found")); + user.getRoles().add(ownerRole); + + user = userRepository.save(user); + + var token = jwtService.generateToken(user); + + auditService.log( + user.getId(), user.getEmail(), null, + AuditAction.AUTH_REGISTER, "user:" + user.getId(), + null, sourceIp, "SUCCESS", + Map.of("name", user.getName()) + ); + + return new AuthResponse(token, user.getEmail(), user.getName()); + } +} +``` + +- [ ] **Step 8: Run unit tests** + +Run: `./mvnw test -Dtest=AuthServiceTest` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/auth/ src/test/java/net/siegeln/cameleer/saas/auth/ +git commit -m "feat: add user entity, registration, and RBAC model" +``` + +--- + +### Task 6: Ed25519 JWT Service + +**Files:** +- Modify: `src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java` + +- [ ] **Step 1: Write failing tests for JwtService** + +```java +// src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java +package net.siegeln.cameleer.saas.auth; + +import net.siegeln.cameleer.saas.config.JwtConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtServiceTest { + + private JwtService jwtService; + + @BeforeEach + void setUp() { + var config = new JwtConfig(); + config.init(); // Generates Ed25519 key pair + jwtService = new JwtService(config); + } + + @Test + void generateToken_producesValidJwt() { + var user = createUser("test@example.com", "OWNER"); + + var token = jwtService.generateToken(user); + + assertThat(token).isNotBlank(); + assertThat(token.split("\\.")).hasSize(3); // header.payload.signature + } + + @Test + void extractEmail_returnsCorrectEmail() { + var user = createUser("test@example.com", "OWNER"); + var token = jwtService.generateToken(user); + + var email = jwtService.extractEmail(token); + + assertThat(email).isEqualTo("test@example.com"); + } + + @Test + void isTokenValid_returnsTrueForValidToken() { + var user = createUser("test@example.com", "OWNER"); + var token = jwtService.generateToken(user); + + assertThat(jwtService.isTokenValid(token)).isTrue(); + } + + @Test + void isTokenValid_returnsFalseForTamperedToken() { + var user = createUser("test@example.com", "OWNER"); + var token = jwtService.generateToken(user); + var tampered = token.substring(0, token.length() - 5) + "XXXXX"; + + assertThat(jwtService.isTokenValid(tampered)).isFalse(); + } + + @Test + void extractRoles_returnsUserRoles() { + var user = createUser("test@example.com", "ADMIN"); + var token = jwtService.generateToken(user); + + var roles = jwtService.extractRoles(token); + + assertThat(roles).contains("ADMIN"); + } + + @Test + void extractUserId_returnsCorrectId() { + var user = createUser("test@example.com", "OWNER"); + var token = jwtService.generateToken(user); + + var userId = jwtService.extractUserId(token); + + assertThat(userId).isEqualTo(user.getId()); + } + + private UserEntity createUser(String email, String roleName) { + var role = new RoleEntity(); + role.setName(roleName); + var user = new UserEntity(); + user.setEmail(email); + user.setName("Test User"); + user.getRoles().add(role); + // Reflectively set ID for testing + try { + var idField = UserEntity.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(user, UUID.randomUUID()); + } catch (Exception e) { + throw new RuntimeException(e); + } + return user; + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./mvnw test -Dtest=JwtServiceTest` +Expected: FAIL — JwtConfig doesn't have init(), JwtService constructor doesn't match + +- [ ] **Step 3: Implement JwtConfig with Ed25519 key generation** + +```java +// src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java +package net.siegeln.cameleer.saas.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.*; +import java.util.Base64; + +@Component +public class JwtConfig { + + @Value("${cameleer.jwt.expiration:86400}") + private long expirationSeconds; + + private KeyPair keyPair; + + @PostConstruct + public void init() { + try { + var keyGen = KeyPairGenerator.getInstance("Ed25519"); + this.keyPair = keyGen.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Ed25519 not available", e); + } + } + + public PrivateKey getPrivateKey() { return keyPair.getPrivate(); } + public PublicKey getPublicKey() { return keyPair.getPublic(); } + public long getExpirationSeconds() { return expirationSeconds; } + + public String getPublicKeyBase64() { + return Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + } +} +``` + +- [ ] **Step 4: Implement full JwtService with Ed25519 signing** + +The JDK Ed25519 support uses `Signature` with `Ed25519` algorithm. We build JWT manually (header + payload + signature) to avoid pulling in a JWT library — Ed25519 is not supported by most Java JWT libraries (nimbus-jose-jwt does support it, but manual construction is simpler and avoids the dependency). + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java +package net.siegeln.cameleer.saas.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.config.JwtConfig; +import org.springframework.stereotype.Service; + +import java.security.Signature; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class JwtService { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private final JwtConfig jwtConfig; + + public JwtService(JwtConfig jwtConfig) { + this.jwtConfig = jwtConfig; + } + + public String generateToken(UserEntity user) { + try { + var now = Instant.now(); + var exp = now.plusSeconds(jwtConfig.getExpirationSeconds()); + + var header = Map.of("alg", "EdDSA", "typ", "JWT"); + var payload = new LinkedHashMap(); + payload.put("sub", user.getEmail()); + payload.put("uid", user.getId().toString()); + payload.put("name", user.getName()); + payload.put("roles", user.getRoles().stream() + .map(RoleEntity::getName) + .collect(Collectors.toList())); + payload.put("iat", now.getEpochSecond()); + payload.put("exp", exp.getEpochSecond()); + + var headerB64 = base64Url(MAPPER.writeValueAsBytes(header)); + var payloadB64 = base64Url(MAPPER.writeValueAsBytes(payload)); + var signingInput = headerB64 + "." + payloadB64; + + var sig = Signature.getInstance("Ed25519"); + sig.initSign(jwtConfig.getPrivateKey()); + sig.update(signingInput.getBytes()); + var signatureB64 = base64Url(sig.sign()); + + return signingInput + "." + signatureB64; + } catch (Exception e) { + throw new RuntimeException("Failed to generate JWT", e); + } + } + + public String extractEmail(String token) { + var claims = parseClaims(token); + return claims != null ? (String) claims.get("sub") : null; + } + + public UUID extractUserId(String token) { + var claims = parseClaims(token); + return claims != null ? UUID.fromString((String) claims.get("uid")) : null; + } + + @SuppressWarnings("unchecked") + public Set extractRoles(String token) { + var claims = parseClaims(token); + if (claims == null) return Set.of(); + var roles = (List) claims.get("roles"); + return roles != null ? new HashSet<>(roles) : Set.of(); + } + + public boolean isTokenValid(String token) { + try { + var parts = token.split("\\."); + if (parts.length != 3) return false; + + var signingInput = parts[0] + "." + parts[1]; + var signatureBytes = Base64.getUrlDecoder().decode(parts[2]); + + var sig = Signature.getInstance("Ed25519"); + sig.initVerify(jwtConfig.getPublicKey()); + sig.update(signingInput.getBytes()); + if (!sig.verify(signatureBytes)) return false; + + var claims = parseClaims(token); + if (claims == null) return false; + + var exp = ((Number) claims.get("exp")).longValue(); + return Instant.now().getEpochSecond() < exp; + } catch (Exception e) { + return false; + } + } + + @SuppressWarnings("unchecked") + private Map parseClaims(String token) { + try { + var parts = token.split("\\."); + if (parts.length != 3) return null; + var payloadJson = Base64.getUrlDecoder().decode(parts[1]); + return MAPPER.readValue(payloadJson, Map.class); + } catch (Exception e) { + return null; + } + } + + private String base64Url(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `./mvnw test -Dtest=JwtServiceTest` +Expected: PASS — all 6 tests green + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java +git commit -m "feat: add Ed25519 JWT signing and verification" +``` + +--- + +### Task 7: Login Endpoint + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java` +- Modify: `src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java` (add login method) +- Modify: `src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java` (add login tests) + +- [ ] **Step 1: Write failing tests for login** + +Add to `AuthServiceTest.java`: + +```java +@Test +void login_returnsTokenForValidCredentials() { + var user = new UserEntity(); + user.setEmail("user@example.com"); + user.setName("Test User"); + user.setPassword("$2a$hashed"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("SecurePass123!", "$2a$hashed")).thenReturn(true); + when(jwtService.generateToken(user)).thenReturn("jwt.token.here"); + + var request = new LoginRequest("user@example.com", "SecurePass123!"); + var response = authService.login(request, "192.168.1.1"); + + assertThat(response.token()).isEqualTo("jwt.token.here"); + verify(auditService).log(any(), eq("user@example.com"), any(), eq(AuditAction.AUTH_LOGIN), anyString(), any(), eq("192.168.1.1"), eq("SUCCESS"), any()); +} + +@Test +void login_rejectsInvalidPassword() { + var user = new UserEntity(); + user.setEmail("user@example.com"); + user.setPassword("$2a$hashed"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrong", "$2a$hashed")).thenReturn(false); + + var request = new LoginRequest("user@example.com", "wrong"); + + assertThatThrownBy(() -> authService.login(request, "10.0.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid credentials"); + verify(auditService).log(any(), eq("user@example.com"), any(), eq(AuditAction.AUTH_LOGIN_FAILED), anyString(), any(), eq("10.0.0.1"), eq("FAILURE"), any()); +} + +@Test +void login_rejectsUnknownEmail() { + when(userRepository.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + var request = new LoginRequest("nobody@example.com", "whatever"); + + assertThatThrownBy(() -> authService.login(request, "10.0.0.1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid credentials"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./mvnw test -Dtest=AuthServiceTest` +Expected: FAIL — LoginRequest doesn't exist, login method doesn't exist + +- [ ] **Step 3: Create LoginRequest DTO** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java +package net.siegeln.cameleer.saas.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank @Email String email, + @NotBlank String password +) {} +``` + +- [ ] **Step 4: Add login method to AuthService** + +Add to `AuthService.java`: + +```java +public AuthResponse login(LoginRequest request, String sourceIp) { + var user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new IllegalArgumentException("Invalid credentials")); + + if (!passwordEncoder.matches(request.password(), user.getPassword())) { + auditService.log( + user.getId(), user.getEmail(), null, + AuditAction.AUTH_LOGIN_FAILED, "user:" + user.getId(), + null, sourceIp, "FAILURE", + Map.of("reason", "invalid_password") + ); + throw new IllegalArgumentException("Invalid credentials"); + } + + var token = jwtService.generateToken(user); + + auditService.log( + user.getId(), user.getEmail(), null, + AuditAction.AUTH_LOGIN, "user:" + user.getId(), + null, sourceIp, "SUCCESS", null + ); + + return new AuthResponse(token, user.getEmail(), user.getName()); +} +``` + +- [ ] **Step 5: Run tests** + +Run: `./mvnw test -Dtest=AuthServiceTest` +Expected: PASS — all 5 tests green + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java +git commit -m "feat: add login with password verification and audit logging" +``` + +--- + +### Task 8: Spring Security Configuration + JWT Filter + +**Files:** +- Create: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java` +- Create: `src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java` +- Create: `src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java` + +- [ ] **Step 1: Write failing integration tests for auth endpoints** + +```java +// src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java +package net.siegeln.cameleer.saas.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.TestcontainersConfig; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfig.class) +@ActiveProfiles("test") +class AuthControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Test + void register_returns201WithToken() throws Exception { + var request = new RegisterRequest("newuser@example.com", "New User", "SecurePass123!"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.email").value("newuser@example.com")); + } + + @Test + void register_returns409ForDuplicateEmail() throws Exception { + var request = new RegisterRequest("duplicate@example.com", "User", "SecurePass123!"); + + // First registration succeeds + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + // Second registration fails + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void login_returns200WithToken() throws Exception { + // Register first + var registerReq = new RegisterRequest("logintest@example.com", "Login User", "SecurePass123!"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerReq))) + .andExpect(status().isCreated()); + + // Then login + var loginReq = new LoginRequest("logintest@example.com", "SecurePass123!"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginReq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isNotEmpty()); + } + + @Test + void login_returns401ForBadPassword() throws Exception { + var registerReq = new RegisterRequest("badpass@example.com", "User", "SecurePass123!"); + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerReq))) + .andExpect(status().isCreated()); + + var loginReq = new LoginRequest("badpass@example.com", "WrongPassword!"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginReq))) + .andExpect(status().isUnauthorized()); + } + + @Test + void protectedEndpoint_returns401WithoutToken() throws Exception { + mockMvc.perform(get("/api/health/secured")) + .andExpect(status().isUnauthorized()); + } + + @Test + void protectedEndpoint_returns200WithValidToken() throws Exception { + // Register and get token + var registerReq = new RegisterRequest("secured@example.com", "User", "SecurePass123!"); + var result = mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerReq))) + .andExpect(status().isCreated()) + .andReturn(); + + var token = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("token").asText(); + + mockMvc.perform(get("/api/health/secured") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./mvnw test -Dtest=AuthControllerTest` +Expected: FAIL — no controller, no security config, no filter + +- [ ] **Step 3: Implement JwtAuthenticationFilter** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java +package net.siegeln.cameleer.saas.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + var authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + var token = authHeader.substring(7); + if (!jwtService.isTokenValid(token)) { + filterChain.doFilter(request, response); + return; + } + + var email = jwtService.extractEmail(token); + var userId = jwtService.extractUserId(token); + var roles = jwtService.extractRoles(token); + + var authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + + var auth = new UsernamePasswordAuthenticationToken(email, userId, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + + filterChain.doFilter(request, response); + } +} +``` + +- [ ] **Step 4: Implement SecurityConfig** + +```java +// src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +package net.siegeln.cameleer.saas.config; + +import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} +``` + +- [ ] **Step 5: Implement AuthController** + +```java +// src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java +package net.siegeln.cameleer.saas.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import net.siegeln.cameleer.saas.auth.dto.AuthResponse; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request, + HttpServletRequest httpRequest) { + try { + var response = authService.register(request, getClientIp(httpRequest)); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", e.getMessage())); + } + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + try { + var response = authService.login(request, getClientIp(httpRequest)); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Invalid credentials")); + } + } + + private String getClientIp(HttpServletRequest request) { + var xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} +``` + +The `/api/health/secured` endpoint lives in a separate `HealthController`, not under `AuthController` (which is scoped to `/api/auth/**` — a permitAll path). Create `HealthController` alongside the AuthController: + +```java +// src/main/java/net/siegeln/cameleer/saas/config/HealthController.java +package net.siegeln.cameleer.saas.config; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/health") +public class HealthController { + + @GetMapping("/secured") + public ResponseEntity> securedHealth() { + return ResponseEntity.ok(Map.of("status", "authenticated")); + } +} +``` + +- [ ] **Step 6: Run integration tests** + +Run: `./mvnw test -Dtest=AuthControllerTest` +Expected: PASS — all 6 tests green + +- [ ] **Step 7: Commit** + +```bash +git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/java/net/siegeln/cameleer/saas/config/HealthController.java src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java +git commit -m "feat: add Spring Security with JWT filter, auth controller, and health endpoint" +``` + +--- + +### Task 9: Dockerfile + Gitea Actions CI + +**Files:** +- Create: `Dockerfile` +- Create: `.gitea/workflows/ci.yml` +- Create: `.mvn/wrapper/maven-wrapper.properties` + +- [ ] **Step 1: Create Maven wrapper** + +Run: `mvn wrapper:wrapper -Dmaven=3.9.9` +(Or download manually — ensures `./mvnw` works in CI) + +- [ ] **Step 2: Create multi-stage Dockerfile** + +```dockerfile +# Dockerfile +FROM eclipse-temurin:21-jdk-alpine AS build +WORKDIR /build +COPY .mvn/ .mvn/ +COPY mvnw pom.xml ./ +RUN ./mvnw dependency:go-offline -B +COPY src/ src/ +RUN ./mvnw package -DskipTests -B + +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +RUN addgroup -S cameleer && adduser -S cameleer -G cameleer +COPY --from=build /build/target/*.jar app.jar +USER cameleer +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +- [ ] **Step 3: Create Gitea Actions CI workflow** + +```yaml +# .gitea/workflows/ci.yml +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: cameleer_saas_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Run tests + run: ./mvnw verify -B + env: + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/cameleer_saas_test + SPRING_DATASOURCE_USERNAME: test + SPRING_DATASOURCE_PASSWORD: test + + - name: Build Docker image + run: docker build -t cameleer-saas:${{ github.sha }} . +``` + +- [ ] **Step 4: Verify Docker build works locally** + +Run: `docker build -t cameleer-saas:dev .` +Expected: Image builds successfully + +- [ ] **Step 5: Verify the app starts with Docker Compose** + +Run: `docker compose up -d && sleep 5 && curl -s http://localhost:8080/actuator/health` +Expected: `{"status":"UP"}` +Cleanup: `docker compose down` + +- [ ] **Step 6: Commit** + +```bash +git add Dockerfile .gitea/workflows/ci.yml .mvn/ mvnw mvnw.cmd +git commit -m "feat: add Dockerfile and Gitea Actions CI pipeline" +``` + +--- + +### Task 10: Run Full Test Suite + Final Verification + +- [ ] **Step 1: Run all tests** + +Run: `./mvnw verify` +Expected: All tests pass (unit + integration) + +- [ ] **Step 2: Verify test count** + +Expected test classes and approximate counts: +- `CameleerSaasApplicationTest` — 1 test (context loads) +- `AuditServiceTest` — 2 tests +- `AuditRepositoryTest` — 3 tests +- `AuthServiceTest` — 5 tests +- `JwtServiceTest` — 6 tests +- `AuthControllerTest` — 6 tests +- **Total: ~23 tests** + +- [ ] **Step 3: Verify the full flow manually** + +```bash +# Start the stack +docker compose up -d +./mvnw spring-boot:run -Dspring-boot.run.profiles=dev + +# Register +curl -s -X POST http://localhost:8080/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"hendrik@example.com","name":"Hendrik","password":"TestPassword123!"}' | jq . + +# Login +curl -s -X POST http://localhost:8080/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"hendrik@example.com","password":"TestPassword123!"}' | jq . + +# Use the token from login response +TOKEN="" + +# Access secured endpoint +curl -s http://localhost:8080/api/health/secured \ + -H "Authorization: Bearer $TOKEN" | jq . + +# Verify unauthenticated access is blocked +curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/health/secured +# Expected: 401 +``` + +- [ ] **Step 4: Final commit with all files** + +Run: `git status` — ensure no uncommitted files +If clean: Phase 1 is complete. + +--- + +## Summary + +Phase 1 delivers: +- **10 tasks**, approximately **60 steps** +- Maven project with Spring Boot 3.4.3 + Java 21 +- PostgreSQL with 4 Flyway migrations (users, roles, permissions, audit_log) +- Immutable audit logging framework (SOC 2 foundation) +- User registration with bcrypt password hashing +- Ed25519 JWT signing and verification (no third-party JWT library) +- Login endpoint with audit trail +- Spring Security filter chain with JWT authentication +- RBAC with 4 predefined roles and 9 permissions +- Dockerfile + Gitea Actions CI +- ~23 tests (unit + integration with TestContainers) + +**Next phase:** Phase 2 (Tenants + Licensing) adds multi-tenant data model, tenant-scoped auth, team management, and license token generation. diff --git a/docs/superpowers/plans/2026-03-29-phase-roadmap.md b/docs/superpowers/plans/2026-03-29-phase-roadmap.md new file mode 100644 index 0000000..35d1142 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-phase-roadmap.md @@ -0,0 +1,243 @@ +# Cameleer SaaS — Phase Roadmap + +> Implementation phases derived from the [SaaS Platform PRD](../specs/2026-03-29-saas-platform-prd.md). +> Each phase gets its own detailed implementation plan when it starts. + +**Principle:** Each phase produces working, testable, deployable software. No phase requires a future phase to be useful. + +--- + +## Phase Dependency Graph + +``` +Phase 1: Foundation + Auth + ↓ +Phase 2: Tenants + Licensing + ↓ +Phase 3: K8s Infrastructure + Provisioning + ↓ +Phase 4: Camel Application Runtime + ↓ +Phase 5: Secrets + Config Management + ↓ +Phase 6: Observability Integration + ↓ +Phase 7: Billing & Metering + ↓ +Phase 8: Security Hardening + Self-Monitoring + ↓ +Phase 9: Frontend (React Shell) +``` + +Note: Phase 9 (Frontend) can be developed in parallel with Phases 3-8, building UI for each backend capability as it lands. Listed last because the backend API must exist first. + +--- + +## Phase 1: Foundation + Auth + +**PRD Sections:** 3 (Architecture), 4 (Data Architecture), 5 (Identity & Access), 12 (Security — audit logging) +**Gitea Epics:** #1 (Management Platform), #2 (Identity & Access), #11 (Security — audit only) +**Produces:** Running Spring Boot 3 application with PostgreSQL, user registration, login, Ed25519 JWT issuance, RBAC (Owner/Admin/Developer/Viewer), and immutable audit logging. + +**Key deliverables:** +- Maven project with modular package structure +- Docker Compose local dev environment (PostgreSQL) +- Flyway migrations for platform DB (users, roles, permissions, audit_log) +- Audit logging framework (append-only, no UPDATE/DELETE) +- User registration + password hashing (bcrypt) +- Ed25519 key pair generation + JWT signing/validation +- Login endpoint returning JWT +- Spring Security filter chain with JWT validation +- RBAC with predefined roles +- Health + readiness endpoints +- Dockerfile + Gitea Actions CI pipeline + +**Plan:** `2026-03-29-phase-1-foundation-auth.md` + +--- + +## Phase 2: Tenants + Licensing + +**PRD Sections:** 4 (Data Architecture — tenants), 5 (IAM — team management), 10 (License & Feature Gating) +**Gitea Epics:** #2 (Identity & Access — teams), #7 (License & Feature Gating) +**Depends on:** Phase 1 +**Produces:** Multi-tenant data model, tenant CRUD, team management, invite flow, Ed25519-signed license token generation, license API, and tenant-scoped auth (JWT carries tenant ID + role). + +**Key deliverables:** +- Tenant entity + CRUD API +- Tenant membership (user-to-tenant with role) +- Team management API (create, invite, assign roles) +- Tenant context in JWT (tenant ID, role claims) +- License token generation (Ed25519-signed JWT with tier + feature flags + limits) +- License API endpoint (GET /api/license/{tenant}) +- License lifecycle (generate on signup, regenerate on tier change) +- Tenant-scoped authorization (@PreAuthorize with tenant context) + +--- + +## Phase 3: K8s Infrastructure + Provisioning + +**PRD Sections:** 6 (Tenant Provisioning), 11 (Networking & Tenant Isolation) +**Gitea Epics:** #3 (Tenant Provisioning), #8 (Networking) +**Depends on:** Phase 2 +**Produces:** Automated tenant provisioning pipeline. Signup creates tenant → Flux HelmRelease generated → namespace provisioned → cameleer3-server deployed → PostgreSQL schema + OpenSearch index created → tenant ACTIVE. NetworkPolicies enforced. + +**Key deliverables:** +- Provisioning state machine (idempotent, retryable) +- Flux HelmRelease CR generation from tenant config +- GitOps repo integration (commit HelmRelease CRs) +- Per-tenant PostgreSQL schema provisioning + credentials +- Per-tenant OpenSearch index template + credentials +- Readiness checking (poll tenant server health) +- Tenant lifecycle operations (suspend, reactivate, delete) +- K8s NetworkPolicy templates (default deny + allow rules) +- Helm chart for cameleer3-server tenant deployment + +--- + +## Phase 4: Camel Application Runtime + +**PRD Sections:** 7 (Camel Application Runtime), 16 (Environments & Promotion) +**Gitea Epics:** #5 (Camel Application Runtime), #10 (Environments & Promotion Pipeline) +**Depends on:** Phase 3 +**Produces:** Customers can upload a Camel JAR, platform builds an immutable container image with agent auto-injected, deploys to tenant namespace, promotes between environments. + +**Key deliverables:** +- Application entity + CRUD API +- JAR upload endpoint with validation (type, size, checksum, Trivy scan) +- Dockerfile templating (distroless JRE + customer JAR + agent JAR + -javaagent) +- Container image build pipeline (buildx or Kaniko) +- Platform container registry integration (push, tag, list) +- K8s Deployment creation in tenant namespace +- Environment model (tier-based: Low=1, Mid=2, High+=unlimited) +- Promotion pipeline (same image tag, different config/secrets) +- Rollback (redeploy previous tag) +- Application lifecycle API (deploy, promote, rollback, scale, stop, delete, logs) +- Image signing (cosign) + SBOM generation + +--- + +## Phase 5: Secrets + Config Management + +**PRD Sections:** 9 (Secrets Management), 7 (Application Deployment Page — config tab) +**Gitea Epics:** #9 (Secrets Management) +**Depends on:** Phase 4 +**Produces:** Applications can use platform-managed secrets and external vault credentials. Per-environment config management. + +**Key deliverables:** +- Platform-native secret store (encrypted in K8s Secrets via sealed-secrets/SOPS) +- Secret CRUD API (scoped per tenant + environment) +- K8s External Secrets Operator setup +- HashiCorp Vault integration (ExternalSecret CR generation) +- Secret injection into Camel app containers (env vars or mounted files) +- Secret rotation → rolling restart +- Per-environment config overlays (env vars, JVM options, application properties) +- Config change → rolling restart (no rebuild) +- Secret access audit logging +- Envelope encryption with per-tenant keys + +--- + +## Phase 6: Observability Integration + +**PRD Sections:** 8 (Observability Integration) +**Gitea Epics:** #6 (Observability Integration), #13 (Exchange Replay — gating only) +**Depends on:** Phase 3 (server already deployed per tenant), Phase 2 (license for feature gating) +**Produces:** Tenants see their cameleer3-server UI embedded in the SaaS shell. API gateway routes to tenant server. MOAT features gated by license tier. + +**Key deliverables:** +- Ingress routing rules: `/t/{tenant}/api/*` → tenant's cameleer3-server +- cameleer3-server "managed mode" configuration (trust SaaS JWT, report metrics) +- Bootstrap token generation API +- MOAT feature gating via license (topology=all, lineage=limited/full, correlation=mid+, debugger=high+, replay=high+) +- Server UI embedding approach (iframe or reverse proxy with path rewriting) +- External agent connectivity (bootstrap tokens for customer-hosted apps) + +--- + +## Phase 7: Billing & Metering + +**PRD Sections:** 14 (Billing & Metering) +**Gitea Epics:** #4 (Billing & Metering) +**Depends on:** Phase 3 (tenants exist), Phase 4 (runtime exists for metering) +**Produces:** Stripe-integrated billing. Usage-based metering for shared tiers. Committed resource subscriptions for dedicated tiers. + +**Key deliverables:** +- Stripe customer creation on tenant signup +- Stripe subscription management (usage-based and committed) +- Metrics collection pipeline (K8s metrics → aggregation) +- Usage aggregator (hourly, per-tenant, per-dimension: CPU, RAM, data volume) +- Stripe Usage Records API reporting +- Billing UI API (current usage, cost estimate, invoices, plan management) +- Metering pipeline reliability (idempotent, staleness alerting, reconciliation) +- Stripe webhook handling (payment events, subscription changes) + +--- + +## Phase 8: Security Hardening + Self-Monitoring + +**PRD Sections:** 12 (Security & SOC 2), 13 (Platform Operations & Self-Monitoring) +**Gitea Epics:** #11 (Security & SOC 2 — hardening), #12 (Platform Operations) +**Depends on:** Phases 1-7 (hardens the full stack) +**Produces:** Production-ready security posture and operational visibility. SOC 2 evidence collection. Platform self-monitoring with alerting. + +**Key deliverables:** +- Container hardening: distroless base, read-only FS, Pod Security Standards restricted +- Image signing (cosign/sigstore) + SBOM generation in CI +- Trivy scanning in CI (block on critical CVEs) +- Per-tenant payload encryption (application-level, per-tenant keys) +- Audit log shipped to separate write-only sink +- Prometheus + Grafana deployment (self-monitoring) +- Loki for log aggregation +- Alertmanager + day-1 alert rules (10 critical alerts from PRD) +- Platform dashboards (overview, per-tenant, billing, infra, security) +- SLA reporting automation +- Falco or equivalent for runtime security scanning +- Breach detection alerting (anomalous API patterns, auth failures) + +--- + +## Phase 9: Frontend (React Shell) + +**PRD Sections:** 15 (Management Platform UI) +**Gitea Epics:** #1 (Management Platform — frontend) +**Depends on:** Phase 1+ (builds incrementally as backend APIs land) +**Produces:** Complete SaaS management UI built with @cameleer/design-system. + +**Key deliverables:** +- Vite + React 19 + TypeScript project setup +- @cameleer/design-system integration (Gitea npm registry) +- Auth pages (login, register, invite acceptance) +- SaaS shell (navigation, tenant switcher, user menu) +- Dashboard (platform overview) +- Apps list + App deployment page (upload, config, secrets, status, logs, versions) +- Observability section (embedded cameleer3-server UI) +- Team management pages +- Settings pages (tenant config, SSO/OIDC, vault connections) +- Billing pages (usage, invoices, plan management) + +--- + +## Cross-Cutting Concerns (Woven Throughout) + +These are not separate phases — they're applied in every phase: + +- **Audit logging** — Every state-changing API logs to audit_log (Phase 1 establishes the framework) +- **RBAC enforcement** — Every endpoint checks permissions (Phase 1 establishes the framework) +- **Input validation** — Every API boundary validates input +- **TLS** — All connections encrypted (configured at infrastructure level) +- **Testing** — TDD: every feature has tests before implementation +- **CI** — Every commit runs tests, linting, scanning + +--- + +## Build Order Rationale + +1. **Phase 1 first** because everything depends on auth + audit +2. **Phase 2 before 3** because provisioning needs tenant + license entities +3. **Phase 3 before 4** because runtime needs a provisioned namespace to deploy into +4. **Phase 4 before 5** because secrets are injected into running apps +5. **Phase 6 can overlap with 4-5** — server is already deployed in Phase 3, feature gating just needs license from Phase 2 +6. **Phase 7 after 4** because metering needs running apps to measure +7. **Phase 8 after 1-7** because it hardens the complete stack +8. **Phase 9 parallel** with 3-8, building UI for each backend capability