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

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

1810 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 cameleer-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 cameleer-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:**
- cameleer-server: `C:\Users\Hendrik\Documents\projects\cameleer-server` (Phase 1)
- cameleer-saas: `C:\Users\Hendrik\Documents\projects\cameleer-saas` (Phases 2-3)
- cameleer (agent): NO CHANGES
---
## Phase 1: cameleer-server — OIDC Resource Server Support
All Phase 1 work is in `C:\Users\Hendrik\Documents\projects\cameleer-server`.
### Task 1: Add OAuth2 Resource Server dependency and config properties
**Files:**
- Modify: `cameleer-server-app/pom.xml`
- Modify: `cameleer-server-app/src/main/resources/application.yml`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityProperties.java`
- [ ] **Step 1: Add dependency to pom.xml**
In `cameleer-server-app/pom.xml`, add after the `spring-boot-starter-security` dependency (around line 88):
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
```
- [ ] **Step 2: Add OIDC properties to application.yml**
In `cameleer-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 `cameleer-server-app/src/main/java/com/cameleer/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/cameleer-server && ./mvnw compile -pl cameleer-server-app -q`
Expected: BUILD SUCCESS
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-app/pom.xml cameleer-server-app/src/main/resources/application.yml cameleer-server-app/src/main/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java`
- [ ] **Step 1: Write the failing test**
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/OidcJwtDecoderBeanTest.java`:
```java
package com.cameleer.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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
Expected: FAIL — method `oidcJwtDecoder` does not exist
- [ ] **Step 3: Add the oidcJwtDecoder method to SecurityBeanConfig**
In `cameleer-server-app/src/main/java/com/cameleer/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.
* <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`:
```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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=OidcJwtDecoderBeanTest -q`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityBeanConfig.java cameleer-server-app/src/test/java/com/cameleer/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: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java`
- [ ] **Step 1: Write the failing test**
Create `cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java`:
```java
package com.cameleer.server.app.security;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.InvalidTokenException;
import com.cameleer.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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
Expected: FAIL — constructor doesn't accept 3 args
- [ ] **Step 3: Update JwtAuthenticationFilter**
Replace `cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java` with:
```java
package com.cameleer.server.app.security;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import com.cameleer.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/cameleer-server && ./mvnw test -pl cameleer-server-app -Dtest=JwtAuthenticationFilterOidcTest -q`
Expected: PASS (all 4 tests)
- [ ] **Step 5: Commit**
```bash
git add cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtAuthenticationFilterOidcTest.java
git commit -m "feat: add OIDC token fallback to JwtAuthenticationFilter"
```
---
### Task 4: Wire OIDC decoder into SecurityConfig
**Files:**
- Modify: `cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java`
- Modify: `cameleer-server-app/src/main/java/com/cameleer/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/cameleer-server && ./mvnw test -pl cameleer-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 cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java cameleer-server-app/src/main/java/com/cameleer/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 V004V010 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<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):
```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<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**
```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<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**
```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<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`:
```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**
```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<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:
```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 cameleer-server**
In `docker-compose.yml`, remove the forward-auth middleware labels from `cameleer-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 `cameleer-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) |