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>
2214 lines
72 KiB
Markdown
2214 lines
72 KiB
Markdown
# 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.
|