From 1397267be53566a878039f4ec2f1f00036fe8e95 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 5 Apr 2026 12:26:47 +0200 Subject: [PATCH] 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) --- .../plans/2026-04-05-auth-overhaul.md | 1809 +++++++++++++++++ 1 file changed, 1809 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-auth-overhaul.md diff --git a/docs/superpowers/plans/2026-04-05-auth-overhaul.md b/docs/superpowers/plans/2026-04-05-auth-overhaul.md new file mode 100644 index 0000000..56d6346 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-auth-overhaul.md @@ -0,0 +1,1809 @@ +# 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): + +```xml + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + +``` + +- [ ] **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): + +```yaml + 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): + +```java +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** + +```bash +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`: + +```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: + +```java +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: + +```java +/** + * 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. + *

+ * 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( + JWSAlgorithm.ES384, jwkSource); + + var processor = new DefaultJWTProcessor(); + 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 validator; + String audience = properties.getOidcAudience(); + if (audience != null && !audience.isBlank()) { + validator = new DelegatingOAuth2TokenValidator<>( + JwtValidators.createDefaultWithIssuer(issuerUri), + new JwtClaimValidator>("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`: + +```java +@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** + +```bash +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`: + +```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: + +```java +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: + *

    + *
  1. Internal HMAC-SHA256 tokens (agents, local users) — validated by {@link JwtService}
  2. + *
  3. OIDC tokens from Logto (SaaS M2M, OIDC users) — validated by {@link JwtDecoder} via JWKS
  4. + *
+ * Internal tokens are tried first. OIDC is a fallback when configured. + *

+ * 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 roles = result.roles(); + if (!subject.startsWith("user:") && roles.isEmpty()) { + roles = List.of("AGENT"); + } + List 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 roles = extractRolesFromOidcToken(jwt); + List 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 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 toAuthorities(List 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** + +```bash +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: + +```java +@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: + +```java +public SecurityFilterChain filterChain(HttpSecurity http, + JwtService jwtService, + AgentRegistryService registryService, + CorsConfigurationSource corsConfigurationSource) +``` + +to: + +```java +public SecurityFilterChain filterChain(HttpSecurity http, + JwtService jwtService, + AgentRegistryService registryService, + CorsConfigurationSource corsConfigurationSource, + SecurityProperties securityProperties, + SecurityBeanConfig securityBeanConfig) +``` + +Then update the filter construction line from: + +```java +.addFilterBefore( + new JwtAuthenticationFilter(jwtService, registryService), + UsernamePasswordAuthenticationFilter.class +); +``` + +to: + +```java +.addFilterBefore( + new JwtAuthenticationFilter(jwtService, registryService, + securityBeanConfig.oidcJwtDecoder(securityProperties)), + UsernamePasswordAuthenticationFilter.class +); +``` + +Add import: +```java +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** + +```bash +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** + +```bash +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** + +```bash +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 V004–V010 files + +- [ ] **Step 1: Remove old migrations** + +```bash +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`: + +```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`: + +```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`: + +```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`: + +```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`: + +```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`: + +```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`: + +```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** + +```bash +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: + +```java +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 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( + JWSAlgorithm.ES384, jwkSource); + + var processor = new DefaultJWTProcessor(); + 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): + +```yaml + 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): + +```yaml + bootstrap-token: ${CAMELEER_AUTH_TOKEN:} +``` + +- [ ] **Step 3: Commit** + +```bash +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: + +```java +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> 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 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> tenants = tenant != null + ? List.of(Map.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> logtoOrgs = logtoClient.getUserOrganizations(userId); + List> tenants = logtoOrgs.stream() + .map(org -> tenantService.getByLogtoOrgId(org.get("id")) + .map(t -> Map.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** + +```bash +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: + +```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.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> listAll() { + List tenants = tenantService.findAll().stream() + .map(this::toResponse).toList(); + return ResponseEntity.ok(tenants); + } + + @PostMapping + @PreAuthorize("hasRole('platform-admin')") + public ResponseEntity 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 getById(@PathVariable UUID id) { + return tenantService.getById(id) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/by-slug/{slug}") + public ResponseEntity 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** + +```bash +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`: + +```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`: + +```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`: + +```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 { + Optional findByKeyHashAndStatus(String keyHash, String status); + List findByEnvironmentId(UUID environmentId); + List findByEnvironmentIdAndStatus(UUID environmentId, String status); +} +``` + +- [ ] **Step 5: Create ApiKeyService** + +Create `src/main/java/net/siegeln/cameleer/saas/apikey/ApiKeyService.java`: + +```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 validate(String plaintext) { + String hash = sha256Hex(plaintext); + return repository.findByKeyHashAndStatus(hash, "ACTIVE"); + } + + public GeneratedKey rotate(UUID environmentId) { + // Mark existing active keys as ROTATED + List 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 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** + +```bash +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: +```java + @Column(name = "bootstrap_token", nullable = false, columnDefinition = "TEXT") + private String bootstrapToken; +``` + +And remove: +```java + 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: + +```java + 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): +```java + 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** + +```bash +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** + +```bash +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: + +```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.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** + +```bash +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: + +```typescript +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: + +```typescript +import { useOrgStore } from '../auth/useOrganization'; + +const ROLE_PERMISSIONS: Record = { + '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(); + 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: +```typescript +currentOrgRoles: string[] | null; +setCurrentOrgRoles: (roles: string[] | null) => void; +``` + +Add to the store create: +```typescript +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** + +```bash +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): + +```yaml + - 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): + +```yaml + - 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: +```yaml + - traefik.http.routers.dashboard.middlewares=forward-auth,dashboard-strip +``` +to: +```yaml + - traefik.http.routers.dashboard.middlewares=dashboard-strip +``` + +- [ ] **Step 3: Remove keys volume mount from cameleer-saas** + +Remove line 99: +```yaml + - ./keys:/etc/cameleer/keys:ro +``` + +- [ ] **Step 4: Remove dead env vars, add OIDC env vars** + +In `cameleer-saas` environment, remove: +```yaml + 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: +```yaml + CAMELEER_OIDC_ISSUER_URI: ${LOGTO_ISSUER_URI:-http://logto:3001/oidc} + CAMELEER_OIDC_AUDIENCE: ${CAMELEER_OIDC_AUDIENCE:-https://api.cameleer.local} +``` + +- [ ] **Step 5: Commit** + +```bash +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: +```json + "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: +```bash +# 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: +```bash +M2M_SECRET="${CACHED_M2M_SECRET:-}" +TRAD_SECRET="${CACHED_TRAD_SECRET:-}" +``` + +- [ ] **Step 3: Commit** + +```bash +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) |