From 8caf688ee46afb90cdd97934fab9a36c656c17f9 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 23 Sep 2026 11:56:30 +0200 Subject: [PATCH 1/4] swarm: revoke an agent's queue credential when it is declared destroyed A per-agent queue credential is minted at agent creation and nothing has ever removed it. An agent declared destroyed loses its container and keeps its credential: a bearer secret recovered from a snapshot or a stale capture still authenticates as that agent, so the set of usable credentials only grows. Delete the path the mint published, on the one transition that ends an agent's life. It mirrors step 3 of `mint_and_verify` and no other step: the leaf, the ACL document and the cert role are what a hive uses to collect an agent's secrets and are re-minted on every run of the mint. Every version, not the newest. The mint rewrites the path when the principal it names needs correcting, so KV v2's plain delete would leave the identical secret readable at ?version=N. That is a separately-ACL'd path, hence the second stanza in the controller's grant -- `delete` on metadata discloses nothing, and `update` on the data path already lets this principal destroy any agent credential's usability. The destroy is not blocked by a failed revocation: the declaration is already published and refusing the call would leave an operator with an agent they cannot tear down. The failure is logged at error instead, naming the agent, since a silent orphan is the fault being removed. --- nix/host-modules/swarm-bao.nix | 26 ++++--- nix/module-eval/bao-grants.nix | 19 +++++ swarm-controller/src/agent_identity.rs | 48 ++++++++++++ swarm-controller/src/main.rs | 46 +++++++++++ swarm-secret-client/src/client.rs | 102 +++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 12 deletions(-) diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 20fe13db..e4a3927d 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -362,18 +362,16 @@ let # ../module-eval.nix cannot read them, and a heredoc would make the HCL's # indentation a function of this file's. # - # The last grant is a different kind from the others: they let the controller - # bootstrap hives, this lets it write an agent's credentials. Two things about - # it do not read as they look. - # - # `secret/data/` is KV v2's ACL prefix, not part of the path the code passes: - # `swarm-secret-client` writes `swarm/agents//...` under mount - # `secret`, and the engine inserts `data/`. Matching the code's spelling - # literally would grant nothing. - # - # `read` too: `mint_and_verify` reads a credential back before writing so a - # re-run keeps the value a live agent already holds instead of rotating it — - # the read is required, not incidental. + # The credential-write grant below (unlike the bootstrap ones above it) has + # three things about its paths that do not read as written. `secret/data/` is + # KV v2's ACL prefix, not part of the path the code passes: + # `swarm-secret-client` writes `swarm/agents//...` under mount `secret`, + # and the engine inserts `data/` — matching the code's spelling literally would + # grant nothing. `read` is required too: `mint_and_verify` reads a credential + # back before writing so a re-run keeps the value a live agent already holds + # instead of rotating it. `metadata/` is the revocation half: `delete` on + # `data/` only soft-deletes the newest version, and `+` being one path segment + # keeps this to the queue leaf alone. # # The swarm appservice token and its own OIDC client secret, read-only: it # uses both and writes neither. matrix-ctl publishes the token @@ -402,6 +400,10 @@ let capabilities = ["create", "read", "update"] } + path "${credentialMountPath}/metadata/swarm/agents/*" { + capabilities = ["delete"] + } + path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" { capabilities = ["read"] } diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index cd66ed66..a02fb9d3 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1147,6 +1147,25 @@ let in lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"read\", \"update\"]" s; } + { + # Revocation, and the reason it is a stanza of its own: `delete` on the + # `data/` path soft-deletes the newest version and leaves earlier ones + # readable, so a credential the mint had ever rewritten would survive it. + # `metadata/` is the path that removes every version, and the store ACLs + # it separately — without this grant the revocation is a 403 and a + # destroyed agent's credential stays valid. + # + # Pinned as the whole capability list for the same reason as the stanza + # above: `read` or `list` here would hand a write-only principal the + # version history of every agent's secrets. + name = "the controller may revoke an agent credential, and only by removing every version of it"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; + in + lib.hasInfix "path \"secret/metadata/swarm/agents/*\" {\n capabilities = [\"delete\"]" s + && !(lib.hasInfix "secret/metadata/*" s); + } { # The swarm appservice token is a homeserver-admin credential. The # controller mints agents' accounts with it and has no business replacing diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index 1bab3eac..3928fbe9 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -220,6 +220,54 @@ pub async fn mint_and_verify(agent: &str) -> Result<()> { Ok(()) } +/// Revoke `agent`'s queue credential: delete the path +/// [`mint_and_verify`]'s step 3 published, and everything ever written at it. +/// +/// The undo of that one step and of no other. The leaf, the ACL document and +/// the cert-auth role that make up the rest of an agent's identity stay where +/// they are — they are what a *hive* uses to collect an agent's secrets, they +/// are minted afresh on every run of the mint, and tearing them down is not +/// what the queue credential outliving its holder is about. +/// +/// **Deletes every version, not the newest one.** The mint rewrites this path +/// whenever the principal it names has to be corrected, so a soft delete would +/// leave the identical secret sitting in version history, readable at +/// `?version=N` by anything that can read the path at all — a value still +/// recoverable has not been revoked. See +/// [`SecretStore::delete_all_versions`][swarm_secret_client::SecretStore::delete_all_versions]. +/// +/// **Idempotent**: revoking an agent that never had a credential, or one +/// already revoked, succeeds and says so. A teardown that runs twice is +/// ordinary, and a second run that failed would be a worse fault than the one +/// this exists to fix. +/// +/// # Errors +/// When the store cannot be reached or refuses the delete. The caller decides +/// what that costs — `set_agent_state` logs it and lets the destroy proceed, +/// since a credential that is still live is a smaller harm than an agent that +/// cannot be torn down. +pub async fn revoke_queue_credential(agent: &str) -> Result<()> { + let queue_path = queue::agent_queue_path(agent)?; + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + store + .delete_all_versions(&queue_path) + .await + .with_context(|| format!("revoking the agent queue credential at {queue_path}"))?; + // At `info` and unconditional: an unlogged revocation is indistinguishable + // from a leak, and this line is the only record an operator has that the + // credential stopped being usable. It cannot say whether one was there — + // the controller's grant on these paths is write-only by design, so it + // deletes blind. + tracing::info!( + agent, + %queue_path, + "agent queue credential revoked: every version of the path deleted" + ); + Ok(()) +} + /// The consumer of everything [`mint_and_verify`] wrote: log in **as the /// agent**, with the leaf just issued, and read back both paths just /// published. diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index cacd1454..32996cc7 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1050,9 +1050,55 @@ async fn set_agent_state( tracing::warn!(hive = %hive, agent = %agent, error = %format!("{e:#}"), "declaring agent state failed"); error_problem(wanted_error_status(&e), &format!("{e:#}")) })?; + + // After the declaration and never before it: the write above is the + // destroy order, and until it lands the agent is not being torn down, so + // its credential has to keep working. Sequenced rather than spawned so + // the revocation attempt is finished — and its outcome logged — by the + // time the operator's call returns. + if revokes_queue_credential(req.state) { + revoke_or_complain(&hive, &agent).await; + } Ok(Json(render(&declaration))) } +/// Whether declaring an agent into `state` ends its queue credential's life. +/// +/// Exhaustive on purpose, like every other match on [`AgentState`] in this +/// tree: a state added later has to say whether it revokes rather than +/// inheriting "no" from a catch-all. `Offline` and `Paused` deliberately do +/// not — both are states an agent comes back from, and revoking on either +/// would mean an agent that could be stopped but never restarted. +fn revokes_queue_credential(state: swarm_queue_client::wanted::AgentState) -> bool { + use swarm_queue_client::wanted::AgentState; + match state { + AgentState::Destroyed => true, + AgentState::Up | AgentState::Offline | AgentState::Paused => false, + } +} + +/// Revoke the credential, and make a failure impossible to miss without +/// letting it stop the teardown. +/// +/// **The destroy is not blocked by this.** The declaration is already +/// published, the hive will converge on it, and refusing the operator's call +/// at this point would leave them with an agent they cannot destroy — the +/// larger of the two harms. What is left is that the failure must not be +/// quiet, or the outcome is the silent orphan this whole path exists to +/// remove: hence `error`, naming the agent, with the cause chain attached. +async fn revoke_or_complain(hive: &str, agent: &str) { + if let Err(e) = agent_identity::revoke_queue_credential(agent).await { + tracing::error!( + hive, + agent, + error = %format!("{e:#}"), + "revoking this agent's queue credential FAILED; it was destroyed anyway, so \ + the credential outlives it and may still authenticate. Declaring the agent \ + destroyed again re-runs this revocation." + ); + } +} + /// What this swarm currently declares for a hive. /// /// Read back from the bucket rather than from a second copy kept here: the diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index 4b5d8e05..98f2002b 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -211,6 +211,41 @@ impl SecretStore { Ok(()) } + /// Delete `path` outright: every version of it, and the metadata that + /// lists them. + /// + /// The undo of [`write`][Self::write], and deliberately **not** KV v2's + /// plain delete. That one soft-deletes the newest version only, leaving + /// every earlier one readable at `?version=N` and the newest one + /// recoverable by `undelete` — so a caller revoking a bearer secret that + /// [`write`][Self::write] has ever replaced would leave the old value + /// sitting in version history, still usable. `metadata` is the one verb + /// that removes the lot. + /// + /// Addresses `secret/metadata/`, which the store ACLs separately + /// from the `secret/data/` the other methods here use: a token + /// granted write on the data path is refused at this one until its policy + /// names the metadata path too. + /// + /// **Idempotent.** Deleting a path that holds nothing succeeds, because + /// the caller is tearing something down and a teardown that runs twice — + /// or on a principal that never got a secret — must not fail the second + /// time. Only a 404 is absence, exactly as in + /// [`read_optional`][Self::read_optional]: a 403 stays an error, or a + /// principal whose grant does not cover the path would report a + /// revocation it never performed. + /// + /// # Errors + /// [`Error::Vault`] for anything that is not a 404 — a denial, or a store + /// that could not be reached. + pub async fn delete_all_versions(&self, path: &str) -> Result<(), Error> { + match vaultrs::kv2::delete_metadata(&self.inner, MOUNT, path).await { + Ok(()) => Ok(()), + Err(e) if is_absent(&e) => Ok(()), + Err(e) => Err(e.into()), + } + } + /// Replace the ACL policy named `name` with `policy`. /// /// A whole-document write, not a merge: the store has no other verb, and @@ -308,6 +343,17 @@ impl SecretStore { } } +/// Whether a failed store call means the path holds nothing — the one status +/// [`SecretStore::delete_all_versions`] is allowed to treat as success. +/// +/// The same rule [`SecretStore::read_optional`] documents, named here because +/// the delete side has to be able to state it in a test: a 403 is a principal +/// whose grant misses the path, and swallowing that would let a caller report +/// a revocation the store refused to perform. +fn is_absent(e: &vaultrs::error::ClientError) -> bool { + matches!(e, vaultrs::error::ClientError::APIError { code: 404, .. }) +} + /// Write an ACL policy, spelled out here because [`vaultrs`] does not have it: /// its `sys::policy` module targets `sys/policy/`, the deprecated alias, /// and the store ACLs that path separately from `sys/policies/acl/`. A @@ -554,4 +600,60 @@ mod tests { assert!(!e.to_string().contains("SECRET-KEY-BYTES"), "{e}"); } } + + /// What makes [`SecretStore::delete_all_versions`] safe to run twice: the + /// second run finds nothing and must still succeed. A teardown that + /// hard-errors on an already-revoked credential is a worse fault than the + /// credential outliving its holder. + #[test] + fn a_path_that_holds_nothing_is_not_a_failed_delete() { + assert!(is_absent(&vaultrs::error::ClientError::APIError { + code: 404, + errors: vec![], + })); + } + + /// The other half, and the one that matters for a revocation: a token + /// whose policy misses the path is refused with a 403, and reading that + /// as "nothing was there" would log a revocation the store never made. + #[test] + fn a_denial_is_not_absence() { + for code in [400, 403, 500, 503] { + assert!( + !is_absent(&vaultrs::error::ClientError::APIError { + code, + errors: vec![], + }), + "{code} must stay a failure" + ); + } + } + + /// Revocation addresses `secret/metadata/…`, not `secret/data/…`. + /// + /// The distinction is the whole point of the verb: the data path's delete + /// leaves earlier versions readable, so revoking a secret that was ever + /// rewritten there would leave the old value recoverable. It is also a + /// separately-ACL'd path, which is why the grant had to gain a stanza. + #[test] + fn a_revocation_targets_every_version_and_not_just_the_newest() { + use rustify::endpoint::Endpoint as _; + + let request = vaultrs::api::kv2::requests::DeleteSecretMetadataRequest::builder() + .mount(MOUNT) + .path("swarm/agents/atlas/queue") + .build() + .expect("the request builds"); + assert_eq!(request.path(), "secret/metadata/swarm/agents/atlas/queue"); + + // The control: the verb deliberately not used, which addresses the + // data path and soft-deletes exactly one version of it. + let soft = vaultrs::api::kv2::requests::DeleteLatestSecretVersionRequest::builder() + .mount(MOUNT) + .path("swarm/agents/atlas/queue") + .build() + .expect("the request builds"); + assert_eq!(soft.path(), "secret/data/swarm/agents/atlas/queue"); + assert_ne!(request.path(), soft.path()); + } } From c21ec7719da9d0891fc94117eede7edf3969b218 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 23 Sep 2026 12:14:58 +0200 Subject: [PATCH 2/4] swarm: tests and docs for the queue-credential revocation Pins the three properties the revocation rests on and cannot check against a store: that only `Destroyed` revokes (a revocation on `Offline` or `Paused` would give an agent that stops and never restarts), that a 404 is absence while a 403 stays a failure, and that the delete addresses `secret/metadata/` -- the path that takes every version, which is the string the grant has to match. docs/swarm/credentials.md gains the revocation section and its table cell stops describing the deletion as something an operator does by hand. --- docs/swarm/credentials.md | 48 ++++++++++++++++++-------- swarm-controller/src/agent_identity.rs | 33 ++++++++++++++++++ swarm-controller/src/main.rs | 26 ++++++++++++++ swarm-secret-client/src/client.rs | 10 ++++-- 4 files changed, 101 insertions(+), 16 deletions(-) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 306f5b57..bc1bc7fa 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -59,20 +59,20 @@ of the cell says how. -| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull | -| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `swarm/agents//matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated | -| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass | -| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts | -| `swarm/agents//bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires | -| `swarm/agents//queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged | ❌ fetched when the container starts. The queue checks the secret only at connect, so an open connection survives a re-mint, but a reconnect before the next restart is denied | -| `swarm/agents//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated | -| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | must be stated | -| `swarm/hives//queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | automatic re-mint | automatic re-pull | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm/agents//matrix/main` | `swarm-controller`, with the swarm's appservice token, at agent creation and in a five-minute pass | the agent container itself, under the certificate its hive passed in | ✅ the pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ `hive-matrix-daemon` exits when the homeserver rejects its token, and a five-minute timer restarts it, which reads the store again | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | must be stated | +| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl`, inside the `hive-matrix` container, once | `swarm-controller`, under its own certificate | ❌ `swarm-matrix-ctl` mints it once; the container keeps its copy and republishes it when the store's differs | ✅ the controller reads it on every five-minute matrix pass | +| `swarm/controller/swarm-controller/oidc/client` | authelia, at its first boot, where the controller registers its client; `swarm-secret-publish` copies it in | `swarm-controller`, under its own certificate, once at start | ❌ authelia mints it once. A re-mint is republished by `swarm-secret-publish`'s path unit | ❌ read once at start; the controller holds the old value until it restarts | +| `swarm/agents//bao-mtls` | the store's agent PKI mount (`deploy.bao.agentPkiMountPath`), which generates the key, at `swarm-controller`'s request at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | ✅ `swarm-controller`'s five-minute pass re-issues a live agent's leaf once it's past half its validity (45 of 90 days, read from the certificate itself) | ❌ `hive-c0re` reads it when it writes the container config, so the agent presents a new leaf from its next start; the old leaf stays valid until it expires | +| `swarm/agents//queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ❌ fetched when the container starts. The queue checks the secret only at connect, so an open connection survives a re-mint, but a reconnect before the next restart is denied — including a reconnect after the credential was revoked | +| `swarm/agents//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | ✅ the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes) | ✅ the agent re-fetches on a 10-minute timer | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | must be stated | +| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | must be stated | +| `swarm/hives//queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | must be stated | @@ -137,6 +137,26 @@ that makes a container require a credential it may not have.** A container whose credential is absent doesn't start — that's deliberate, and it's what makes the backfill a step rather than a suggestion. +**Revoking an agent's queue credential.** Declaring an agent `destroyed` — +`PUT /api/hives/{hive}/agents/{agent}/state` — deletes +`swarm/agents//queue` and every version it ever held, right after the +declaration lands. The credential is a bearer secret, so a soft delete would +leave the same value readable at an older version number; the controller +removes the path's metadata, which takes the versions with it. + +An agent recreated under the same name draws a fresh secret, because the +mint keeps an existing value only when it finds one at that path +(`agent_identity::mint_and_verify`, step 3) and the revocation left nothing +to find. + +Two things this deliberately doesn't do. It doesn't touch the agent's mTLS +leaf, its ACL document or its cert-auth role — the mint rewrites all three on +every run, so a re-created agent gets new ones regardless. And it doesn't +fail the destroy: the declaration is already published by the time the +revocation runs, so a store that refuses the delete gets a `swarm-controller` +log line at `error` naming the agent, and the teardown continues. Declare the +agent destroyed again to re-run the revocation. + **Who reads that row, and what happens to it.** `hive-c0re` reads it every time it writes an agent's container configuration (`lifecycle::agent_identity`), stages the certificate and its key `0600` diff --git a/swarm-controller/src/agent_identity.rs b/swarm-controller/src/agent_identity.rs index 3928fbe9..c36f1e6b 100644 --- a/swarm-controller/src/agent_identity.rs +++ b/swarm-controller/src/agent_identity.rs @@ -364,6 +364,39 @@ mod tests { assert!(!a.contains('='), "{a}"); } + /// Revocation undoes step 3 of the mint and nothing else, and both ends + /// spell the path through one function — the property that keeps a + /// revocation from missing the object it is meant to remove. + /// + /// The second half is what stops this being a tautology: the agent's + /// other published object, the mTLS leaf, is at a different path and is + /// deliberately left alone. Revoking both would take away the identity a + /// re-created agent is re-minted under, for a credential problem that is + /// only about the queue. + #[test] + fn revocation_names_the_path_the_mint_published_and_not_the_leaf_beside_it() { + use swarm_secret_client::{mtls, queue}; + + assert_eq!( + queue::agent_queue_path("atlas").expect("a plain name is legal"), + "swarm/agents/atlas/queue" + ); + assert_ne!( + queue::agent_queue_path("atlas").expect("legal"), + mtls::identity_path("atlas").expect("legal"), + ); + } + + /// A name the store must never be asked to delete under. `revoke_queue_credential` + /// builds its path with the same validating function the mint does, so a + /// traversal is refused before a request is made rather than addressing + /// some other principal's secret. + #[test] + fn a_traversal_never_becomes_a_revocation() { + swarm_secret_client::queue::agent_queue_path("../pr1ma") + .expect_err("a traversal is not a legal agent name"); + } + fn both(k: &str) -> Option { match k { ENV_AGENT_PKI_MOUNT => Some("pki-agents".to_owned()), diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 32996cc7..445de332 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -3308,6 +3308,32 @@ mod tests { ); } + /// `Destroyed` is the one declaration that ends an agent's life, so it is + /// the one that revokes. The credential is a bearer secret: left behind, + /// anything that recovers it still authenticates as an agent that no + /// longer exists. + #[test] + fn declaring_an_agent_destroyed_revokes_its_queue_credential() { + assert!(crate::revokes_queue_credential( + swarm_queue_client::wanted::AgentState::Destroyed + )); + } + + /// The control, and the more expensive half to get wrong: a revocation on + /// `Offline` or `Paused` would give an agent that can be stopped and never + /// restarted. Every non-terminal state is listed rather than sampled, so + /// a state added later shows up here as well as in the match itself. + #[test] + fn no_state_an_agent_comes_back_from_revokes() { + use swarm_queue_client::wanted::AgentState; + for state in [AgentState::Up, AgentState::Offline, AgentState::Paused] { + assert!( + !crate::revokes_queue_credential(state), + "{state:?} is a state an agent returns from" + ); + } + } + /// The whole point of the node: a freshly created agent is declared /// `Paused`, so it does not start driving turns the moment its hive /// brings it up. Any other state here is the bug this fixes. diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index 98f2002b..7a80b89b 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -633,8 +633,14 @@ mod tests { /// /// The distinction is the whole point of the verb: the data path's delete /// leaves earlier versions readable, so revoking a secret that was ever - /// rewritten there would leave the old value recoverable. It is also a - /// separately-ACL'd path, which is why the grant had to gain a stanza. + /// rewritten there would leave the old value recoverable. + /// + /// Asserted on [`vaultrs`]'s own request type rather than on a call, since + /// there is no store to call: it pins the endpoint + /// `kv2::delete_metadata` targets, which is the string + /// `swarm-bao.nix`'s grant has to match. A dependency bump that moved it + /// would otherwise surface as a 403 at the first revocation, far from + /// here. #[test] fn a_revocation_targets_every_version_and_not_just_the_newest() { use rustify::endpoint::Endpoint as _; From 215a8aedc4033f4896353efed0b1893193a79221 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 23 Sep 2026 12:34:21 +0200 Subject: [PATCH 3/4] swarm-controller: link AgentState by path in the revocation doc comment rustdoc runs with -D rustdoc::broken-intra-doc-links and the type is imported inside the function body, not at module scope, so the bare link resolved to nothing and failed the workspace-doc derivation. --- swarm-controller/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 445de332..4adcde3d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1064,7 +1064,8 @@ async fn set_agent_state( /// Whether declaring an agent into `state` ends its queue credential's life. /// -/// Exhaustive on purpose, like every other match on [`AgentState`] in this +/// Exhaustive on purpose, like every other match on +/// [`AgentState`][swarm_queue_client::wanted::AgentState] in this /// tree: a state added later has to say whether it revokes rather than /// inheriting "no" from a catch-all. `Offline` and `Paused` deliberately do /// not — both are states an agent comes back from, and revoking on either From 642be5767897e9788cfe995d1dbb8657cc116969 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 27 Sep 2026 21:55:35 +0200 Subject: [PATCH 4/4] swarm: narrow the revoke grant to the queue leaf, not the whole agent prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revoke_queue_credential only ever deletes swarm/agents//queue (agent_queue_path + a literal "queue" suffix), never anything else under an agent's prefix. secret/metadata/swarm/agents/+/queue matches that exactly — `+` is bao's single-segment glob, the same form swarm-nats-auth's read grant already uses for the data-side path. Also rewords the module-eval test's stale note about a read/list grant handing a "write-only principal" the version history: the controller has held read on secret/data/swarm/agents/* since the mint-and-verify read-before-write change, so it was never write-only on that path. --- nix/host-modules/swarm-bao.nix | 2 +- nix/module-eval/bao-grants.nix | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index e4a3927d..493749c2 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -400,7 +400,7 @@ let capabilities = ["create", "read", "update"] } - path "${credentialMountPath}/metadata/swarm/agents/*" { + path "${credentialMountPath}/metadata/swarm/agents/+/queue" { capabilities = ["delete"] } diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index a02fb9d3..0c4ac164 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1155,15 +1155,19 @@ let # it separately — without this grant the revocation is a 403 and a # destroyed agent's credential stays valid. # - # Pinned as the whole capability list for the same reason as the stanza - # above: `read` or `list` here would hand a write-only principal the - # version history of every agent's secrets. - name = "the controller may revoke an agent credential, and only by removing every version of it"; + # `+` is one path segment, so this reaches `swarm/agents//queue` + # and nothing else an agent holds; `agents/*` would reach every object + # under the prefix, which `revoke_queue_credential` never asks the store + # to delete. Pinned as the whole capability list too: `read` or `list` + # here would let the controller read back the queue-secret version + # history it is meant only to delete. + name = "the controller may revoke an agent's queue credential, and only that one"; ok = let s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script; in - lib.hasInfix "path \"secret/metadata/swarm/agents/*\" {\n capabilities = [\"delete\"]" s + lib.hasInfix "path \"secret/metadata/swarm/agents/+/queue\" {\n capabilities = [\"delete\"]" s + && !(lib.hasInfix "secret/metadata/swarm/agents/*" s) && !(lib.hasInfix "secret/metadata/*" s); } {