Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/client.rs
atlas d5de1e4943 swarm UI: log an agent into claude from its card; the credentials go to bao and the agent picks them up
A new swarm service, `swarm-claude-login`, in its own container on the host
that sets `deploy.claude-login.enable` (defaulted on by `allSwarmServices`
where the host has the minter's store leaf, like the review classifier).

- swarm-ui: an agent card showing "login needed" gets a "log in" button.
  Its dialog starts the login, shows the URL claude printed, and takes the
  pasted code.
- swarm-controller: `POST/DELETE /api/agents/{name}/claude-login` and
  `POST /api/agents/{name}/claude-login/code`, behind the swarm UI's
  authelia-gated `/api/`, relay the steps to the minter as requests on
  `$SWARM.claude-login.request`.
- swarm-claude-login: runs `claude auth login` over piped stdio with HOME
  and CLAUDE_CONFIG_DIR in a per-login scratch dir, scrapes the first
  https URL, writes the code to stdin, and stores the `.credentials.json`
  at `swarm/agents/<agent>/claude-credentials` with a KV v2 write, a new
  version over any earlier one. The scratch dir goes on every exit path; a
  login waits 15 minutes for its code.
- hive-agent: while parked in `wait_for_login`, reads that leaf every 30 s
  under the agent's own cert and installs it only when its KV version is
  newer than the one recorded in `<harness>/claude-credentials-version`.
  An equal or older version is never put back, because claude rotates the
  refresh token and the stored copy is stale after the first refresh.
- swarm-secret-client: `claude_login` paths and `SecretStore::read_versioned`.
- Credentials: a `claude-login` store leaf minted by glue-bao-tls, a
  `swarm-claude-login` policy (create/update on
  `swarm/agents/+/claude-credentials`, read on its own queue client
  secret), an authelia machine client, and a `--claude-login-client`
  grant in the queue's auth-callout responder. The unit runs as a static
  system user.

The hive's own web-UI login is unchanged.

Refs #5268
2026-10-15 13:26:29 +02:00

789 lines
32 KiB
Rust

//! A logged-in handle on the store, built from this deployment's environment.
use std::time::Duration;
use rustify_derive::Endpoint;
use serde::{Serialize, de::DeserializeOwned};
use vaultrs::{
api::pki::{requests::GenerateCertificateRequest, responses::GenerateCertificateResponse},
client::{Client, VaultClient, VaultClientSettings, VaultClientSettingsBuilder},
};
use crate::{Error, mtls, path::MOUNT};
/// The store's address.
pub const ENV_ADDR: &str = "BAO_ADDR";
/// PEM client certificate presented to the store's listener.
pub const ENV_CLIENT_CERT: &str = "BAO_CLIENT_CERT";
/// PEM private key for [`ENV_CLIENT_CERT`].
pub const ENV_CLIENT_KEY: &str = "BAO_CLIENT_KEY";
/// CA bundle the store's own certificate is verified against. Optional:
/// absent means the system trust store, which is what a deployment with a
/// real CA wants and what a self-signed one must not be left with.
pub const ENV_CACERT: &str = "BAO_CACERT";
/// The auth mount a cert login goes through, unless a caller says otherwise.
pub const DEFAULT_CERT_MOUNT: &str = "cert";
/// Upper bound on one request to the store, login included.
///
/// vaultrs sets no timeout by default, so a store that accepts the connection
/// and never answers would hold every [`SecretStore`] call open forever; with
/// this bound the call fails with [`Error::Vault`] instead.
const VAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Where the store is and which identity we present to it.
///
/// Separate from the connect so the environment can be checked without one:
/// every arm below is a misconfiguration an operator has to read an error
/// about, and none of them needs a reachable store to happen.
#[derive(Debug, PartialEq, Eq)]
pub struct Settings {
address: String,
cert_path: String,
key_path: String,
ca_path: Option<String>,
}
impl Settings {
/// The vaultrs settings for this store, presenting `identity`.
fn client_settings(
&self,
identity: Option<reqwest::Identity>,
) -> Result<VaultClientSettings, Error> {
let mut builder = VaultClientSettingsBuilder::default();
builder
.address(self.address.clone())
.identity(identity)
.timeout(Some(VAULT_REQUEST_TIMEOUT));
if let Some(ca) = &self.ca_path {
builder.ca_certs(vec![ca.clone()]);
}
builder.build().map_err(|e| Error::Settings(e.to_string()))
}
/// Read the `BAO_*` variables from the process environment.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_env() -> Result<Self, Error> {
Self::from_lookup(|k| std::env::var(k).ok())
}
/// [`Settings::from_env`] against an arbitrary lookup.
///
/// [`vaultrs`]'s own defaults are deliberately not used: it looks for
/// `VAULT_ADDR` / `VAULT_CLIENT_CERT` / `VAULT_CLIENT_KEY`, and every unit
/// in this tree sets the `BAO_` spellings. Falling through to those
/// defaults yields a client with **no identity**, which fails at the TLS
/// handshake rather than anywhere that names the cause.
///
/// # Errors
/// [`Error::MissingEnv`] naming the first variable that is unset or empty.
pub fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self, Error> {
let required = |var: &'static str| -> Result<String, Error> {
get(var)
.filter(|v| !v.is_empty())
.ok_or(Error::MissingEnv(var))
};
Ok(Self {
address: required(ENV_ADDR)?,
cert_path: required(ENV_CLIENT_CERT)?,
key_path: required(ENV_CLIENT_KEY)?,
ca_path: get(ENV_CACERT).filter(|v| !v.is_empty()),
})
}
}
/// A client that has already exchanged its certificate for a token.
pub struct SecretStore {
inner: VaultClient,
}
fn read_file(var: &'static str, path: &str) -> Result<Vec<u8>, Error> {
std::fs::read(path).map_err(|source| Error::Identity {
var,
path: path.to_owned(),
source,
})
}
impl SecretStore {
/// Connect using the `BAO_*` environment and log in with the certificate
/// auth method, returning a handle that already holds a token.
///
/// `cert_role` is the role configured on the store's `cert` mount; it
/// selects which policy the returned token carries.
///
/// # Errors
/// As [`SecretStore::connect`], plus [`Error::MissingEnv`].
pub async fn from_env(cert_role: &str) -> Result<Self, Error> {
Self::connect(&Settings::from_env()?, cert_role, DEFAULT_CERT_MOUNT).await
}
/// Present `settings`' identity to the store and log in at `cert_mount`.
///
/// # Errors
/// [`Error::Identity`] when a named file cannot be read, [`Error::Tls`]
/// when the cert/key pair does not form a usable identity,
/// [`Error::Settings`] when the address will not parse, and
/// [`Error::Vault`] when the store refuses the login — which is what an
/// unconfigured `cert` mount looks like from here.
pub async fn connect(
settings: &Settings,
cert_role: &str,
cert_mount: &str,
) -> Result<Self, Error> {
// One PEM blob holding both, which is the shape `Identity::from_pem`
// wants; the two stay separate on disk because the key is the half
// that gets `0600`.
let mut pem = read_file(ENV_CLIENT_CERT, &settings.cert_path)?;
pem.push(b'\n');
pem.extend_from_slice(&read_file(ENV_CLIENT_KEY, &settings.key_path)?);
Self::connect_with_identity(settings, &pem, cert_role, cert_mount).await
}
/// Log in at the store `settings` names, presenting an identity the caller
/// is already holding rather than one named by the environment.
///
/// `identity_pem` is the certificate and its private key concatenated into
/// one PEM blob — the shape [`reqwest::Identity::from_pem`] takes, and the
/// shape [`SecretStore::connect`] builds out of the two files
/// [`ENV_CLIENT_CERT`] and [`ENV_CLIENT_KEY`] name. Only the address and
/// the CA are read out of `settings` here; its two identity paths are not
/// touched.
///
/// This is what a **minter** needs. A process that has just issued a leaf
/// holds the bytes, and the safest place for a freshly minted private key
/// is the memory it was generated in: writing it to a file purely so that
/// a `Settings` could name it would put a key on disk for no other reason
/// than to log in with it once.
///
/// # Errors
/// [`Error::Tls`] when `identity_pem` is not a usable certificate/key
/// pair, [`Error::Settings`] when the address will not parse, and
/// [`Error::Vault`] when the store refuses the login — which is what a
/// certificate no cert-auth role accepts looks like from here.
pub async fn connect_with_identity(
settings: &Settings,
identity_pem: &[u8],
cert_role: &str,
cert_mount: &str,
) -> Result<Self, Error> {
let identity = reqwest::Identity::from_pem(identity_pem).map_err(Error::Tls)?;
let built = settings.client_settings(Some(identity))?;
let mut inner = VaultClient::new(built)?;
let auth = vaultrs::auth::cert::login(&inner, cert_mount, cert_role).await?;
inner.set_token(&auth.client_token);
Ok(Self { inner })
}
/// Read the object stored at `path`, decoded as `T`.
///
/// The shape belongs to whoever owns the path — see [`crate::matrix`] for
/// the one kind of secret that exists — so this moves bytes and knows no
/// fields.
///
/// # Errors
/// [`Error::Vault`] when the path does not exist, the token's policy does
/// not cover it, or the stored object does not decode as `T`.
pub async fn read<T: DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
Ok(vaultrs::kv2::read(&self.inner, MOUNT, path).await?)
}
/// Read the object stored at `path`, or `None` when nothing is stored
/// there.
///
/// The verb for a caller whose write must be a no-op if the path is
/// already populated. [`read`][Self::read] cannot serve that: it collapses
/// *absent*, *denied* and *undecodable* into one `Err`, so a caller
/// treating every failure as absence would overwrite a live credential
/// whenever the store was merely unreachable.
///
/// **Only a 404 is absence.** A denial is a 403 and stays an error — a
/// principal whose grant does not cover the path must not conclude the
/// path is empty, which is how a write ends up clobbering something the
/// caller was never allowed to see.
///
/// # Errors
/// [`Error::Vault`] for anything that is not a 404: a denial, an
/// unreachable store, or an object that does not decode as `T`.
pub async fn read_optional<T: DeserializeOwned>(&self, path: &str) -> Result<Option<T>, Error> {
match vaultrs::kv2::read(&self.inner, MOUNT, path).await {
Ok(value) => Ok(Some(value)),
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// The newest version of the object at `path` and its KV v2 version
/// number, or `None` when nothing is stored there.
///
/// One read, so the number is the one the value was stored as. Every
/// [`write`][Self::write] makes a larger one.
///
/// # Errors
/// As [`read_optional`][Self::read_optional].
pub async fn read_versioned<T: DeserializeOwned>(
&self,
path: &str,
) -> Result<Option<(T, u64)>, Error> {
let endpoint = vaultrs::api::kv2::requests::ReadSecretRequest::builder()
.mount(MOUNT)
.path(path)
.build()
.map_err(|e| Error::Settings(e.to_string()))?;
match vaultrs::api::exec_with_result(&self.inner, endpoint).await {
Ok(res) => {
let value = serde_json::from_value(res.data)
.map_err(|source| vaultrs::error::ClientError::JsonParseError { source })?;
Ok(Some((value, res.metadata.version)))
}
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// The keys stored directly under `path`, or none when nothing is.
///
/// A key ending in `/` is a directory below `path` rather than an object;
/// keys come back as the store spells them, so a caller wanting objects
/// filters those out.
///
/// Addresses `secret/metadata/<path>`, which the store ACLs separately
/// from the `secret/data/<path>` that [`read`][Self::read] uses: a token
/// that may read every object under a path is refused here until its
/// policy grants `list` on the metadata path too.
///
/// **Only a 404 is "none"**, as in [`read_optional`][Self::read_optional]:
/// the store answers a `LIST` on an empty directory with a 404, and a
/// denial stays an error.
///
/// # Errors
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
/// unreachable store.
pub async fn list(&self, path: &str) -> Result<Vec<String>, Error> {
match vaultrs::kv2::list(&self.inner, MOUNT, path).await {
Ok(keys) => Ok(keys),
Err(e) if is_absent(&e) => Ok(Vec::new()),
Err(e) => Err(e.into()),
}
}
/// Write `value` at `path`, creating a new version.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover the path.
pub async fn write<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
vaultrs::kv2::set(&self.inner, MOUNT, path, value).await?;
Ok(())
}
/// Write `value` at `path` only if nothing is stored there yet.
///
/// KV v2's check-and-set with `cas=0`, so of two writers racing on an
/// empty path exactly one lands. The other gets an error, and a caller
/// that only needs *a* value there reads the winner's back.
///
/// # Errors
/// [`Error::Vault`] when something is already stored at `path`, or the
/// token's policy does not cover it. Under a grant of `create` without
/// `update` the first of those is a 403, not a check-and-set mismatch:
/// the store checks the capability for an existing path before it gets
/// to `cas`.
pub async fn create<T: Serialize + Sync>(&self, path: &str, value: &T) -> Result<(), Error> {
vaultrs::kv2::set_with_options(&self.inner, MOUNT, path, value, create_only()).await?;
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/<path>`, which the store ACLs separately
/// from the `secret/data/<path>` 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
/// the caller renders the document from the current agent set anyway, so
/// a stanza that is gone from the render is meant to be gone from the
/// grant. Render the text with [`crate::policy`] rather than by hand.
///
/// # Errors
/// [`Error::Vault`] when the token's own policy does not cover
/// `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> {
let request = WriteAclPolicy {
name: name.to_owned(),
policy: policy.to_owned(),
};
vaultrs::api::exec_with_empty(&self.inner, request).await?;
Ok(())
}
/// Create or replace the cert-auth role `name`, so that a certificate
/// issued by `certificate` and carrying `common_name` logs in with
/// `policy`.
///
/// `certificate` is the authority by value, not a path: the store keeps
/// its own copy inside the role and never reads a file of ours.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover
/// `auth/<mount>/certs/<name>`, or the store rejects the authority.
pub async fn write_cert_role(
&self,
mount: &str,
name: &str,
certificate: &str,
common_name: &str,
policy: &str,
) -> Result<(), Error> {
let mut opts =
vaultrs::api::auth::cert::requests::CreateCaCertificateRoleRequest::builder();
opts.allowed_common_names(vec![common_name.to_owned()])
.token_policies(vec![policy.to_owned()]);
vaultrs::auth::cert::ca_cert_role::set(
&self.inner,
mount,
name,
certificate,
Some(&mut opts),
)
.await?;
Ok(())
}
/// Have the PKI role `role` on `mount` issue a client certificate for
/// `common_name`, returning it with its key and the issuing CA.
///
/// The store generates the key: it exists in the store's answer and in the
/// returned value, nowhere else. What the certificate may carry (names,
/// usages, lifetime) is the role's to decide, so nothing but the name is
/// asked for here.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover
/// `<mount>/issue/<role>` or the role refuses `common_name`, and
/// [`Error::IncompleteIssue`] when the answer lacks any of the three.
pub async fn issue_client_certificate(
&self,
mount: &str,
role: &str,
common_name: &str,
) -> Result<mtls::Credential, Error> {
let issued =
vaultrs::api::exec_with_result(&self.inner, issue_request(mount, role, common_name))
.await?;
credential_from_issue(issued)
}
/// The names of every cert-auth role under `mount`, or none when the
/// mount has no roles at all.
///
/// **Only a 404 is "none".** The store answers a `LIST` on an empty
/// directory with a 404; a denial is a 403 and stays an error, so a caller
/// iterating the result never mistakes "not allowed to look" for "nothing
/// there".
///
/// # Errors
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
/// unreachable store.
pub async fn list_cert_roles(&self, mount: &str) -> Result<Vec<String>, Error> {
match vaultrs::auth::cert::ca_cert_role::list(&self.inner, mount).await {
Ok(listed) => Ok(listed.keys),
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(Vec::new()),
Err(e) => Err(e.into()),
}
}
}
/// 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/<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,
}
/// The options [`SecretStore::create`] writes with: version 0, which only a
/// path holding nothing is at.
fn create_only() -> vaultrs::api::kv2::requests::SetSecretRequestOptions {
vaultrs::api::kv2::requests::SetSecretRequestOptions { cas: 0 }
}
/// The request [`SecretStore::issue_client_certificate`] sends.
///
/// The name stays out of the SANs so the certificate carries exactly one name,
/// the one the cert-auth role matches. PKCS#8 because that is the key shape
/// every reader of the published identity already parses.
fn issue_request(mount: &str, role: &str, common_name: &str) -> GenerateCertificateRequest {
GenerateCertificateRequest {
mount: mount.to_owned(),
role: role.to_owned(),
common_name: Some(common_name.to_owned()),
exclude_cn_from_sans: Some(true),
private_key_format: Some("pkcs8".to_owned()),
..GenerateCertificateRequest::default()
}
}
/// The store's answer, moved straight into the redacting
/// [`mtls::Credential`]. `GenerateCertificateResponse` derives `Debug` and
/// holds the private key, so it is consumed here and never formatted.
///
/// `issuing_ca` rather than `ca_chain` because it is the one certificate a
/// cert-auth role pins: the authority that signed this leaf.
fn credential_from_issue(issued: GenerateCertificateResponse) -> Result<mtls::Credential, Error> {
for (field, value) in [
("certificate", &issued.certificate),
("private_key", &issued.private_key),
("issuing_ca", &issued.issuing_ca),
] {
if value.trim().is_empty() {
return Err(Error::IncompleteIssue(field));
}
}
Ok(mtls::Credential {
cert: issued.certificate,
key: issued.private_key,
ca: issued.issuing_ca,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// A lookup standing in for a fully-configured unit's environment.
fn full(k: &str) -> Option<String> {
match k {
ENV_ADDR => Some("https://store.invalid:8200".to_owned()),
ENV_CLIENT_CERT => Some("/run/c.pem".to_owned()),
ENV_CLIENT_KEY => Some("/run/k.pem".to_owned()),
_ => None,
}
}
#[test]
fn the_client_is_built_with_a_request_timeout() {
let s = Settings::from_lookup(full).expect("every required variable is set");
let built = s.client_settings(None).expect("settings build");
assert_eq!(built.timeout, Some(VAULT_REQUEST_TIMEOUT));
}
#[test]
fn a_complete_environment_is_accepted() {
// The control: without this, every assertion below could be passing
// because `from_lookup` rejects everything.
let s = Settings::from_lookup(full).expect("every required variable is set");
assert_eq!(s.address, "https://store.invalid:8200");
assert_eq!(s.ca_path, None, "an absent CA is the system trust store");
}
#[test]
fn each_required_variable_is_named_when_it_is_the_missing_one() {
for var in [ENV_ADDR, ENV_CLIENT_CERT, ENV_CLIENT_KEY] {
let e = Settings::from_lookup(|k| if k == var { None } else { full(k) })
.expect_err("one required variable is absent");
assert!(
matches!(e, Error::MissingEnv(v) if v == var),
"dropping {var} should name {var}, got {e:?}"
);
}
}
#[test]
fn an_empty_variable_is_as_absent_as_an_unset_one() {
// systemd writes `Environment=BAO_CACERT=` for an unset nix option, so
// empty is the shape this actually arrives in.
let e = Settings::from_lookup(|k| {
if k == ENV_ADDR {
Some(String::new())
} else {
full(k)
}
})
.expect_err("an empty address is not an address");
assert!(matches!(e, Error::MissingEnv(ENV_ADDR)), "got {e:?}");
let s = Settings::from_lookup(|k| {
if k == ENV_CACERT {
Some(String::new())
} else {
full(k)
}
})
.expect("an empty CA is optional, not fatal");
assert_eq!(s.ca_path, None);
}
#[test]
fn an_unreadable_cert_names_the_file_and_the_variable() {
let e = read_file(ENV_CLIENT_CERT, "/nonexistent/cert.pem")
.expect_err("the file does not exist");
match e {
Error::Identity { var, ref path, .. } => {
assert_eq!(var, ENV_CLIENT_CERT);
assert_eq!(path, "/nonexistent/cert.pem");
}
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("pr1ma").expect("a plain name is legal"),
};
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("pr1ma").expect("a plain name is legal"),
};
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("pr1ma").expect("legal")
);
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"
);
}
/// The controller's grant names exactly this path with `update`, which is
/// what a POST needs; any other path or verb is a 403.
#[test]
fn an_issue_request_posts_to_the_roles_issue_path() {
use rustify::endpoint::Endpoint as _;
let request = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas");
assert_eq!(request.path(), "pki-agents/issue/swarm-agent");
assert!(
matches!(request.method(), rustify::enums::RequestMethod::POST),
"{:?}",
request.method()
);
}
#[test]
fn an_issue_request_asks_for_the_name_and_nothing_the_role_decides() {
use rustify::endpoint::Endpoint as _;
let body = issue_request("pki-agents", "swarm-agent", "hive-agent-atlas")
.body()
.expect("the body serialises")
.expect("an issue request sends one");
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(sent["common_name"], "hive-agent-atlas");
assert_eq!(sent["exclude_cn_from_sans"], true);
assert_eq!(sent["private_key_format"], "pkcs8");
for decided_by_the_role in ["ttl", "alt_names", "ip_sans", "uri_sans", "other_sans"] {
assert!(
sent.get(decided_by_the_role)
.is_none_or(serde_json::Value::is_null),
"{decided_by_the_role} is the role's to set: {sent}"
);
}
}
fn issued() -> GenerateCertificateResponse {
GenerateCertificateResponse {
ca_chain: None,
certificate: "LEAF".to_owned(),
expiration: None,
issuing_ca: "AGENT-CA".to_owned(),
private_key: "SECRET-KEY-BYTES".to_owned(),
private_key_type: "ec".to_owned(),
serial_number: "01".to_owned(),
}
}
#[test]
fn a_complete_answer_becomes_the_published_credential() {
let credential = credential_from_issue(issued()).expect("every field is present");
assert_eq!(credential.cert, "LEAF");
assert_eq!(credential.key, "SECRET-KEY-BYTES");
assert_eq!(credential.ca, "AGENT-CA", "the role pins the issuing CA");
assert!(
!format!("{credential:?}").contains("SECRET-KEY-BYTES"),
"the key must not reach a formatted value"
);
}
#[test]
fn an_answer_missing_any_part_of_the_identity_is_refused_by_name() {
type Blank = fn(&mut GenerateCertificateResponse);
let cases: [(&str, Blank); 3] = [
("certificate", |r| r.certificate.clear()),
("private_key", |r| r.private_key = " \n".to_owned()),
("issuing_ca", |r| r.issuing_ca.clear()),
];
for (field, blank) in cases {
let mut answer = issued();
blank(&mut answer);
let e = credential_from_issue(answer).expect_err("an incomplete identity");
assert!(
matches!(e, Error::IncompleteIssue(f) if f == field),
"blanking {field} gave {e:?}"
);
assert!(!e.to_string().contains("SECRET-KEY-BYTES"), "{e}");
}
}
/// A create is a write with `cas=0` in the body: without it the store
/// takes the write as an overwrite of whatever is there, and the two
/// racing writers [`SecretStore::create`] exists for would both land.
#[test]
fn a_create_writes_the_data_path_only_while_it_holds_nothing() {
use rustify::endpoint::Endpoint as _;
let request = vaultrs::api::kv2::requests::SetSecretRequest::builder()
.mount(MOUNT)
.path("swarm/public/x")
.data(serde_json::json!({ "value": "v" }))
.options(create_only())
.build()
.expect("the request builds");
assert_eq!(request.path(), "secret/data/swarm/public/x");
let body = request
.body()
.expect("the body serialises")
.expect("a write sends one");
let sent: serde_json::Value = serde_json::from_slice(&body).expect("JSON");
assert_eq!(sent["options"]["cas"], 0);
assert_eq!(sent["data"]["value"], "v");
}
/// 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());
}
}