feat(sse)!: always reject commands targeting agents without requireSignedCommands

BREAKING: removes cameleer.server.security.enforce-signed-commands flag — was
default false. Now always on. Agent team is coordinated; pre-Phase-4 agents
must upgrade to advertise requireSignedCommands=true before they can receive
operator-issued single-target or group commands. Broadcast path is unchanged
(best-effort fan-out; signed payloads silently ignored by pre-verification
agents until they upgrade).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-29 13:47:36 +02:00
parent bf79a6f5ae
commit 7f095dc26c
5 changed files with 22 additions and 98 deletions

View File

@@ -45,5 +45,10 @@ re-serializing, and Ed25519-verifying against the resulting bytes — this requi
byte-equivalence between the server-signed bytes and the agent-reconstructed bytes.
The `requireSignedCommands` capability bit on `AgentInfo` (set from
`AgentRegistrationRequest`) is currently informational only — server-side gating
on this bit is a future release per the agent team's migration sequence step 4.
`AgentRegistrationRequest`) is hard-enforced: `AgentCommandController` refuses
(409 Conflict) any single-target or group command sent to an agent that has not
advertised `requireSignedCommands=true`. The broadcast path (`POST /api/v1/agents/commands`)
is exempt — blocking an ops broadcast because one old agent is still in the fleet is
too disruptive; signed payloads are silently ignored by pre-verification agents until
they upgrade. E4 warn + metric (`recordCommandToNonCapableAgent`) fire for all
non-capable targets even when the 409 guard triggers, giving operators observability.

View File

@@ -4,7 +4,6 @@ import io.cameleer.server.app.agent.SseConnectionManager;
import io.cameleer.server.app.dto.CommandAckRequest;
import io.cameleer.server.app.metrics.ServerMetrics;
import io.cameleer.server.app.security.AgentOwnershipGuard;
import io.cameleer.server.app.security.SecurityProperties;
import io.cameleer.server.app.dto.CommandBroadcastResponse;
import io.cameleer.server.app.dto.CommandGroupResponse;
import io.cameleer.server.app.dto.CommandRequest;
@@ -73,7 +72,6 @@ public class AgentCommandController {
private final AuditService auditService;
private final AgentOwnershipGuard ownershipGuard;
private final ServerMetrics serverMetrics;
private final SecurityProperties securityProperties;
public AgentCommandController(AgentRegistryService registryService,
SseConnectionManager connectionManager,
@@ -81,8 +79,7 @@ public class AgentCommandController {
AgentEventService agentEventService,
AuditService auditService,
AgentOwnershipGuard ownershipGuard,
ServerMetrics serverMetrics,
SecurityProperties securityProperties) {
ServerMetrics serverMetrics) {
this.registryService = registryService;
this.connectionManager = connectionManager;
this.objectMapper = objectMapper;
@@ -90,7 +87,6 @@ public class AgentCommandController {
this.auditService = auditService;
this.ownershipGuard = ownershipGuard;
this.serverMetrics = serverMetrics;
this.securityProperties = securityProperties;
}
@PostMapping("/{id}/commands")
@@ -109,7 +105,7 @@ public class AgentCommandController {
CommandType type = mapCommandType(request.type());
// E4: warn when targeting a non-capable agent (one that won't verify the signature)
// E4: warn + metric when targeting a non-capable agent (one that won't verify the signature)
if (!agent.requireSignedCommands()) {
log.warn("Operator sent {} command to agent {} which does not advertise requireSignedCommands=true. " +
"The agent will accept the signed payload but won't actually verify the signature. Upgrade the agent.",
@@ -117,12 +113,11 @@ public class AgentCommandController {
serverMetrics.recordCommandToNonCapableAgent(type.name());
}
// E5: hard-enforce when flag is enabled
if (securityProperties.isEnforceSignedCommands() && !agent.requireSignedCommands()) {
// Hard-enforce: refuse to send commands to agents that have not advertised requireSignedCommands=true
if (!agent.requireSignedCommands()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Refusing to send " + type + " command to agent " + id + ": agent does not advertise " +
"requireSignedCommands=true and cameleer.server.security.enforce-signed-commands is enabled. " +
"Upgrade the agent or disable enforcement.");
"requireSignedCommands=true. Upgrade the agent.");
}
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";
@@ -169,13 +164,12 @@ public class AgentCommandController {
nonCapableIds.forEach(agentId -> serverMetrics.recordCommandToNonCapableAgent(type.name()));
}
// E5: refuse the whole batch — loud failure is better than silent partial fan-out
if (securityProperties.isEnforceSignedCommands() && !nonCapableIds.isEmpty()) {
// Hard-enforce: refuse the whole batch — loud failure is better than silent partial fan-out
if (!nonCapableIds.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"Refusing to send " + type + " command to group " + group + ": " +
nonCapableIds.size() + " agent(s) do not advertise requireSignedCommands=true " +
"and cameleer.server.security.enforce-signed-commands is enabled. " +
"Non-capable agents: " + nonCapableIds + ". Upgrade the agents or disable enforcement.");
nonCapableIds.size() + " agent(s) do not advertise requireSignedCommands=true. " +
"Non-capable agents: " + nonCapableIds + ". Upgrade the agents.");
}
String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}";

View File

@@ -22,7 +22,6 @@ public class SecurityProperties {
private String uiOrigin;
private String jwtSecret;
private String corsAllowedOrigins;
private boolean enforceSignedCommands = false;
private Oidc oidc = new Oidc();
public static class Oidc {
@@ -59,8 +58,6 @@ public class SecurityProperties {
public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; }
public String getCorsAllowedOrigins() { return corsAllowedOrigins; }
public void setCorsAllowedOrigins(String corsAllowedOrigins) { this.corsAllowedOrigins = corsAllowedOrigins; }
public boolean isEnforceSignedCommands() { return enforceSignedCommands; }
public void setEnforceSignedCommands(boolean enforceSignedCommands) { this.enforceSignedCommands = enforceSignedCommands; }
public Oidc getOidc() { return oidc; }
public void setOidc(Oidc oidc) { this.oidc = oidc; }

View File

@@ -1,70 +0,0 @@
package io.cameleer.server.app.controller;
import io.cameleer.server.app.AbstractPostgresIT;
import io.cameleer.server.app.TestSecurityHelper;
import io.cameleer.server.core.agent.AgentRegistryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* E4/E5 integration test — enforce-signed-commands=false (default).
* Non-capable agents must receive a 202 and the command must be enqueued.
*/
@TestPropertySource(properties = "cameleer.server.security.enforce-signed-commands=false")
class AgentCommandEnforcementDisabledIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private AgentRegistryService registryService;
private String operatorJwt;
@BeforeEach
void setUp() {
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
operatorJwt = securityHelper.operatorToken();
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test
void sendCommand_nonCapableAgent_returns202WhenEnforcementDisabled() throws Exception {
String agentId = "enforce-off-" + UUID.randomUUID().toString().substring(0, 8);
// requireSignedCommands=false (the default overload)
registryService.register(agentId, agentId, "test-app", "default", "1.0", List.of(), Map.of());
ResponseEntity<String> response = sendConfigUpdate(agentId);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("commandId")).isTrue();
}
private ResponseEntity<String> sendConfigUpdate(String agentId) {
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>("""
{"type": "config-update", "payload": {"key": "value"}}
""", securityHelper.authHeaders(operatorJwt)),
String.class);
}
}

View File

@@ -13,7 +13,6 @@ import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import java.util.Map;
@@ -22,11 +21,10 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* E4/E5 integration test enforce-signed-commands=true (hard gate).
* E4/E5 integration test requireSignedCommands enforcement is always-on.
* Non-capable agents must receive 409; capable agents must still receive 202.
*/
@TestPropertySource(properties = "cameleer.server.security.enforce-signed-commands=true")
class AgentCommandEnforcementEnabledIT extends AbstractPostgresIT {
class AgentCommandEnforcementIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@@ -47,8 +45,8 @@ class AgentCommandEnforcementEnabledIT extends AbstractPostgresIT {
}
@Test
void sendCommand_nonCapableAgent_returns409WhenEnforcementEnabled() {
String agentId = "enforce-on-noncap-" + UUID.randomUUID().toString().substring(0, 8);
void sendCommand_nonCapableAgent_returns409() {
String agentId = "enforce-noncap-" + UUID.randomUUID().toString().substring(0, 8);
registryService.register(agentId, agentId, "test-app", "default", "1.0", List.of(), Map.of());
ResponseEntity<String> response = sendConfigUpdate(agentId);
@@ -57,8 +55,8 @@ class AgentCommandEnforcementEnabledIT extends AbstractPostgresIT {
}
@Test
void sendCommand_capableAgent_returns202WhenEnforcementEnabled() throws Exception {
String agentId = "enforce-on-cap-" + UUID.randomUUID().toString().substring(0, 8);
void sendCommand_capableAgent_returns202() throws Exception {
String agentId = "enforce-cap-" + UUID.randomUUID().toString().substring(0, 8);
// requireSignedCommands=true agent has been upgraded to verify signatures
registryService.register(agentId, agentId, "test-app", "default", "2.0", List.of(), Map.of(), true);