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/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 20fe13db..493749c2 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/+/queue" { + 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..0c4ac164 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1147,6 +1147,29 @@ 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. + # + # `+` 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/+/queue\" {\n capabilities = [\"delete\"]" s + && !(lib.hasInfix "secret/metadata/swarm/agents/*" 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..c36f1e6b 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. @@ -316,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 cacd1454..4adcde3d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -1050,9 +1050,56 @@ 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`][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 +/// 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 @@ -3262,6 +3309,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 4b5d8e05..7a80b89b 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,66 @@ 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. + /// + /// 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 _; + + 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()); + } }