swarm: give agent containers their own queue principal

Agents have authelia *users*; they had no machine identity at all, so an
agent could not authenticate to the swarm queue as anything. This mints
one `agent-<hive>` OIDC client per hive beside the existing
`hive-<hive>` one, teaches the auth-callout responder an agent arm, and
opens the queue's client port on the bridge so a container can reach it.

One client per HIVE, not per agent: agents are created at runtime, and a
per-agent client would make creating one a config change plus an
authelia reload. The cost is that agents on a hive are indistinguishable
to the broker, which is deliberate and tracked separately.

The agent grant is deny-by-default twice over. An agent id matches no
hive rule, so it gets a hive's status-key grant from neither; and with
no agent subject configured the responder returns no grant at all rather
than an empty publish list, which would be a denial wearing a grant's
shape. What an agent may publish is a deployment's decision, taken
through `--agent-publish-subject` the same way `--hive-publish-subject`
already works.

`Policy::new` now refuses two prefixes where one contains the other. The
arms are tried in order, so that overlap does not error at match time -
it silently hands one principal the other's grant.

Not shipped here, and neither is reachable without it: no subject is
configured for agents anywhere in nix, and nothing yet delivers
`agent-<hive>.secret` into an agent container. Both belong to the stream
that will be the first consumer.
This commit is contained in:
atlas 2026-08-31 17:30:00 +02:00 committed by mara
commit fe9417ae52
5 changed files with 301 additions and 13 deletions

View file

@ -82,6 +82,16 @@ struct Args {
#[arg(long, default_value = "hive-")]
hive_client_prefix: String,
/// Client-id prefix that marks an agent container. `swarm-authelia.nix`
/// mints one machine client per roster entry as `agent-<hive>` — per
/// **hive**, not per agent, because agents are created at runtime and a
/// per-agent client would make creating one a config change plus a reload.
///
/// So this identity says which hive an agent belongs to and never which
/// agent: two agents on one hive are indistinguishable to this responder.
#[arg(long, default_value = "agent-")]
agent_client_prefix: String,
/// Client ids allowed to read every hive's status. Repeatable. The
/// default is the swarm controller, which is the only reader that exists.
#[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])]
@ -96,6 +106,17 @@ struct Args {
/// subject still lands inside that hive's own namespace.
#[arg(long = "hive-publish-subject")]
hive_publish_subjects: Vec<String>,
/// Subjects an agent may publish to, with `{hive}` standing for the hive
/// its identity names. Repeatable, empty by default.
///
/// Empty means agents get **no grant at all** rather than a grant that can
/// do nothing — the identity exists, and what it may say is a deployment's
/// decision, not this responder's. The first consumer is the agent
/// terminal-event stream; until one is configured, an agent that connects
/// is refused, which is loud rather than silently over-broad.
#[arg(long = "agent-publish-subject")]
agent_publish_subjects: Vec<String>,
}
/// Read a secret file and strip surrounding whitespace.
@ -140,9 +161,11 @@ async fn main() -> anyhow::Result<()> {
// silently over-broad grant is not.
let policy = policy::Policy::new(
args.hive_client_prefix.clone(),
args.agent_client_prefix.clone(),
swarm_queue_client::status::BUCKET.to_owned(),
args.reader_clients.clone(),
args.hive_publish_subjects.clone(),
args.agent_publish_subjects.clone(),
)?;
let http = reqwest::Client::new();
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)

View file

@ -40,19 +40,24 @@ pub struct Permissions {
#[derive(Debug, Clone)]
pub struct Policy {
hive_prefix: String,
agent_prefix: String,
bucket: String,
readers: Vec<String>,
extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
}
/// Placeholder replaced with the hive's own name in `extra_hive_subjects`.
/// Placeholder replaced with the hive's own name in `extra_hive_subjects` and
/// `extra_agent_subjects`.
const HIVE_PLACEHOLDER: &str = "{hive}";
impl Policy {
/// `hive_prefix` is the client-id prefix that marks a hive, `bucket` the
/// KV bucket hives report status in, `readers` the client ids allowed to
/// read every hive's key, and `extra_hive_subjects` additional subjects a
/// hive may publish to (with `{hive}` standing for its own name).
/// `hive_prefix` is the client-id prefix that marks a hive and
/// `agent_prefix` the one that marks a hive's agent containers; `bucket` is
/// the KV bucket hives report status in, `readers` the client ids allowed
/// to read every hive's key, and `extra_hive_subjects` /
/// `extra_agent_subjects` additional subjects each may publish to (with
/// `{hive}` standing for the hive's own name).
///
/// # Errors
///
@ -62,11 +67,18 @@ impl Policy {
/// whose only purpose is to provide one. Returning a `Result` rather than
/// checking at the call site is deliberate: it makes an unscoped policy
/// unconstructible instead of merely unlikely.
///
/// Two prefixes where one of them is a prefix of the other is refused for
/// the same reason: the arms are tried in order, so the overlap does not
/// error at match time — it silently hands one principal the other's
/// grant.
pub fn new(
hive_prefix: String,
agent_prefix: String,
bucket: String,
readers: Vec<String>,
extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
) -> anyhow::Result<Self> {
if let Some(bad) = extra_hive_subjects
.iter()
@ -78,11 +90,40 @@ impl Policy {
a per-hive namespace"
);
}
// Same rule, same reason: an agent's identity is its *hive's*, so a
// template with no placeholder is one subject shared by every agent in
// the swarm rather than a per-hive one.
if let Some(bad) = extra_agent_subjects
.iter()
.find(|s| !s.contains(HIVE_PLACEHOLDER))
{
anyhow::bail!(
"--agent-publish-subject {bad:?} contains no {HIVE_PLACEHOLDER}: every agent \
in the swarm would be granted that exact subject, so it is a shared channel \
rather than a per-hive namespace"
);
}
// Checked here rather than trusted from nix, because the two prefixes
// are two flags and nothing downstream compares them. `permissions`
// tries the hive arm first, so an agent prefix of `""` — or either one
// being a prefix of the other — would route agents into the hive grant
// with no error anywhere: a client that authenticates fine and is
// granted more than it should have, which is the one failure this
// module must not have.
if hive_prefix.starts_with(&agent_prefix) || agent_prefix.starts_with(&hive_prefix) {
anyhow::bail!(
"--hive-client-prefix {hive_prefix:?} and --agent-client-prefix \
{agent_prefix:?} overlap: one is a prefix of the other, so a client id \
matching the longer one is granted by whichever rule is tried first"
);
}
Ok(Self {
hive_prefix,
agent_prefix,
bucket,
readers,
extra_hive_subjects,
extra_agent_subjects,
})
}
@ -97,6 +138,15 @@ impl Policy {
publish: self.hive_subjects(hive),
});
}
// An agent's identity names its hive, never the agent — see
// `Self::agent_hive`. The grant is whatever the deployment configured
// for agents, expanded for that hive, and **nothing when it configured
// none**: an empty publish list would be a denial wearing a grant's
// shape, so this returns `None` and the client is refused outright.
if let Some(hive) = self.agent_hive(client_id) {
let publish = self.agent_subjects(hive);
return (!publish.is_empty()).then_some(Permissions { publish });
}
if self.readers.iter().any(|r| r == client_id) {
return Some(Permissions {
publish: self.reader_subjects(),
@ -134,6 +184,19 @@ impl Policy {
.filter(|name| !name.is_empty())
}
/// The hive whose agents a client id names, when it names one.
///
/// Deliberately the same shape as [`Self::hive_name`] one prefix over, and
/// every caveat above applies unchanged. One more on top: the agent client
/// is minted per **hive**, so this answers *whose agents*, never *which
/// agent*. Two agents on one hive are indistinguishable here — not a gap in
/// the parsing but the identity itself, which does not carry the agent.
fn agent_hive<'a>(&self, client_id: &'a str) -> Option<&'a str> {
client_id
.strip_prefix(&self.agent_prefix)
.filter(|name| !name.is_empty())
}
fn stream(&self) -> String {
format!("KV_{}", self.bucket)
}
@ -314,6 +377,19 @@ impl Policy {
subjects
}
/// Subjects an agent of `hive` may publish to.
///
/// No `JetStream` minimum, unlike [`Self::hive_subjects`]: a hive needs it
/// because it writes the status bucket, and an agent publishes to plain
/// subjects. Granting it anyway would be reasoning by analogy about a
/// permission set the module documents as minimal by removal.
fn agent_subjects(&self, hive: &str) -> Vec<String> {
self.extra_agent_subjects
.iter()
.map(|s| s.replace(HIVE_PLACEHOLDER, hive))
.collect()
}
fn reader_subjects(&self) -> Vec<String> {
let stream = self.stream();
let mut subjects = Self::jetstream_minimum().to_vec();
@ -419,13 +495,30 @@ mod tests {
fn policy() -> Policy {
Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
vec!["swarm-controller".to_owned()],
Vec::new(),
Vec::new(),
)
.expect("the default policy is valid")
}
/// The same policy with agents given something to publish, since the
/// default deployment configures no agent subject and an agent is then
/// refused outright.
fn policy_with_agent_subject() -> Policy {
Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
vec!["swarm-controller".to_owned()],
Vec::new(),
vec!["$SWARM.term.{hive}.>".to_owned()],
)
.expect("a per-hive agent template is valid")
}
#[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects();
@ -520,9 +613,11 @@ mod tests {
// way to remove it - silently, and only in the deployment that set it.
let err = Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.all".to_owned()],
Vec::new(),
)
.expect_err("a subject shared by every hive must not be accepted");
let msg = format!("{err}");
@ -538,9 +633,11 @@ mod tests {
// from the option being broken.
Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
)
.expect("a per-hive template is the shape this option is for");
}
@ -944,13 +1041,88 @@ mod tests {
// another's events even though its status key is scoped.
let p = Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
)
.expect("a per-hive template is valid");
let g = p.permissions("hive-alpha").expect("a hive is admitted");
assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned()));
assert!(!g.publish.iter().any(|s| s.contains("{hive}")));
}
#[test]
fn an_agent_with_no_configured_subject_is_refused() {
// The shipped default: the identity exists, what it may say does not.
// A grant with an empty publish list would let it connect and then
// fail on every publish, which reads as a broken queue rather than as
// a deployment that configured nothing.
assert!(policy().permissions("agent-alpha").is_none());
}
#[test]
fn an_agent_publishes_inside_its_own_hives_namespace() {
// The presence control for the test above - without it, "refused"
// would also be the answer if the agent arm never matched at all.
let g = policy_with_agent_subject()
.permissions("agent-alpha")
.expect("an agent with a configured subject is admitted");
assert_eq!(g.publish, vec!["$SWARM.term.alpha.>".to_owned()]);
}
#[test]
fn an_agent_does_not_get_its_hives_grant() {
// The whole point of a second prefix. An agent holds a credential that
// sits in a container; a hive's grant includes writing that hive's
// status key, which an agent must not be able to forge.
let g = policy_with_agent_subject()
.permissions("agent-alpha")
.expect("an agent is admitted");
assert!(!g.publish.iter().any(|s| s.starts_with("$KV.")));
assert!(!g.publish.iter().any(|s| s.starts_with("$JS.API.")));
}
#[test]
fn an_agent_subject_without_the_placeholder_is_refused() {
let err = Policy::new(
"hive-".to_owned(),
"agent-".to_owned(),
"hive-status".to_owned(),
Vec::new(),
Vec::new(),
vec!["$SWARM.term.all".to_owned()],
)
.expect_err("a subject shared by every agent in the swarm is not per-hive");
assert!(
format!("{err}").contains("$SWARM.term.all"),
"the error must name the offending template"
);
}
#[test]
fn overlapping_prefixes_are_refused() {
// 🩸 The arms are tried in order, so an overlap does not error at match
// time - it hands one principal the other's grant. Both directions,
// because which one wins depends only on the order above.
for (hive, agent) in [("hive-", "hive-agent-"), ("agent-x-", "agent-"), ("h", "h")] {
Policy::new(
hive.to_owned(),
agent.to_owned(),
"hive-status".to_owned(),
Vec::new(),
Vec::new(),
Vec::new(),
)
.expect_err("prefixes where one contains the other must not construct");
}
}
#[test]
fn the_agent_prefix_alone_names_no_hive() {
// Same trap as `the_prefix_alone_names_no_hive`: `agent-` with nothing
// after it would expand `{hive}` to the empty string.
assert!(policy_with_agent_subject().permissions("agent-").is_none());
}
}