Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/lib.rs
atlas baab0f393e swarm-secret-client: a read policy scoped to one agent
The identity half of giving an agent container its own way into the store.
The operator's ruling: minting an identity the agent itself can authenticate
with takes the delivery from four pieces (mint -> store -> pass -> use) to
three (mint -> store -> use), and makes the agent that much less dependent on
the hive it runs on — but only if the grant behind that identity is scoped to
that agent's secrets, read-only. Otherwise it is a second way to be everyone.

So `render_agent` is a SIBLING of `render`, not a parameter added to it. A
hive's document is wide in two of its kinds on purpose (the module header says
why each of those is a decision), and none of that breadth transfers: an
agent's path names the agent, so scoping to it costs nothing and drifts
nowhere, and an agent has no business with a service's OIDC client secret,
another agent's credentials, a hive's, or the controller's. The tests say that
as an exhaustive check over `path::Kind` rather than as a list of paths, so a
kind added later cannot be granted here by a renderer nobody re-read.

`render`'s own text is untouched, and its byte-for-byte assertion is left
exactly as it stands: a hive can still read every agent's secrets, and closing
that is the separate decision its doc comment already prices.

The object name is `hive-agent-<agent>`, one string for the policy, the
cert-auth role and the certificate subject. Inside `hive-` rather than beside
it because `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole
of what the controller may create, and the controller is the only principal
that learns an agent exists — at no cost in authority, since it already holds
create/update on `secret/data/swarm/agents/*`. It still cannot collide with
`hive-<hive>`: that needs a hive named `agent-<agent>`, and a hive name may not
contain `agent` (nix/reserved-hive-fragments.nix, the reservation the queue's
`hive-<name>-agent` client ids already lean on). A test spells that collision
out and fails if the prefix moves somewhere that guard does not cover.

Rendering only. Nothing mints a leaf, writes a role or delivers a certificate
yet, and nothing calls either new function — because nothing at the module
layer knows which agents exist: hive-c0re creates them at runtime and renders
them into its own meta flake, which is why even the gateway's per-agent vhosts
are a generated conf rather than nix. Issuance therefore has to be runtime, and
that design is the operator's to approve before it is built.

Refs #4386
2026-09-14 00:58:48 +02:00

86 lines
3.5 KiB
Rust

//! The swarm's secret-store client: where a credential lives, and how both ends
//! reach it.
//!
//! The HTTP is [`vaultrs`]'s job. What this crate owns is the *agreements* —
//! the rules every path obeys ([`path`]), the translation from this
//! deployment's environment into a logged-in client ([`client`]), and, per kind
//! of secret, the path it lives at together with the fields it holds
//! ([`matrix`], [`queue`]). Each of those is a thing the controller and a hive
//! must say identically, so it is said once here.
//!
//! [`policy`] is the same kind of agreement seen from the other side: which of
//! those paths a given principal's own token may read — a hive's, and an
//! agent's, which are two documents because they are two shapes of grant rather
//! than one with a name in it. It belongs here rather than in the controller
//! because the grant and the path are one statement — spelled differently they
//! produce a 403 that names neither.
//!
//! [`client`] is deliberately ignorant of all of it: it moves whatever type a
//! caller names, so a second kind of secret is a new module beside [`matrix`]
//! and not another field on a struct shared with it.
pub mod client;
pub mod matrix;
pub mod path;
pub mod policy;
pub mod queue;
pub use client::SecretStore;
/// What can go wrong between "we have a client certificate" and "we have the
/// credential".
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A name that would have addressed something other than what the caller
/// meant. See [`path`].
#[error("{kind} name {value:?} is not a single path segment of [A-Za-z0-9_-]")]
PathSegment {
/// Which name was rejected. A principal's kind in the singular
/// (`agent`, `hive`, `service`, `controller`) when the name addresses
/// one, or what the name is to the secret otherwise — `account`, for
/// a matrix credential.
kind: &'static str,
/// The offending value, quoted in the message because the caller
/// usually got it from config and needs to see which one.
value: String,
},
/// A variable the store's address or identity comes from is unset or
/// empty. Named rather than defaulted: a wrong store address fails much
/// later and much less clearly than a missing one.
#[error("{0} is unset or empty")]
MissingEnv(&'static str),
/// A client-certificate file named by the environment could not be read.
#[error("reading {path} (from {var}): {source}")]
Identity {
/// The variable that named the file.
var: &'static str,
/// The path it named.
path: String,
/// The underlying IO failure.
source: std::io::Error,
},
/// The address would not parse into a URL the client can use.
#[error("the store's settings are unusable: {0}")]
Settings(String),
/// The store refused us, was unreachable, or answered something we could
/// not parse.
#[error(transparent)]
Vault(#[from] Box<vaultrs::error::ClientError>),
/// The client certificate and key did not form a usable identity, or the
/// CA bundle did not parse.
#[error("building the TLS identity: {0}")]
Tls(#[source] reqwest::Error),
}
impl From<vaultrs::error::ClientError> for Error {
fn from(e: vaultrs::error::ClientError) -> Self {
// Boxed because `ClientError` is large enough that carrying it inline
// makes every `Result` in the crate pay for the rare arm.
Self::Vault(Box::new(e))
}
}