Watch
0
0
Fork
You've already forked hyperhive
0

bring existing agent cert roles to the 1h token ttl on controller start

This commit is contained in:
atlas 2026-10-29 13:47:32 +01:00 • committed by mara
commit 76de27fc97

View file

@ -37,6 +37,10 @@ pub const DEFAULT_CERT_MOUNT: &str = "cert";
/// again rather than keep a handle past this.
pub const CERT_ROLE_TOKEN_TTL: &str = "1h";
/// [`CERT_ROLE_TOKEN_TTL`] in seconds, the unit the store reports a role's
/// lifetimes in.
pub const CERT_ROLE_TOKEN_TTL_SECS: u64 = 3600;
/// Upper bound on one request to the store, login included.
///
/// vaultrs sets no timeout by default, so a store that accepts the connection
@ -388,6 +392,46 @@ impl SecretStore {
Ok(())
}
/// The token lifetimes of the cert-auth role `name`, or `None` when the
/// role does not exist.
///
/// # Errors
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
/// unreachable store.
pub async fn cert_role_token_ttls(
&self,
mount: &str,
name: &str,
) -> Result<Option<CertRoleTokenTtls>, Error> {
let request = ReadCertRoleTtls {
mount: mount.to_owned(),
name: name.to_owned(),
};
match vaultrs::api::exec_with_result(&self.inner, request).await {
Ok(ttls) => Ok(Some(ttls)),
Err(e) if is_absent(&e) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Set the token lifetimes of the existing cert-auth role `name` to
/// [`CERT_ROLE_TOKEN_TTL`], leaving the rest of the role as it is.
///
/// The write names no other field, and the store keeps the fields a write
/// leaves out (`builtin/credential/cert/path_certs.go`, `pathCertWrite`,
/// `OpenBao` 2.6.5), so the role's certificate, allowed names and policies
/// stay. A role that does not exist would be created without a certificate
/// and refused; callers read the role first.
///
/// # Errors
/// [`Error::Vault`] when the token's policy does not cover
/// `auth/<mount>/certs/<name>`, or the store refuses the write.
pub async fn set_cert_role_token_ttls(&self, mount: &str, name: &str) -> Result<(), Error> {
vaultrs::api::exec_with_empty(&self.inner, set_cert_role_token_ttls_request(mount, name))
.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.
///
@ -647,6 +691,61 @@ struct WriteAclPolicy {
policy: String,
}
/// A cert-auth role's token lifetimes in seconds, `0` when unset (the mount's
/// default then applies). The role's certificate and names are not read.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct CertRoleTokenTtls {
pub token_ttl: u64,
pub token_max_ttl: u64,
}
impl CertRoleTokenTtls {
/// Whether both lifetimes are [`CERT_ROLE_TOKEN_TTL`].
#[must_use]
pub fn is_current(self) -> bool {
self.token_ttl == CERT_ROLE_TOKEN_TTL_SECS && self.token_max_ttl == CERT_ROLE_TOKEN_TTL_SECS
}
}
/// Read a cert-auth role. Spelled out because vaultrs's response type requires
/// every field the store answers with, and only the two lifetimes are wanted.
#[derive(Endpoint)]
#[endpoint(
path = "auth/{self.mount}/certs/{self.name}",
method = "GET",
response = "CertRoleTokenTtls"
)]
struct ReadCertRoleTtls {
#[endpoint(skip)]
mount: String,
#[endpoint(skip)]
name: String,
}
/// Write the two token lifetimes of a cert-auth role and nothing else.
/// vaultrs's `CreateCaCertificateRoleRequest` always sends `certificate`, which
/// would replace the role's.
#[derive(Endpoint)]
#[endpoint(path = "auth/{self.mount}/certs/{self.name}", method = "POST")]
struct SetCertRoleTtls {
#[endpoint(skip)]
mount: String,
#[endpoint(skip)]
name: String,
token_ttl: String,
token_max_ttl: String,
}
/// The request [`SecretStore::set_cert_role_token_ttls`] sends.
fn set_cert_role_token_ttls_request(mount: &str, name: &str) -> SetCertRoleTtls {
SetCertRoleTtls {
mount: mount.to_owned(),
name: name.to_owned(),
token_ttl: CERT_ROLE_TOKEN_TTL.to_owned(),
token_max_ttl: CERT_ROLE_TOKEN_TTL.to_owned(),
}
}
/// The options [`SecretStore::create`] writes with: version 0, which only a
/// path holding nothing is at.
fn create_only() -> vaultrs::api::kv2::requests::SetSecretRequestOptions {
@ -749,6 +848,48 @@ mod tests {
assert_eq!(r.certificate, "PEM");
}
#[test]
fn the_ttl_in_seconds_is_the_ttl_the_roles_are_written_with() {
assert_eq!(CERT_ROLE_TOKEN_TTL, "1h");
assert_eq!(CERT_ROLE_TOKEN_TTL_SECS, 3600);
}
#[test]
fn a_role_is_current_only_when_both_lifetimes_are_the_ttl() {
let ttls = |token_ttl, token_max_ttl| CertRoleTokenTtls {
token_ttl,
token_max_ttl,
};
assert!(ttls(3600, 3600).is_current());
assert!(!ttls(0, 0).is_current(), "unset takes the mount default");
assert!(!ttls(3600, 0).is_current());
assert!(!ttls(0, 3600).is_current());
assert!(!ttls(900, 900).is_current());
}
/// The store keeps the fields a write leaves out, so the write names these
/// two and no others: a `certificate` here would replace the role's.
#[test]
fn the_ttl_write_names_the_two_lifetimes_and_nothing_else() {
use rustify::Endpoint as _;
let r = set_cert_role_token_ttls_request("cert", "hive-agent-a")
.request("http://store.invalid")
.expect("builds");
assert_eq!(r.uri().path(), "/auth/cert/certs/hive-agent-a");
let body: serde_json::Value = serde_json::from_slice(r.body()).expect("json body");
assert_eq!(
body,
serde_json::json!({"token_ttl": "1h", "token_max_ttl": "1h"})
);
// The control: the full role write does carry the certificate, so a
// body without one is the narrow write and not a failure to serialise.
let full = cert_role_request("cert", "hive-agent-a", "PEM", "hive-agent-a", &["p"])
.request("http://store.invalid")
.expect("builds");
let full: serde_json::Value = serde_json::from_slice(full.body()).expect("json body");
assert_eq!(full["certificate"], "PEM");
}
#[test]
fn a_complete_environment_is_accepted() {
// The control: without this, every assertion below could be passing