Files
cameleer-saas/docs/superpowers/plans/2026-03-29-phase-1-foundation-auth.md

2214 lines
72 KiB
Markdown
Raw Normal View History

# 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
<?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**
```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<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)**
```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<AuditEntity, UUID> {
Page<AuditEntity> findByTenantIdAndCreatedAtBetween(
UUID tenantId, Instant from, Instant to, Pageable pageable
);
Page<AuditEntity> 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<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**
```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<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; }
}
```
```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<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**
```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<UserEntity, UUID> {
Optional<UserEntity> 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<RoleEntity, UUID> {
Optional<RoleEntity> 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<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**
```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<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**
```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="<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.