Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-secret-client/src/lib.rs

180 lines
6.8 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`], [`mtls`], [`forge`], [`github`], [`grafana`],
//! [`acp`], [`attic`], [`backend_env`], [`ci`], [`review_classifier`], [`builder`], [`ci_metrics`],
//! [`claude_login`], [`proxy`]).
//! 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.
//!
//! [`mtls`] is the one module about reaching the store rather than about a
//! value inside it, and its doc explains why that is not circular.
//!
//! [`install_crypto_provider`] is process setup, not an agreement. It lives
//! here because this crate depends on no other swarm crate, so a binary that
//! needs only the provider and the store links nothing else.
pub mod acp;
pub mod attic;
pub mod backend_env;
pub mod builder;
pub mod cert;
#[cfg(feature = "otel")]
pub mod cert_metrics;
pub mod ci;
pub mod ci_metrics;
pub mod claude_login;
pub mod client;
pub mod forge;
pub mod github;
pub mod grafana;
pub mod matrix;
pub mod mtls;
pub mod path;
pub mod policy;
pub mod proxy;
pub mod queue;
pub mod review_classifier;
pub mod startup_read;
pub mod stored_secret;
pub use client::SecretStore;
pub use startup_read::read_with_startup_retry;
/// 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, did not answer within the
/// client's request timeout, 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),
/// The store answered an issue request with a field empty that a usable
/// identity needs. Names the field, never its value.
#[error("the store issued a certificate whose {0} is empty")]
IncompleteIssue(&'static str),
/// The text was not one PEM-encoded X.509 certificate.
#[error("decoding the certificate: {0}")]
Certificate(String),
}
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))
}
}
impl Error {
/// Whether the store answered 403: the principal's policy does not cover
/// the path. A policy the store's host has not written yet looks the same
/// as one that never will, so a caller that can do without the path
/// checks this before treating the failure as an outage.
#[must_use]
pub fn is_denied(&self) -> bool {
matches!(
self,
Self::Vault(e) if matches!(**e, vaultrs::error::ClientError::APIError { code: 403, .. })
)
}
}
/// Install `aws-lc-rs` as the process-wide rustls crypto provider. Call it
/// first thing in `main` of every binary that dials the queue or reaches the
/// secret store over TLS.
///
/// rustls is built with both `ring` and `aws-lc-rs`; without an installed
/// default, the first TLS handshake panics. On the queue side async-nats
/// builds its config with `ClientConfig::builder()`, so the client never
/// connects. On the store side the client is reqwest, via vaultrs.
/// `aws-lc-rs` because it is what reqwest uses when nothing is installed.
pub fn install_crypto_provider() {
// `Err` only means a provider is already installed, which is all this needs.
let _already_installed = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_403_is_a_denial() {
let api = |code| {
Error::from(vaultrs::error::ClientError::APIError {
code,
errors: Vec::new(),
})
};
assert!(api(403).is_denied());
// The controls: a miss and an outage are not denials.
assert!(!api(404).is_denied());
assert!(!api(503).is_denied());
assert!(!Error::MissingEnv("BAO_ADDR").is_denied());
}
#[test]
fn a_tls_client_config_builds_once_the_provider_is_installed() {
install_crypto_provider();
let _config = rustls::ClientConfig::builder()
.with_root_certificates(rustls::RootCertStore::empty())
.with_no_client_auth();
}
}