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