Files
cameleer-server/.planning/phases/04-security/04-02-PLAN.md
hsiegeln cb3ebfea7c
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
chore: rename cameleer3 to cameleer
Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

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

18 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-security 02 execute 2
04-01
cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java
cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java
cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java
cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java
cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java
cameleer-server-app/src/test/java/com/cameleer/server/app/security/BootstrapTokenIT.java
cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java
cameleer-server-app/src/test/java/com/cameleer/server/app/security/TestSecurityConfig.java
true
SECU-01
SECU-02
SECU-05
truths artifacts key_links
All API endpoints except health, register, and docs reject requests without valid JWT
POST /register requires bootstrap token in Authorization header, returns JWT + refresh token + Ed25519 public key
POST /agents/{id}/refresh accepts refresh token and returns new access JWT
SSE endpoint accepts JWT via ?token= query parameter
Health endpoint and Swagger UI remain publicly accessible
path provides
cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java OncePerRequestFilter extracting JWT from header or query param
path provides
cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java SecurityFilterChain with permitAll for public paths, authenticated for rest
path provides
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java Updated register endpoint with bootstrap token validation, JWT issuance, public key
from to via pattern
JwtAuthenticationFilter JwtService.validateAndExtractAgentId Filter delegates JWT validation to service jwtService.validateAndExtractAgentId
from to via pattern
SecurityConfig JwtAuthenticationFilter addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) addFilterBefore
from to via pattern
AgentRegistrationController.register BootstrapTokenValidator.validate Validates bootstrap token before processing registration bootstrapTokenValidator.validate
from to via pattern
AgentRegistrationController.register JwtService.createAccessToken + createRefreshToken Issues tokens in registration response jwtService.create(Access|Refresh)Token
Wire Spring Security into the application: JWT authentication filter, SecurityFilterChain configuration, bootstrap token validation on registration, JWT issuance in registration response, refresh endpoint, and SSE query-parameter authentication. Update existing tests to work with security enabled.

Purpose: Protects all endpoints with JWT authentication while keeping public endpoints accessible and providing the full agent registration-to-authentication flow. Output: Working security filter chain with protected/public endpoints, registration returns JWT + public key, refresh flow works, all tests pass.

<execution_context> @C:/Users/Hendrik/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/Hendrik/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/04-security/04-CONTEXT.md @.planning/phases/04-security/04-RESEARCH.md @.planning/phases/04-security/04-VALIDATION.md @.planning/phases/04-security/04-01-SUMMARY.md

@cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java @cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java @cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java @cameleer-server-app/src/test/java/com/cameleer/server/app/AbstractClickHouseIT.java @cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java

From core/security/JwtService.java:

public interface JwtService {
    String createAccessToken(String agentId, String group);
    String createRefreshToken(String agentId, String group);
    String validateAndExtractAgentId(String token); // access tokens only
    String validateRefreshToken(String token);       // refresh tokens only
}

From core/security/Ed25519SigningService.java:

public interface Ed25519SigningService {
    String sign(String payload);
    String getPublicKeyBase64();
}

From app/security/BootstrapTokenValidator.java:

public class BootstrapTokenValidator {
    public boolean validate(String provided);
}

From app/security/SecurityProperties.java:

@ConfigurationProperties(prefix = "security")
public class SecurityProperties {
    long accessTokenExpiryMs;   // default 3600000
    long refreshTokenExpiryMs;  // default 604800000
    String bootstrapToken;      // from CAMELEER_AUTH_TOKEN env
    String bootstrapTokenPrevious; // from CAMELEER_AUTH_TOKEN_PREVIOUS env, nullable
}

From core/agent/AgentRegistryService.java:

public class AgentRegistryService {
    public AgentInfo register(String id, String name, String group, String version, List<String> routeIds, Map<String, Object> capabilities);
    public AgentInfo findById(String id);
}
Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java, cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java, cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java, cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentSseController.java, cameleer-server-app/src/main/java/com/cameleer/server/app/config/WebConfig.java 1. Create `JwtAuthenticationFilter extends OncePerRequestFilter` (NOT annotated @Component -- constructed in SecurityConfig to avoid double registration): - Constructor takes `JwtService` and `AgentRegistryService` - `doFilterInternal`: extract token via `extractToken(request)`, if token present: call `jwtService.validateAndExtractAgentId(token)`, verify agent exists via `agentRegistry.findById(agentId)`, if valid set `UsernamePasswordAuthenticationToken(agentId, null, List.of())` in `SecurityContextHolder`. If any exception, log debug and do NOT set auth (Spring Security rejects). Always call `chain.doFilter(request, response)`. - `extractToken(request)`: first check `Authorization` header for `Bearer ` prefix, then check `request.getParameter("token")` for SSE query param. Return null if neither.
2. Create `SecurityConfig` as `@Configuration @EnableWebSecurity`:
   - Single `@Bean SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService, AgentRegistryService registryService)`:
     - `csrf(AbstractHttpConfigurer::disable)` -- REST API, no browser forms
     - `sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))`
     - `authorizeHttpRequests`: permitAll for `/api/v1/health`, `/api/v1/agents/register`, `/api/v1/api-docs/**`, `/api/v1/swagger-ui/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/swagger-ui.html`. `anyRequest().authenticated()`.
     - `addFilterBefore(new JwtAuthenticationFilter(jwtService, registryService), UsernamePasswordAuthenticationFilter.class)`
   - Also disable default form login and httpBasic: `.formLogin(AbstractHttpConfigurer::disable).httpBasic(AbstractHttpConfigurer::disable)`

3. Update `AgentRegistrationController.register()`:
   - Add `BootstrapTokenValidator`, `JwtService`, `Ed25519SigningService` as constructor dependencies
   - Before processing registration body, extract bootstrap token from `Authorization: Bearer <token>` header (use `@RequestHeader("Authorization")` or extract from HttpServletRequest). If missing or invalid (`bootstrapTokenValidator.validate()` returns false), return `401 Unauthorized` with no detail body.
   - After successful registration, generate tokens: `jwtService.createAccessToken(agentId, group)` and `jwtService.createRefreshToken(agentId, group)`
   - Update response map: replace `"serverPublicKey", null` with `"serverPublicKey", ed25519SigningService.getPublicKeyBase64()`. Add `"accessToken"` and `"refreshToken"` fields.

4. Add a new refresh endpoint in `AgentRegistrationController` (or a new controller -- keep it in the same controller since it's agent auth flow):
   - `POST /api/v1/agents/{id}/refresh` with request body containing `{"refreshToken": "..."}`.
   - Validate refresh token via `jwtService.validateRefreshToken(token)`, extract agentId, verify it matches path `{id}`, verify agent exists.
   - Return new access token: `{"accessToken": "..."}`.
   - Return 401 for invalid/expired refresh token, 404 for unknown agent.
   - NOTE: This endpoint must be AUTHENTICATED (requires valid JWT OR the refresh token itself). Per the user decision, the refresh endpoint uses the refresh token for auth, so add `/api/v1/agents/*/refresh` to permitAll in SecurityConfig, and validate the refresh token in the controller itself.

5. Update `AgentSseController.events()`:
   - The SSE endpoint uses `?token=<jwt>` query parameter. The `JwtAuthenticationFilter` already handles this (extracts from query param). No changes needed to the controller itself -- Spring Security handles auth via the filter.
   - However, verify the SSE endpoint path `/api/v1/agents/{id}/events` is NOT in permitAll (it should require JWT auth).

6. Update `WebConfig` if needed: The `ProtocolVersionInterceptor` excluded paths should align with Spring Security public paths. The SSE events path is already excluded from protocol version check (Phase 3 decision). Verify no conflicts.
cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean compile -pl cameleer-server-app - SecurityConfig creates stateless filter chain with correct public/protected path split - JwtAuthenticationFilter extracts JWT from header or query param, validates, sets SecurityContext - Registration endpoint requires bootstrap token, returns accessToken + refreshToken + serverPublicKey - Refresh endpoint issues new access token from valid refresh token - Application compiles with all security wiring Task 2: Security integration tests + existing test adaptation cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/TestSecurityConfig.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/security/BootstrapTokenIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/OpenApiIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java, cameleer-server-app/src/test/java/com/cameleer/server/app/controller/HealthControllerIT.java 1. Replace the Plan 01 temporary `TestSecurityConfig` (permit-all) with real security active in tests. Remove the permit-all override so tests run with actual security enforcement.
2. Create `TestSecurityHelper` utility class in test root:
   - Autowire `JwtService` and `AgentRegistryService`
   - `registerTestAgent(String agentId)`: calls `registryService.register(agentId, "test", "test-group", "1.0", List.of(), Map.of())` and returns `jwtService.createAccessToken(agentId, "test-group")`
   - `authHeaders(String jwt)`: returns HttpHeaders with `Authorization: Bearer <jwt>` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
   - `bootstrapHeaders()`: returns HttpHeaders with `Authorization: Bearer test-bootstrap-token` and `X-Cameleer-Protocol-Version: 1` and `Content-Type: application/json`
   - Make it a Spring `@Component` so it can be autowired in test classes

3. Update ALL existing IT classes (17 files) to use JWT authentication:
   - Autowire `TestSecurityHelper`
   - In `@BeforeEach` or at test start, call `helper.registerTestAgent("test-agent-<testclass>")` to get a JWT
   - Replace all `protocolHeaders()` calls with headers that include the JWT Bearer token
   - For HealthControllerIT and OpenApiIT: verify these still work WITHOUT JWT (they're public endpoints)
   - For AgentRegistrationControllerIT: update `registerAgent()` helper to use bootstrap token header, verify response now includes `accessToken`, `refreshToken`, `serverPublicKey` (non-null)

4. Create new security-specific integration tests:

   `SecurityFilterIT` (extends AbstractClickHouseIT):
   - Test: GET /api/v1/agents without JWT returns 401 or 403
   - Test: GET /api/v1/agents with valid JWT returns 200
   - Test: GET /api/v1/health without JWT returns 200 (public)
   - Test: POST /api/v1/data/executions without JWT returns 401 or 403
   - Test: Request with expired JWT returns 401 or 403
   - Test: Request with malformed JWT returns 401 or 403

   `BootstrapTokenIT` (extends AbstractClickHouseIT):
   - Test: POST /register without bootstrap token returns 401
   - Test: POST /register with wrong bootstrap token returns 401
   - Test: POST /register with correct bootstrap token returns 200 with tokens
   - Test: POST /register with previous bootstrap token returns 200 (dual-token rotation)

   `RegistrationSecurityIT` (extends AbstractClickHouseIT):
   - Test: Registration response contains non-null `serverPublicKey` (Base64 string)
   - Test: Registration response contains `accessToken` and `refreshToken`
   - Test: Access token from registration can be used to access protected endpoints

   `JwtRefreshIT` (extends AbstractClickHouseIT):
   - Test: POST /agents/{id}/refresh with valid refresh token returns new access token
   - Test: POST /agents/{id}/refresh with expired refresh token returns 401
   - Test: POST /agents/{id}/refresh with access token (wrong type) returns 401
   - Test: POST /agents/{id}/refresh with mismatched agent ID returns 401
   - Test: New access token from refresh can access protected endpoints
cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify - All 17 existing ITs pass with JWT authentication - SecurityFilterIT: protected endpoints reject unauthenticated requests, public endpoints remain open - BootstrapTokenIT: registration requires valid bootstrap token, supports dual-token rotation - RegistrationSecurityIT: registration returns public key + tokens - JwtRefreshIT: refresh flow issues new access tokens, rejects invalid refresh tokens - Full `mvn clean verify` is green mvn clean verify All existing tests pass with JWT auth. New security ITs validate protected/public endpoint split, bootstrap token flow, registration security, and refresh flow.

<success_criteria>

  • Protected endpoints return 401/403 without JWT, 200 with valid JWT
  • Public endpoints (health, register, docs) remain accessible without JWT
  • Registration requires bootstrap token, returns accessToken + refreshToken + serverPublicKey
  • Refresh endpoint issues new access JWT from valid refresh token
  • SSE endpoint accepts JWT via query parameter
  • All 17 existing ITs adapted and passing
  • 4 new security ITs passing </success_criteria>
After completion, create `.planning/phases/04-security/04-02-SUMMARY.md`