swarm-secret-client: write hive policies to the modern ACL path
`write_policy` called `vaultrs::sys::policy::set`, which targets `sys/policy/<name>` — the deprecated alias the store gates as a path of its own. Every grant in this tree names `sys/policies/acl/hive-*`, so a correctly-scoped controller was refused with a 403 and no hive read policy has ever been written, on any deployment. The doc comment three lines above the call already named the modern path; the code is what moves to meet it. vaultrs has no endpoint for that path (`grep policies/acl` over 0.8.0: zero hits, against 8 for `sys/policy`), so this defines one over its own endpoint machinery — which keeps the client's token header and `/v1` prefix middleware rather than re-deriving them. The alternative was to widen the grant to cover the legacy path. This way needs no policy change at all: the deployed grant already permits the write, so no store is re-bootstrapped and nothing is entrenched on an alias upstream has deprecated. Two tests pin the path and the body shape. The reason this survived deployment is that nothing in the tree ever asserted either one. Closes #4177.
This commit is contained in:
parent
61f60d0039
commit
560f727797
4 changed files with 69 additions and 1 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4770,6 +4770,8 @@ name = "swarm-secret-client"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"rustify",
|
||||
"rustify_derive",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<name>` — 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/<name>`, the deprecated alias,
|
||||
/// and the store ACLs that path separately from `sys/policies/acl/<name>`. 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/<name>` and
|
||||
/// `sys/policies/acl/<name>` 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue