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>
294 lines
18 KiB
Markdown
294 lines
18 KiB
Markdown
---
|
|
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>
|