Files
cameleer-server/.planning/phases/04-security/04-02-PLAN.md

294 lines
18 KiB
Markdown
Raw Normal View History

---
phase: 04-security
plan: 02
type: execute
wave: 2
depends_on: ["04-01"]
files_modified:
- 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
autonomous: true
requirements:
- SECU-01
- SECU-02
- SECU-05
must_haves:
truths:
- "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"
artifacts:
- path: "cameleer-server-app/src/main/java/com/cameleer/server/app/security/JwtAuthenticationFilter.java"
provides: "OncePerRequestFilter extracting JWT from header or query param"
- path: "cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java"
provides: "SecurityFilterChain with permitAll for public paths, authenticated for rest"
- path: "cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java"
provides: "Updated register endpoint with bootstrap token validation, JWT issuance, public key"
key_links:
- from: "JwtAuthenticationFilter"
to: "JwtService.validateAndExtractAgentId"
via: "Filter delegates JWT validation to service"
pattern: "jwtService\\.validateAndExtractAgentId"
- from: "SecurityConfig"
to: "JwtAuthenticationFilter"
via: "addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)"
pattern: "addFilterBefore"
- from: "AgentRegistrationController.register"
to: "BootstrapTokenValidator.validate"
via: "Validates bootstrap token before processing registration"
pattern: "bootstrapTokenValidator\\.validate"
- from: "AgentRegistrationController.register"
to: "JwtService.createAccessToken + createRefreshToken"
via: "Issues tokens in registration response"
pattern: "jwtService\\.create(Access|Refresh)Token"
---
<objective>
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.
</objective>
<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>
<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
<interfaces>
<!-- From Plan 01 (will exist after execution): -->
From core/security/JwtService.java:
```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:
```java
public interface Ed25519SigningService {
String sign(String payload);
String getPublicKeyBase64();
}
```
From app/security/BootstrapTokenValidator.java:
```java
public class BootstrapTokenValidator {
public boolean validate(String provided);
}
```
From app/security/SecurityProperties.java:
```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:
```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);
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: SecurityFilterChain + JwtAuthenticationFilter + registration/refresh integration</name>
<files>
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
</files>
<action>
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.
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean compile -pl cameleer-server-app</automated>
</verify>
<done>
- 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
</done>
</task>
<task type="auto">
<name>Task 2: Security integration tests + existing test adaptation</name>
<files>
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
</files>
<action>
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
</action>
<verify>
<automated>cd /c/Users/Hendrik/Documents/projects/cameleer-server && mvn clean verify</automated>
</verify>
<done>
- 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
</done>
</task>
</tasks>
<verification>
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.
</verification>
<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>
<output>
After completion, create `.planning/phases/04-security/04-02-SUMMARY.md`
</output>