Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats-auth: verify an agent's own token against the store

An `auth_token` spelled `swarm-agent.<agent>.<secret>` is no longer sent
to introspection. The responder reads `swarm/agents/<agent>/queue` with
an identity of its own, checks that the stored object names the same
agent, compares the secret in constant time, and grants the subjects
`--agent-token-publish-subject` lists with `{agent}` expanded. Every
other outcome denies: a malformed token, no store identity, nothing
stored, a failed or slow lookup, a different secret. A token without
the prefix takes the OIDC path unchanged.

The journal's `auth request` line names such a caller `agent:<agent>`;
the hive-shared credential keeps `hive-<h>-agent`.

The new principal: a `swarm-nats-auth` cert-auth role and policy with
read on `secret/data/swarm/agents/+/queue` alone, a leaf signed by the
store's PKI glue, and `glue-nats-auth-bao-identity.nix` pairing the two.
The copy unit delivers the identity into the queue's container, and an
absent leaf is delivered empty so the responder still starts and only
agent tokens are refused.

The policy and role are written by `swarm-bao-nats-auth-policy`, logged in
as the bao granter: both names fall under its `swarm-*` globs, so the
deploy writes them with no operator step. module-eval counts it among the
granting units, so every generic granting-unit case covers it.

The secret compare uses `subtle`, already in the lock file through the
TLS stack; no workspace crate offered one directly.
This commit is contained in:
atlas 2026-09-27 20:38:34 +02:00
commit 9bad58d86d
14 changed files with 846 additions and 51 deletions

View file

@ -45,12 +45,16 @@ pub struct Policy {
readers: Vec<String>,
extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
agent_token_subjects: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects` and
/// `extra_agent_subjects`.
const HIVE_PLACEHOLDER: &str = "{hive}";
/// Placeholder replaced with the agent's own name in `agent_token_subjects`.
const AGENT_PLACEHOLDER: &str = "{agent}";
impl Policy {
/// `hive_prefix` is the client-id prefix that marks a hive and
/// `agent_suffix` what a hive's agent containers carry **on top of** it —
@ -130,9 +134,43 @@ impl Policy {
readers,
extra_hive_subjects,
extra_agent_subjects,
agent_token_subjects: Vec::new(),
})
}
/// Set the subjects an agent that proved its own credential may publish
/// to, with `{agent}` standing for its name.
///
/// # Errors
///
/// A template with no `{agent}` in it is refused: it would be one subject
/// shared by every agent in the swarm rather than the agent's own.
pub fn with_agent_token_subjects(mut self, subjects: Vec<String>) -> anyhow::Result<Self> {
if let Some(bad) = subjects.iter().find(|s| !s.contains(AGENT_PLACEHOLDER)) {
anyhow::bail!(
"--agent-token-publish-subject {bad:?} contains no {AGENT_PLACEHOLDER}: every \
agent in the swarm would be granted that exact subject"
);
}
self.agent_token_subjects = subjects;
Ok(self)
}
/// The permissions for an agent whose own credential was verified, or
/// `None` when none are configured.
///
/// Keyed on the agent alone: its identity is not tied to a hive. `agent`
/// must already be a single `[A-Za-z0-9_-]` segment, which the token parse
/// guarantees, so it cannot widen a subject with `.`, `*` or `>`.
pub fn agent_token_permissions(&self, agent: &str) -> Option<Permissions> {
let publish: Vec<String> = self
.agent_token_subjects
.iter()
.map(|s| s.replace(AGENT_PLACEHOLDER, agent))
.collect();
(!publish.is_empty()).then_some(Permissions { publish })
}
/// The permissions for `client_id`, or `None` when no rule matches.
///
/// `None` is a denial. It is not "grant nothing and let them connect":
@ -1175,4 +1213,35 @@ mod tests {
"an empty hive name must never be expanded into a subject: {g:?}"
);
}
#[test]
fn an_agent_token_grant_is_the_agents_own_subjects_and_nothing_else() {
let p = policy_with_agent_subject()
.with_agent_token_subjects(vec![
"$SWARM.term.{agent}".to_owned(),
"$SWARM.agent-state.{agent}".to_owned(),
])
.expect("per-agent templates are valid");
let g = p.agent_token_permissions("atlas").expect("configured");
assert_eq!(
g.publish,
vec![
"$SWARM.term.atlas".to_owned(),
"$SWARM.agent-state.atlas".to_owned(),
]
);
}
#[test]
fn an_agent_token_subject_without_the_placeholder_is_refused() {
let err = policy()
.with_agent_token_subjects(vec!["$SWARM.term.all".to_owned()])
.expect_err("a subject shared by every agent is not the agent's own");
assert!(format!("{err}").contains("$SWARM.term.all"), "{err}");
}
#[test]
fn with_no_agent_token_subject_configured_an_agent_token_is_refused() {
assert!(policy().agent_token_permissions("atlas").is_none());
}
}