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:
@@ -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.
|
||||
|
||||
@@ -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()) : "{}";
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user