# 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.