Files
cameleer-server/docs/superpowers/plans/2026-04-29-security-fixes-and-sse-command-signing.md

62 KiB

Security Fixes + SSE Command Signing 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: Close the four security findings from the 2026-04-29 review and ship the universal SSE command signing required by the agent team's handoff (C:\Users\Hendrik\Documents\projects\cameleer\docs\server-team-sse-command-signing.md).

Architecture: Four independently-shippable phases, each ending in a release-tagged commit:

  • Phase 1 — Authorization bypass on /api/v1/agents/{id}/... endpoints. Centralize a requireAgentOwnership(id, request) guard and apply it to SSE, heartbeat, deregister, and command-ack handlers.
  • Phase 2 — Refresh-token role retention. Plug the three holes: (a) validateRefreshToken ignores token_revoked_before; (b) removeRoleFromUser / group ops don't bump it; (c) refresh lifts roles from the token claim. Re-read effective roles from the DB and bump revocation on every RBAC mutation.
  • Phase 3 — Insecure default admin credentials in application.yml. Drop the :admin defaults and add a @PostConstruct startup guard that fails-fast on the literal admin/admin pair when no OIDC issuer is configured.
  • Phase 4 — Universal SSE command signing per agent handoff §"Migration sequence" step 1. The signing infrastructure (SsePayloadSigner) already wraps every payload routed through SseConnectionManager.onCommandReady; this phase audits that all 7 command types produce signable JSON-object payloads, adds canonicalization byte-equivalence tests, adds nonces where missing, and surfaces the new requireSignedCommands capability bit on RegistrationRequest so the rollout sequence can be enforced server-side later.

Tech Stack: Java 17, Spring Boot 3.4.3 (spring-security, spring-web), Nimbus JOSE JWT, BouncyCastle Ed25519, Jackson 2.x, JUnit 5, Spring MockMvc, Testcontainers (PostgreSQL + ClickHouse via AbstractPostgresIT), Maven Surefire/Failsafe.

Branch / worktree: Work on a new branch security-fixes-2026-04-29 off main. Do NOT commit execution-api-response.json or overlay-screenshot.png already present in the repo (these are local debugging artifacts — exclude with .gitignore if they reappear).

Build commands (from repo root):

  • Fast unit-only loop while iterating tasks: mvn -pl cameleer-server-app -am verify -DskipITs
  • Full pre-commit pass on a phase (includes Testcontainers ITs): mvn verify
  • Single test class: mvn -pl cameleer-server-app -Dtest=ClassName test
  • Single integration test class: mvn -pl cameleer-server-app -Dit.test=ClassNameIT verify -DskipUTs=true

Commit convention (matches recent git log): <type>(<scope>): <subject>. Use fix(security): ... for finding fixes and feat(sse): ... for the signing work.

After every code-changing commit, the project requires:

  • Run gitnexus_detect_changes() (per CLAUDE.md self-check) to confirm scope.
  • Re-run npx gitnexus analyze (handled by PostToolUse hook for git commits, but worth a sanity check between phases).

File Structure

Phase 1 (agent endpoint authorization)

  • Create: cameleer-server-app/src/main/java/io/cameleer/server/app/security/AgentOwnershipGuard.java — pure helper that compares JwtValidationResult.subject() to a path-id and throws ResponseStatusException(403) on mismatch. One responsibility; no Spring beans needed beyond @Component.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentSseController.java — apply guard before any registry side effects.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentRegistrationController.java — apply guard to heartbeat and deregister.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java — apply guard to acknowledgeCommand.
  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/AgentOwnershipGuardTest.java — pure unit tests.
  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentEndpointOwnershipIT.java — integration test asserting cross-agent JWTs receive 403 on each endpoint.

Phase 2 (refresh-token role retention)

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtServiceImpl.java — make validateRefreshToken honor token_revoked_before (extract a shared assertNotRevoked(JwtValidationResult) private method consumed by both validate paths, OR move the check into a Filter-level + refresh-controller-level pair — see Task 8 for chosen design).
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/security/UiAuthController.java — in refresh, re-read roles from rbacService.getSystemRoleNames(userId) rather than copying result.roles().
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/UserAdminController.java — bump users.token_revoked_before on assignRoleToUser, removeRoleFromUser, addUserToGroup, removeUserFromGroup, deleteUser. (resetPassword already does this.)
  • Modify (read-only verification): cameleer-server-app/src/main/java/io/cameleer/server/app/controller/GroupAdminController.java, RoleAdminController.java, ClaimMappingAdminController.java — inspect; if any of these mutate role/group→role mappings transitively, add the same revocation bump for affected users.
  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/RefreshTokenRevocationIT.java — IT that exercises the privesc loop and confirms it is closed.

Phase 3 (default credentials)

  • Modify: cameleer-server-app/src/main/resources/application.yml — drop literal defaults to empty.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java — add @PostConstruct validator that rejects uipassword == "admin" (banned-list-of-1 for now; more banned values are a separate task) when OIDC.issueruri is unset.
  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/SecurityPropertiesValidationTest.java — pure unit tests for the validator.
  • Modify: HOWTO.md — note in the "Initial setup" section that CAMELEER_SERVER_SECURITY_UIUSER and CAMELEER_SERVER_SECURITY_UIPASSWORD MUST be set, or OIDC must be configured.

Phase 4 (universal SSE command signing)

  • Modify (audit only first): cameleer-server-core/src/main/java/io/cameleer/server/core/agent/AgentCommand.java and the AgentCommandType enum — confirm payload field is a String of JSON, and that all command emission sites pre-serialize a JSON object (not a raw value).
  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseCommandSigningContractTest.java — for each of the 7 command types, build a representative payload, push it through SsePayloadSigner.signPayload, then run the same parse-remove-reserialize flow that DefaultCommandHandler#verifyCommandSignature uses on the agent (per the handoff doc §"Verification logic on the agent") and assert byte-identity of the canonical form.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SsePayloadSigner.java — strengthen the failure mode: today on serialization failure it returns the unsigned payload (line 73-74); change to throw so silent unsigned commands cannot reach the wire when signing is required.
  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java — the onCommandReady path already calls signPayload unconditionally; verify and add an assertion-style log if a payload is non-object.
  • Modify: the command-emit sites for replay and route-control (search the codebase) to confirm the existing UUIDv4 nonce field is still emitted in addition to the signature; add a nonce field to set-taps, test-expression, set-traced-processors, and deep-trace for replay-attack defense (per handoff §"Why" — signatures are additive, nonces remain).
  • Modify: RegistrationRequest (in cameleer-server-core/src/main/java/io/cameleer/server/core/agent/) and AgentInfo to surface the requireSignedCommands capability bit advertised by future agents. No enforcement in this PR — this is the data plumbing, the gating is migration-sequence step 4 (server release N+2).

Phase 1 — Agent endpoint authorization fixes (Vulns 1 & 4)

Phase outcome: Any AGENT JWT is restricted to operating on its own agentId. Cross-agent SSE hijack, heartbeat spoof, deregister, and ack-forgery all return 403.

Task 1.1: Test-first — AgentOwnershipGuard unit test

Files:

  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/AgentOwnershipGuardTest.java

  • Step 1: Write the failing test

package io.cameleer.server.app.security;

import io.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.server.ResponseStatusException;

import java.time.Instant;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AgentOwnershipGuardTest {

    private final AgentOwnershipGuard guard = new AgentOwnershipGuard();

    @Test
    void allowsWhenSubjectMatchesPathId() {
        HttpServletRequest request = requestWith(jwt("agent-A"));
        guard.requireAgentOwnership("agent-A", request); // does not throw
    }

    @Test
    void rejectsWhenSubjectMismatchesPathId() {
        HttpServletRequest request = requestWith(jwt("agent-A"));
        assertThatThrownBy(() -> guard.requireAgentOwnership("agent-B", request))
                .isInstanceOf(ResponseStatusException.class)
                .extracting(e -> ((ResponseStatusException) e).getStatusCode())
                .isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsWhenJwtAttributeMissing() {
        HttpServletRequest request = new MockHttpServletRequest();
        assertThatThrownBy(() -> guard.requireAgentOwnership("agent-A", request))
                .isInstanceOf(ResponseStatusException.class)
                .extracting(e -> ((ResponseStatusException) e).getStatusCode())
                .isEqualTo(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsWhenSubjectIsNull() {
        HttpServletRequest request = requestWith(jwt(null));
        assertThatThrownBy(() -> guard.requireAgentOwnership("agent-A", request))
                .isInstanceOf(ResponseStatusException.class);
    }

    @Test
    void exposesSubjectViaResolvedHelper() {
        HttpServletRequest request = requestWith(jwt("agent-A"));
        assertThat(guard.resolveAgentSubject(request)).isEqualTo("agent-A");
    }

    private JwtValidationResult jwt(String subject) {
        return new JwtValidationResult(subject, "agent", List.of("AGENT"),
                "default", "default-app", Instant.now());
    }

    private HttpServletRequest requestWith(JwtValidationResult result) {
        MockHttpServletRequest req = new MockHttpServletRequest();
        req.setAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR, result);
        return req;
    }
}

Note: The JwtValidationResult constructor signature above mirrors the record fields used in JwtAuthenticationFilter.tryInternalToken (subject, tokenType, roles, environment, application, issuedAt). If your branch's record has additional fields, copy them from the existing usage and update this test verbatim — do NOT introduce a new constructor.

  • Step 2: Run the test to verify it fails

Run: mvn -pl cameleer-server-app -Dtest=AgentOwnershipGuardTest test Expected: compile failure (AgentOwnershipGuard does not exist).

Task 1.2: Implement AgentOwnershipGuard

Files:

  • Create: cameleer-server-app/src/main/java/io/cameleer/server/app/security/AgentOwnershipGuard.java

  • Step 1: Write the minimal implementation

package io.cameleer.server.app.security;

import io.cameleer.server.core.security.JwtService.JwtValidationResult;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;

/**
 * Verifies that the authenticated JWT subject matches the {@code {id}} path
 * variable on agent self-service endpoints. Required wherever an AGENT-role
 * caller could otherwise act on another agent's identity.
 */
@Component
public class AgentOwnershipGuard {

    public void requireAgentOwnership(String pathId, HttpServletRequest request) {
        String subject = resolveAgentSubject(request);
        if (subject == null || !subject.equals(pathId)) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN,
                    "Agent token does not own the requested agent id");
        }
    }

    public String resolveAgentSubject(HttpServletRequest request) {
        Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR);
        if (attr instanceof JwtValidationResult result) {
            return result.subject();
        }
        return null;
    }
}
  • Step 2: Run the test to verify it passes

Run: mvn -pl cameleer-server-app -Dtest=AgentOwnershipGuardTest test Expected: PASS (5 tests).

  • Step 3: Commit
git add cameleer-server-app/src/main/java/io/cameleer/server/app/security/AgentOwnershipGuard.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/security/AgentOwnershipGuardTest.java
git commit -m "feat(security): introduce AgentOwnershipGuard for agent-id JWT subject check"

Task 1.3: Wire guard into AgentSseController (Vuln 1 fix)

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentSseController.java

  • Step 1: Edit the controller

Replace the current constructor and events body with the snippet below. Specifically:

  1. Add AgentOwnershipGuard to the constructor.
  2. Call ownershipGuard.requireAgentOwnership(id, httpRequest) as the FIRST line of events, before the registry lookup.
  3. Keep the existing auto-heal branch logic for the case where findById returns null — it remains correct (and now defense-in-depth) once ownership is already enforced.

Show the resulting file body of events and the constructor:

private final SseConnectionManager connectionManager;
private final AgentRegistryService registryService;
private final AgentOwnershipGuard ownershipGuard;

public AgentSseController(SseConnectionManager connectionManager,
                           AgentRegistryService registryService,
                           AgentOwnershipGuard ownershipGuard) {
    this.connectionManager = connectionManager;
    this.registryService = registryService;
    this.ownershipGuard = ownershipGuard;
}

@GetMapping(value = "/{id}/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "Open SSE event stream",
        description = "Opens a Server-Sent Events stream for the specified agent. "
                + "Commands (config-update, deep-trace, replay) are pushed as events. "
                + "Ping keepalive comments sent every 15 seconds.")
@ApiResponse(responseCode = "200", description = "SSE stream opened")
@ApiResponse(responseCode = "403", description = "Agent token does not own the requested id")
@ApiResponse(responseCode = "404", description = "Agent not registered and cannot be auto-registered")
public SseEmitter events(
        @PathVariable String id,
        @Parameter(description = "Last received event ID (no replay, acknowledged only)")
        @RequestHeader(value = "Last-Event-ID", required = false) String lastEventId,
        HttpServletRequest httpRequest) {

    ownershipGuard.requireAgentOwnership(id, httpRequest);

    AgentInfo agent = registryService.findById(id);
    if (agent == null) {
        var jwtResult = (JwtService.JwtValidationResult) httpRequest.getAttribute(
                JwtAuthenticationFilter.JWT_RESULT_ATTR);
        // ownershipGuard already verified jwtResult.subject().equals(id); auto-heal
        // is now safe to run unconditionally.
        String application = jwtResult.application() != null ? jwtResult.application() : "default";
        String env = jwtResult.environment() != null ? jwtResult.environment() : "default";
        registryService.register(id, id, application, env, "unknown", List.of(), Map.of());
        log.info("Auto-registered agent {} (app={}, env={}) from SSE connect after server restart", id, application, env);
    }

    if (lastEventId != null) {
        log.debug("Agent {} reconnecting with Last-Event-ID: {} (no replay)", id, lastEventId);
    }

    return connectionManager.connect(id);
}
  • Step 2: Run the existing AgentSseController tests if any

Run: mvn -pl cameleer-server-app -Dtest='AgentSse*Test' test Expected: PASS (or "no tests found" — that's fine; the cross-controller IT in Task 1.6 covers the scenario).

Task 1.4: Wire guard into AgentRegistrationController.heartbeat and deregister (Vuln 4)

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentRegistrationController.java

  • Step 1: Add the guard to constructor and call sites

Add AgentOwnershipGuard ownershipGuard to the constructor (final field). At the top of heartbeat(...) and deregister(...), before any other side effect, insert:

ownershipGuard.requireAgentOwnership(id, httpRequest);

Both methods already accept HttpServletRequest httpRequest (search to confirm; if not, add the parameter — Spring will inject it). Do NOT change register (that's the bootstrap-token-protected path) or refresh (that has its own JWT subject check that should be reviewed but is out of scope for this phase — note in PR description as follow-up).

  • Step 2: Run repository unit tests for the controller

Run: mvn -pl cameleer-server-app -Dtest='AgentRegistration*Test' test Expected: PASS.

Task 1.5: Wire guard into AgentCommandController.acknowledgeCommand

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java

  • Step 1: Add guard call

Add AgentOwnershipGuard ownershipGuard to the constructor (final field). At the top of acknowledgeCommand(...), before any registry mutation:

ownershipGuard.requireAgentOwnership(agentId, httpRequest);

Confirm the parameter is agentId (per app-classes.md controller path is POST /{agentId}/commands/{commandId}/ack); if it's named id, use that. Inject HttpServletRequest httpRequest if absent.

Do NOT add the guard to the operator fan-out endpoints (POST /{agentId}/commands, POST /groups/{group}/commands, POST /commands broadcast, POST /{agentId}/replay) — those are operator-role endpoints, not agent-self-service. Their authorization is ROLE_OPERATOR via SecurityConfig, which is correct.

  • Step 2: Run controller unit tests

Run: mvn -pl cameleer-server-app -Dtest='AgentCommand*Test' test Expected: PASS.

Task 1.6: Cross-controller integration test

Files:

  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentEndpointOwnershipIT.java

  • Step 1: Write the failing IT

package io.cameleer.server.app.controller;

import io.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
class AgentEndpointOwnershipIT extends AbstractPostgresIT {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Autowired
    private TestAgentJwtMinter jwtMinter; // see helper note below

    private MockMvc mockMvc;

    @org.junit.jupiter.api.BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity())
                .build();
    }

    @Test
    void sseEndpointReturns403WhenSubjectMismatchesPathId() throws Exception {
        String agentAjwt = jwtMinter.mintAgentToken("agent-A");

        mockMvc.perform(get("/api/v1/agents/agent-B/events")
                        .header("Authorization", "Bearer " + agentAjwt))
                .andExpect(status().isForbidden());
    }

    @Test
    void heartbeatReturns403WhenSubjectMismatchesPathId() throws Exception {
        String agentAjwt = jwtMinter.mintAgentToken("agent-A");

        mockMvc.perform(post("/api/v1/agents/agent-B/heartbeat")
                        .header("Authorization", "Bearer " + agentAjwt)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"environmentId\":\"default\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    void deregisterReturns403WhenSubjectMismatchesPathId() throws Exception {
        String agentAjwt = jwtMinter.mintAgentToken("agent-A");

        mockMvc.perform(post("/api/v1/agents/agent-B/deregister")
                        .header("Authorization", "Bearer " + agentAjwt))
                .andExpect(status().isForbidden());
    }

    @Test
    void ackCommandReturns403WhenSubjectMismatchesPathId() throws Exception {
        String agentAjwt = jwtMinter.mintAgentToken("agent-A");

        mockMvc.perform(post("/api/v1/agents/agent-B/commands/00000000-0000-0000-0000-000000000001/ack")
                        .header("Authorization", "Bearer " + agentAjwt)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"status\":\"SUCCESS\",\"message\":\"forged\"}"))
                .andExpect(status().isForbidden());
    }

    @Test
    void sseEndpointReturns200WhenSubjectMatchesPathId() throws Exception {
        String agentAjwt = jwtMinter.mintAgentToken("agent-A");

        mockMvc.perform(get("/api/v1/agents/agent-A/events")
                        .header("Authorization", "Bearer " + agentAjwt)
                        .accept(MediaType.TEXT_EVENT_STREAM))
                .andExpect(status().isOk());
    }
}

Helper note: If TestAgentJwtMinter does not already exist in the test sources (search cameleer-server-app/src/test/java -name '*Jwt*Minter*'), create it as a @TestComponent that wraps the existing JwtService.createAccessToken(subject, "agent", List.of("AGENT")). If a similar utility already exists with a different name (e.g. JwtTestSupport), use that instead — do not duplicate. Document the chosen helper in this task's commit message.

  • Step 2: Run the IT to verify it passes (now that Phase 1 fixes are in place)

Run: mvn -pl cameleer-server-app -Dit.test=AgentEndpointOwnershipIT verify -DskipUTs=true Expected: 5/5 PASS.

  • Step 3: Commit Phase 1
git add cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentSseController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentRegistrationController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentEndpointOwnershipIT.java
git commit -m "fix(security): require agent JWT subject to match path id on /agents/{id}/{events,heartbeat,deregister,commands/*/ack}"
  • Step 4: Pre-merge gitnexus check

Run: npx gitnexus detect_changes --scope=staged (or via the MCP tool gitnexus_detect_changes). Expected: only the four controllers + new guard + new test files. No collateral changes.


Phase 2 — Refresh-token role retention (Vuln 2)

Phase outcome: A demoted user cannot retain their old roles past the next request. RBAC mutations bump token_revoked_before. Refresh re-reads roles from the DB. validateRefreshToken honors revocation.

Task 2.1: Test-first — refresh-after-demotion IT

Files:

  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/RefreshTokenRevocationIT.java

  • Step 1: Write the failing IT

package io.cameleer.server.app.security;

import io.cameleer.server.app.AbstractPostgresIT;
import io.cameleer.server.core.rbac.RbacService;
import io.cameleer.server.core.rbac.SystemRole;
import io.cameleer.server.core.security.JwtService;
import io.cameleer.server.core.security.UserInfo;
import io.cameleer.server.core.security.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import java.time.Instant;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
class RefreshTokenRevocationIT extends AbstractPostgresIT {

    @Autowired private WebApplicationContext webApplicationContext;
    @Autowired private JwtService jwtService;
    @Autowired private RbacService rbacService;
    @Autowired private UserRepository userRepository;

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .apply(org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity())
                .build();
        // Seed user "alice" with ADMIN role
        userRepository.upsert(new UserInfo("alice", "local", "alice@example.com", "Alice", Instant.now()));
        rbacService.assignRoleToUser("alice", SystemRole.ADMIN_ID);
    }

    @Test
    void refreshAfterRoleRemovalDoesNotRetainOldRoles() throws Exception {
        // Alice as ADMIN gets a refresh token with roles=[ADMIN]
        String refreshToken = jwtService.createRefreshToken("user:alice", "user", java.util.List.of("ADMIN"));

        // Admin removes ADMIN role (this MUST bump token_revoked_before)
        rbacService.removeRoleFromUser("alice", SystemRole.ADMIN_ID);
        userRepository.revokeTokensBefore("alice", Instant.now().plusMillis(1)); // simulating what UserAdminController will do post-fix

        // Refresh attempt must fail (revoked) OR succeed with reduced roles only
        mockMvc.perform(post("/api/v1/auth/refresh")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"refreshToken\":\"" + refreshToken + "\"}"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void refreshLiftsRolesFromDbNotFromToken() throws Exception {
        // Alice currently has ADMIN
        String refreshToken = jwtService.createRefreshToken("user:alice", "user", java.util.List.of("ADMIN"));

        // Forge a token claiming OPERATOR roles too — DB should win
        // (Test relies on createRefreshToken signing whatever roles we pass; refresh handler should ignore them.)

        // No revocation bump: ensure refresh still works for the unchanged user
        mockMvc.perform(post("/api/v1/auth/refresh")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"refreshToken\":\"" + refreshToken + "\"}"))
                .andExpect(status().isOk());
    }
}
  • Step 2: Run the IT — first scenario must FAIL pre-fix

Run: mvn -pl cameleer-server-app -Dit.test=RefreshTokenRevocationIT verify -DskipUTs=true Expected: refreshAfterRoleRemovalDoesNotRetainOldRoles FAILS (status 200 instead of 401) — this is the privesc Vuln 2 demonstrates.

Task 2.2: Make validateRefreshToken honor revocation

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/security/JwtServiceImpl.java

  • Step 1: Audit — locate the validate methods

Open JwtServiceImpl.java and find both validateAccessToken(...) and validateRefreshToken(...). Confirm JwtAuthenticationFilter.tryInternalToken (lines 94-103) is the ONLY current caller of the revocation check. The fix: extract a private method assertNotRevoked(JwtValidationResult) and call it from both validate methods inside JwtServiceImpl, OR add a dedicated refresh-time check inside UiAuthController.refresh. Choose the latter — JwtServiceImpl lives in core which doesn't know about UserRepository. Move the check to UiAuthController.refresh (and its OIDC counterpart) before re-issuing tokens.

  • Step 2: Implement in UiAuthController.refresh and add helper

Add UserRepository userRepository is already injected. Update the refresh method body to:

JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
if (!result.subject().startsWith("user:")) {
    throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not a UI token");
}

String userId = stripSubjectPrefix(result.subject());

// Revocation check: parallels JwtAuthenticationFilter.tryInternalToken's check on the access path.
userRepository.findById(userId).ifPresent(user -> {
    Instant revoked = user.tokenRevokedBefore();
    if (revoked != null && result.issuedAt() != null && result.issuedAt().isBefore(revoked)) {
        throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
    }
});

// Re-read effective roles from the DB instead of trusting the refresh token claim.
List<String> roles = rbacService.getSystemRoleNames(userId);
if (roles.isEmpty()) {
    roles = List.of("VIEWER");
}

String accessToken = jwtService.createAccessToken(result.subject(), "user", roles);
String refreshToken = jwtService.createRefreshToken(result.subject(), "user", roles);

String displayName = userRepository.findById(userId)
        .map(UserInfo::displayName)
        .orElse(userId);
auditService.log(result.subject(), "token_refresh", AuditCategory.AUTH, null, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken, displayName, null));
  • Step 3: Apply the same revocation + re-read pattern to OidcAuthController.refresh

Search for the OIDC refresh handler (OidcAuthController or wherever /api/v1/auth/oidc/refresh is wired). Apply the same:

  1. Check token_revoked_before against the refresh token's iat.
  2. Re-read roles via rbacService.getSystemRoleNames(userId) instead of preserving from the refresh token.

If OIDC refresh is not implemented in this codebase (search for the path; OIDC may rely on the IdP's refresh flow instead), document that finding in the commit message and skip.

Task 2.3: Bump token_revoked_before on RBAC mutations

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/UserAdminController.java

  • Step 1: Identify mutation sites

Open UserAdminController.java. Identify these handlers:

  • assignRoleToUser — typically POST /api/v1/admin/users/{id}/roles
  • removeRoleFromUser — typically DELETE /api/v1/admin/users/{id}/roles/{roleId}
  • addUserToGroupPOST /api/v1/admin/users/{id}/groups
  • removeUserFromGroupDELETE /api/v1/admin/users/{id}/groups/{groupId}
  • deleteUserDELETE /api/v1/admin/users/{id}

(The exact method names may differ; map by HTTP-verb + path.)

  • Step 2: After each rbacService.* mutation succeeds, bump revocation

For each of the five handlers above, after the rbacService.*(...) call and BEFORE the auditService.log(...) call, add:

userRepository.revokeTokensBefore(userId, Instant.now().plusMillis(1));

userId here is the unprefixed form already used by these endpoints (the path variable id). The +1ms guard is consistent with the existing logout handler in UiAuthController.java:199.

For deleteUser: skip the bump if the user row no longer exists (the delete already invalidates everything by removing the FK target). Alternatively, bump BEFORE the delete to invalidate any in-flight tokens still referenced by other code paths.

  • Step 3: Audit GroupAdminController and RoleAdminController

Open GroupAdminController.java (modifies group_roles — affects ALL users in the group) and RoleAdminController.java (deleting a role affects every user assigned to it). For each mutation that changes who has what role, the set of affected users must each get their token_revoked_before bumped.

In GroupAdminController.addRoleToGroup / removeRoleFromGroup, fetch all users in the group via rbacService.getGroupMembers(groupId) and bulk-bump:

List<String> affectedUserIds = rbacService.getGroupMembers(groupId);
Instant now = Instant.now().plusMillis(1);
for (String uid : affectedUserIds) {
    userRepository.revokeTokensBefore(uid, now);
}

If getGroupMembers does not exist on RbacService, add it as part of this task — it's a single SQL SELECT user_id FROM user_groups WHERE group_id = ?.

For RoleAdminController.deleteRole, fetch users via SELECT DISTINCT user_id FROM user_roles WHERE role_id = ? UNION SELECT DISTINCT ug.user_id FROM user_groups ug JOIN group_roles gr ON gr.group_id = ug.group_id WHERE gr.role_id = ? and bulk-bump.

For ClaimMappingAdminController, mutations there affect mappings used at OIDC login time only — they do NOT affect users with currently-issued tokens (those tokens carry roles already). No revocation bump needed; document this rationale in a code comment to prevent the question from re-arising.

Task 2.4: Run the IT — privesc must now be closed

  • Step 1: Re-run the IT from Task 2.1

Run: mvn -pl cameleer-server-app -Dit.test=RefreshTokenRevocationIT verify -DskipUTs=true Expected: both tests PASS. The refreshAfterRoleRemovalDoesNotRetainOldRoles test should now return 401 because (a) removeRoleFromUser bumps revocation and (b) refresh checks it.

Task 2.5: Commit Phase 2

  • Step 1: Stage and commit
git add cameleer-server-app/src/main/java/io/cameleer/server/app/security/UiAuthController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/UserAdminController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/GroupAdminController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/RoleAdminController.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/security/OidcAuthController.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/security/RefreshTokenRevocationIT.java
git commit -m "fix(security): re-read roles from DB on refresh and revoke tokens on RBAC mutations"

(Add only the files actually modified.)

  • Step 2: Run the full verify pass

Run: mvn -pl cameleer-server-app -am verify Expected: green. If any pre-existing tests break, the most likely cause is a test that relied on refresh preserving forged roles — fix or annotate as appropriate.


Phase 3 — Default admin credentials (Vuln 3)

Phase outcome: A boot with no env vars and no OIDC issuer fails-fast with a clear error. admin/admin no longer logs in anyone.

Task 3.1: Test-first — SecurityProperties validator unit test

Files:

  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/security/SecurityPropertiesValidationTest.java

  • Step 1: Write the failing test

package io.cameleer.server.app.security;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class SecurityPropertiesValidationTest {

    @Test
    void rejectsLiteralAdminPasswordWhenOidcUnset() {
        SecurityProperties props = new SecurityProperties();
        props.setUiUser("admin");
        props.setUiPassword("admin");
        props.setOidcIssuerUri(null);

        assertThatThrownBy(props::validateOnStartup)
                .isInstanceOf(IllegalStateException.class)
                .hasMessageContaining("uipassword");
    }

    @Test
    void allowsAdminPasswordWhenOidcConfigured() {
        SecurityProperties props = new SecurityProperties();
        props.setUiUser("admin");
        props.setUiPassword("admin");
        props.setOidcIssuerUri("https://idp.example.com/oidc");

        // OIDC is the canonical login path; local-admin escape hatch is
        // documented as last-resort, so allow the literal default in that mode.
        props.validateOnStartup(); // does not throw
    }

    @Test
    void allowsBlankPassword() {
        SecurityProperties props = new SecurityProperties();
        props.setUiUser("");
        props.setUiPassword("");
        props.setOidcIssuerUri(null);

        // Blank means the env-match path is disabled (login.java line 95-98 short-circuits);
        // operators must rely on DB-provisioned users.
        props.validateOnStartup(); // does not throw
    }

    @Test
    void allowsNonAdminPassword() {
        SecurityProperties props = new SecurityProperties();
        props.setUiUser("admin");
        props.setUiPassword("a-real-secret-from-an-env-var");
        props.setOidcIssuerUri(null);

        props.validateOnStartup(); // does not throw
    }

    @Test
    void rejectsAdminPasswordCaseInsensitive() {
        SecurityProperties props = new SecurityProperties();
        props.setUiPassword("ADMIN");
        props.setOidcIssuerUri(null);

        assertThatThrownBy(props::validateOnStartup)
                .isInstanceOf(IllegalStateException.class);
    }
}
  • Step 2: Run the test to verify it fails

Run: mvn -pl cameleer-server-app -Dtest=SecurityPropertiesValidationTest test Expected: compile failure (validateOnStartup does not exist).

Task 3.2: Implement validator + drop literal defaults

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java

  • Modify: cameleer-server-app/src/main/resources/application.yml

  • Step 1: Drop the :admin literal defaults in application.yml

Edit application.yml lines 85-86. Change:

uiuser: ${CAMELEER_SERVER_SECURITY_UIUSER:admin}
uipassword: ${CAMELEER_SERVER_SECURITY_UIPASSWORD:admin}

to:

uiuser: ${CAMELEER_SERVER_SECURITY_UIUSER:}
uipassword: ${CAMELEER_SERVER_SECURITY_UIPASSWORD:}

(Empty default. The blank-guard in UiAuthController.login at lines 95-98 already short-circuits when blank, so this alone closes the trivial-default attack.)

  • Step 2: Add @PostConstruct validator to SecurityProperties

In SecurityProperties.java, add the @PostConstruct method validateOnStartup:

import jakarta.annotation.PostConstruct;
import java.util.Set;

private static final Set<String> BANNED_PASSWORDS = Set.of(
        "admin", "password", "changeme", "default"
);

@PostConstruct
public void validateOnStartup() {
    String pwd = uiPassword == null ? "" : uiPassword.trim();
    if (pwd.isEmpty()) {
        return; // env-match path is disabled by the blank guard in UiAuthController
    }

    boolean banned = BANNED_PASSWORDS.contains(pwd.toLowerCase(java.util.Locale.ROOT));
    boolean oidcConfigured = oidcIssuerUri != null && !oidcIssuerUri.isBlank();

    if (banned && !oidcConfigured) {
        throw new IllegalStateException(
                "Refusing to start: cameleer.server.security.uipassword is a banned default ('" + pwd + "'). "
                + "Set CAMELEER_SERVER_SECURITY_UIPASSWORD to a strong value, or configure OIDC via "
                + "CAMELEER_SERVER_SECURITY_OIDCISSUERURI to use SSO instead.");
    }
}

(If oidcIssuerUri is not currently a property of SecurityProperties, search for where the OIDC issuer URI lives — likely in OidcConfig or a sibling @ConfigurationProperties class — and inject it. The simplest path: read cameleer.server.security.oidcissueruri directly from the Spring environment via ApplicationContextAware or, cleaner, accept it as a constructor parameter from Environment in a @Configuration class that calls the validator manually rather than relying on @PostConstruct. Pick whichever fits the existing code; the test just needs setOidcIssuerUri(...) to wire through.)

  • Step 3: Run the unit test

Run: mvn -pl cameleer-server-app -Dtest=SecurityPropertiesValidationTest test Expected: 5/5 PASS.

Task 3.3: Document the requirement in HOWTO.md

Files:

  • Modify: HOWTO.md

  • Step 1: Add a "Bootstrap admin credentials" subsection

Find the "Initial setup" or "Configuration" section. Add or update:

### Bootstrap admin credentials

The server provides two ways to authenticate the first admin:

1. **Environment variables (recommended):** Set `CAMELEER_SERVER_SECURITY_UIUSER` and
   `CAMELEER_SERVER_SECURITY_UIPASSWORD` to a strong username + password. On first
   login the user is upserted into the `users` table with the ADMIN role.

2. **OIDC:** Set `CAMELEER_SERVER_SECURITY_OIDCISSUERURI` and the matching
   `CAMELEER_SERVER_SECURITY_OIDCCLIENTID` / `CAMELEER_SERVER_SECURITY_OIDCCLIENTSECRET`.
   The first user matching an admin claim mapping rule becomes ADMIN.

The server **refuses to start** if neither is set AND the password is one of the
banned defaults (`admin`, `password`, `changeme`, `default`). If you want a true
"no local accounts" deployment (OIDC-only), leave `CAMELEER_SERVER_SECURITY_UIPASSWORD`
unset — the env-match login path is disabled when the password is blank.
  • Step 2: Commit Phase 3
git add cameleer-server-app/src/main/resources/application.yml \
        cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/security/SecurityPropertiesValidationTest.java \
        HOWTO.md
git commit -m "fix(security): drop admin/admin defaults and fail-fast on banned passwords without OIDC"
  • Step 3: Smoke-test boot fails as expected

Run from a clean shell:

unset CAMELEER_SERVER_SECURITY_UIUSER
unset CAMELEER_SERVER_SECURITY_UIPASSWORD
unset CAMELEER_SERVER_SECURITY_OIDCISSUERURI
mvn -pl cameleer-server-app spring-boot:run -DskipTests

Wait, the new defaults are blank (not "admin"), so this should now START SUCCESSFULLY (env-match disabled, no banned-pw violation). Then verify the banned-password path:

export CAMELEER_SERVER_SECURITY_UIPASSWORD=admin
mvn -pl cameleer-server-app spring-boot:run -DskipTests

Expected: startup ABORTED with the IllegalStateException message.

export CAMELEER_SERVER_SECURITY_UIPASSWORD=a-real-strong-password
mvn -pl cameleer-server-app spring-boot:run -DskipTests

Expected: starts cleanly. Stop the server (Ctrl-C) once verified.


Phase 4 — Universal SSE command signing

Phase outcome: All 7 SSE command types ship a valid Ed25519 signature field with a canonical form byte-equivalent to the agent's parse-remove-reserialize verification step. requireSignedCommands capability bit plumbed through RegistrationRequest for the migration sequence step 4 enforcement (later release).

Task 4.1: Audit — locate every command emit site

  • Step 1: Map command types to emit code paths

Search:

grep -rn "AgentCommand\|AgentCommandType\|enqueueCommand" cameleer-server-app/src/main/java cameleer-server-core/src/main/java

Confirm the 7 types from the handoff doc:

  1. config-update
  2. replay
  3. route-control
  4. set-taps
  5. test-expression
  6. set-traced-processors
  7. deep-trace

For each, identify the controller / service that constructs the AgentCommand and the JSON payload string. Confirm every payload is built via objectMapper.writeValueAsString(<some POJO/record>) — i.e. the result IS a JSON object ({ ... }), not a JSON array or a primitive. If any command emits a non-object payload, that is the gap that prevents SsePayloadSigner.signPayload from adding a signature field (line 64-71: it returns the unsigned payload when node is not an ObjectNode).

Document findings in a scratch file (e.g. cameleer-server-app/src/test/resources/sse-command-payload-shapes.md, NOT committed). For each command list: emit method, payload class, current shape (object/array/primitive), nonce-present? (UUIDv4).

Task 4.2: Test-first — canonicalization byte-equivalence contract test

Files:

  • Create: cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseCommandSigningContractTest.java

  • Step 1: Write the contract test

package io.cameleer.server.app.agent;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.cameleer.server.core.security.Ed25519SigningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.util.Base64;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * Contract test for the agent team's "byte-identical canonical form" requirement
 * (cameleer/docs/server-team-sse-command-signing.md §"Canonicalization rule").
 *
 * For each of the 7 command types, this test:
 *   1. Builds a representative payload.
 *   2. Signs via SsePayloadSigner.
 *   3. Performs the agent-side parse-remove-signature-reserialize step.
 *   4. Asserts the reserialized form equals the original signed input
 *      (this is what DefaultCommandHandler#verifyCommandSignature on the
 *      agent feeds into Ed25519 verification).
 */
class SseCommandSigningContractTest {

    private SsePayloadSigner signer;
    private ObjectMapper mapper;
    private byte[] publicKeyBytes;

    @BeforeEach
    void setUp() throws Exception {
        // Use the shared CameleerJson mapper config to match what the agent runs.
        // If io.cameleer.common.json.CameleerJson is available on the test classpath,
        // prefer that. Else replicate its config inline.
        mapper = io.cameleer.common.json.CameleerJson.mapper();

        KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
        KeyPair kp = kpg.generateKeyPair();
        publicKeyBytes = kp.getPublic().getEncoded();

        Ed25519SigningService signingService = new Ed25519SigningService(kp.getPrivate());
        signer = new SsePayloadSigner(signingService, mapper);
    }

    static Stream<org.junit.jupiter.params.provider.Arguments> commandPayloads() {
        return Stream.of(
            org.junit.jupiter.params.provider.Arguments.of("config-update",
                "{\"sensitiveKeys\":[\"password\",\"token\"],\"appConfig\":{\"foo\":\"bar\"}}"),
            org.junit.jupiter.params.provider.Arguments.of("replay",
                "{\"correlationId\":\"abc-123\",\"duration\":\"PT5M\",\"nonce\":\"550e8400-e29b-41d4-a716-446655440000\"}"),
            org.junit.jupiter.params.provider.Arguments.of("route-control",
                "{\"routeId\":\"route-1\",\"action\":\"stop\",\"nonce\":\"550e8400-e29b-41d4-a716-446655440001\"}"),
            org.junit.jupiter.params.provider.Arguments.of("set-taps",
                "{\"taps\":[{\"routeId\":\"r1\",\"expr\":\"${header.foo}\"}],\"nonce\":\"550e8400-e29b-41d4-a716-446655440002\"}"),
            org.junit.jupiter.params.provider.Arguments.of("test-expression",
                "{\"expr\":\"${body}\",\"language\":\"simple\",\"nonce\":\"550e8400-e29b-41d4-a716-446655440003\"}"),
            org.junit.jupiter.params.provider.Arguments.of("set-traced-processors",
                "{\"processorIds\":[\"p1\",\"p2\"],\"nonce\":\"550e8400-e29b-41d4-a716-446655440004\"}"),
            org.junit.jupiter.params.provider.Arguments.of("deep-trace",
                "{\"durationSeconds\":300,\"nonce\":\"550e8400-e29b-41d4-a716-446655440005\"}")
        );
    }

    @ParameterizedTest(name = "[{index}] {0}")
    @MethodSource("commandPayloads")
    void signedPayloadMatchesAgentSideCanonicalForm(String commandType, String unsignedJson) throws Exception {
        // 1. Sign
        String signedJson = signer.signPayload(unsignedJson);
        assertThat(signedJson)
                .as("signer must add a signature field for command %s", commandType)
                .contains("\"signature\":");

        // 2. Agent-side: parse, remove signature, reserialize
        JsonNode node = mapper.readTree(signedJson);
        String signatureBase64 = node.path("signature").asText();
        ObjectNode copy = ((ObjectNode) node).deepCopy();
        copy.remove("signature");
        String reSerialized = mapper.writeValueAsString(copy);

        // 3. Byte-identity check (the handoff doc's hard requirement)
        assertThat(reSerialized)
                .as("reserialized form must byte-match the input for %s", commandType)
                .isEqualTo(unsignedJson);

        // 4. Verify the signature actually validates against the original input
        Signature verifier = Signature.getInstance("Ed25519");
        verifier.initVerify(java.security.KeyFactory.getInstance("Ed25519")
                .generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyBytes)));
        verifier.update(reSerialized.getBytes(java.nio.charset.StandardCharsets.UTF_8));
        boolean valid = verifier.verify(Base64.getDecoder().decode(signatureBase64));
        assertThat(valid).as("Ed25519 signature must verify for %s", commandType).isTrue();
    }
}
  • Step 2: Run the test

Run: mvn -pl cameleer-server-app -Dtest=SseCommandSigningContractTest test Expected: each parameterized invocation either passes or fails. Failures will tell you which command shapes don't survive the parse-remove-reserialize round-trip — those are the canonicalization bugs.

The most likely failure: input JSON has a different field order than mapper.writeValueAsString(copy) produces. The mapper preserves parsed insertion order, so the input JSON has to be written in the same field order Jackson emits. If a command type uses a record whose Jackson serialization differs from the order you typed in the test, fix the test's input JSON to match the record's emission order. The contract is "the wire bytes the server signs == the bytes the server transmits, modulo the added signature field" — the test's input JSON is meant to mirror the server's pre-signature serialization output, not arbitrary input.

Task 4.3: Strengthen SsePayloadSigner failure mode

Files:

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SsePayloadSigner.java

  • Step 1: Replace silent fallthrough with throw

Today (lines 64-75) the signer returns the unsigned payload when:

  • The payload is not a JSON object (line 67-71)
  • Serialization fails (line 72-74)

After this fix, the agent will REJECT unsigned commands. So the signer must never silently emit unsigned payloads — that path becomes the agent dropping the command in production.

Change the body to:

public String signPayload(String jsonPayload) {
    if (jsonPayload == null || jsonPayload.isEmpty() || jsonPayload.isBlank()) {
        throw new IllegalArgumentException("Cannot sign null/empty/blank SSE command payload");
    }

    try {
        String signatureBase64 = ed25519SigningService.sign(jsonPayload);
        JsonNode node = objectMapper.readTree(jsonPayload);
        if (!(node instanceof ObjectNode objectNode)) {
            throw new IllegalArgumentException(
                    "SSE command payload must be a JSON object, was: " + node.getNodeType());
        }
        objectNode.put("signature", signatureBase64);
        return objectMapper.writeValueAsString(objectNode);
    } catch (RuntimeException e) {
        throw e;
    } catch (Exception e) {
        throw new IllegalStateException("Failed to sign SSE command payload", e);
    }
}
  • Step 2: Update the existing SsePayloadSignerTest if any tests assumed the old silent behavior

Open cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SsePayloadSignerTest.java. Anywhere the test asserts signPayload(...) returns the unsigned input on null/empty/non-object, switch to assertThatThrownBy(...). The "happy path" tests should still pass unchanged.

  • Step 3: Run the existing signer test

Run: mvn -pl cameleer-server-app -Dtest=SsePayloadSignerTest test Expected: PASS after the assertions are flipped.

Task 4.4: Audit SseConnectionManager.onCommandReady for impact

Files:

  • Modify (only if a command type passes a non-object payload): cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java

  • Step 1: Confirm signer is called unconditionally

Open SseConnectionManager.java. Confirm line 162 is still String signedPayload = ssePayloadSigner.signPayload(command.payload()); and that NO branch in onCommandReady skips the signer.

If any code path constructs a non-object payload (per Task 4.1's audit), fix the emit site to wrap it in a JSON object. Example: a command whose body was historically a JSON string "some-id" becomes {"id":"some-id"}. This is a wire format change — coordinate with the agent team if any of the 7 commands need shape changes (most should already be objects per the handoff doc's payload examples).

Task 4.5: Add nonces to commands that lack them

Files:

  • Modify: each command-emit code path identified in Task 4.1 that lacks a nonce field.

  • Step 1: Add nonce (UUIDv4) to set-taps, test-expression, set-traced-processors, deep-trace

For each, locate the payload-construction site (a record constructor, a Map.of(...) builder, or a ObjectNode.put(...) chain). Add UUID.randomUUID().toString() as a nonce field. Verify the receiving agent (cameleer repo) will tolerate the new field — Jackson defaults to ignoring unknown properties on records, so this should be backwards-compatible.

If the payload is a record, add a String nonce field; deserialize agent-side requires no change because the agent's CameleerJson mapper is configured with FAIL_ON_UNKNOWN_PROPERTIES=false (verify in cameleer repo if you have access; the handoff's reference verification snippet doesn't reject extras).

  • Step 2: Update the contract test inputs to match emitted shapes

If your nonce field lands in a different position than the existing record fields would emit, edit commandPayloads() in SseCommandSigningContractTest to mirror the actual emission order. Re-run the test.

Task 4.6: Plumb requireSignedCommands capability

Files:

  • Modify: cameleer-server-core/src/main/java/io/cameleer/server/core/agent/RegistrationRequest.java (path is best-effort — locate via Glob "**/RegistrationRequest.java")

  • Modify: cameleer-server-core/src/main/java/io/cameleer/server/core/agent/AgentInfo.java

  • Modify: cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentRegistrationController.java

  • Step 1: Add the field to RegistrationRequest

Add a Boolean requireSignedCommands field (Boolean not boolean so old agents that don't send it deserialize as null). Default to null in any constructor that doesn't supply it; treat null as "unspecified, assume false" in any consumer.

  • Step 2: Persist on AgentInfo

Add boolean requireSignedCommands to AgentInfo (default false). Wire RegistrationRequest.requireSignedCommands into the registry call in AgentRegistrationController.register. NO enforcement code yet — this PR just stores the bit. The migration-sequence step 4 (a future server release) will read this bit to refuse to issue unsigned commands when it's set.

  • Step 3: Update OpenAPI

Run: cd ui && npm run generate-api:live (per CLAUDE.md "Regenerating OpenAPI schema").

Commit the regenerated ui/src/api/schema.d.ts and ui/src/api/openapi.json.

Task 4.7: Commit Phase 4

  • Step 1: Run full verify

Run: mvn verify Expected: green.

  • Step 2: Stage and commit
git add cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SsePayloadSigner.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseCommandSigningContractTest.java \
        cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SsePayloadSignerTest.java \
        cameleer-server-core/src/main/java/io/cameleer/server/core/agent/RegistrationRequest.java \
        cameleer-server-core/src/main/java/io/cameleer/server/core/agent/AgentInfo.java \
        cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentRegistrationController.java \
        ui/src/api/schema.d.ts \
        ui/src/api/openapi.json
# plus any command-emit-site edits and SseConnectionManager.java if changed
git commit -m "feat(sse): universally sign all 7 command types and plumb requireSignedCommands capability

Per cameleer/docs/server-team-sse-command-signing.md handoff. Signing is
non-breaking for old agents (they ignore the extra field). Capability bit
on RegistrationRequest is data-plane only; enforcement ships in a later release."

Cross-phase final steps

Task 5.1: Update .claude/rules/

Files:

  • Modify: .claude/rules/app-classes.md — note the new AgentOwnershipGuard and the role of requireAgentOwnership on each affected controller.

  • Modify: .claude/rules/security.md (create if it doesn't exist) — document the refresh-token revocation invariant: "any code path that mutates user_roles, user_groups, or group_roles MUST bump users.token_revoked_before for affected users."

  • Step 1: Edit .claude/rules/app-classes.md

In the AgentSseController, AgentRegistrationController, and AgentCommandController entries, add a sentence: "JWT subject must equal path id — enforced via AgentOwnershipGuard.requireAgentOwnership(...)."

Task 5.2: GitNexus self-check

  • Step 1: Per CLAUDE.md "Self-Check Before Finishing"

Run (via MCP tools):

  1. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) — confirm only the expected files changed across the four phases.
  2. For each modified controller method, gitnexus_impact({target: "<methodName>", direction: "upstream"}) — verify no HIGH/CRITICAL risk surfaces unaddressed.
  3. Re-run npx gitnexus analyze to refresh the index for the next session.

Task 5.3: Open the PR

  • Step 1: Push branch and open PR
git push -u origin security-fixes-2026-04-29

Then via gh (if gh is configured against gitea):

gh pr create --title "Security fixes (2026-04-29 review) + universal SSE command signing" --body "$(cat <<'EOF'
## Summary

Closes the four findings from the 2026-04-29 security review and ships the server-side half of the universal SSE command signing handoff from the agent team.

- **Phase 1 (HIGH):** AgentSseController + heartbeat/deregister/ack now reject cross-agent JWTs (Vulns 1 & 4).
- **Phase 2 (HIGH):** Refresh path re-reads roles from the DB; RBAC mutations bump `token_revoked_before`; refresh validation honors revocation (Vuln 2).
- **Phase 3 (HIGH):** `application.yml` no longer ships `admin/admin` defaults; `SecurityProperties` fails-fast on banned passwords without OIDC (Vuln 3).
- **Phase 4 (feat):** All 7 SSE command types ship a valid Ed25519 signature with byte-equivalent canonical form. `requireSignedCommands` capability bit plumbed (no enforcement yet — that's a future server release per migration sequence step 4).

## Test plan

- [ ] `mvn verify` green.
- [ ] `AgentEndpointOwnershipIT` 5/5 pass.
- [ ] `RefreshTokenRevocationIT` 2/2 pass.
- [ ] `SecurityPropertiesValidationTest` 5/5 pass.
- [ ] `SseCommandSigningContractTest` 7/7 pass.
- [ ] Manual: boot with `CAMELEER_SERVER_SECURITY_UIPASSWORD=admin` aborts; with a strong password starts.
- [ ] Manual: SSE smoke test — connect agent, push config-update, confirm signature verifies on agent side (requires cameleer agent on universal-verification branch).

## Coordination

Per the handoff doc: this is migration-sequence step 1 (server signs all 7 commands). The agent release with universal verification is step 3. Old agents ignore the extra `signature` field on commands they don't currently verify, so this PR is backwards-compatible. Do NOT enforce `requireSignedCommands` in this PR.
EOF
)"

Self-Review

Spec coverage:

Requirement Task
Vuln 1 — SSE cross-agent hijack 1.1, 1.2, 1.3, 1.6
Vuln 2 — refresh role retention 2.1, 2.2, 2.3, 2.4
Vuln 3 — admin/admin defaults 3.1, 3.2, 3.3
Vuln 4 — heartbeat/deregister/ack hijack 1.1, 1.2, 1.4, 1.5, 1.6
SSE handoff §"Signing contract" — all 7 commands 4.1, 4.2, 4.3
SSE handoff §"Canonicalization rule" — byte-identity 4.2
SSE handoff §"Test vector" sanity check 4.2 (the parameterized test embeds it)
SSE handoff §"Migration sequence" step 1 (server-first) Phase 4 as a whole
SSE handoff §"Open question 2" — capability negotiation 4.6
SSE handoff §"Open question 1" — key rotation NOT in this plan — flag in PR for follow-up
SSE handoff §"Open question 3" — audit logging on verification failure NOT in this plan — server already records COMMAND_FAILED events; flag for follow-up

Placeholder scan: None remaining. Each task has runnable code or a specific edit.

Type consistency: JwtValidationResult record fields used identically across Tasks 1.1, 1.2, 2.2. userId is the bare form throughout (per app-classes.md "User ID conventions"). AgentOwnershipGuard.requireAgentOwnership signature consistent across all four controller call sites.

Known follow-ups (NOT in this plan):

  • Handoff Open Question 1 — Key rotation via signed config-update carrying new public key.
  • Handoff Open Question 3 — Server-side alert on signature verification failures (independent feature).
  • AgentRegistrationController.refresh JWT subject check (Phase 1 audit notes it as a follow-up).
  • ClaimMappingAdminController revocation impact analysis (Phase 2 documents the rationale for skipping).