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

@ -332,7 +332,10 @@ store, and `swarm-controller`, already logged in under its own host leaf, asks
that mount's one role for a `hive-agent-<agent>` client certificate at agent
creation. Host roles never pin that CA and agent roles pin only it, so an
agent's certificate opens that agent's own `swarm/agents/<agent>/*` and
nothing else.
nothing else: `read` on the values there and `list` on their names.
`swarm-controller` writes that policy at agent creation and rewrites every
agent's at its own start, so a change to it reaches existing agents with the
next controller restart.
### Per-principal identities

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"));
}
}

View file

@ -114,12 +114,19 @@ pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
.collect()
}
/// One read stanza. The only shape this module emits, so "read-only" is a
/// property of the renderer rather than of each call site.
/// One read stanza. With [`list_stanza`], the only shapes this module emits, so
/// "writes nothing" is a property of the renderers rather than of each call
/// site.
fn read_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
}
/// One list stanza: the key names under a `metadata/` path. Neither a value
/// nor the metadata itself, which take `read` on `data/` and on `metadata/`.
fn list_stanza(path: &str) -> String {
format!("path \"{path}\" {{\n capabilities = [\"list\"]\n}}\n")
}
/// Render `hive`'s policy document: read on every agent's credentials, on this
/// hive's own, and on the swarm services'.
///
@ -166,15 +173,16 @@ pub fn render(hive: &str) -> Result<String, Error> {
Ok(format!("{agents}{own}{services}"))
}
/// Render `agent`'s policy document: read on that one agent's credentials, and
/// on nothing else at all.
/// Render `agent`'s policy document: read on that one agent's credentials, list
/// on their names (how `hive-matrix-daemon` finds its linked accounts), and
/// nothing else at all.
///
/// One stanza, and the single interpolated name in it is the whole document: an
/// agent authenticating with its own certificate gets a token that fetches
/// `swarm/agents/<agent>/…` and is refused every other path in the store. That
/// is the point — the reason to give an agent an identity is that it then
/// depends on its hive for one file (the certificate) rather than for every
/// credential it uses.
/// Two stanzas over one subtree, and the single interpolated name is the whole
/// document: an agent authenticating with its own certificate gets a token that
/// fetches `swarm/agents/<agent>/…`, lists the keys under it, and is refused
/// every other path in the store. That is the point — the reason to give an
/// agent an identity is that it then depends on its hive for one file (the
/// certificate) rather than for every credential it uses.
///
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
@ -185,23 +193,22 @@ pub fn render(hive: &str) -> Result<String, Error> {
/// no business with a service's OIDC secret, another agent's credentials, a
/// hive's, or the controller's. Narrow here does **not** narrow the hive's: a
/// hive still reads this agent's secrets, and closing that is its own decision.
/// Read-only, for the reason the hive's is: an agent that could write its own
/// credentials could hand itself an identity it was never issued.
/// Nothing here writes, for the reason the hive's does not: an agent that could
/// write its own credentials could hand itself an identity it was never issued.
///
/// ⚠️ Unlike [`render`]'s, this name is not deploy-time at every layer: nothing
/// in `nix/host-modules/` knows which agents exist — hive-c0re creates them at
/// runtime into its own meta flake (`hive_c0re::meta`).
/// ⚠️ Not deploy-time: hive-c0re creates agents at runtime (`hive_c0re::meta`).
/// swarm-controller rewrites every agent's document at its own start, so an
/// edit here reaches existing agents with the next controller restart.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]` — it
/// is interpolated into a policy path, so a name that could close the stanza
/// could grant itself anything.
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`: the
/// name is interpolated into the policy text, where it could close the stanza.
pub fn render_agent(agent: &str) -> Result<String, Error> {
checked_segment("agent", agent)?;
Ok(read_stanza(&format!(
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
<&str>::from(Kind::Agent)
)))
let kind = <&str>::from(Kind::Agent);
let read = read_stanza(&format!("{MOUNT}/data/{ROOT}/{kind}/{agent}/*"));
let list = list_stanza(&format!("{MOUNT}/metadata/{ROOT}/{kind}/{agent}/*"));
Ok(format!("{read}{list}"))
}
#[cfg(test)]
@ -349,26 +356,30 @@ mod tests {
assert!(n.starts_with(HIVE_PREFIX));
}
/// What `render_agent("atlas")` must render, byte for byte.
const ATLAS_DOCUMENT: &str = "path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n\
path \"secret/metadata/swarm/agents/atlas/*\" {\n capabilities = [\"list\"]\n}\n";
#[test]
fn an_agents_document_is_one_stanza_naming_that_agent() {
fn an_agents_document_is_two_stanzas_naming_that_agent() {
assert_eq!(
render_agent("atlas").expect("a plain name is legal"),
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
ATLAS_DOCUMENT
);
}
#[test]
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
// The arm the whole change exists for. `render`'s agent stanza is
// `agents/*` on purpose, and inheriting one character of that here
// would give every agent every other agent's credentials while the
// document still read as per-agent.
// `render`'s agent stanza is `agents/*` on purpose, and inheriting one
// character of that here would give every agent every other agent's
// credentials while the document still read as per-agent.
let p = render_agent("atlas").expect("legal");
assert!(
!p.contains("swarm/agents/*"),
"the agent stanza must not widen to the kind: {p}"
);
assert!(p.contains("swarm/agents/atlas/*"));
assert!(p.contains("secret/data/swarm/agents/atlas/*"));
assert!(p.contains("secret/metadata/swarm/agents/atlas/*"));
}
#[test]
@ -389,19 +400,16 @@ mod tests {
assert!(!p.contains("argus"), "no other principal is named");
assert_eq!(
p.matches("path \"").count(),
1,
"one stanza, or the document grants something unaccounted for: {p}"
2,
"two stanzas, or the document grants something unaccounted for: {p}"
);
}
#[test]
fn an_agents_document_is_exactly_its_own_read_stanza() {
fn an_agents_document_is_exactly_its_own_two_stanzas() {
// Pinned byte for byte: an added stanza (a hive's queue credential, a
// second agent) is exactly what a presence check misses.
assert_eq!(
render_agent("atlas").expect("legal"),
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
);
assert_eq!(render_agent("atlas").expect("legal"), ATLAS_DOCUMENT);
// The control: the pin discriminates between agents.
assert!(
!render_agent("other")
@ -411,18 +419,42 @@ mod tests {
}
#[test]
fn an_agents_grant_is_read_only() {
fn an_agents_grant_writes_nothing() {
// An agent that could write its own credentials could hand itself an
// identity it was never issued — and `create`/`update` on that path is
// exactly what the controller holds, so the wall is the capability.
let p = render_agent("atlas").expect("legal");
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
for capability in ["create", "update", "delete", "sudo", "patch"] {
assert!(
!p.contains(capability),
"an agent's document must not grant {capability}: {p}"
);
}
assert!(p.contains("capabilities = [\"read\"]"));
}
#[test]
fn an_agent_reads_values_and_lists_names_and_nothing_crosswise() {
// `read` on `data/` is the values; `list` on `metadata/` is the key
// names. `read` on `metadata/` (version history, deletion times) is not
// granted, nor is `list` anywhere but `metadata/`.
let p = render_agent("atlas").expect("legal");
let stanzas: Vec<&str> = p.split_inclusive("}\n").collect();
assert_eq!(stanzas.len(), 2, "{p}");
for stanza in stanzas {
let on_data = stanza.starts_with("path \"secret/data/");
let on_metadata = stanza.starts_with("path \"secret/metadata/");
assert!(on_data != on_metadata, "{stanza}");
assert_eq!(
stanza.contains("capabilities = [\"read\"]"),
on_data,
"{stanza}"
);
assert_eq!(
stanza.contains("capabilities = [\"list\"]"),
on_metadata,
"{stanza}"
);
}
}
#[test]