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
4 changed files with 513 additions and 13 deletions

View file

@ -0,0 +1,233 @@
//! The token lifetime of the agents' cert-auth roles that already exist.
//!
//! A role is written with its lifetimes only when an agent's certificate is
//! minted or renewed, and a role written before the lifetimes were set leaves
//! every login at the mount's default of 32 days, one store lease each. The
//! start pass in [`crate::read_policy`] calls [`bring_current`] so those roles
//! take [`swarm_secret_client::client::CERT_ROLE_TOKEN_TTL`] without waiting for
//! a renewal.
//!
//! Only the two lifetimes are written: the role's certificate, allowed names
//! and policies stay as they are, and no token or lease is revoked. Tokens
//! issued before expire on their own.
use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, policy};
use crate::store::AccessStore;
/// What one pass did, one count per role.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Counts {
pub rewritten: usize,
pub already_current: usize,
pub failed: usize,
}
/// Give every agent role among `roles`, a listing of the cert mount's roles,
/// the current token lifetimes, and log the counts once.
///
/// A role that cannot be read or written is named in a warning and skipped, so
/// it never stops the rest or the start. A role gone since the listing is
/// counted nowhere: there is nothing left to bring current.
pub async fn bring_current(store: &impl AccessStore, roles: &[String]) -> Counts {
let mut counts = Counts::default();
for agent in policy::agents_from_role_names(roles) {
let Ok(role) = policy::agent_object_name(&agent) else {
continue;
};
match bring_one(store, &role).await {
Ok(Outcome::Rewritten) => counts.rewritten += 1,
Ok(Outcome::AlreadyCurrent) => counts.already_current += 1,
Ok(Outcome::Gone) => {}
Err(e) => {
counts.failed += 1;
tracing::warn!(role, error = %e, "this agent role's token lifetime could not be brought current");
}
}
}
tracing::info!(
rewritten = counts.rewritten,
already_current = counts.already_current,
failed = counts.failed,
"agent role token lifetimes checked"
);
counts
}
enum Outcome {
Rewritten,
AlreadyCurrent,
Gone,
}
async fn bring_one(
store: &impl AccessStore,
role: &str,
) -> Result<Outcome, swarm_secret_client::Error> {
let Some(ttls) = store.cert_role_token_ttls(DEFAULT_CERT_MOUNT, role).await? else {
return Ok(Outcome::Gone);
};
if ttls.is_current() {
return Ok(Outcome::AlreadyCurrent);
}
store
.set_cert_role_token_ttls(DEFAULT_CERT_MOUNT, role)
.await?;
Ok(Outcome::Rewritten)
}
#[cfg(test)]
mod tests {
use swarm_secret_client::client::{CERT_ROLE_TOKEN_TTL_SECS, CertRoleTokenTtls};
use crate::store::fake::{FakeAccess, FakeRole};
use super::{Counts, bring_current};
const CURRENT: CertRoleTokenTtls = CertRoleTokenTtls {
token_ttl: CERT_ROLE_TOKEN_TTL_SECS,
token_max_ttl: CERT_ROLE_TOKEN_TTL_SECS,
};
const STALE: CertRoleTokenTtls = CertRoleTokenTtls {
token_ttl: 0,
token_max_ttl: 0,
};
fn role(agent: &str, ttls: CertRoleTokenTtls) -> FakeRole {
FakeRole {
certificate: format!("CA-{agent}"),
common_name: format!("hive-agent-{agent}"),
policies: vec!["hive-agent".to_owned()],
ttls,
}
}
fn listing(agents: &[&str]) -> Vec<String> {
agents.iter().map(|a| format!("hive-agent-{a}")).collect()
}
#[tokio::test]
async fn a_stale_role_takes_the_ttl_and_keeps_its_certificate_names_and_policies() {
let before = role("a", STALE);
let store = FakeAccess::default().with_role("hive-agent-a", before.clone());
let counts = bring_current(&store, &listing(&["a"])).await;
assert_eq!(
counts,
Counts {
rewritten: 1,
..Counts::default()
}
);
assert_eq!(
store.role("hive-agent-a"),
Some(FakeRole {
ttls: CURRENT,
..before
})
);
}
#[tokio::test]
async fn a_role_with_a_lifetime_unset_or_different_is_rewritten() {
let store = FakeAccess::default()
.with_role(
"hive-agent-a",
role(
"a",
CertRoleTokenTtls {
token_ttl: CERT_ROLE_TOKEN_TTL_SECS,
token_max_ttl: 0,
},
),
)
.with_role(
"hive-agent-b",
role(
"b",
CertRoleTokenTtls {
token_ttl: 900,
token_max_ttl: 900,
},
),
);
let counts = bring_current(&store, &listing(&["a", "b"])).await;
assert_eq!(counts.rewritten, 2);
assert_eq!(store.role("hive-agent-a").expect("kept").ttls, CURRENT);
assert_eq!(store.role("hive-agent-b").expect("kept").ttls, CURRENT);
}
#[tokio::test]
async fn a_current_role_is_read_and_not_written() {
let store = FakeAccess::default().with_role("hive-agent-a", role("a", CURRENT));
let counts = bring_current(&store, &listing(&["a"])).await;
assert_eq!(
counts,
Counts {
already_current: 1,
..Counts::default()
}
);
assert_eq!(store.calls(), ["cert_role_token_ttls hive-agent-a"]);
}
#[tokio::test]
async fn a_role_that_fails_does_not_stop_the_others() {
let store = FakeAccess::default()
.with_role("hive-agent-a", role("a", STALE))
.with_role("hive-agent-b", role("b", STALE))
.with_role("hive-agent-c", role("c", STALE))
.with_role("hive-agent-d", role("d", CURRENT))
.with_unreadable_role("hive-agent-a")
.with_unwritable_role("hive-agent-b");
let counts = bring_current(&store, &listing(&["a", "b", "c", "d"])).await;
assert_eq!(
counts,
Counts {
rewritten: 1,
already_current: 1,
failed: 2,
}
);
// The ones after the failures were still handled.
assert_eq!(store.role("hive-agent-c").expect("kept").ttls, CURRENT);
// The failed ones were left as they were.
assert_eq!(store.role("hive-agent-a").expect("kept").ttls, STALE);
assert_eq!(store.role("hive-agent-b").expect("kept").ttls, STALE);
}
#[tokio::test]
async fn only_agent_roles_are_touched() {
let store = FakeAccess::default()
.with_role("hive-agent-a", role("a", STALE))
.with_role("hive-pr1ma", role("pr1ma", STALE))
.with_role("swarm-controller", role("c", STALE));
let roles = vec![
"hive-agent-a".to_owned(),
"hive-pr1ma".to_owned(),
"swarm-controller".to_owned(),
];
let counts = bring_current(&store, &roles).await;
assert_eq!(counts.rewritten, 1);
assert_eq!(store.role("hive-agent-a").expect("kept").ttls, CURRENT);
assert_eq!(store.role("hive-pr1ma").expect("kept").ttls, STALE);
assert_eq!(store.role("swarm-controller").expect("kept").ttls, STALE);
}
#[tokio::test]
async fn a_role_gone_since_the_listing_is_counted_nowhere() {
let store = FakeAccess::default().with_role("hive-agent-b", role("b", STALE));
let counts = bring_current(&store, &listing(&["a", "b"])).await;
assert_eq!(
counts,
Counts {
rewritten: 1,
..Counts::default()
}
);
assert!(
!store
.calls()
.contains(&"set_cert_role_token_ttls hive-agent-a".to_owned())
);
}
}

View file

@ -31,6 +31,7 @@ mod agent_limits;
mod agent_login;
mod agent_permissions;
mod agent_renewal;
mod agent_role_ttl;
mod agent_state_stream;
mod agent_status;
mod agents_md;
@ -224,7 +225,8 @@ async fn main() -> Result<()> {
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
read_policy::ensure_hive_access(hive_names).await;
// The one policy every agent's role attaches, and the entity each agent's
// login is read under, are checked here at every start.
// login is read under, are checked here at every start, and so is the
// token lifetime of the roles that exist.
read_policy::ensure_agent_policies().await;
let state = AppState {

View file

@ -22,7 +22,8 @@
//! a config change means a redeploy.
//!
//! The shared agent policy is written by the same kind of pass
//! ([`ensure_agent_policies`]), together with each agent's identity entity.
//! ([`ensure_agent_policies`]), together with each agent's identity entity and
//! the token lifetimes of its existing role ([`crate::agent_role_ttl`]).
//! Its roster is the store's `hive-agent-*` cert-auth roles.
use std::{future::Future, time::Duration};
@ -299,12 +300,13 @@ pub async fn ensure_agent_policies() {
}
}
/// One pass: log in once, list the agents, write the shared agent policy and
/// give each agent its identity entity.
/// One pass: log in once, list the agents, write the shared agent policy, give
/// each agent its identity entity and bring each existing role's token
/// lifetimes current.
///
/// The policy and entities only. An agent's cert-auth role and certificate are
/// `agent_identity::mint_and_verify`'s, and that reissues the certificate, so
/// it is not what a pass on every start may call.
/// The roles' certificate, names and policies are not touched. They and the
/// certificate are `agent_identity::mint_and_verify`'s, and that reissues the
/// certificate, so it is not what a pass on every start may call.
///
/// # Errors
/// [`Unreachable`] when the store cannot be reached or will not list its
@ -342,6 +344,7 @@ async fn rewrite_agent_policies() -> Result<(), Unreachable> {
),
}
crate::agent_entity::ensure_agents(&store, &policy::agents_from_role_names(&roles)).await;
crate::agent_role_ttl::bring_current(&store, &roles).await;
Ok(())
}

View file

@ -8,7 +8,10 @@
use std::future::Future;
use swarm_secret_client::{Error, SecretStore, client::EntityRef};
use swarm_secret_client::{
Error, SecretStore,
client::{CertRoleTokenTtls, EntityRef},
};
/// The cert-auth role the controller logs in under.
///
@ -48,6 +51,20 @@ pub(crate) trait AccessStore {
policies: &[&str],
) -> impl Future<Output = Result<(), Error>> + Send;
/// As [`SecretStore::cert_role_token_ttls`].
fn cert_role_token_ttls(
&self,
mount: &str,
name: &str,
) -> impl Future<Output = Result<Option<CertRoleTokenTtls>, Error>> + Send;
/// As [`SecretStore::set_cert_role_token_ttls`].
fn set_cert_role_token_ttls(
&self,
mount: &str,
name: &str,
) -> impl Future<Output = Result<(), Error>> + Send;
/// As [`SecretStore::auth_mount_accessor`].
fn auth_mount_accessor(
&self,
@ -99,6 +116,18 @@ impl AccessStore for SecretStore {
SecretStore::write_cert_role(self, mount, name, certificate, common_name, policies).await
}
async fn cert_role_token_ttls(
&self,
mount: &str,
name: &str,
) -> Result<Option<CertRoleTokenTtls>, Error> {
SecretStore::cert_role_token_ttls(self, mount, name).await
}
async fn set_cert_role_token_ttls(&self, mount: &str, name: &str) -> Result<(), Error> {
SecretStore::set_cert_role_token_ttls(self, mount, name).await
}
async fn auth_mount_accessor(&self, mount: &str) -> Result<String, Error> {
SecretStore::auth_mount_accessor(self, mount).await
}
@ -137,16 +166,35 @@ impl AccessStore for SecretStore {
/// outcome of the access calls.
#[cfg(test)]
pub(crate) mod fake {
use std::sync::Mutex;
use std::{collections::BTreeMap, sync::Mutex};
use swarm_secret_client::{Error, client::EntityRef};
use swarm_secret_client::{
Error,
client::{CERT_ROLE_TOKEN_TTL_SECS, CertRoleTokenTtls, EntityRef},
};
use super::AccessStore;
/// A cert-auth role as the fake keeps it: what a write of the role sets,
/// and the lifetimes a write of only those can change.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FakeRole {
pub(crate) certificate: String,
pub(crate) common_name: String,
pub(crate) policies: Vec<String>,
pub(crate) ttls: CertRoleTokenTtls,
}
/// What the fake answers; the default is a store with a readable cert mount
/// and no entities, which accepts every write.
#[derive(Default)]
pub(crate) struct FakeAccess {
/// The cert-auth roles, by name.
roles: Mutex<BTreeMap<String, FakeRole>>,
/// Roles whose lifetimes cannot be read.
unreadable_roles: Vec<String>,
/// Roles whose lifetimes cannot be written.
unwritable_roles: Vec<String>,
/// The accessor read fails.
no_accessor: bool,
/// Every policy write is refused.
@ -182,6 +230,32 @@ pub(crate) mod fake {
}
}
/// A store that holds `role` under `name`.
pub(crate) fn with_role(self, name: &str, role: FakeRole) -> Self {
self.roles
.lock()
.expect("not poisoned")
.insert(name.to_owned(), role);
self
}
/// A store that cannot read the lifetimes of the role `name`.
pub(crate) fn with_unreadable_role(mut self, name: &str) -> Self {
self.unreadable_roles.push(name.to_owned());
self
}
/// A store that refuses to write the lifetimes of the role `name`.
pub(crate) fn with_unwritable_role(mut self, name: &str) -> Self {
self.unwritable_roles.push(name.to_owned());
self
}
/// The role `name` as the store holds it now.
pub(crate) fn role(&self, name: &str) -> Option<FakeRole> {
self.roles.lock().expect("not poisoned").get(name).cloned()
}
/// The calls so far, as `verb name` strings, in order.
pub(crate) fn calls(&self) -> Vec<String> {
self.calls.lock().expect("not poisoned").clone()
@ -217,11 +291,50 @@ pub(crate) mod fake {
&self,
_mount: &str,
name: &str,
_certificate: &str,
_common_name: &str,
certificate: &str,
common_name: &str,
policies: &[&str],
) -> Result<(), Error> {
self.log(format!("write_cert_role {name} {}", policies.join(",")));
self.roles.lock().expect("not poisoned").insert(
name.to_owned(),
FakeRole {
certificate: certificate.to_owned(),
common_name: common_name.to_owned(),
policies: policies.iter().map(|p| (*p).to_owned()).collect(),
ttls: CertRoleTokenTtls {
token_ttl: CERT_ROLE_TOKEN_TTL_SECS,
token_max_ttl: CERT_ROLE_TOKEN_TTL_SECS,
},
},
);
Ok(())
}
async fn cert_role_token_ttls(
&self,
_mount: &str,
name: &str,
) -> Result<Option<CertRoleTokenTtls>, Error> {
self.log(format!("cert_role_token_ttls {name}"));
if self.unreadable_roles.iter().any(|r| r == name) {
return Err(Self::denied());
}
Ok(self.role(name).map(|r| r.ttls))
}
async fn set_cert_role_token_ttls(&self, _mount: &str, name: &str) -> Result<(), Error> {
self.log(format!("set_cert_role_token_ttls {name}"));
if self.unwritable_roles.iter().any(|r| r == name) {
return Err(Self::denied());
}
// Only the lifetimes: the store keeps the fields a write omits.
if let Some(role) = self.roles.lock().expect("not poisoned").get_mut(name) {
role.ttls = CertRoleTokenTtls {
token_ttl: CERT_ROLE_TOKEN_TTL_SECS,
token_max_ttl: CERT_ROLE_TOKEN_TTL_SECS,
};
}
Ok(())
}