diff --git a/Cargo.lock b/Cargo.lock index 28d61b02..bda9c2d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4770,6 +4770,8 @@ name = "swarm-secret-client" version = "0.1.0" dependencies = [ "reqwest", + "rustify", + "rustify_derive", "serde", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml index c7ad7dfb..82bff01f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,11 @@ swarm-queue-client = { path = "swarm-queue-client" } swarm-secret-client = { path = "swarm-secret-client" } thiserror = "2" vaultrs = "0.8" +# vaultrs's transport crates. Direct dependencies because one endpoint is +# defined here rather than by it; both versions must stay the ones vaultrs +# resolves, since its `exec_with_empty` takes *its* `Endpoint` trait. +rustify = "0.7" +rustify_derive = "0.5" tower-http = { version = "0.7", features = ["fs"] } uuid = { version = "1", features = ["v4"] } rmcp = { version = "2", default-features = false, features = [ diff --git a/swarm-secret-client/Cargo.toml b/swarm-secret-client/Cargo.toml index bb59a56e..bf65953d 100644 --- a/swarm-secret-client/Cargo.toml +++ b/swarm-secret-client/Cargo.toml @@ -8,6 +8,10 @@ edition.workspace = true # the `BAO_*` files) rather than by its env defaults, so the dependency is # direct rather than incidental. reqwest.workspace = true +# vaultrs's own endpoint machinery, for the one endpoint it does not +# implement — see `client::WriteAclPolicy`. +rustify.workspace = true +rustify_derive.workspace = true serde.workspace = true thiserror.workspace = true vaultrs.workspace = true diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index be8d6a9b..93891cf4 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -1,5 +1,6 @@ //! A logged-in handle on the store, built from this deployment's environment. +use rustify_derive::Endpoint; use serde::{Serialize, de::DeserializeOwned}; use vaultrs::client::{Client, VaultClient, VaultClientSettingsBuilder}; @@ -164,7 +165,11 @@ impl SecretStore { /// `sys/policies/acl/` — which is what a controller scoped to the /// `hive-*` namespace gets for any other name. pub async fn write_policy(&self, name: &str, policy: &str) -> Result<(), Error> { - vaultrs::sys::policy::set(&self.inner, name, policy).await?; + let request = WriteAclPolicy { + name: name.to_owned(), + policy: policy.to_owned(), + }; + vaultrs::api::exec_with_empty(&self.inner, request).await?; Ok(()) } @@ -202,6 +207,21 @@ impl SecretStore { } } +/// 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 +/// token granted the latter — which is what every grant in this tree names — +/// is refused at the former with a 403. +#[derive(Endpoint, Serialize)] +#[endpoint(path = "sys/policies/acl/{self.name}", method = "PUT")] +struct WriteAclPolicy { + name: String, + /// Marked as the body so `name` stays in the path alone: an untagged field + /// would be serialised into the request too. + #[endpoint(body)] + policy: String, +} + #[cfg(test)] mod tests { use super::*; @@ -274,4 +294,41 @@ mod tests { other => panic!("wanted an Identity error, got {other:?}"), } } + + /// The defect this pins: the store gates `sys/policy/` and + /// `sys/policies/acl/` separately, so a client on the first is + /// refused by a grant naming the second — and nothing else in the tree + /// says which one is addressed. + #[test] + fn a_policy_write_addresses_the_modern_acl_path() { + use rustify::endpoint::Endpoint as _; + + let request = WriteAclPolicy { + name: "hive-pr1ma".to_owned(), + policy: crate::policy::render(), + }; + assert_eq!(request.path(), "sys/policies/acl/hive-pr1ma"); + } + + #[test] + fn the_request_body_carries_the_policy_and_not_the_name() { + use rustify::endpoint::Endpoint as _; + + let request = WriteAclPolicy { + name: "hive-pr1ma".to_owned(), + policy: crate::policy::render(), + }; + let body = request + .body() + .expect("the body serialises") + .expect("a policy write sends one"); + let sent: serde_json::Value = + serde_json::from_slice(&body).expect("the body is the JSON the store parses"); + assert_eq!(sent["policy"], crate::policy::render()); + assert!( + sent.get("name").is_none(), + "`name` addresses the policy in the path; sending it too would make \ + the store's copy of the document disagree with its own name" + ); + } }