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>
2.7 KiB
Security invariants
Agent self-service endpoint ownership
Every endpoint under /api/v1/agents/{id}/... that is intended for agent self-service
must enforce AgentOwnershipGuard.requireAgentOwnership(id, httpRequest) as its FIRST
statement, before any registry lookup, audit log, or other side effect.
Currently enforced on: SSE events, heartbeat, deregister, command-ack.
Currently NOT enforced (out of scope): register (bootstrap-token-gated), refresh,
replayExchange. Adding the guard there is a follow-up.
RBAC revocation invariant
Any code path that mutates the effective role set of one or more users — including:
user_roles(direct role assignment)user_groups(group membership)group_roles(group's role list)- Role deletion
- User deletion
- Password reset
— MUST bump users.token_revoked_before for each affected user (use
Instant.now().plusMillis(1) to guard against same-millisecond races with token
issuance, since JWT iat is millisecond-quantized).
UiAuthController.refresh and JwtAuthenticationFilter.tryInternalToken both
consult users.token_revoked_before; without the bump on RBAC mutation, refresh-
token role-retention privesc is possible (Vuln 2 of the 2026-04-29 review).
ClaimMappingAdminController mutations are EXEMPT — they affect role assignment at
the next OIDC login (via OidcAuthController.applyClaimMappings), not currently-
issued tokens.
SSE command signing
Every command emitted via SseConnectionManager.onCommandReady is signed with
Ed25519 by SsePayloadSigner. The signer THROWS on null/empty/blank/non-object
input — there is no silent unsigned fallback.
Emit-site contract: build the payload via objectMapper.writeValueAsString(...)
so the input is in Jackson's canonical (no-whitespace, insertion-order) form.
The agent verifies by parsing the wire JSON, removing the signature field,
re-serializing, and Ed25519-verifying against the resulting bytes — this requires
byte-equivalence between the server-signed bytes and the agent-reconstructed bytes.
The requireSignedCommands capability bit on AgentInfo (set from
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.