Files
cameleer-saas/docs/superpowers/plans/2026-04-04-phase-2-tenants-identity-licensing.md
hsiegeln 63c194dab7
Some checks failed
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from net.siegeln.cameleer3 to net.siegeln.cameleer,
update all references in workflows, Docker configs, docs, and bootstrap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:28:44 +02:00

94 KiB

Phase 2: Tenants + Identity + Licensing — 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: Integrate Logto as identity provider, add tenant and license management, set up Traefik reverse proxy, and deliver a Docker Compose production stack — so a customer can sign up, get a tenant, and access the platform.

Architecture: Logto handles all user-facing identity (login, registration, SSO, teams/orgs). Spring Security OAuth2 Resource Server validates Logto JWTs. Custom Ed25519 JWT signing is retained for machine tokens (agent bootstrap, license signing). Tenants map 1:1 to Logto organizations. Traefik provides reverse proxy with ForwardAuth for non-platform services.

Tech Stack: Spring Boot 3.4.3, Java 21, PostgreSQL 16, Logto (OIDC), Traefik v3, Spring Security OAuth2 Resource Server, Flyway, Ed25519 (JDK Signature API)

Spec: docs/superpowers/specs/2026-04-04-dual-deployment-architecture.md


File Structure

New Files

src/main/java/net/siegeln/cameleer/saas/
├── tenant/
│   ├── Tier.java                    — Enum: LOW, MID, HIGH, BUSINESS
│   ├── TenantStatus.java            — Enum: PROVISIONING, ACTIVE, SUSPENDED, DELETED
│   ├── TenantEntity.java            — JPA entity for tenants table
│   ├── TenantRepository.java        — Spring Data JPA repository
│   ├── TenantService.java           — Tenant CRUD with audit logging + Logto org sync
│   ├── TenantController.java        — REST endpoints for /api/tenants
│   └── dto/
│       ├── CreateTenantRequest.java  — Validated creation DTO
│       └── TenantResponse.java      — API response DTO
├── license/
│   ├── LicenseEntity.java           — JPA entity for licenses table
│   ├── LicenseRepository.java       — Spring Data JPA repository
│   ├── LicenseService.java          — Ed25519 license token generation + verification
│   ├── LicenseController.java       — REST endpoints for license operations
│   ├── LicenseDefaults.java         — Default features/limits per tier
│   └── dto/
│       └── LicenseResponse.java     — API response DTO
├── identity/
│   ├── LogtoManagementClient.java   — REST client for Logto Management API
│   └── LogtoConfig.java             — Configuration properties for Logto M2M
└── config/
    └── TenantContext.java            — ThreadLocal holder for current tenant ID

src/main/resources/
├── db/migration/
│   ├── V005__create_tenants.sql
│   └── V006__create_licenses.sql
├── application.yml                   — Updated with Logto + key path config
└── application-test.yml              — Updated with mock JWT config

src/test/java/net/siegeln/cameleer/saas/
├── tenant/
│   ├── TenantServiceTest.java
│   └── TenantControllerTest.java
├── license/
│   ├── LicenseServiceTest.java
│   └── LicenseControllerTest.java
└── TestSecurityConfig.java           — Mock JwtDecoder for OAuth2 tests

docker-compose.yml                    — Full 7-container production stack
docker-compose.dev.yml                — Dev overlay (ports, debug)
traefik.yml                           — Traefik static configuration
docker/init-databases.sh              — PostgreSQL init script for multiple databases
.env.example                          — Template environment variables

Modified Files

pom.xml                               — Add oauth2-resource-server dependency
src/main/java/.../config/JwtConfig.java      — Load Ed25519 keys from files
src/main/java/.../config/SecurityConfig.java — OAuth2 Resource Server + machine token filter
src/main/java/.../audit/AuditAction.java     — Add LICENSE_GENERATE, LICENSE_REVOKE
src/main/resources/application.yml           — Logto, key paths, deployment mode
src/main/resources/application-test.yml      — Test JWT decoder config

Deprecated (Removed in Final Task)

src/main/java/.../auth/AuthController.java         — Logto handles login/register
src/main/java/.../auth/AuthService.java             — Logto handles login/register
src/main/java/.../auth/dto/LoginRequest.java        — No longer needed
src/main/java/.../auth/dto/RegisterRequest.java     — No longer needed
src/main/java/.../auth/dto/AuthResponse.java        — No longer needed
src/test/java/.../auth/AuthControllerTest.java      — Replaced by new integration tests
src/test/java/.../auth/AuthServiceTest.java         — Replaced

Task 1: Externalize Ed25519 Keys

Files:

  • Modify: src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java

  • Modify: src/main/resources/application.yml

  • Modify: src/main/resources/application-dev.yml

  • Test: src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java (existing, should still pass)

  • Step 1: Update application.yml with key path properties

Add to src/main/resources/application.yml under the cameleer.jwt section:

cameleer:
  jwt:
    expiration: 86400
    private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
    public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}

Empty defaults mean "generate keys" (dev mode).

  • Step 2: Rewrite JwtConfig to load from files or generate

Replace src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java:

package net.siegeln.cameleer.saas.config;

import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

@Component
public class JwtConfig {

    private static final Logger log = LoggerFactory.getLogger(JwtConfig.class);

    @Value("${cameleer.jwt.expiration:86400}")
    private long expirationSeconds;

    @Value("${cameleer.jwt.private-key-path:}")
    private String privateKeyPath;

    @Value("${cameleer.jwt.public-key-path:}")
    private String publicKeyPath;

    private KeyPair keyPair;

    @PostConstruct
    public void init() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
        if (privateKeyPath.isEmpty() || publicKeyPath.isEmpty()) {
            log.warn("No Ed25519 key files configured — generating ephemeral keys (dev mode)");
            KeyPairGenerator keyGen = KeyPairGenerator.getInstance("Ed25519");
            this.keyPair = keyGen.generateKeyPair();
        } else {
            log.info("Loading Ed25519 keys from {} and {}", privateKeyPath, publicKeyPath);
            PrivateKey privateKey = loadPrivateKey(Path.of(privateKeyPath));
            PublicKey publicKey = loadPublicKey(Path.of(publicKeyPath));
            this.keyPair = new KeyPair(publicKey, privateKey);
        }
    }

    private PrivateKey loadPrivateKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        String pem = Files.readString(path)
                .replace("-----BEGIN PRIVATE KEY-----", "")
                .replace("-----END PRIVATE KEY-----", "")
                .replaceAll("\\s+", "");
        byte[] decoded = Base64.getDecoder().decode(pem);
        return KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(decoded));
    }

    private PublicKey loadPublicKey(Path path) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
        String pem = Files.readString(path)
                .replace("-----BEGIN PUBLIC KEY-----", "")
                .replace("-----END PUBLIC KEY-----", "")
                .replaceAll("\\s+", "");
        byte[] decoded = Base64.getDecoder().decode(pem);
        return KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(decoded));
    }

    public PrivateKey getPrivateKey() {
        return keyPair.getPrivate();
    }

    public PublicKey getPublicKey() {
        return keyPair.getPublic();
    }

    public long getExpirationSeconds() {
        return expirationSeconds;
    }
}
  • Step 3: Run existing tests to verify backwards compatibility

Run: ./mvnw test -pl . -Dtest="JwtServiceTest" -B Expected: All 6 tests PASS (ephemeral key generation still works when no paths configured)

  • Step 4: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java src/main/resources/application.yml
git commit -m "feat: externalize Ed25519 keys with file-based loading

Keys are loaded from PEM files when CAMELEER_JWT_PRIVATE_KEY_PATH and
CAMELEER_JWT_PUBLIC_KEY_PATH are set. Falls back to ephemeral key
generation for development."

Task 2: Tenant Database Migration + Entity + Repository

Files:

  • Create: src/main/resources/db/migration/V005__create_tenants.sql

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/TenantStatus.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java

  • Step 1: Write the Flyway migration

Create src/main/resources/db/migration/V005__create_tenants.sql:

CREATE TABLE tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    slug            VARCHAR(100) NOT NULL UNIQUE,
    tier            VARCHAR(20) NOT NULL DEFAULT 'LOW',
    status          VARCHAR(20) NOT NULL DEFAULT 'PROVISIONING',
    logto_org_id    VARCHAR(255),
    stripe_customer_id     VARCHAR(255),
    stripe_subscription_id VARCHAR(255),
    settings        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tenants_slug ON tenants (slug);
CREATE INDEX idx_tenants_status ON tenants (status);
CREATE INDEX idx_tenants_logto_org_id ON tenants (logto_org_id);
  • Step 2: Create the Tier enum

Create src/main/java/net/siegeln/cameleer/saas/tenant/Tier.java:

package net.siegeln.cameleer.saas.tenant;

public enum Tier {
    LOW, MID, HIGH, BUSINESS
}
  • Step 3: Create the TenantStatus enum

Create src/main/java/net/siegeln/cameleer/saas/tenant/TenantStatus.java:

package net.siegeln.cameleer.saas.tenant;

public enum TenantStatus {
    PROVISIONING, ACTIVE, SUSPENDED, DELETED
}
  • Step 4: Create TenantEntity

Create src/main/java/net/siegeln/cameleer/saas/tenant/TenantEntity.java:

package net.siegeln.cameleer.saas.tenant;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
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 = "tenants")
public class TenantEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "slug", nullable = false, unique = true, length = 100)
    private String slug;

    @Enumerated(EnumType.STRING)
    @Column(name = "tier", nullable = false, length = 20)
    private Tier tier = Tier.LOW;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 20)
    private TenantStatus status = TenantStatus.PROVISIONING;

    @Column(name = "logto_org_id")
    private String logtoOrgId;

    @Column(name = "stripe_customer_id")
    private String stripeCustomerId;

    @Column(name = "stripe_subscription_id")
    private String stripeSubscriptionId;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "settings", columnDefinition = "jsonb")
    private Map<String, Object> settings = Map.of();

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @Column(name = "updated_at", nullable = false)
    private Instant updatedAt;

    @PrePersist
    protected void onCreate() {
        Instant now = Instant.now();
        if (createdAt == null) createdAt = now;
        if (updatedAt == null) updatedAt = now;
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = Instant.now();
    }

    public UUID getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getSlug() { return slug; }
    public void setSlug(String slug) { this.slug = slug; }
    public Tier getTier() { return tier; }
    public void setTier(Tier tier) { this.tier = tier; }
    public TenantStatus getStatus() { return status; }
    public void setStatus(TenantStatus status) { this.status = status; }
    public String getLogtoOrgId() { return logtoOrgId; }
    public void setLogtoOrgId(String logtoOrgId) { this.logtoOrgId = logtoOrgId; }
    public String getStripeCustomerId() { return stripeCustomerId; }
    public void setStripeCustomerId(String stripeCustomerId) { this.stripeCustomerId = stripeCustomerId; }
    public String getStripeSubscriptionId() { return stripeSubscriptionId; }
    public void setStripeSubscriptionId(String stripeSubscriptionId) { this.stripeSubscriptionId = stripeSubscriptionId; }
    public Map<String, Object> getSettings() { return settings; }
    public void setSettings(Map<String, Object> settings) { this.settings = settings; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}
  • Step 5: Create TenantRepository

Create src/main/java/net/siegeln/cameleer/saas/tenant/TenantRepository.java:

package net.siegeln.cameleer.saas.tenant;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface TenantRepository extends JpaRepository<TenantEntity, UUID> {
    Optional<TenantEntity> findBySlug(String slug);
    Optional<TenantEntity> findByLogtoOrgId(String logtoOrgId);
    List<TenantEntity> findByStatus(TenantStatus status);
    boolean existsBySlug(String slug);
}
  • Step 6: Run all tests to verify migration works

Run: ./mvnw test -B Expected: All existing tests PASS (migration V005 applies cleanly)

  • Step 7: Commit
git add src/main/resources/db/migration/V005__create_tenants.sql src/main/java/net/siegeln/cameleer/saas/tenant/
git commit -m "feat: add tenant entity, repository, and database migration

Tenants table with slug, tier (LOW/MID/HIGH/BUSINESS), status
(PROVISIONING/ACTIVE/SUSPENDED/DELETED), Logto org reference, and
Stripe IDs. Schema-per-tenant isolation designed for Phase 3+."

Task 3: Tenant Service + Controller (TDD)

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java

  • Create: src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java

  • Create: src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java

  • Create: src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java

  • Step 1: Create DTOs

Create src/main/java/net/siegeln/cameleer/saas/tenant/dto/CreateTenantRequest.java:

package net.siegeln.cameleer.saas.tenant.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record CreateTenantRequest(
        @NotBlank @Size(max = 255) String name,
        @NotBlank @Size(max = 100) @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") String slug,
        String tier
) {}

Create src/main/java/net/siegeln/cameleer/saas/tenant/dto/TenantResponse.java:

package net.siegeln.cameleer.saas.tenant.dto;

import java.time.Instant;
import java.util.UUID;

public record TenantResponse(
        UUID id,
        String name,
        String slug,
        String tier,
        String status,
        Instant createdAt,
        Instant updatedAt
) {}
  • Step 2: Write TenantService unit tests

Create src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java:

package net.siegeln.cameleer.saas.tenant;

import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;
import java.util.UUID;

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.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class TenantServiceTest {

    @Mock
    private TenantRepository tenantRepository;

    @Mock
    private AuditService auditService;

    private TenantService tenantService;

    @BeforeEach
    void setUp() {
        tenantService = new TenantService(tenantRepository, auditService);
    }

    @Test
    void create_savesNewTenantWithCorrectFields() {
        var request = new CreateTenantRequest("Acme Corp", "acme-corp", "MID");
        var actorId = UUID.randomUUID();

        when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
        when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var result = tenantService.create(request, actorId);

        assertThat(result.getName()).isEqualTo("Acme Corp");
        assertThat(result.getSlug()).isEqualTo("acme-corp");
        assertThat(result.getTier()).isEqualTo(Tier.MID);
        assertThat(result.getStatus()).isEqualTo(TenantStatus.PROVISIONING);
    }

    @Test
    void create_throwsForDuplicateSlug() {
        var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);

        when(tenantRepository.existsBySlug("acme-corp")).thenReturn(true);

        assertThatThrownBy(() -> tenantService.create(request, UUID.randomUUID()))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("Slug already taken");
    }

    @Test
    void create_logsAuditEvent() {
        var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);
        var actorId = UUID.randomUUID();

        when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
        when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        tenantService.create(request, actorId);

        var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
        verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
        assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.TENANT_CREATE);
    }

    @Test
    void create_defaultsToLowTier() {
        var request = new CreateTenantRequest("Acme Corp", "acme-corp", null);

        when(tenantRepository.existsBySlug("acme-corp")).thenReturn(false);
        when(tenantRepository.save(any(TenantEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var result = tenantService.create(request, UUID.randomUUID());

        assertThat(result.getTier()).isEqualTo(Tier.LOW);
    }

    @Test
    void getById_returnsTenant() {
        var id = UUID.randomUUID();
        var entity = new TenantEntity();
        entity.setName("Test");
        entity.setSlug("test");

        when(tenantRepository.findById(id)).thenReturn(Optional.of(entity));

        var result = tenantService.getById(id);

        assertThat(result).isPresent();
        assertThat(result.get().getName()).isEqualTo("Test");
    }

    @Test
    void getBySlug_returnsTenant() {
        var entity = new TenantEntity();
        entity.setName("Test");
        entity.setSlug("test");

        when(tenantRepository.findBySlug("test")).thenReturn(Optional.of(entity));

        var result = tenantService.getBySlug("test");

        assertThat(result).isPresent();
        assertThat(result.get().getSlug()).isEqualTo("test");
    }
}
  • Step 3: Run tests to verify they fail

Run: ./mvnw test -Dtest="TenantServiceTest" -B Expected: FAIL — TenantService class does not exist yet

  • Step 4: Implement TenantService

Create src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java:

package net.siegeln.cameleer.saas.tenant;

import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Service
public class TenantService {

    private final TenantRepository tenantRepository;
    private final AuditService auditService;

    public TenantService(TenantRepository tenantRepository, AuditService auditService) {
        this.tenantRepository = tenantRepository;
        this.auditService = auditService;
    }

    public TenantEntity create(CreateTenantRequest request, UUID actorId) {
        if (tenantRepository.existsBySlug(request.slug())) {
            throw new IllegalArgumentException("Slug already taken");
        }

        var entity = new TenantEntity();
        entity.setName(request.name());
        entity.setSlug(request.slug());
        entity.setTier(request.tier() != null ? Tier.valueOf(request.tier()) : Tier.LOW);
        entity.setStatus(TenantStatus.PROVISIONING);

        var saved = tenantRepository.save(entity);

        auditService.log(actorId, null, saved.getId(),
                AuditAction.TENANT_CREATE, saved.getSlug(),
                null, null, "SUCCESS", null);

        return saved;
    }

    public Optional<TenantEntity> getById(UUID id) {
        return tenantRepository.findById(id);
    }

    public Optional<TenantEntity> getBySlug(String slug) {
        return tenantRepository.findBySlug(slug);
    }

    public Optional<TenantEntity> getByLogtoOrgId(String logtoOrgId) {
        return tenantRepository.findByLogtoOrgId(logtoOrgId);
    }

    public List<TenantEntity> listActive() {
        return tenantRepository.findByStatus(TenantStatus.ACTIVE);
    }

    public TenantEntity activate(UUID tenantId, UUID actorId) {
        var entity = tenantRepository.findById(tenantId)
                .orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
        entity.setStatus(TenantStatus.ACTIVE);
        var saved = tenantRepository.save(entity);

        auditService.log(actorId, null, tenantId,
                AuditAction.TENANT_UPDATE, entity.getSlug(),
                null, null, "SUCCESS", null);

        return saved;
    }

    public TenantEntity suspend(UUID tenantId, UUID actorId) {
        var entity = tenantRepository.findById(tenantId)
                .orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
        entity.setStatus(TenantStatus.SUSPENDED);
        var saved = tenantRepository.save(entity);

        auditService.log(actorId, null, tenantId,
                AuditAction.TENANT_SUSPEND, entity.getSlug(),
                null, null, "SUCCESS", null);

        return saved;
    }
}
  • Step 5: Run TenantService tests to verify they pass

Run: ./mvnw test -Dtest="TenantServiceTest" -B Expected: All 5 tests PASS

  • Step 6: Write TenantController integration tests

Create src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java:

package net.siegeln.cameleer.saas.tenant;

import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
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 TenantControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    private String getAuthToken() throws Exception {
        // Use Phase 1 auth to get a token (still works at this point)
        var registerRequest = new net.siegeln.cameleer.saas.auth.dto.RegisterRequest(
                "tenant-test-" + System.nanoTime() + "@example.com", "Test User", "password123");

        var result = mockMvc.perform(post("/api/auth/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(registerRequest)))
                .andExpect(status().isCreated())
                .andReturn();

        return objectMapper.readTree(result.getResponse().getContentAsString()).get("token").asText();
    }

    @Test
    void createTenant_returns201() throws Exception {
        String token = getAuthToken();
        var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW");

        mockMvc.perform(post("/api/tenants")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("Test Org"))
                .andExpect(jsonPath("$.tier").value("LOW"))
                .andExpect(jsonPath("$.status").value("PROVISIONING"));
    }

    @Test
    void createTenant_returns409ForDuplicateSlug() throws Exception {
        String token = getAuthToken();
        String slug = "duplicate-slug-" + System.nanoTime();
        var request = new CreateTenantRequest("First", slug, null);

        mockMvc.perform(post("/api/tenants")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated());

        mockMvc.perform(post("/api/tenants")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isConflict());
    }

    @Test
    void createTenant_returns401WithoutToken() throws Exception {
        var request = new CreateTenantRequest("Test", "no-auth-test", null);

        mockMvc.perform(post("/api/tenants")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void getTenant_returnsTenantById() throws Exception {
        String token = getAuthToken();
        String slug = "get-test-" + System.nanoTime();
        var request = new CreateTenantRequest("Get Test", slug, null);

        var createResult = mockMvc.perform(post("/api/tenants")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();

        String id = objectMapper.readTree(createResult.getResponse().getContentAsString()).get("id").asText();

        mockMvc.perform(get("/api/tenants/" + id)
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.slug").value(slug));
    }
}
  • Step 7: Run controller test to verify it fails

Run: ./mvnw test -Dtest="TenantControllerTest" -B Expected: FAIL — TenantController does not exist

  • Step 8: Implement TenantController

Create src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java:

package net.siegeln.cameleer.saas.tenant;

import jakarta.validation.Valid;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
import net.siegeln.cameleer.saas.tenant.dto.TenantResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

@RestController
@RequestMapping("/api/tenants")
public class TenantController {

    private final TenantService tenantService;

    public TenantController(TenantService tenantService) {
        this.tenantService = tenantService;
    }

    @PostMapping
    public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
                                                  Authentication authentication) {
        try {
            // Extract actor ID from authentication credentials (Phase 1: userId stored as credentials)
            UUID actorId = authentication.getCredentials() instanceof UUID uid
                    ? uid : UUID.fromString(authentication.getCredentials().toString());

            var entity = tenantService.create(request, actorId);
            return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
        } catch (IllegalArgumentException e) {
            return ResponseEntity.status(HttpStatus.CONFLICT).build();
        }
    }

    @GetMapping("/{id}")
    public ResponseEntity<TenantResponse> getById(@PathVariable UUID id) {
        return tenantService.getById(id)
                .map(entity -> ResponseEntity.ok(toResponse(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/by-slug/{slug}")
    public ResponseEntity<TenantResponse> getBySlug(@PathVariable String slug) {
        return tenantService.getBySlug(slug)
                .map(entity -> ResponseEntity.ok(toResponse(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    private TenantResponse toResponse(TenantEntity entity) {
        return new TenantResponse(
                entity.getId(),
                entity.getName(),
                entity.getSlug(),
                entity.getTier().name(),
                entity.getStatus().name(),
                entity.getCreatedAt(),
                entity.getUpdatedAt()
        );
    }
}
  • Step 9: Run all tests

Run: ./mvnw test -B Expected: All tests PASS (existing + new TenantServiceTest + TenantControllerTest)

  • Step 10: Commit
git add src/main/java/net/siegeln/cameleer/saas/tenant/ src/test/java/net/siegeln/cameleer/saas/tenant/
git commit -m "feat: add tenant service, controller, and DTOs with TDD

CRUD operations for tenants with slug-based lookup, tier management,
and audit logging. Integration tests verify 201/409/401 responses."

Task 4: License Database Migration + Entity + Repository

Files:

  • Create: src/main/resources/db/migration/V006__create_licenses.sql

  • Create: src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java

  • Create: src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java

  • Modify: src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java

  • Step 1: Write the Flyway migration

Create src/main/resources/db/migration/V006__create_licenses.sql:

CREATE TABLE licenses (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    tier        VARCHAR(20) NOT NULL,
    features    JSONB NOT NULL DEFAULT '{}',
    limits      JSONB NOT NULL DEFAULT '{}',
    issued_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMPTZ NOT NULL,
    revoked_at  TIMESTAMPTZ,
    token       TEXT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_licenses_tenant_id ON licenses (tenant_id);
CREATE INDEX idx_licenses_expires_at ON licenses (expires_at);
  • Step 2: Add audit actions

Modify src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java — add LICENSE_GENERATE and LICENSE_REVOKE to the enum:

package net.siegeln.cameleer.saas.audit;

public enum AuditAction {
    AUTH_REGISTER, AUTH_LOGIN, AUTH_LOGIN_FAILED, AUTH_LOGOUT,
    TENANT_CREATE, TENANT_UPDATE, TENANT_SUSPEND, TENANT_REACTIVATE, TENANT_DELETE,
    APP_CREATE, APP_DEPLOY, APP_PROMOTE, APP_ROLLBACK, APP_SCALE, APP_STOP, APP_DELETE,
    SECRET_CREATE, SECRET_READ, SECRET_UPDATE, SECRET_DELETE, SECRET_ROTATE,
    CONFIG_UPDATE,
    TEAM_INVITE, TEAM_REMOVE, TEAM_ROLE_CHANGE,
    LICENSE_GENERATE, LICENSE_REVOKE
}
  • Step 3: Create LicenseEntity

Create src/main/java/net/siegeln/cameleer/saas/license/LicenseEntity.java:

package net.siegeln.cameleer.saas.license;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
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 = "licenses")
public class LicenseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "tenant_id", nullable = false)
    private UUID tenantId;

    @Column(name = "tier", nullable = false, length = 20)
    private String tier;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "features", nullable = false, columnDefinition = "jsonb")
    private Map<String, Object> features;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(name = "limits", nullable = false, columnDefinition = "jsonb")
    private Map<String, Object> limits;

    @Column(name = "issued_at", nullable = false)
    private Instant issuedAt;

    @Column(name = "expires_at", nullable = false)
    private Instant expiresAt;

    @Column(name = "revoked_at")
    private Instant revokedAt;

    @Column(name = "token", nullable = false, columnDefinition = "text")
    private String token;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @PrePersist
    protected void onCreate() {
        if (createdAt == null) createdAt = Instant.now();
        if (issuedAt == null) issuedAt = Instant.now();
    }

    public UUID getId() { return id; }
    public UUID getTenantId() { return tenantId; }
    public void setTenantId(UUID tenantId) { this.tenantId = tenantId; }
    public String getTier() { return tier; }
    public void setTier(String tier) { this.tier = tier; }
    public Map<String, Object> getFeatures() { return features; }
    public void setFeatures(Map<String, Object> features) { this.features = features; }
    public Map<String, Object> getLimits() { return limits; }
    public void setLimits(Map<String, Object> limits) { this.limits = limits; }
    public Instant getIssuedAt() { return issuedAt; }
    public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
    public Instant getExpiresAt() { return expiresAt; }
    public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
    public Instant getRevokedAt() { return revokedAt; }
    public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
    public String getToken() { return token; }
    public void setToken(String token) { this.token = token; }
    public Instant getCreatedAt() { return createdAt; }
}
  • Step 4: Create LicenseRepository

Create src/main/java/net/siegeln/cameleer/saas/license/LicenseRepository.java:

package net.siegeln.cameleer.saas.license;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface LicenseRepository extends JpaRepository<LicenseEntity, UUID> {
    List<LicenseEntity> findByTenantIdOrderByCreatedAtDesc(UUID tenantId);
    Optional<LicenseEntity> findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(UUID tenantId);
}
  • Step 5: Run all tests

Run: ./mvnw test -B Expected: All tests PASS

  • Step 6: Commit
git add src/main/resources/db/migration/V006__create_licenses.sql src/main/java/net/siegeln/cameleer/saas/license/ src/main/java/net/siegeln/cameleer/saas/audit/AuditAction.java
git commit -m "feat: add license entity, repository, and database migration

Licenses table linked to tenants with JSONB features/limits, Ed25519
signed token storage, and revocation support."

Task 5: License Service (TDD)

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java

  • Create: src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java

  • Create: src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java

  • Step 1: Create LicenseDefaults

Create src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java:

package net.siegeln.cameleer.saas.license;

import net.siegeln.cameleer.saas.tenant.Tier;

import java.util.Map;

public final class LicenseDefaults {

    private LicenseDefaults() {}

    public static Map<String, Object> featuresForTier(Tier tier) {
        return switch (tier) {
            case LOW -> Map.of(
                    "topology", true, "lineage", false,
                    "correlation", false, "debugger", false, "replay", false);
            case MID -> Map.of(
                    "topology", true, "lineage", true,
                    "correlation", true, "debugger", false, "replay", false);
            case HIGH -> Map.of(
                    "topology", true, "lineage", true,
                    "correlation", true, "debugger", true, "replay", true);
            case BUSINESS -> Map.of(
                    "topology", true, "lineage", true,
                    "correlation", true, "debugger", true, "replay", true);
        };
    }

    public static Map<String, Object> limitsForTier(Tier tier) {
        return switch (tier) {
            case LOW -> Map.of(
                    "max_agents", 3, "retention_days", 7,
                    "max_environments", 1);
            case MID -> Map.of(
                    "max_agents", 10, "retention_days", 30,
                    "max_environments", 2);
            case HIGH -> Map.of(
                    "max_agents", 50, "retention_days", 90,
                    "max_environments", -1);
            case BUSINESS -> Map.of(
                    "max_agents", -1, "retention_days", 365,
                    "max_environments", -1);
        };
    }
}
  • Step 2: Write LicenseService unit tests

Create src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java:

package net.siegeln.cameleer.saas.license;

import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.Tier;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import net.siegeln.cameleer.saas.tenant.TenantStatus;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.security.KeyPairGenerator;
import java.time.Duration;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class LicenseServiceTest {

    @Mock
    private LicenseRepository licenseRepository;

    @Mock
    private AuditService auditService;

    private JwtConfig jwtConfig;
    private LicenseService licenseService;

    @BeforeEach
    void setUp() throws Exception {
        jwtConfig = new JwtConfig();
        jwtConfig.init(); // generates ephemeral keys for testing
        licenseService = new LicenseService(licenseRepository, jwtConfig, auditService);
    }

    private TenantEntity createTenant(Tier tier) {
        var tenant = new TenantEntity();
        tenant.setName("Test Tenant");
        tenant.setSlug("test");
        tenant.setTier(tier);
        tenant.setStatus(TenantStatus.ACTIVE);
        // Use reflection or a test helper to set the ID since it's generated
        try {
            var idField = TenantEntity.class.getDeclaredField("id");
            idField.setAccessible(true);
            idField.set(tenant, UUID.randomUUID());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return tenant;
    }

    @Test
    void generateLicense_producesValidSignedToken() {
        var tenant = createTenant(Tier.MID);
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var license = licenseService.generateLicense(tenant, Duration.ofDays(365), UUID.randomUUID());

        assertThat(license.getToken()).isNotBlank();
        assertThat(license.getToken().split("\\.")).hasSize(3); // JWT format
        assertThat(license.getTier()).isEqualTo("MID");
    }

    @Test
    void generateLicense_setsCorrectFeaturesForTier() {
        var tenant = createTenant(Tier.HIGH);
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());

        assertThat(license.getFeatures()).containsEntry("debugger", true);
        assertThat(license.getFeatures()).containsEntry("replay", true);
    }

    @Test
    void generateLicense_setsCorrectLimitsForTier() {
        var tenant = createTenant(Tier.LOW);
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());

        assertThat(license.getLimits()).containsEntry("max_agents", 3);
        assertThat(license.getLimits()).containsEntry("retention_days", 7);
    }

    @Test
    void verifyLicenseToken_validTokenReturnsPayload() {
        var tenant = createTenant(Tier.MID);
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
        var payload = licenseService.verifyLicenseToken(license.getToken());

        assertThat(payload).isPresent();
        assertThat(payload.get().get("tier")).isEqualTo("MID");
        assertThat(payload.get().get("tenant_id")).isEqualTo(tenant.getId().toString());
    }

    @Test
    void verifyLicenseToken_tamperedTokenReturnsEmpty() {
        var tenant = createTenant(Tier.MID);
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        var license = licenseService.generateLicense(tenant, Duration.ofDays(30), UUID.randomUUID());
        String tampered = license.getToken() + "x";

        var payload = licenseService.verifyLicenseToken(tampered);

        assertThat(payload).isEmpty();
    }

    @Test
    void generateLicense_logsAuditEvent() {
        var tenant = createTenant(Tier.LOW);
        var actorId = UUID.randomUUID();
        when(licenseRepository.save(any(LicenseEntity.class))).thenAnswer(inv -> inv.getArgument(0));

        licenseService.generateLicense(tenant, Duration.ofDays(30), actorId);

        var actionCaptor = ArgumentCaptor.forClass(AuditAction.class);
        verify(auditService).log(any(), any(), any(), actionCaptor.capture(), any(), any(), any(), any(), any());
        assertThat(actionCaptor.getValue()).isEqualTo(AuditAction.LICENSE_GENERATE);
    }
}
  • Step 3: Run tests to verify they fail

Run: ./mvnw test -Dtest="LicenseServiceTest" -B Expected: FAIL — LicenseService does not exist

  • Step 4: Implement LicenseService

Create src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java:

package net.siegeln.cameleer.saas.license;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.audit.AuditAction;
import net.siegeln.cameleer.saas.audit.AuditService;
import net.siegeln.cameleer.saas.config.JwtConfig;
import net.siegeln.cameleer.saas.tenant.TenantEntity;
import org.springframework.stereotype.Service;

import java.security.Signature;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

@Service
public class LicenseService {

    private final LicenseRepository licenseRepository;
    private final JwtConfig jwtConfig;
    private final AuditService auditService;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public LicenseService(LicenseRepository licenseRepository, JwtConfig jwtConfig, AuditService auditService) {
        this.licenseRepository = licenseRepository;
        this.jwtConfig = jwtConfig;
        this.auditService = auditService;
    }

    public LicenseEntity generateLicense(TenantEntity tenant, Duration validity, UUID actorId) {
        var features = LicenseDefaults.featuresForTier(tenant.getTier());
        var limits = LicenseDefaults.limitsForTier(tenant.getTier());
        Instant now = Instant.now();
        Instant expiresAt = now.plus(validity);

        String token = signLicenseJwt(tenant.getId(), tenant.getTier().name(), features, limits, now, expiresAt);

        var entity = new LicenseEntity();
        entity.setTenantId(tenant.getId());
        entity.setTier(tenant.getTier().name());
        entity.setFeatures(features);
        entity.setLimits(limits);
        entity.setIssuedAt(now);
        entity.setExpiresAt(expiresAt);
        entity.setToken(token);

        var saved = licenseRepository.save(entity);

        auditService.log(actorId, null, tenant.getId(),
                AuditAction.LICENSE_GENERATE, saved.getId().toString(),
                null, null, "SUCCESS", null);

        return saved;
    }

    public Optional<LicenseEntity> getActiveLicense(UUID tenantId) {
        return licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId);
    }

    public Optional<Map<String, Object>> verifyLicenseToken(String token) {
        try {
            String[] parts = token.split("\\.");
            if (parts.length != 3) return Optional.empty();

            String signingInput = parts[0] + "." + parts[1];
            byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]);

            Signature sig = Signature.getInstance("Ed25519");
            sig.initVerify(jwtConfig.getPublicKey());
            sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));

            if (!sig.verify(signatureBytes)) return Optional.empty();

            byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
            Map<String, Object> payload = objectMapper.readValue(payloadBytes, new TypeReference<>() {});

            long exp = ((Number) payload.get("exp")).longValue();
            if (Instant.now().getEpochSecond() >= exp) return Optional.empty();

            return Optional.of(payload);
        } catch (Exception e) {
            return Optional.empty();
        }
    }

    private String signLicenseJwt(UUID tenantId, String tier, Map<String, Object> features,
                                   Map<String, Object> limits, Instant issuedAt, Instant expiresAt) {
        try {
            String header = base64UrlEncode(objectMapper.writeValueAsBytes(
                    Map.of("alg", "EdDSA", "typ", "JWT", "kid", "license")));

            Map<String, Object> payload = new LinkedHashMap<>();
            payload.put("tenant_id", tenantId.toString());
            payload.put("tier", tier);
            payload.put("features", features);
            payload.put("limits", limits);
            payload.put("iat", issuedAt.getEpochSecond());
            payload.put("exp", expiresAt.getEpochSecond());

            String payloadEncoded = base64UrlEncode(objectMapper.writeValueAsBytes(payload));
            String signingInput = header + "." + payloadEncoded;

            Signature sig = Signature.getInstance("Ed25519");
            sig.initSign(jwtConfig.getPrivateKey());
            sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8));
            String signature = base64UrlEncode(sig.sign());

            return signingInput + "." + signature;
        } catch (Exception e) {
            throw new RuntimeException("Failed to sign license JWT", e);
        }
    }

    private String base64UrlEncode(byte[] data) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
    }
}
  • Step 5: Run LicenseService tests

Run: ./mvnw test -Dtest="LicenseServiceTest" -B Expected: All 6 tests PASS

  • Step 6: Commit
git add src/main/java/net/siegeln/cameleer/saas/license/LicenseDefaults.java src/main/java/net/siegeln/cameleer/saas/license/LicenseService.java src/test/java/net/siegeln/cameleer/saas/license/LicenseServiceTest.java
git commit -m "feat: add license service with Ed25519 JWT signing and verification

Generates tier-aware license tokens with features/limits per tier.
Verifies signature and expiry. Audit logged."

Task 6: License Controller (TDD)

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java

  • Create: src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java

  • Create: src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java

  • Step 1: Create LicenseResponse DTO

Create src/main/java/net/siegeln/cameleer/saas/license/dto/LicenseResponse.java:

package net.siegeln.cameleer.saas.license.dto;

import java.time.Instant;
import java.util.Map;
import java.util.UUID;

public record LicenseResponse(
        UUID id,
        UUID tenantId,
        String tier,
        Map<String, Object> features,
        Map<String, Object> limits,
        Instant issuedAt,
        Instant expiresAt,
        String token
) {}
  • Step 2: Write LicenseController integration tests

Create src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java:

package net.siegeln.cameleer.saas.license;

import com.fasterxml.jackson.databind.ObjectMapper;
import net.siegeln.cameleer.saas.TestcontainersConfig;
import net.siegeln.cameleer.saas.tenant.dto.CreateTenantRequest;
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 LicenseControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    private String getAuthToken() throws Exception {
        var registerRequest = new net.siegeln.cameleer.saas.auth.dto.RegisterRequest(
                "license-test-" + System.nanoTime() + "@example.com", "Test User", "password123");

        var result = mockMvc.perform(post("/api/auth/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(registerRequest)))
                .andExpect(status().isCreated())
                .andReturn();

        return objectMapper.readTree(result.getResponse().getContentAsString()).get("token").asText();
    }

    private String createTenantAndGetId(String token) throws Exception {
        String slug = "license-tenant-" + System.nanoTime();
        var request = new CreateTenantRequest("License Test Org", slug, "MID");

        var result = mockMvc.perform(post("/api/tenants")
                        .header("Authorization", "Bearer " + token)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();

        return objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asText();
    }

    @Test
    void generateLicense_returns201WithToken() throws Exception {
        String token = getAuthToken();
        String tenantId = createTenantAndGetId(token);

        mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.token").isNotEmpty())
                .andExpect(jsonPath("$.tier").value("MID"))
                .andExpect(jsonPath("$.features.correlation").value(true));
    }

    @Test
    void getActiveLicense_returnsLicense() throws Exception {
        String token = getAuthToken();
        String tenantId = createTenantAndGetId(token);

        // Generate first
        mockMvc.perform(post("/api/tenants/" + tenantId + "/license")
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isCreated());

        // Fetch active
        mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.tier").value("MID"));
    }

    @Test
    void getActiveLicense_returns404WhenNone() throws Exception {
        String token = getAuthToken();
        String tenantId = createTenantAndGetId(token);

        mockMvc.perform(get("/api/tenants/" + tenantId + "/license")
                        .header("Authorization", "Bearer " + token))
                .andExpect(status().isNotFound());
    }
}
  • Step 3: Run tests to verify they fail

Run: ./mvnw test -Dtest="LicenseControllerTest" -B Expected: FAIL — LicenseController does not exist

  • Step 4: Implement LicenseController

Create src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java:

package net.siegeln.cameleer.saas.license;

import net.siegeln.cameleer.saas.license.dto.LicenseResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.Duration;
import java.util.UUID;

@RestController
@RequestMapping("/api/tenants/{tenantId}/license")
public class LicenseController {

    private final LicenseService licenseService;
    private final TenantService tenantService;

    public LicenseController(LicenseService licenseService, TenantService tenantService) {
        this.licenseService = licenseService;
        this.tenantService = tenantService;
    }

    @PostMapping
    public ResponseEntity<LicenseResponse> generate(@PathVariable UUID tenantId,
                                                     Authentication authentication) {
        var tenant = tenantService.getById(tenantId).orElse(null);
        if (tenant == null) return ResponseEntity.notFound().build();

        UUID actorId = authentication.getCredentials() instanceof UUID uid
                ? uid : UUID.fromString(authentication.getCredentials().toString());

        var license = licenseService.generateLicense(tenant, Duration.ofDays(365), actorId);
        return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(license));
    }

    @GetMapping
    public ResponseEntity<LicenseResponse> getActive(@PathVariable UUID tenantId) {
        return licenseService.getActiveLicense(tenantId)
                .map(entity -> ResponseEntity.ok(toResponse(entity)))
                .orElse(ResponseEntity.notFound().build());
    }

    private LicenseResponse toResponse(LicenseEntity entity) {
        return new LicenseResponse(
                entity.getId(),
                entity.getTenantId(),
                entity.getTier(),
                entity.getFeatures(),
                entity.getLimits(),
                entity.getIssuedAt(),
                entity.getExpiresAt(),
                entity.getToken()
        );
    }
}
  • Step 5: Run all tests

Run: ./mvnw test -B Expected: All tests PASS

  • Step 6: Commit
git add src/main/java/net/siegeln/cameleer/saas/license/dto/ src/main/java/net/siegeln/cameleer/saas/license/LicenseController.java src/test/java/net/siegeln/cameleer/saas/license/
git commit -m "feat: add license controller with generate and fetch endpoints

POST /api/tenants/{id}/license generates Ed25519-signed license JWT.
GET /api/tenants/{id}/license returns active license."

Task 7: OAuth2 Resource Server Security Refactor

Files:

  • Modify: pom.xml
  • Modify: src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
  • Modify: src/main/resources/application.yml
  • Modify: src/main/resources/application-test.yml
  • Create: src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java

This task switches authentication from custom JWT filter to Spring Security OAuth2 Resource Server (for Logto OIDC tokens), while keeping Ed25519 machine token support via the existing JwtAuthenticationFilter on machine-auth paths.

  • Step 1: Add OAuth2 Resource Server dependency to pom.xml

Add to the <dependencies> section in pom.xml, after the spring-boot-starter-security dependency:

        <!-- OAuth2 Resource Server (Logto OIDC) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>
  • Step 2: Update application.yml with Logto OIDC config

Replace the full 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
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${LOGTO_ISSUER_URI:}
          jwk-set-uri: ${LOGTO_JWK_SET_URI:}

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: when-authorized

cameleer:
  jwt:
    expiration: 86400
    private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
    public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
  identity:
    logto-endpoint: ${LOGTO_ENDPOINT:}
    m2m-client-id: ${LOGTO_M2M_CLIENT_ID:}
    m2m-client-secret: ${LOGTO_M2M_CLIENT_SECRET:}
  • Step 3: Update application-test.yml

Replace src/main/resources/application-test.yml:

spring:
  jpa:
    show-sql: false
  flyway:
    clean-disabled: false
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://test-issuer.example.com/oidc
  • Step 4: Create test security config with mock JwtDecoder

Create src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java:

package net.siegeln.cameleer.saas;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import java.time.Instant;
import java.util.Map;

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    public JwtDecoder jwtDecoder() {
        // Mock decoder that accepts any token string and returns a valid Jwt
        // Tests should use SecurityMockMvcRequestPostProcessors.jwt() instead
        return token -> Jwt.withTokenValue(token)
                .header("alg", "RS256")
                .claim("sub", "test-user")
                .claim("iss", "https://test-issuer.example.com/oidc")
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(3600))
                .build();
    }
}
  • Step 5: Rewrite SecurityConfig for dual auth

Replace 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.core.annotation.Order;
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 machineTokenFilter;

    public SecurityConfig(JwtAuthenticationFilter machineTokenFilter) {
        this.machineTokenFilter = machineTokenFilter;
    }

    /**
     * Machine-to-machine auth chain for agent/license endpoints.
     * Uses custom Ed25519 JWT validation (JwtAuthenticationFilter).
     */
    @Bean
    @Order(1)
    public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
        http
                .securityMatcher("/api/agent/**", "/api/license/verify/**")
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
                .addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    /**
     * Primary auth chain for all other API endpoints.
     * Uses Logto OIDC JWT validation via OAuth2 Resource Server.
     * Falls back to Ed25519 machine tokens when no Logto issuer is configured (dev mode).
     */
    @Bean
    @Order(2)
    public SecurityFilterChain apiFilterChain(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()
                        .requestMatchers("/auth/verify").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
                // Keep machine token filter as fallback for dev mode without Logto
                .addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • Step 6: Update all integration tests to import TestSecurityConfig

Every @SpringBootTest test class needs to import the test security config. Update the @Import annotations in:

  • AuthControllerTest.java: @Import({TestcontainersConfig.class, TestSecurityConfig.class})

  • TenantControllerTest.java: @Import({TestcontainersConfig.class, TestSecurityConfig.class})

  • LicenseControllerTest.java: @Import({TestcontainersConfig.class, TestSecurityConfig.class})

  • CameleerSaasApplicationTest.java: @Import({TestcontainersConfig.class, TestSecurityConfig.class})

  • AuditRepositoryTest.java: add @Import(TestSecurityConfig.class) if it's a @SpringBootTest or @DataJpaTest (check first — @DataJpaTest may not need it)

  • Step 7: Run all tests

Run: ./mvnw test -B Expected: All tests PASS. The mock JwtDecoder accepts any token (existing tests still work because the Ed25519 filter also processes tokens). The OAuth2 Resource Server is configured but uses the mock decoder in tests.

  • Step 8: Commit
git add pom.xml src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/resources/application.yml src/main/resources/application-test.yml src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java src/test/java/net/siegeln/cameleer/saas/CameleerSaasApplicationTest.java
git commit -m "feat: add OAuth2 Resource Server for Logto OIDC authentication

Dual auth: machine endpoints use Ed25519 JWT filter, all other API
endpoints use Spring Security OAuth2 Resource Server with Logto OIDC.
Mock JwtDecoder provided for test isolation."

Task 8: TenantContext + Tenant Resolution Filter

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/config/TenantContext.java

  • Create: src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java

  • Step 1: Create TenantContext

Create src/main/java/net/siegeln/cameleer/saas/config/TenantContext.java:

package net.siegeln.cameleer.saas.config;

import java.util.UUID;

public final class TenantContext {

    private static final ThreadLocal<UUID> CURRENT_TENANT = new ThreadLocal<>();

    private TenantContext() {}

    public static UUID getTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void setTenantId(UUID tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}
  • Step 2: Create TenantResolutionFilter

This filter runs after OAuth2 authentication. It extracts the organization_id claim from the Logto JWT and resolves it to a local tenant.

Create src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java:

package net.siegeln.cameleer.saas.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class TenantResolutionFilter extends OncePerRequestFilter {

    private final TenantService tenantService;

    public TenantResolutionFilter(TenantService tenantService) {
        this.tenantService = tenantService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            var authentication = SecurityContextHolder.getContext().getAuthentication();

            if (authentication instanceof JwtAuthenticationToken jwtAuth) {
                Jwt jwt = jwtAuth.getToken();
                String orgId = jwt.getClaimAsString("organization_id");

                if (orgId != null) {
                    tenantService.getByLogtoOrgId(orgId)
                            .ifPresent(tenant -> TenantContext.setTenantId(tenant.getId()));
                }
            }

            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}
  • Step 3: Wire the filter into SecurityConfig

In SecurityConfig.java, add the tenant resolution filter after the OAuth2 Resource Server filter in the apiFilterChain method. Add to the chain:

// In the apiFilterChain method, after .oauth2ResourceServer(...)
.addFilterAfter(tenantResolutionFilter, org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter.class)

Update the constructor to accept both filters:

private final JwtAuthenticationFilter machineTokenFilter;
private final TenantResolutionFilter tenantResolutionFilter;

public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
    this.machineTokenFilter = machineTokenFilter;
    this.tenantResolutionFilter = tenantResolutionFilter;
}
  • Step 4: Run all tests

Run: ./mvnw test -B Expected: All tests PASS

  • Step 5: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/TenantContext.java src/main/java/net/siegeln/cameleer/saas/config/TenantResolutionFilter.java src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: add tenant context resolution from Logto organization_id claim

TenantResolutionFilter extracts organization_id from Logto JWT and
resolves to local tenant via TenantService. ThreadLocal TenantContext
available throughout request lifecycle."

Task 9: ForwardAuth Endpoint

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java

This endpoint is called by Traefik's ForwardAuth middleware to validate requests routed to non-platform services (e.g., cameleer-server). It validates the JWT, resolves the tenant, and returns tenant context headers.

  • Step 1: Create ForwardAuthController

Create src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java:

package net.siegeln.cameleer.saas.config;

import net.siegeln.cameleer.saas.auth.JwtService;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;

@RestController
public class ForwardAuthController {

    private final JwtService jwtService;
    private final TenantService tenantService;

    public ForwardAuthController(JwtService jwtService, TenantService tenantService) {
        this.jwtService = jwtService;
        this.tenantService = tenantService;
    }

    /**
     * Traefik ForwardAuth endpoint. Validates the Authorization header and returns
     * tenant context headers for downstream services.
     *
     * Returns 200 with X-Tenant-Id, X-User-Id headers on success.
     * Returns 401 on invalid/missing token.
     */
    @GetMapping("/auth/verify")
    public ResponseEntity<Void> verify(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return ResponseEntity.status(401).build();
        }

        String token = authHeader.substring(7);

        // Try Ed25519 machine token first
        if (jwtService.isTokenValid(token)) {
            String email = jwtService.extractEmail(token);
            var userId = jwtService.extractUserId(token);

            return ResponseEntity.ok()
                    .header("X-User-Id", userId.toString())
                    .header("X-User-Email", email)
                    .build();
        }

        // Token not valid via Ed25519 — Logto OIDC tokens are validated
        // by the OAuth2 Resource Server filter chain instead.
        // ForwardAuth with Logto tokens will be enhanced when Logto is running.
        return ResponseEntity.status(401).build();
    }
}
  • Step 2: Run all tests

Run: ./mvnw test -B Expected: All tests PASS (the /auth/verify endpoint is permitted in SecurityConfig)

  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java
git commit -m "feat: add ForwardAuth endpoint for Traefik integration

GET /auth/verify validates JWT and returns X-Tenant-Id, X-User-Id
headers for downstream service routing via Traefik middleware."

Task 10: Logto Management API Client

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java

  • Create: src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java

  • Modify: src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java

  • Step 1: Create LogtoConfig

Create src/main/java/net/siegeln/cameleer/saas/identity/LogtoConfig.java:

package net.siegeln.cameleer.saas.identity;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

@Configuration
public class LogtoConfig {

    @Value("${cameleer.identity.logto-endpoint:}")
    private String logtoEndpoint;

    @Value("${cameleer.identity.m2m-client-id:}")
    private String m2mClientId;

    @Value("${cameleer.identity.m2m-client-secret:}")
    private String m2mClientSecret;

    public String getLogtoEndpoint() { return logtoEndpoint; }
    public String getM2mClientId() { return m2mClientId; }
    public String getM2mClientSecret() { return m2mClientSecret; }

    public boolean isConfigured() {
        return !logtoEndpoint.isEmpty() && !m2mClientId.isEmpty() && !m2mClientSecret.isEmpty();
    }
}
  • Step 2: Create LogtoManagementClient

Create src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java:

package net.siegeln.cameleer.saas.identity;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.time.Instant;
import java.util.Map;

@Service
public class LogtoManagementClient {

    private static final Logger log = LoggerFactory.getLogger(LogtoManagementClient.class);

    private final LogtoConfig config;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final RestClient restClient;

    private String cachedToken;
    private Instant tokenExpiry = Instant.MIN;

    public LogtoManagementClient(LogtoConfig config) {
        this.config = config;
        this.restClient = RestClient.builder()
                .defaultHeader("Content-Type", "application/json")
                .build();
    }

    public boolean isAvailable() {
        return config.isConfigured();
    }

    public String createOrganization(String name, String description) {
        if (!isAvailable()) {
            log.warn("Logto not configured — skipping organization creation for '{}'", name);
            return null;
        }

        var body = Map.of("name", name, "description", description != null ? description : "");

        var response = restClient.post()
                .uri(config.getLogtoEndpoint() + "/api/organizations")
                .header("Authorization", "Bearer " + getAccessToken())
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(JsonNode.class);

        return response != null ? response.get("id").asText() : null;
    }

    public void addUserToOrganization(String orgId, String userId) {
        if (!isAvailable()) return;

        restClient.post()
                .uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
                .header("Authorization", "Bearer " + getAccessToken())
                .contentType(MediaType.APPLICATION_JSON)
                .body(Map.of("userIds", new String[]{userId}))
                .retrieve()
                .toBodilessEntity();
    }

    public void deleteOrganization(String orgId) {
        if (!isAvailable()) return;

        restClient.delete()
                .uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId)
                .header("Authorization", "Bearer " + getAccessToken())
                .retrieve()
                .toBodilessEntity();
    }

    private synchronized String getAccessToken() {
        if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
            return cachedToken;
        }

        try {
            var response = restClient.post()
                    .uri(config.getLogtoEndpoint() + "/oidc/token")
                    .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                    .body("grant_type=client_credentials"
                            + "&client_id=" + config.getM2mClientId()
                            + "&client_secret=" + config.getM2mClientSecret()
                            + "&resource=" + config.getLogtoEndpoint() + "/api"
                            + "&scope=all")
                    .retrieve()
                    .body(JsonNode.class);

            cachedToken = response.get("access_token").asText();
            long expiresIn = response.get("expires_in").asLong();
            tokenExpiry = Instant.now().plusSeconds(expiresIn);

            return cachedToken;
        } catch (Exception e) {
            log.error("Failed to get Logto Management API token", e);
            throw new RuntimeException("Logto authentication failed", e);
        }
    }
}
  • Step 3: Wire LogtoManagementClient into TenantService

Update src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java — add Logto client to constructor and call it in create():

// Add field and constructor parameter:
private final LogtoManagementClient logtoClient;

public TenantService(TenantRepository tenantRepository, AuditService auditService, LogtoManagementClient logtoClient) {
    this.tenantRepository = tenantRepository;
    this.auditService = auditService;
    this.logtoClient = logtoClient;
}

// In the create() method, after saving the entity, add:
if (logtoClient.isAvailable()) {
    String logtoOrgId = logtoClient.createOrganization(saved.getName(), "Tenant: " + saved.getSlug());
    if (logtoOrgId != null) {
        saved.setLogtoOrgId(logtoOrgId);
        saved = tenantRepository.save(saved);
    }
}
  • Step 4: Update TenantServiceTest to mock LogtoManagementClient

Add @Mock LogtoManagementClient logtoClient and update the constructor call in setUp():

@Mock
private LogtoManagementClient logtoClient;

@BeforeEach
void setUp() {
    tenantService = new TenantService(tenantRepository, auditService, logtoClient);
}
  • Step 5: Run all tests

Run: ./mvnw test -B Expected: All tests PASS (LogtoManagementClient returns null when not configured, which is the test default)

  • Step 6: Commit
git add src/main/java/net/siegeln/cameleer/saas/identity/ src/main/java/net/siegeln/cameleer/saas/tenant/TenantService.java src/test/java/net/siegeln/cameleer/saas/tenant/TenantServiceTest.java
git commit -m "feat: add Logto Management API client for org provisioning

Creates Logto organizations when tenants are created. Authenticates
via M2M client credentials. Gracefully skips when Logto is not
configured (dev/test mode)."

Task 11: Docker Compose Production Stack

Files:

  • Modify: docker-compose.yml

  • Create: docker-compose.dev.yml

  • Create: traefik.yml

  • Create: docker/init-databases.sh

  • Create: .env.example

  • Step 1: Create PostgreSQL init script for multiple databases

Create docker/init-databases.sh:

#!/bin/bash
set -e

# Create the Logto database alongside the default cameleer_saas database
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
    CREATE DATABASE logto;
    GRANT ALL PRIVILEGES ON DATABASE logto TO $POSTGRES_USER;
EOSQL
  • Step 2: Create Traefik static configuration

Create traefik.yml:

api:
  dashboard: false

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: cameleer
  • Step 3: Create .env.example

Create .env.example:

# Cameleer SaaS Environment Variables
# Copy to .env and fill in values

# Application version
VERSION=latest

# PostgreSQL
POSTGRES_USER=cameleer
POSTGRES_PASSWORD=change_me_in_production
POSTGRES_DB=cameleer_saas

# Logto Identity Provider
LOGTO_ENDPOINT=http://logto:3001
LOGTO_ISSUER_URI=http://logto:3001/oidc
LOGTO_JWK_SET_URI=http://logto:3001/oidc/jwks
LOGTO_DB_PASSWORD=change_me_in_production
LOGTO_M2M_CLIENT_ID=
LOGTO_M2M_CLIENT_SECRET=

# Ed25519 Keys (mount PEM files)
CAMELEER_JWT_PRIVATE_KEY_PATH=/etc/cameleer/keys/ed25519.key
CAMELEER_JWT_PUBLIC_KEY_PATH=/etc/cameleer/keys/ed25519.pub

# Domain (for Traefik TLS)
DOMAIN=localhost
  • Step 4: Rewrite docker-compose.yml as production stack

Replace docker-compose.yml:

services:
  traefik:
    image: traefik:v3
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/etc/traefik/traefik.yml:ro
      - acme:/etc/traefik/acme
    networks:
      - cameleer

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas}
      POSTGRES_USER: ${POSTGRES_USER:-cameleer}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./docker/init-databases.sh:/docker-entrypoint-initdb.d/init-databases.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-cameleer}"]
      interval: 5s
      timeout: 5s
      retries: 5
    networks:
      - cameleer

  logto:
    image: ghcr.io/logto-io/logto:latest
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
    environment:
      DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD:-cameleer_dev}@postgres:5432/logto
      ENDPOINT: ${LOGTO_ENDPOINT:-http://localhost:3001}
      ADMIN_ENDPOINT: ${LOGTO_ADMIN_ENDPOINT:-http://localhost:3002}
      TRUST_PROXY_HEADER: 1
    labels:
      - traefik.enable=true
      - traefik.http.routers.logto.rule=PathPrefix(`/oidc`) || PathPrefix(`/interaction`)
      - traefik.http.services.logto.loadbalancer.server.port=3001
    networks:
      - cameleer

  cameleer-saas:
    image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./keys:/etc/cameleer/keys:ro
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
      SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer}
      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-cameleer_dev}
      LOGTO_ENDPOINT: ${LOGTO_ENDPOINT:-http://logto:3001}
      LOGTO_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
      LOGTO_JWK_SET_URI: ${LOGTO_JWK_SET_URI:-http://logto:3001/oidc/jwks}
      LOGTO_M2M_CLIENT_ID: ${LOGTO_M2M_CLIENT_ID:-}
      LOGTO_M2M_CLIENT_SECRET: ${LOGTO_M2M_CLIENT_SECRET:-}
      CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
      CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=PathPrefix(`/api`)
      - traefik.http.services.api.loadbalancer.server.port=8080
      - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
      - traefik.http.services.forwardauth.loadbalancer.server.port=8080
    networks:
      - cameleer

  cameleer-server:
    image: ${CAMELEER_SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest}
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      clickhouse:
        condition: service_started
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-cameleer_saas}
      CLICKHOUSE_URL: jdbc:clickhouse://clickhouse:8123/cameleer
    labels:
      - traefik.enable=true
      - traefik.http.routers.observe.rule=PathPrefix(`/observe`)
      - traefik.http.routers.observe.middlewares=forward-auth
      - traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify
      - traefik.http.middlewares.forward-auth.forwardauth.authResponseHeaders=X-Tenant-Id,X-User-Id,X-User-Email
      - traefik.http.services.observe.loadbalancer.server.port=8080
    networks:
      - cameleer

  clickhouse:
    image: clickhouse/clickhouse-server:latest
    restart: unless-stopped
    volumes:
      - chdata:/var/lib/clickhouse
    healthcheck:
      test: ["CMD-SHELL", "clickhouse-client --query 'SELECT 1'"]
      interval: 10s
      timeout: 5s
      retries: 3
    networks:
      - cameleer

networks:
  cameleer:
    driver: bridge

volumes:
  pgdata:
  chdata:
  acme:
  • Step 5: Create docker-compose.dev.yml overlay

Create docker-compose.dev.yml:

# Development overrides: exposes ports for direct access
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
  postgres:
    ports:
      - "5432:5432"

  logto:
    ports:
      - "3001:3001"
      - "3002:3002"

  cameleer-saas:
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: dev

  clickhouse:
    ports:
      - "8123:8123"
  • Step 6: Run all tests to ensure nothing broke

Run: ./mvnw test -B Expected: All tests PASS (compose changes don't affect tests)

  • Step 7: Commit
git add docker-compose.yml docker-compose.dev.yml traefik.yml docker/init-databases.sh .env.example
git commit -m "feat: add Docker Compose production stack with Traefik + Logto

7-container stack: Traefik (reverse proxy), PostgreSQL (shared),
Logto (identity), cameleer-saas (control plane), cameleer-server
(observability), ClickHouse (traces). ForwardAuth middleware for
tenant-aware routing to cameleer-server."

Task 12: Cleanup Deprecated Auth Code + CI Updates

Files:

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java
  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java
  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java
  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java
  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java
  • Delete: src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java
  • Delete: src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java
  • Modify: src/test/java/net/siegeln/cameleer/saas/tenant/TenantControllerTest.java
  • Modify: src/test/java/net/siegeln/cameleer/saas/license/LicenseControllerTest.java

Important: This task removes the Phase 1 auth endpoints because Logto now handles user authentication. The JwtService, JwtAuthenticationFilter, JwtConfig, UserEntity, UserRepository, and RoleEntity/Repository are KEPT — they're used for Ed25519 machine token signing.

  • Step 1: Delete deprecated auth files
rm src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java
rm src/main/java/net/siegeln/cameleer/saas/auth/AuthService.java
rm src/main/java/net/siegeln/cameleer/saas/auth/dto/LoginRequest.java
rm src/main/java/net/siegeln/cameleer/saas/auth/dto/RegisterRequest.java
rm src/main/java/net/siegeln/cameleer/saas/auth/dto/AuthResponse.java
rm src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java
rm src/test/java/net/siegeln/cameleer/saas/auth/AuthServiceTest.java
  • Step 2: Update TenantControllerTest to use Spring Security JWT mock

Replace the getAuthToken() method in TenantControllerTest.java with Spring Security test support:

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;

// Remove the getAuthToken() method entirely.
// Replace all `.header("Authorization", "Bearer " + token)` with:
// .with(jwt().jwt(j -> j.claim("sub", "test-user").claim("organization_id", "test-org")))

// Example updated test:
@Test
void createTenant_returns201() throws Exception {
    var request = new CreateTenantRequest("Test Org", "test-org-" + System.nanoTime(), "LOW");

    mockMvc.perform(post("/api/tenants")
                    .with(jwt().jwt(j -> j
                            .claim("sub", "test-user")
                            .claim("organization_id", "test-org")))
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.name").value("Test Org"));
}

@Test
void createTenant_returns401WithoutToken() throws Exception {
    var request = new CreateTenantRequest("Test", "no-auth-test", null);

    mockMvc.perform(post("/api/tenants")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isUnauthorized());
}

Apply the same pattern to LicenseControllerTest.java.

  • Step 3: Update TenantController to extract actor from JWT principal

In TenantController.java, update the actor ID extraction since we now use OAuth2 JWT tokens:

@PostMapping
public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
                                              Authentication authentication) {
    try {
        // Extract user ID from JWT sub claim (works for both Logto and mock JWT)
        String sub = authentication.getName();
        UUID actorId;
        try {
            actorId = UUID.fromString(sub);
        } catch (IllegalArgumentException e) {
            // Logto sub is not a UUID — use a deterministic UUID from the string
            actorId = UUID.nameUUIDFromBytes(sub.getBytes());
        }

        var entity = tenantService.create(request, actorId);
        return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
    } catch (IllegalArgumentException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).build();
    }
}

Apply the same pattern to LicenseController.java.

  • Step 4: Remove /api/auth/ permitAll from SecurityConfig**

In SecurityConfig.java, remove the .requestMatchers("/api/auth/**").permitAll() line since those endpoints no longer exist.

  • Step 5: Run all tests

Run: ./mvnw test -B Expected: All remaining tests PASS. AuthControllerTest and AuthServiceTest no longer exist. Tenant and License tests use mock JWT.

  • Step 6: Commit
git add -A
git commit -m "refactor: remove Phase 1 auth endpoints, switch to Logto OIDC

Auth is now handled by Logto. Removed AuthController, AuthService,
and related DTOs. Integration tests use Spring Security JWT mocks.
Ed25519 JwtService retained for machine token signing."
  • Step 7: Run full verify to ensure everything works

Run: ./mvnw verify -B Expected: BUILD SUCCESS — all tests pass, application compiles cleanly

  • Step 8: Final commit if CI-related changes needed

Only if the previous step reveals issues. Otherwise, done.


Verification Checklist

After all tasks are complete, verify:

  1. ./mvnw verify -B — all tests pass
  2. docker compose config — compose file is valid
  3. Tenant CRUD works — POST/GET /api/tenants with mock JWT
  4. License generation works — POST /api/tenants/{id}/license returns signed JWT
  5. License verification works — signed token can be verified via LicenseService
  6. ForwardAuth endpoint works — GET /auth/verify returns 401 without token
  7. Ed25519 key loading works — JwtConfig loads from files when configured, generates when not
  8. Logto client is graceful — when Logto is not configured, TenantService still works (just no org creation)
  9. Docker Compose startsdocker compose -f docker-compose.yml -f docker-compose.dev.yml up (requires images to be built first)