Files
cameleer-saas/docs/superpowers/plans/2026-04-05-auth-overhaul.md
hsiegeln 1397267be5 docs: add auth overhaul implementation plan
16 tasks across 3 phases: server OIDC support, SaaS auth rewrite,
infrastructure updates. TDD, complete code, greenfield migrations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:26:47 +02:00

65 KiB
Raw Blame History

Auth Overhaul 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: Replace the incoherent three-system auth in cameleer-saas with Logto-centric architecture, and add OIDC resource server support to cameleer3-server for M2M.

Architecture: Logto is the single identity provider for all humans. Spring OAuth2 Resource Server validates Logto JWTs in both the SaaS platform and cameleer3-server. Agents authenticate with per-environment API keys exchanged for server-issued JWTs. Ed25519 command signing is unchanged. Zero trust: every service validates tokens independently via JWKS.

Tech Stack: Spring Boot 3.4, Spring Security OAuth2 Resource Server, Nimbus JOSE+JWT, Logto, React + @logto/react, Zustand, PostgreSQL, Flyway

Spec: docs/superpowers/specs/2026-04-05-auth-overhaul-design.md

Repos:

  • cameleer3-server: C:\Users\Hendrik\Documents\projects\cameleer3-server (Phase 1)
  • cameleer-saas: C:\Users\Hendrik\Documents\projects\cameleer-saas (Phases 2-3)
  • cameleer3 (agent): NO CHANGES

Phase 1: cameleer3-server — OIDC Resource Server Support

All Phase 1 work is in C:\Users\Hendrik\Documents\projects\cameleer3-server.

Task 1: Add OAuth2 Resource Server dependency and config properties

Files:

  • Modify: cameleer3-server-app/pom.xml

  • Modify: cameleer3-server-app/src/main/resources/application.yml

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java

  • Step 1: Add dependency to pom.xml

In cameleer3-server-app/pom.xml, add after the spring-boot-starter-security dependency (around line 88):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
  • Step 2: Add OIDC properties to application.yml

In cameleer3-server-app/src/main/resources/application.yml, add two new properties under the security: block (after line 52):

  oidc-issuer-uri: ${CAMELEER_OIDC_ISSUER_URI:}
  oidc-audience: ${CAMELEER_OIDC_AUDIENCE:}
  • Step 3: Add fields to SecurityProperties.java

In cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java, add after the jwtSecret field (line 19):

private String oidcIssuerUri;
private String oidcAudience;

public String getOidcIssuerUri() { return oidcIssuerUri; }
public void setOidcIssuerUri(String oidcIssuerUri) { this.oidcIssuerUri = oidcIssuerUri; }
public String getOidcAudience() { return oidcAudience; }
public void setOidcAudience(String oidcAudience) { this.oidcAudience = oidcAudience; }
  • Step 4: Verify build compiles

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw compile -pl cameleer3-server-app -q Expected: BUILD SUCCESS

  • Step 5: Commit
git add cameleer3-server-app/pom.xml cameleer3-server-app/src/main/resources/application.yml cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityProperties.java
git commit -m "feat: add oauth2-resource-server dependency and OIDC config properties"

Task 2: Add conditional OIDC JwtDecoder bean

Files:

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java

  • Step 1: Write the failing test

Create cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java:

package com.cameleer3.server.app.security;

import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import static org.assertj.core.api.Assertions.assertThat;

class OidcJwtDecoderBeanTest {

    @Test
    void shouldNotCreateDecoderWhenIssuerUriBlank() {
        var properties = new SecurityProperties();
        properties.setBootstrapToken("test-token");
        properties.setOidcIssuerUri("");

        var config = new SecurityBeanConfig();
        JwtDecoder decoder = config.oidcJwtDecoder(properties);

        assertThat(decoder).isNull();
    }

    @Test
    void shouldNotCreateDecoderWhenIssuerUriNull() {
        var properties = new SecurityProperties();
        properties.setBootstrapToken("test-token");
        properties.setOidcIssuerUri(null);

        var config = new SecurityBeanConfig();
        JwtDecoder decoder = config.oidcJwtDecoder(properties);

        assertThat(decoder).isNull();
    }
}
  • Step 2: Run test to verify it fails

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q Expected: FAIL — method oidcJwtDecoder does not exist

  • Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig

In cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java, add these imports at the top:

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import java.net.URL;
import java.util.List;

Add this method to the class:

/**
 * Creates an OIDC-aware JwtDecoder when {@code security.oidc-issuer-uri} is configured.
 * Returns {@code null} when not configured, so the filter skips OIDC validation.
 * <p>
 * Handles Logto's {@code typ: at+jwt} (RFC 9068) by disabling type verification.
 * Discovers JWKS URI from the issuer's well-known endpoint.
 */
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
    String issuerUri = properties.getOidcIssuerUri();
    if (issuerUri == null || issuerUri.isBlank()) {
        return null;
    }

    try {
        String jwksUri = issuerUri.replaceAll("/+$", "") + "/jwks";
        var jwkSource = JWKSourceBuilder.create(new URL(jwksUri)).build();
        var keySelector = new JWSVerificationKeySelector<SecurityContext>(
                JWSAlgorithm.ES384, jwkSource);

        var processor = new DefaultJWTProcessor<SecurityContext>();
        processor.setJWSKeySelector(keySelector);
        // Accept both "JWT" and "at+jwt" token types (Logto uses at+jwt per RFC 9068)
        processor.setJWSTypeVerifier((type, context) -> { });

        var decoder = new NimbusJwtDecoder(processor);

        OAuth2TokenValidator<Jwt> validator;
        String audience = properties.getOidcAudience();
        if (audience != null && !audience.isBlank()) {
            validator = new DelegatingOAuth2TokenValidator<>(
                    JwtValidators.createDefaultWithIssuer(issuerUri),
                    new JwtClaimValidator<List<String>>("aud",
                            aud -> aud != null && aud.contains(audience)));
        } else {
            validator = JwtValidators.createDefaultWithIssuer(issuerUri);
        }
        decoder.setJwtValidator(validator);

        return decoder;
    } catch (Exception e) {
        throw new IllegalStateException("Failed to create OIDC JwtDecoder for " + issuerUri, e);
    }
}
  • Step 4: Wire the bean with @Bean annotation

Now wrap the method call in a proper @Bean method. Add to SecurityBeanConfig:

@Bean
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
    // body is the method above
}

Actually, rename the existing method to createOidcJwtDecoder (private) and add the @Bean method that calls it:

Replace the method added in step 3 — make it a @Bean directly. The method signature stays the same, just add @Bean annotation. Spring will call it; if properties are blank, it returns null, and @Autowired(required = false) in SecurityConfig will receive null.

Note: Spring won't register a bean that returns null from a @Bean method — it throws. So instead, we should NOT use @Bean for this. Keep it as a factory method called from SecurityConfig. Remove the @Bean annotation and keep the method public.

Update the test to match: the test calls config.oidcJwtDecoder(properties) directly, which returns null when not configured. This is correct.

  • Step 5: Run test to verify it passes

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=OidcJwtDecoderBeanTest -q Expected: PASS

  • Step 6: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/OidcJwtDecoderBeanTest.java
git commit -m "feat: add conditional OIDC JwtDecoder factory for Logto token validation"

Task 3: Update JwtAuthenticationFilter with OIDC fallback

Files:

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java

  • Step 1: Write the failing test

Create cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java:

package com.cameleer3.server.app.security;

import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

class JwtAuthenticationFilterOidcTest {

    private JwtService jwtService;
    private AgentRegistryService registryService;
    private JwtDecoder oidcDecoder;
    private JwtAuthenticationFilter filter;
    private FilterChain chain;

    @BeforeEach
    void setUp() {
        SecurityContextHolder.clearContext();
        jwtService = mock(JwtService.class);
        registryService = mock(AgentRegistryService.class);
        oidcDecoder = mock(JwtDecoder.class);
        filter = new JwtAuthenticationFilter(jwtService, registryService, oidcDecoder);
        chain = mock(FilterChain.class);
    }

    @Test
    void shouldFallBackToOidcWhenHmacFails() throws ServletException, IOException {
        var request = new MockHttpServletRequest();
        request.addHeader("Authorization", "Bearer oidc-token");
        var response = new MockHttpServletResponse();

        when(jwtService.validateAccessToken("oidc-token"))
                .thenThrow(new InvalidTokenException("bad sig"));

        Jwt jwt = Jwt.withTokenValue("oidc-token")
                .header("alg", "ES384")
                .claim("sub", "user-123")
                .claim("client_id", "m2m-app-id")
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(3600))
                .build();
        when(oidcDecoder.decode("oidc-token")).thenReturn(jwt);

        filter.doFilterInternal(request, response, chain);

        var auth = SecurityContextHolder.getContext().getAuthentication();
        assertThat(auth).isNotNull();
        assertThat(auth.getName()).isEqualTo("oidc:user-123");
        verify(chain).doFilter(request, response);
    }

    @Test
    void shouldGrantAdminForM2mToken() throws ServletException, IOException {
        var request = new MockHttpServletRequest();
        request.addHeader("Authorization", "Bearer m2m-token");
        var response = new MockHttpServletResponse();

        when(jwtService.validateAccessToken("m2m-token"))
                .thenThrow(new InvalidTokenException("bad sig"));

        // M2M token: client_id == sub
        Jwt jwt = Jwt.withTokenValue("m2m-token")
                .header("alg", "ES384")
                .claim("sub", "m2m-app-id")
                .claim("client_id", "m2m-app-id")
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(3600))
                .build();
        when(oidcDecoder.decode("m2m-token")).thenReturn(jwt);

        filter.doFilterInternal(request, response, chain);

        var auth = SecurityContextHolder.getContext().getAuthentication();
        assertThat(auth).isNotNull();
        assertThat(auth.getAuthorities()).anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
    }

    @Test
    void shouldSkipOidcWhenDecoderIsNull() throws ServletException, IOException {
        filter = new JwtAuthenticationFilter(jwtService, registryService, null);
        var request = new MockHttpServletRequest();
        request.addHeader("Authorization", "Bearer bad-token");
        var response = new MockHttpServletResponse();

        when(jwtService.validateAccessToken("bad-token"))
                .thenThrow(new InvalidTokenException("bad"));

        filter.doFilterInternal(request, response, chain);

        assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
        verify(chain).doFilter(request, response);
    }

    @Test
    void shouldPreferHmacOverOidc() throws ServletException, IOException {
        var request = new MockHttpServletRequest();
        request.addHeader("Authorization", "Bearer hmac-token");
        var response = new MockHttpServletResponse();

        when(jwtService.validateAccessToken("hmac-token"))
                .thenReturn(new JwtService.JwtValidationResult(
                        "agent-1", "my-app", "prod", List.of("AGENT")));

        filter.doFilterInternal(request, response, chain);

        var auth = SecurityContextHolder.getContext().getAuthentication();
        assertThat(auth.getName()).isEqualTo("agent-1");
        // OIDC decoder should never be called
        verifyNoInteractions(oidcDecoder);
    }
}
  • Step 2: Run test to verify it fails

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q Expected: FAIL — constructor doesn't accept 3 args

  • Step 3: Update JwtAuthenticationFilter

Replace cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java with:

package com.cameleer3.server.app.security;

import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.security.JwtService;
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

/**
 * JWT authentication filter that supports two token types:
 * <ol>
 *   <li>Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}</li>
 *   <li>OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS</li>
 * </ol>
 * Internal tokens are tried first. OIDC is a fallback when configured.
 * <p>
 * Not annotated {@code @Component} — constructed explicitly in {@link SecurityConfig}.
 */
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private static final String BEARER_PREFIX = "Bearer ";
    public static final String JWT_RESULT_ATTR = "cameleer.jwt.result";

    private final JwtService jwtService;
    private final AgentRegistryService agentRegistryService;
    private final JwtDecoder oidcDecoder;

    public JwtAuthenticationFilter(JwtService jwtService,
                                    AgentRegistryService agentRegistryService,
                                    JwtDecoder oidcDecoder) {
        this.jwtService = jwtService;
        this.agentRegistryService = agentRegistryService;
        this.oidcDecoder = oidcDecoder;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        String token = extractToken(request);

        if (token != null) {
            if (tryInternalToken(token, request)) {
                chain.doFilter(request, response);
                return;
            }
            if (oidcDecoder != null) {
                tryOidcToken(token, request);
            }
        }

        chain.doFilter(request, response);
    }

    private boolean tryInternalToken(String token, HttpServletRequest request) {
        try {
            JwtValidationResult result = jwtService.validateAccessToken(token);
            String subject = result.subject();

            List<String> roles = result.roles();
            if (!subject.startsWith("user:") && roles.isEmpty()) {
                roles = List.of("AGENT");
            }
            List<GrantedAuthority> authorities = toAuthorities(roles);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken(subject, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(auth);
            request.setAttribute(JWT_RESULT_ATTR, result);
            return true;
        } catch (Exception e) {
            log.debug("Internal JWT validation failed: {}", e.getMessage());
            return false;
        }
    }

    private void tryOidcToken(String token, HttpServletRequest request) {
        try {
            Jwt jwt = oidcDecoder.decode(token);
            String subject = jwt.getSubject();
            List<String> roles = extractRolesFromOidcToken(jwt);
            List<GrantedAuthority> authorities = toAuthorities(roles);
            UsernamePasswordAuthenticationToken auth =
                    new UsernamePasswordAuthenticationToken("oidc:" + subject, null, authorities);
            SecurityContextHolder.getContext().setAuthentication(auth);
        } catch (Exception e) {
            log.debug("OIDC token validation failed: {}", e.getMessage());
        }
    }

    private List<String> extractRolesFromOidcToken(Jwt jwt) {
        String sub = jwt.getSubject();
        Object clientId = jwt.getClaim("client_id");
        if (clientId != null && clientId.toString().equals(sub)) {
            return List.of("ADMIN");
        }
        return List.of("VIEWER");
    }

    private List<GrantedAuthority> toAuthorities(List<String> roles) {
        return roles.stream()
                .map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role))
                .toList();
    }

    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            return authHeader.substring(BEARER_PREFIX.length());
        }
        return request.getParameter("token");
    }
}
  • Step 4: Run tests

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -Dtest=JwtAuthenticationFilterOidcTest -q Expected: PASS (all 4 tests)

  • Step 5: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/JwtAuthenticationFilter.java cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtAuthenticationFilterOidcTest.java
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"

Task 4: Wire OIDC decoder into SecurityConfig

Files:

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java

  • Modify: cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java

  • Step 1: Add OIDC decoder bean creation to SecurityBeanConfig

In SecurityBeanConfig.java, add this bean method:

@Bean
public JwtDecoder oidcJwtDecoder(SecurityProperties properties) {
    return createOidcJwtDecoder(properties);
}

Wait — Spring does not allow @Bean methods to return null. Instead, make the decoder optional. Create a holder:

Actually, the simplest approach: create the decoder in SecurityConfig directly, not as a bean. In SecurityConfig.java, inject SecurityProperties and call the factory method from SecurityBeanConfig.

Better approach: keep the factory in SecurityBeanConfig as a plain method (not @Bean), and have SecurityConfig call it.

In SecurityBeanConfig.java, make oidcJwtDecoder public but NOT a @Bean (keep it as written in Task 2 — no @Bean annotation).

  • Step 2: Update SecurityConfig to accept and use optional decoder

In SecurityConfig.java, update the filterChain method signature to accept SecurityBeanConfig:

Replace the filterChain method. Change the parameter list from:

public SecurityFilterChain filterChain(HttpSecurity http,
                                        JwtService jwtService,
                                        AgentRegistryService registryService,
                                        CorsConfigurationSource corsConfigurationSource)

to:

public SecurityFilterChain filterChain(HttpSecurity http,
                                        JwtService jwtService,
                                        AgentRegistryService registryService,
                                        CorsConfigurationSource corsConfigurationSource,
                                        SecurityProperties securityProperties,
                                        SecurityBeanConfig securityBeanConfig)

Then update the filter construction line from:

.addFilterBefore(
        new JwtAuthenticationFilter(jwtService, registryService),
        UsernamePasswordAuthenticationFilter.class
);

to:

.addFilterBefore(
        new JwtAuthenticationFilter(jwtService, registryService,
                securityBeanConfig.oidcJwtDecoder(securityProperties)),
        UsernamePasswordAuthenticationFilter.class
);

Add import:

import org.springframework.security.oauth2.jwt.JwtDecoder;
  • Step 3: Run existing tests

Run: cd /c/Users/Hendrik/Documents/projects/cameleer3-server && ./mvnw test -pl cameleer3-server-app -q Expected: All existing tests PASS (no OIDC env vars set, decoder is null, filter behaves as before)

  • Step 4: Commit
git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityBeanConfig.java
git commit -m "feat: wire optional OIDC JwtDecoder into security filter chain"

Phase 2: cameleer-saas — Backend + Frontend Rewrite

All Phase 2 work is in C:\Users\Hendrik\Documents\projects\cameleer-saas.

Task 5: Delete dead auth files

Files:

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java

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

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java

  • Delete: src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java

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

  • Delete: src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java

  • Delete: src/main/resources/db/migration/V001__create_users_table.sql

  • Delete: src/main/resources/db/migration/V002__create_roles_and_permissions.sql

  • Delete: src/main/resources/db/migration/V003__seed_default_roles.sql

  • Step 1: Delete all dead files

cd /c/Users/Hendrik/Documents/projects/cameleer-saas
rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtService.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java
rm -f src/main/java/net/siegeln/cameleer/saas/config/JwtConfig.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserEntity.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/UserRepository.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleEntity.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/RoleRepository.java
rm -f src/main/java/net/siegeln/cameleer/saas/auth/PermissionEntity.java
rm -f src/main/java/net/siegeln/cameleer/saas/config/ForwardAuthController.java
rm -f src/test/java/net/siegeln/cameleer/saas/auth/JwtServiceTest.java
rm -f src/main/resources/db/migration/V001__create_users_table.sql
rm -f src/main/resources/db/migration/V002__create_roles_and_permissions.sql
rm -f src/main/resources/db/migration/V003__seed_default_roles.sql
  • Step 2: Commit
git add -A
git commit -m "chore: delete dead auth code — users/roles/JWTs/ForwardAuth live in Logto now"

Task 6: Clean database migrations (greenfield)

Files:

  • Create: src/main/resources/db/migration/V001__create_tenants.sql (contents from old V005)

  • Create: src/main/resources/db/migration/V002__create_licenses.sql (contents from old V006)

  • Create: src/main/resources/db/migration/V003__create_environments.sql (modified — no bootstrap_token)

  • Create: src/main/resources/db/migration/V004__create_api_keys.sql (new)

  • Create: src/main/resources/db/migration/V005__create_apps.sql (contents from old V008+V010)

  • Create: src/main/resources/db/migration/V006__create_deployments.sql (contents from old V009)

  • Create: src/main/resources/db/migration/V007__create_audit_log.sql (contents from old V004)

  • Delete: old V004V010 files

  • Step 1: Remove old migrations

cd /c/Users/Hendrik/Documents/projects/cameleer-saas
rm -f src/main/resources/db/migration/V004__create_audit_log.sql
rm -f src/main/resources/db/migration/V005__create_tenants.sql
rm -f src/main/resources/db/migration/V006__create_licenses.sql
rm -f src/main/resources/db/migration/V007__create_environments.sql
rm -f src/main/resources/db/migration/V008__create_apps.sql
rm -f src/main/resources/db/migration/V009__create_deployments.sql
rm -f src/main/resources/db/migration/V010__add_exposed_port_to_apps.sql
  • Step 2: Create V001__create_tenants.sql

Write src/main/resources/db/migration/V001__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 3: Create V002__create_licenses.sql

Write src/main/resources/db/migration/V002__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 4: Create V003__create_environments.sql (no bootstrap_token)

Write src/main/resources/db/migration/V003__create_environments.sql:

CREATE TABLE environments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    slug VARCHAR(100) NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(tenant_id, slug)
);

CREATE INDEX idx_environments_tenant_id ON environments(tenant_id);
  • Step 5: Create V004__create_api_keys.sql

Write src/main/resources/db/migration/V004__create_api_keys.sql:

CREATE TABLE api_keys (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
    key_hash VARCHAR(64) NOT NULL,
    key_prefix VARCHAR(12) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ
);

CREATE INDEX idx_api_keys_env ON api_keys(environment_id);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
  • Step 6: Create V005__create_apps.sql (includes exposed_port)

Write src/main/resources/db/migration/V005__create_apps.sql:

CREATE TABLE apps (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    environment_id UUID NOT NULL REFERENCES environments(id) ON DELETE CASCADE,
    slug VARCHAR(100) NOT NULL,
    display_name VARCHAR(255) NOT NULL,
    jar_storage_path VARCHAR(500),
    jar_checksum VARCHAR(64),
    jar_original_filename VARCHAR(255),
    jar_size_bytes BIGINT,
    exposed_port INTEGER,
    current_deployment_id UUID,
    previous_deployment_id UUID,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(environment_id, slug)
);

CREATE INDEX idx_apps_environment_id ON apps(environment_id);
  • Step 7: Create V006__create_deployments.sql

Write src/main/resources/db/migration/V006__create_deployments.sql:

CREATE TABLE deployments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
    version INTEGER NOT NULL,
    image_ref VARCHAR(500) NOT NULL,
    desired_status VARCHAR(20) NOT NULL DEFAULT 'RUNNING',
    observed_status VARCHAR(20) NOT NULL DEFAULT 'BUILDING',
    orchestrator_metadata JSONB DEFAULT '{}',
    error_message TEXT,
    deployed_at TIMESTAMPTZ,
    stopped_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(app_id, version)
);

CREATE INDEX idx_deployments_app_id ON deployments(app_id);
  • Step 8: Create V007__create_audit_log.sql

Write src/main/resources/db/migration/V007__create_audit_log.sql:

CREATE TABLE audit_log (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    actor_id    UUID,
    actor_email VARCHAR(255),
    tenant_id   UUID,
    action      VARCHAR(100)  NOT NULL,
    resource    VARCHAR(500),
    environment VARCHAR(50),
    source_ip   VARCHAR(45),
    result      VARCHAR(20)   NOT NULL DEFAULT 'SUCCESS',
    metadata    JSONB,
    created_at  TIMESTAMPTZ   NOT NULL DEFAULT now()
);

CREATE INDEX idx_audit_log_tenant ON audit_log (tenant_id, created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log (actor_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON audit_log (action, created_at DESC);
  • Step 9: Commit
git add -A
git commit -m "chore: greenfield migrations — remove user/role tables, add api_keys, drop bootstrap_token"

Task 7: Rewrite SecurityConfig + JwtAuthenticationConverter

Files:

  • Rewrite: src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java

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

  • Step 1: Rewrite SecurityConfig.java

Replace the entire file src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java with:

package net.siegeln.cameleer.saas.config;

import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;

import java.net.URL;
import java.util.ArrayList;
import java.util.List;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final TenantResolutionFilter tenantResolutionFilter;

    public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) {
        this.tenantResolutionFilter = tenantResolutionFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/actuator/health").permitAll()
                        .requestMatchers("/api/config").permitAll()
                        .requestMatchers("/", "/index.html", "/login", "/callback",
                                "/environments/**", "/license", "/admin/**").permitAll()
                        .requestMatchers("/assets/**", "/favicon.ico").permitAll()
                        .anyRequest().authenticated()
                )
                .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
                        jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
                .addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            List<GrantedAuthority> authorities = new ArrayList<>();

            // Global roles (e.g., platform-admin)
            var roles = jwt.getClaimAsStringList("roles");
            if (roles != null) {
                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
            }

            // Org roles (e.g., admin, member)
            var orgRoles = jwt.getClaimAsStringList("organization_roles");
            if (orgRoles != null) {
                orgRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_org_" + r)));
            }

            return authorities;
        });
        return converter;
    }

    @Bean
    public JwtDecoder jwtDecoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
        var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
        var keySelector = new JWSVerificationKeySelector<SecurityContext>(
                JWSAlgorithm.ES384, jwkSource);

        var processor = new DefaultJWTProcessor<SecurityContext>();
        processor.setJWSKeySelector(keySelector);
        processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });

        var decoder = new NimbusJwtDecoder(processor);
        if (issuerUri != null && !issuerUri.isEmpty()) {
            decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
        }
        return decoder;
    }
}
  • Step 2: Clean application.yml — remove dead JWT config

In src/main/resources/application.yml, remove the entire cameleer.jwt block (lines 32-35):

  jwt:
    expiration: 86400 # 24 hours in seconds
    private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
    public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}

Also remove bootstrap-token from the runtime block (line 52):

    bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java src/main/resources/application.yml
git commit -m "feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server"

Task 8: Rewrite MeController (JWT claims only)

Files:

  • Rewrite: src/main/java/net/siegeln/cameleer/saas/config/MeController.java

  • Step 1: Rewrite MeController

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

package net.siegeln.cameleer.saas.config;

import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
public class MeController {

    private final TenantService tenantService;
    private final LogtoManagementClient logtoClient;

    public MeController(TenantService tenantService, LogtoManagementClient logtoClient) {
        this.tenantService = tenantService;
        this.logtoClient = logtoClient;
    }

    @GetMapping("/api/me")
    public ResponseEntity<Map<String, Object>> me(Authentication authentication) {
        if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) {
            return ResponseEntity.status(401).build();
        }

        Jwt jwt = jwtAuth.getToken();
        String userId = jwt.getSubject();

        // Read org from JWT claims (Logto includes organization_id in org-scoped tokens)
        String orgId = jwt.getClaimAsString("organization_id");

        // Check platform admin via global roles in token
        List<String> globalRoles = jwt.getClaimAsStringList("roles");
        boolean isPlatformAdmin = globalRoles != null && globalRoles.contains("platform-admin");

        // If org-scoped token, resolve single tenant
        if (orgId != null) {
            var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null);
            List<Map<String, Object>> tenants = tenant != null
                    ? List.of(Map.<String, Object>of(
                            "id", tenant.getId().toString(),
                            "name", tenant.getName(),
                            "slug", tenant.getSlug(),
                            "logtoOrgId", tenant.getLogtoOrgId()))
                    : List.of();

            return ResponseEntity.ok(Map.of(
                    "userId", userId,
                    "isPlatformAdmin", isPlatformAdmin,
                    "tenants", tenants));
        }

        // Non-org-scoped token: enumerate orgs via Management API (cold-start only)
        List<Map<String, String>> logtoOrgs = logtoClient.getUserOrganizations(userId);
        List<Map<String, Object>> tenants = logtoOrgs.stream()
                .map(org -> tenantService.getByLogtoOrgId(org.get("id"))
                        .map(t -> Map.<String, Object>of(
                                "id", t.getId().toString(),
                                "name", t.getName(),
                                "slug", t.getSlug(),
                                "logtoOrgId", t.getLogtoOrgId()))
                        .orElse(null))
                .filter(t -> t != null)
                .toList();

        return ResponseEntity.ok(Map.of(
                "userId", userId,
                "isPlatformAdmin", isPlatformAdmin,
                "tenants", tenants));
    }
}
  • Step 2: Commit
git add src/main/java/net/siegeln/cameleer/saas/config/MeController.java
git commit -m "feat: rewrite MeController — read from JWT claims, Management API only for cold start"

Task 9: Rewrite TenantController authorization

Files:

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

  • Step 1: Rewrite TenantController

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

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.access.prepost.PreAuthorize;
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.List;
import java.util.UUID;

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

    private final TenantService tenantService;

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

    @GetMapping
    @PreAuthorize("hasRole('platform-admin')")
    public ResponseEntity<List<TenantResponse>> listAll() {
        List<TenantResponse> tenants = tenantService.findAll().stream()
                .map(this::toResponse).toList();
        return ResponseEntity.ok(tenants);
    }

    @PostMapping
    @PreAuthorize("hasRole('platform-admin')")
    public ResponseEntity<TenantResponse> create(@Valid @RequestBody CreateTenantRequest request,
                                                  Authentication authentication) {
        try {
            String sub = authentication.getName();
            UUID actorId;
            try {
                actorId = UUID.fromString(sub);
            } catch (IllegalArgumentException e) {
                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();
        }
    }

    @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 2: Commit
git add src/main/java/net/siegeln/cameleer/saas/tenant/TenantController.java
git commit -m "feat: replace manual Logto role check with @PreAuthorize in TenantController"

Task 10: Add ApiKeyEntity + repository + service

Files:

  • Create: src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java

  • Create: src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java

  • Create: src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java

  • Create: src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java

  • Step 1: Write the failing test

Create src/test/java/net/siegeln/cameleer/saas/apikey/ApiKeyServiceTest.java:

package net.siegeln.cameleer.saas.apikey;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ApiKeyServiceTest {

    @Test
    void generatedKeyShouldHaveCmkPrefix() {
        var service = new ApiKeyService(null);
        var key = service.generate();

        assertThat(key.plaintext()).startsWith("cmk_");
        assertThat(key.prefix()).hasSize(12);
        assertThat(key.keyHash()).hasSize(64); // SHA-256 hex
    }

    @Test
    void generatedKeyHashShouldBeConsistent() {
        var service = new ApiKeyService(null);
        var key = service.generate();

        String rehash = ApiKeyService.sha256Hex(key.plaintext());
        assertThat(rehash).isEqualTo(key.keyHash());
    }

    @Test
    void twoGeneratedKeysShouldDiffer() {
        var service = new ApiKeyService(null);
        var key1 = service.generate();
        var key2 = service.generate();

        assertThat(key1.plaintext()).isNotEqualTo(key2.plaintext());
        assertThat(key1.keyHash()).isNotEqualTo(key2.keyHash());
    }
}
  • Step 2: Run test to verify it fails

Run: cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q Expected: FAIL — class not found

  • Step 3: Create ApiKeyEntity

Create src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyEntity.java:

package net.siegeln.cameleer.saas.apikey;

import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "api_keys")
public class ApiKeyEntity {

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

    @Column(name = "environment_id", nullable = false)
    private UUID environmentId;

    @Column(name = "key_hash", nullable = false, length = 64)
    private String keyHash;

    @Column(name = "key_prefix", nullable = false, length = 12)
    private String keyPrefix;

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

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

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

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

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }
    public UUID getEnvironmentId() { return environmentId; }
    public void setEnvironmentId(UUID environmentId) { this.environmentId = environmentId; }
    public String getKeyHash() { return keyHash; }
    public void setKeyHash(String keyHash) { this.keyHash = keyHash; }
    public String getKeyPrefix() { return keyPrefix; }
    public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getRevokedAt() { return revokedAt; }
    public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; }
}
  • Step 4: Create ApiKeyRepository

Create src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyRepository.java:

package net.siegeln.cameleer.saas.apikey;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface ApiKeyRepository extends JpaRepository<ApiKeyEntity, UUID> {
    Optional<ApiKeyEntity> findByKeyHashAndStatus(String keyHash, String status);
    List<ApiKeyEntity> findByEnvironmentId(UUID environmentId);
    List<ApiKeyEntity> findByEnvironmentIdAndStatus(UUID environmentId, String status);
}
  • Step 5: Create ApiKeyService

Create src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java:

package net.siegeln.cameleer.saas.apikey;

import org.springframework.stereotype.Service;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Service
public class ApiKeyService {

    public record GeneratedKey(String plaintext, String keyHash, String prefix) {}

    private final ApiKeyRepository repository;

    public ApiKeyService(ApiKeyRepository repository) {
        this.repository = repository;
    }

    public GeneratedKey generate() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);
        String plaintext = "cmk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        String hash = sha256Hex(plaintext);
        String prefix = plaintext.substring(0, 12);
        return new GeneratedKey(plaintext, hash, prefix);
    }

    public ApiKeyEntity createForEnvironment(UUID environmentId) {
        var key = generate();
        var entity = new ApiKeyEntity();
        entity.setEnvironmentId(environmentId);
        entity.setKeyHash(key.keyHash());
        entity.setKeyPrefix(key.prefix());
        return repository.save(entity);
    }

    public GeneratedKey createForEnvironmentReturningPlaintext(UUID environmentId) {
        var key = generate();
        var entity = new ApiKeyEntity();
        entity.setEnvironmentId(environmentId);
        entity.setKeyHash(key.keyHash());
        entity.setKeyPrefix(key.prefix());
        repository.save(entity);
        return key;
    }

    public Optional<ApiKeyEntity> validate(String plaintext) {
        String hash = sha256Hex(plaintext);
        return repository.findByKeyHashAndStatus(hash, "ACTIVE");
    }

    public GeneratedKey rotate(UUID environmentId) {
        // Mark existing active keys as ROTATED
        List<ApiKeyEntity> active = repository.findByEnvironmentIdAndStatus(environmentId, "ACTIVE");
        for (var k : active) {
            k.setStatus("ROTATED");
        }
        repository.saveAll(active);

        return createForEnvironmentReturningPlaintext(environmentId);
    }

    public void revoke(UUID keyId) {
        repository.findById(keyId).ifPresent(k -> {
            k.setStatus("REVOKED");
            k.setRevokedAt(Instant.now());
            repository.save(k);
        });
    }

    public List<ApiKeyEntity> listByEnvironment(UUID environmentId) {
        return repository.findByEnvironmentId(environmentId);
    }

    public static String sha256Hex(String input) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder(64);
            for (byte b : hash) {
                hex.append(String.format("%02x", b));
            }
            return hex.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 not available", e);
        }
    }
}
  • Step 6: Run tests

Run: cd /c/Users/Hendrik/Documents/projects/cameleer-saas && ./mvnw test -Dtest=ApiKeyServiceTest -q Expected: PASS (all 3 tests)

  • Step 7: Commit
git add src/main/java/net/siegeln/cameleer/saas/apikey/ src/test/java/net/siegeln/cameleer/saas/apikey/
git commit -m "feat: add API key entity, repository, and service with SHA-256 hashing"

Task 11: Update EnvironmentEntity and EnvironmentService

Files:

  • Modify: src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentEntity.java

  • Modify: src/main/java/net/siegeln/cameleer/saas/environment/EnvironmentService.java

  • Step 1: Remove bootstrap_token from EnvironmentEntity

In EnvironmentEntity.java, remove the bootstrapToken field (lines 24-25) and its getter/setter (lines 56-57):

Remove:

    @Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT")
    private String bootstrapToken;

And remove:

    public String getBootstrapToken() { return bootstrapToken; }
    public void setBootstrapToken(String bootstrapToken) { this.bootstrapToken = bootstrapToken; }
  • Step 2: Update EnvironmentService.create()

In EnvironmentService.java, remove the bootstrap token line from create() method. Remove line 44:

        entity.setBootstrapToken(runtimeConfig.getBootstrapToken());

Also remove RuntimeConfig from the constructor and field if it's only used for bootstrap token. Check: runtimeConfig is also used nowhere else in this service — but actually it might be injected for tier limits. Check the imports — no, it's only used for getBootstrapToken(). However, keep the field for now if other code references it; just remove the setBootstrapToken call.

Actually, looking at the code, runtimeConfig is only used on line 44 for getBootstrapToken(). Remove it from constructor and field. Update the constructor:

Replace constructor (lines 23-30):

    public EnvironmentService(EnvironmentRepository environmentRepository,
                              LicenseRepository licenseRepository,
                              AuditService auditService) {
        this.environmentRepository = environmentRepository;
        this.licenseRepository = licenseRepository;
        this.auditService = auditService;
    }

Remove the runtimeConfig field and import.

  • Step 3: Fix compilation — update tests and other references

Search for getBootstrapToken() and setBootstrapToken() in the SaaS codebase. Update:

  • DeploymentService.java line 145: env.getBootstrapToken() — this needs the API key now. For now, this will be addressed in a follow-up task. Comment out or use a placeholder.
  • BootstrapDataSeeder.java: references bootstrap token — will be rewritten in Phase 3.
  • Test files: update to remove setBootstrapToken() calls.

For each test that calls env.setBootstrapToken("..."), simply remove that line.

  • Step 4: Commit
git add src/main/java/net/siegeln/cameleer/saas/environment/
git commit -m "feat: remove bootstrap_token from EnvironmentEntity — API keys managed separately"

Task 12: Simplify LogtoManagementClient

Files:

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

  • Step 1: Remove getUserRoles method

In LogtoManagementClient.java, delete the getUserRoles() method (lines 78-99). Roles now come from JWT claims.

  • Step 2: Fix compilation — remove getUserRoles callers

Search for getUserRoles in the codebase. The only caller was TenantController (already rewritten in Task 9) and MeController (already rewritten in Task 8). Verify no other callers exist.

  • Step 3: Commit
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "refactor: remove getUserRoles from LogtoManagementClient — roles come from JWT"

Task 13: Update TestSecurityConfig

Files:

  • Modify: src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java

  • Step 1: Update mock JwtDecoder to include org and role claims

Replace src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java with:

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.List;

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    public JwtDecoder jwtDecoder() {
        return token -> Jwt.withTokenValue(token)
                .header("alg", "ES384")
                .claim("sub", "test-user")
                .claim("iss", "https://test-issuer.example.com/oidc")
                .claim("organization_id", "test-org-id")
                .claim("roles", List.of("platform-admin"))
                .claim("organization_roles", List.of("admin"))
                .issuedAt(Instant.now())
                .expiresAt(Instant.now().plusSeconds(3600))
                .build();
    }
}
  • Step 2: Commit
git add src/test/java/net/siegeln/cameleer/saas/TestSecurityConfig.java
git commit -m "test: update TestSecurityConfig with org and role claims for Logto tokens"

Task 14: Rewrite frontend auth

Files:

  • Modify: ui/src/auth/useAuth.ts

  • Modify: ui/src/hooks/usePermissions.ts

  • Step 1: Rewrite useAuth.ts

Replace ui/src/auth/useAuth.ts with:

import { useLogto } from '@logto/react';
import { useCallback } from 'react';
import { useOrgStore } from './useOrganization';

export function useAuth() {
  const { isAuthenticated, isLoading, signOut, signIn } = useLogto();
  const { currentTenantId, isPlatformAdmin } = useOrgStore();

  const logout = useCallback(() => {
    signOut(window.location.origin + '/login');
  }, [signOut]);

  return {
    isAuthenticated,
    isLoading,
    tenantId: currentTenantId,
    isPlatformAdmin,
    logout,
    signIn,
  };
}
  • Step 2: Rewrite usePermissions.ts

Replace ui/src/hooks/usePermissions.ts with:

import { useOrgStore } from '../auth/useOrganization';

const ROLE_PERMISSIONS: Record<string, string[]> = {
  'admin': [
    'tenant:manage', 'billing:manage', 'team:manage', 'apps:manage',
    'apps:deploy', 'secrets:manage', 'observe:read', 'observe:debug',
    'settings:manage',
  ],
  'member': ['apps:deploy', 'observe:read', 'observe:debug'],
};

export function usePermissions() {
  const { currentOrgRoles } = useOrgStore();
  const roles = currentOrgRoles ?? [];

  const permissions = new Set<string>();
  for (const role of roles) {
    const perms = ROLE_PERMISSIONS[role];
    if (perms) perms.forEach((p) => permissions.add(p));
  }

  return {
    has: (permission: string) => permissions.has(permission),
    canManageApps: permissions.has('apps:manage'),
    canDeploy: permissions.has('apps:deploy'),
    canManageTenant: permissions.has('tenant:manage'),
    canViewObservability: permissions.has('observe:read'),
    roles,
  };
}

Note: This requires adding currentOrgRoles to the org store. Update ui/src/auth/useOrganization.ts to include it:

Add to the OrgState interface:

currentOrgRoles: string[] | null;
setCurrentOrgRoles: (roles: string[] | null) => void;

Add to the store create:

currentOrgRoles: null,
setCurrentOrgRoles: (roles) => set({ currentOrgRoles: roles }),

Then update OrgResolver.tsx to set org roles from the /api/me response (the backend would need to return orgRoles — or extract from the token claims on the frontend side). For now, the org roles can be hardcoded from the OrgResolver after calling /api/me.

  • Step 3: Commit
git add ui/src/auth/useAuth.ts ui/src/hooks/usePermissions.ts ui/src/auth/useOrganization.ts
git commit -m "feat: rewrite frontend auth — roles from org store, Logto org role names"

Phase 3: Infrastructure Updates

Task 15: Update docker-compose.yml

Files:

  • Modify: docker-compose.yml

  • Step 1: Remove ForwardAuth labels from cameleer-saas service

In docker-compose.yml, remove these two labels from cameleer-saas (lines 122-124):

      - traefik.http.routers.forwardauth.rule=Path(`/auth/verify`)
      - traefik.http.routers.forwardauth.service=forwardauth
      - traefik.http.services.forwardauth.loadbalancer.server.port=8080
  • Step 2: Remove ForwardAuth middleware from cameleer3-server

In docker-compose.yml, remove the forward-auth middleware labels from cameleer3-server (lines 158-159):

      - traefik.http.routers.observe.middlewares=forward-auth
      - traefik.http.middlewares.forward-auth.forwardauth.address=http://cameleer-saas:8080/auth/verify

And change line 163 from:

      - traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip

to:

      - traefik.http.routers.dashboard.middlewares=dashboard-strip
  • Step 3: Remove keys volume mount from cameleer-saas

Remove line 99:

      - ./keys:/etc/cameleer/keys:ro
  • Step 4: Remove dead env vars, add OIDC env vars

In cameleer-saas environment, remove:

      CAMELEER_JWT_PRIVATE_KEY_PATH: ${CAMELEER_JWT_PRIVATE_KEY_PATH:-}
      CAMELEER_JWT_PUBLIC_KEY_PATH: ${CAMELEER_JWT_PUBLIC_KEY_PATH:-}
      CAMELEER_AUTH_TOKEN: ${CAMELEER_AUTH_TOKEN:-default-bootstrap-token}

In cameleer3-server environment, add:

      CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc}
      CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local}
  • Step 5: Commit
git add docker-compose.yml
git commit -m "infra: remove ForwardAuth, keys mount, add OIDC env vars for server"

Task 16: Update bootstrap script

Files:

  • Modify: docker/logto-bootstrap.sh

  • Step 1: Add OIDC env vars to bootstrap output

In docker/logto-bootstrap.sh, add to the bootstrap JSON output (around line 431):

After "tenantAdminUser", add:

  "oidcIssuerUri": "${LOGTO_ENDPOINT}/oidc",
  "oidcAudience": "$API_RESOURCE_INDICATOR"
  • Step 2: Remove direct psql reads for existing app secrets

The script reads Logto's applications table directly via psql for M2M and Traditional app secrets when apps already exist (lines 155-156, 193-194). Replace with reading from the bootstrap JSON file if it exists:

At the top of the script (after variable declarations), add:

# Read cached secrets from previous run
if [ -f "$BOOTSTRAP_FILE" ]; then
  CACHED_M2M_SECRET=$(jq -r '.m2mClientSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
  CACHED_TRAD_SECRET=$(jq -r '.tradAppSecret // empty' "$BOOTSTRAP_FILE" 2>/dev/null)
fi

Then replace the psql fallbacks with:

M2M_SECRET="${CACHED_M2M_SECRET:-}"
TRAD_SECRET="${CACHED_TRAD_SECRET:-}"
  • Step 3: Commit
git add docker/logto-bootstrap.sh
git commit -m "infra: add OIDC config to bootstrap output, stop reading Logto DB for secrets"

Self-Review Checklist

Spec Requirement Task
Delete custom JWT stack (JwtService, filter, config, entities) Task 5
Delete ForwardAuthController Task 5
Delete PasswordEncoder bean Task 7 (SecurityConfig rewrite)
Delete old migrations V001-V003 Task 5 + Task 6
Delete Ed25519 key config from application.yml Task 7
Rewrite SecurityConfig (single chain, OAuth2 RS) Task 7
Add JwtAuthenticationConverter for Logto roles Task 7
Rewrite MeController (JWT claims) Task 8
Rewrite TenantController (@PreAuthorize) Task 9
Add ApiKeyEntity + migration Task 6 + Task 10
Add ApiKeyService Task 10
Update EnvironmentEntity (remove bootstrap_token) Task 11
Simplify LogtoManagementClient Task 12
Update TestSecurityConfig Task 13
Rewrite frontend useAuth.ts Task 14
Rewrite frontend usePermissions.ts Task 14
Remove Traefik ForwardAuth Task 15
Remove keys mount from docker-compose Task 15
Add OIDC env vars to server Task 15
Update bootstrap script Task 16
Server: add oauth2-resource-server dep Task 1
Server: add SecurityProperties fields Task 1
Server: conditional OIDC JwtDecoder Task 2
Server: JwtAuthenticationFilter OIDC fallback Task 3
Server: wire decoder into SecurityConfig Task 4
Agent: no changes N/A (verified)