Repository navigation
fix(fleet): bind each node to the credential that heartbeats it - #1789
Conversation
|
Claude Code Review — skipped: draft PR |
007c2d3 to
ebd1b86
Compare
Claude Code Review ✅ No issues found. Reviewed by |
ebd1b86 to
5ad92a7
Compare
|
@erni-a pelrq ppaove |
5ad92a7 to
e64c431
Compare
SummaryThis PR binds each fleet node to the credential that heartbeats it. It adds an Medium/Low IssuesInstall credentials without a UUID share one binding principalSeverity: Low 🤖 Claude Code PromptReviewed by |
e64c431 to
7afc45e
Compare
|
Addressed the Low finding in 7afc45e, still a single signed-off commit. 🤖 Addressed by Claude Code |
7afc45e to
17272a2
Compare
SummaryThis PR binds each fleet node to the credential that heartbeats it. It adds an Medium/Low IssuesUnbound nodes can be claimed by any narrow credential, so the queue-drain attack still works after migration 055 or a releaseSeverity: Medium 🤖 Claude Code PromptReviewed by |
3158034 to
e9ddad7
Compare
A fleet node was keyed on its caller-supplied node_name alone, so any agent or install credential in a tenant could heartbeat as another node, receive and ack its queued commands (deploy payloads included), report results for its commands and list every node's command payloads (M-85). Migration 055 adds fleet_nodes.owner_principal. fleet_upsert_node binds a new or unbound node to a gateway-verified agent key or install credential in its ON CONFLICT WHERE and refuses a different narrow credential without changing the row; core-api returns 403 FLEET_NODE_BOUND_TO_OTHER_CREDENTIAL. Tenant-wide credentials, including the shared key and caller-asserted X-Agent-ID, still act as any node and take a node back. An install credential without an install UUID is refused rather than sharing a placeholder binding. Command results and listings narrow to the caller's bound nodes. POST /api/v1/fleet/nodes/{node_id}/release lets a tenant credential move a node to a new credential: with bind_agent_id or bind_install_uuid it binds the node at once, leaving no window for another credential, and bare it clears the binding for the next heartbeat. Existing nodes bind on their first heartbeat after the migration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: eldad-caura <eldad@caura.ai>
e9ddad7 to
b42b574
Compare
Claude Code Review ✅ No issues found. Reviewed by |
|
@erni-a ready for human review. CI, CodeQL, DCO and a fresh Claude review (no issues found) all pass on the single commit b42b574. Since the last review:
Both additions were tests-first: the tests-only run 37121821097 failed only the 3 new release tests. Deploy core-storage-api (migration 055) before core-api. Not auto-merging; Eldad merges. 🤖 Posted by Claude Code |
🤖 I have created a release *beep* *boop* --- <details><summary>backend: 3.21.1</summary> ## [3.21.1](backend-v3.21.0...backend-v3.21.1) (2026-10-03) ### Bug Fixes * **fleet:** bind each node to the credential that heartbeats it ([#1789](#1789)) ([078b2c1](078b2c1)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com> Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
…to it (#1792) ## Audit finding Finishes **M-86**. caura PR #1788 bound adapter streams (`cc:` / `cursor:` node ids) to the agent that advances them. Fleet-node windows were still open: an agent or install credential could submit a window for any fleet node of its tenant by naming the node and any `command_id`. That advanced the node's watermark, which only moves forward, and wrote its job doc. ## Change - **Gate.** A window for a fleet node (UUID `node_id`) from an agent or install credential must cite the `interview_request` the scheduler queued for that node, and the node's heartbeat must have delivered it (`acked`). Anything else gets 403 `INTERVIEW_REQUEST_REQUIRED` before the watermark or job doc is touched. This is the same "agent or install credential" test #1788 applies to adapter streams. - **Used once.** Storage claims the request in one conditional UPDATE: this tenant, this node, `interview_request`, `acked`, no `result` yet. The claim writes a marker into `result`, so a second window citing the same id is refused. A used id stops being secret, because the job doc and watermark record it. The node's own result report still overwrites the marker and closes the command. - **Unchanged.** Tenant credentials keep tenant-wide authority, as they do for adapter streams. The plugin already submits once per delivered command and cites its id (`plugin/src/heartbeat.ts`), and the scheduler only looks at `pending` commands, so neither changes. A failed submit is retried by the scheduler as a fresh command, as before. ## Why this relies on caura PR #1789 The request id is the credential here. Since #1789, an agent or install credential can only list or receive commands for nodes bound to it, so it cannot read another node's pending `interview_request` before that node uses it. ## Verification - Route tests cover an uninvited agent and an uninvited install, a queued but undelivered request, one window per request, a request for another node, and a guard that tenant credentials still submit directly. The existing attribution test now cites a delivered request. - Storage tests cover claim once, undelivered, wrong node, wrong tenant, non-interview command, an already completed request, and the node's result report closing a claimed request. - Tests first: the tests-only push ([run 37126305662](https://github.com/caura-ai/caura/actions/runs/37126305662/job/111212158891)) failed exactly the five attack tests against `main`, with 8644 passing. In each one the unfixed route answered `accepted` and moved the node's watermark. The tenant guard and the updated attribution test passed. That step stopped the job before the storage suite ran. The fix is now amended into the same single commit. - No repository scripts, dependency installs, tests or builds were run on Eldad's Mac under its safety hold. CI runs the suites. One signed-off commit. Do not merge or enable auto-merge; Eldad merges after review. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: eldad-caura <eldad@caura.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Audit finding
Addresses M-85: fleet node identity was the caller-supplied
node_name. Any agent or install credential in a tenant could heartbeat as another node, receive and ack its queued commands (deploy payloads included, which an agent credential may not queue itself), report results for any command by UUID, and list every command's payload and result.Change
Each fleet node is now bound to the credential that heartbeats it.
agent:<id>and an install credential asinstall:<uuid>. Every other credential is tenant-wide (tenant): tenant, user and admin keys, the sharedCAURA_API_KEY, standalone mode, and anX-Agent-IDthe caller asserted itself. OSS standalone and shared-key deployments therefore see no change. An install credential that arrives without its install UUID is refused (403INSTALL_UUID_MISSING) rather than bound to a shared placeholder.fleet_upsert_nodedecides in itsON CONFLICT ... WHERE, against the locked row. A new or unbound node takes the caller's principal. A node bound to another narrow credential refuses a narrow caller with 403FLEET_NODE_BOUND_TO_OTHER_CREDENTIAL, and the refused heartbeat changes nothing and delivers nothing. A tenant-wide credential is always admitted and takes the node back.POST /api/v1/fleet/nodes/{node_id}/release(tenant credentials only) moves a node to a new credential, for example after rotating its key. Withbind_agent_idorbind_install_uuidthe node is bound to that credential in the same statement, so no other credential can claim it in between. Bare, it clears the binding and the next heartbeat binds the node.fleet_nodes.owner_principal, with no backfill. Existing nodes bind on their first heartbeat after deploy.Trade-offs (approved policy)
core-storage-api(migration 055) beforecore-api, as the pipeline already does. A newcore-apisendsowner_principal, which an older storage cannot write.Not in this PR
interview_request, used once. It relies on this PR's listing filter, which stops an agent credential reading another node's pending command ids.Verification
tests/test_fleet_node_binding.pyagainst unfixedmain, with 8459 passing. The drain test's response carried the victim node's queued command. The two over-refusal guards passed, as they must without the fix. That step stopped the job before the storage suite ran. The fix is now amended into the same single commit.One signed-off commit. Do not merge or enable auto-merge; Eldad merges after review.
🤖 Generated with Claude Code