Files
cameleer-saas/docs/superpowers/plans/2026-03-29-phase-1-foundation-auth.md
hsiegeln 5e06d31cfb Add phase roadmap and Phase 1 implementation plan
Phase roadmap: 9 phases from foundation to frontend, each producing
working, testable software independently.

Phase 1 plan: Foundation + Auth — 10 tasks, ~60 steps covering:
- Maven project setup (Spring Boot 3.4.3, Java 21)
- PostgreSQL + Docker Compose + TestContainers
- Flyway migrations (users, roles, permissions, audit_log)
- Immutable audit logging framework
- User registration with bcrypt
- Ed25519 JWT signing (no third-party JWT library)
- Login with audit trail
- Spring Security JWT filter + RBAC
- Dockerfile + Gitea Actions CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:58:25 +02:00

72 KiB

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 version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.3</version>
        <relativePath/>
    </parent>

    <groupId>net.siegeln.cameleer</groupId>
    <artifactId>cameleer-saas</artifactId>
    <version>0.1.0-SNAPSHOT</version>
    <name>Cameleer SaaS Platform</name>
    <description>Multi-tenant SaaS platform for Apache Camel applications</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <!-- Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- JPA + PostgreSQL -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Flyway -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>

        <!-- Validation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- AOP (for audit aspect) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- Actuator (health endpoints) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
  • Step 2: Create Spring Boot application class
// 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
# 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
# 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
# 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
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

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

-- 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
-- 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
-- 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
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)

-- 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
// 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
// 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
// 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<String, Object> 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<String, Object> getMetadata() { return metadata; }
    public void setMetadata(Map<String, Object> metadata) { this.metadata = metadata; }
    public Instant getCreatedAt() { return createdAt; }
}
  • Step 6: Implement AuditRepository (no delete methods exposed)
// 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<AuditEntity, UUID> {

    Page<AuditEntity> findByTenantIdAndCreatedAtBetween(
        UUID tenantId, Instant from, Instant to, Pageable pageable
    );

    Page<AuditEntity> findByActorId(UUID actorId, Pageable pageable);
}
  • Step 7: Implement AuditService
// 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<String, Object> 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
// 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
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

// 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
// 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; }
}
// 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<PermissionEntity> 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<PermissionEntity> getPermissions() { return permissions; }
}
// 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<RoleEntity> 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<RoleEntity> getRoles() { return roles; }
    public Instant getCreatedAt() { return createdAt; }
}
  • Step 4: Implement repositories
// 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<UserEntity, UUID> {
    Optional<UserEntity> findByEmail(String email);
    boolean existsByEmail(String email);
}
// 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<RoleEntity, UUID> {
    Optional<RoleEntity> findByName(String name);
}
  • Step 5: Implement DTOs
// 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
) {}
// 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)
// 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
// 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
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

// 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
// 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).

// 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<String, Object>();
            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<String> extractRoles(String token) {
        var claims = parseClaims(token);
        if (claims == null) return Set.of();
        var roles = (List<String>) 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<String, Object> 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
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:

@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
// 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:

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

// 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
// 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
// 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
// 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:

// 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<Map<String, String>> 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
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
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
# .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
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

# 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="<paste token here>"

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