Watch
0
0
Fork
You've already forked hyperhive
0

swarm-secret-client: agents may list their own subtree; controller rewrites agent policies

render_agent gains a second stanza: list on
secret/metadata/swarm/agents/<agent>/*, next to the existing read on
secret/data/swarm/agents/<agent>/*. An agent can now learn which
credentials it holds by listing its own subtree. Metadata read, writes
and every other principal's paths stay refused.

An agent's policy was only written when it was minted, so existing agents
would never get the new stanza. swarm-controller now rewrites every
agent's policy at start (read_policy::ensure_agent_policies), with the
same 30s / 24h retry as ensure_hive_access. The roster is the store's
hive-agent-* cert-auth roles, listed with the controller's existing
`list` on auth/cert/certs; the writes use its existing grant on
sys/policies/acl/hive-*. Only the policy is written: mint_and_verify
also reissues the certificate, so the pass does not call it.

Refs #4348
This commit is contained in:
atlas 2026-10-01 17:41:13 +02:00
commit e04616eb70
5 changed files with 204 additions and 43 deletions

View file

@ -118,8 +118,8 @@ pub(crate) fn generate_queue_secret() -> Result<String> {
/// 3. publish a queue secret at [`queue::agent_queue_path`] — the agent's own
/// identity at the swarm queue, minted here so that the credential an agent
/// presents names *it* rather than its hive;
/// 4. write the ACL document [`policy::render_agent`] renders — read on this
/// one agent's paths and nothing else;
/// 4. write the ACL document [`policy::render_agent`] renders — read and list on
/// this one agent's paths and nothing else;
/// 5. write the cert-auth role that ties the three together, pinning the CA
/// the store named as this leaf's issuer.
///

View file

@ -2836,6 +2836,9 @@ async fn main() -> Result<()> {
// abandoned, since the two of them boot together.
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
read_policy::ensure_hive_access(hive_names).await;
// Agents keep the policy they were minted with until this rewrites it, so
// what `policy::render_agent` grants today reaches them only from here.
read_policy::ensure_agent_policies().await;
let state = AppState {
hives: Arc::new(hives),

View file

@ -20,6 +20,11 @@
//! document to hoist this render up to — doing that hands every hive the stanza
//! naming one of them. The hive list is loaded once because a config change
//! means a redeploy.
//!
//! Every agent's policy is rewritten by the same kind of pass
//! ([`ensure_agent_policies`]), so a change to `policy::render_agent` reaches
//! agents minted before it. Its roster is the store's `hive-agent-*` cert-auth
//! roles.
use std::{future::Future, time::Duration};
@ -192,11 +197,106 @@ async fn write_role(store: &SecretStore, hive: &str, ca: &str) -> Result<()> {
Ok(())
}
/// Rewrite every agent's policy from [`policy::render_agent`], with the retry
/// [`ensure_hive_access`] has and for the same reason.
///
/// Returns once the first pass is done. When that pass cannot reach the store,
/// it repeats in the background for up to [`RETRY_WINDOW`].
pub async fn ensure_agent_policies() {
if let Err(Unreachable(reason)) = rewrite_agent_policies().await {
tracing::warn!(
reason,
retry_in = ?RETRY_INTERVAL,
"agent policy rewrite could not reach the swarm secret store; retrying in the background"
);
tokio::spawn(async {
let reached = retry_until_ok(RETRY_INTERVAL, RETRY_ATTEMPTS, || async {
let outcome = rewrite_agent_policies().await;
if let Err(Unreachable(reason)) = &outcome {
tracing::debug!(reason, "agent policy rewrite: store still unreachable");
}
outcome
})
.await;
if let Some(attempt) = reached {
tracing::info!(attempt, "agent policy rewrite reached the store");
} else {
tracing::warn!(
attempts = RETRY_ATTEMPTS,
window = ?RETRY_WINDOW,
"giving up on the agent policy rewrite; agents keep the policy they were minted with until this daemon is restarted"
);
}
});
}
}
/// One pass: log in once, list the agents, write each one's policy.
///
/// The policy 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.
///
/// # Errors
/// [`Unreachable`] when the store cannot be reached or will not list its
/// roles: either way no agent was written, and a later attempt may get
/// further. A deployment with no store configured is `Ok`.
async fn rewrite_agent_policies() -> Result<(), Unreachable> {
let store = match crate::store::connect().await {
Ok(store) => store,
Err(Error::MissingEnv(var)) => {
tracing::info!(
var,
"no secret store configured; agent policies are not managed here"
);
return Ok(());
}
Err(e) => return Err(Unreachable(e.to_string())),
};
let roles = store
.list_cert_roles(DEFAULT_CERT_MOUNT)
.await
.map_err(|e| Unreachable(format!("listing the store's cert-auth roles: {e}")))?;
let policies = agent_policies(&roles);
let mut written = 0_usize;
for (name, document) in &policies {
match store.write_policy(name, document).await {
Ok(()) => written += 1,
Err(e) => {
tracing::warn!(policy = %name, error = %e, "rewriting this agent's policy failed");
}
}
}
tracing::info!(written, agents = policies.len(), "agent policies rewritten");
Ok(())
}
/// The policy to write for each agent among `roles`, a listing of cert-auth
/// roles: its name, which is the role's own, and its document.
///
/// A role that is not an agent's — a hive's `hive-<hive>`, a host's — gets
/// nothing: the same `write_policy` call with an agent's document would
/// narrow that principal to one agent's subtree.
fn agent_policies(roles: &[String]) -> Vec<(String, String)> {
policy::agents_from_role_names(roles)
.iter()
.filter_map(|agent| {
Some((
policy::agent_object_name(agent).ok()?,
policy::render_agent(agent).ok()?,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, time::Duration};
use super::{RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, retry_until_ok};
use super::{
RETRY_ATTEMPTS, RETRY_INTERVAL, RETRY_WINDOW, Unreachable, agent_policies, policy,
retry_until_ok,
};
#[test]
fn the_retry_bound_is_the_window_it_claims() {
@ -235,4 +335,27 @@ mod tests {
assert_eq!(reached, None, "a store that never appears is given up on");
assert_eq!(calls.get(), 5, "one attempt per interval, and no more");
}
#[test]
fn the_pass_writes_agents_policies_under_their_role_names_and_no_one_elses() {
let roles: Vec<String> = [
"hive-agent-atlas",
"hive-pr1ma",
"swarm-controller",
"hive-agent-argus",
]
.map(str::to_owned)
.to_vec();
let written = agent_policies(&roles);
let names: Vec<&str> = written.iter().map(|(name, _)| name.as_str()).collect();
// The role names an agent's policy by its own name, so a policy written
// under any other name is one the role does not attach.
assert_eq!(names, ["hive-agent-atlas", "hive-agent-argus"]);
assert_eq!(
written[0].1,
policy::render_agent("atlas").expect("legal"),
"the prefix is stripped before rendering, or the document names `agent-atlas`"
);
assert_eq!(written[1].1, policy::render_agent("argus").expect("legal"));
}
}