Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
780df10d9d swarm: name the agent client after its hive, not after "agent"
`agent-<hive>` reads as "the agent named <hive>" — which is the one thing that
identity does not carry, since it is minted per hive. It becomes
`hive-<hive>-agent`: the hive's own id, extended.

The rename is not a string swap. `hive-foo-agent` satisfies the hive parse too
(it strips to a hive named `foo-agent`), so the responder's agent rule now runs
BEFORE its hive rule — most specific wins. Hive-first would have handed every
agent its hive's grant, including writing that hive's status key, with nothing
to report it: the client authenticates and is merely able to do more than it
should.

`Policy::new`'s overlap check goes with the prefix it was written for. The
invariant the suffix form needs instead is that the suffix is non-empty: an
empty one makes `strip_suffix` succeed on every hive id, so the two principals
become one string and whichever arm runs first answers for both.

The suffix form also introduces a collision the prefix form did not have: a hive
genuinely named `foo-agent` mints `hive-foo-agent`, which is hive `foo`'s agent
id. The responder cannot see it — it has no roster, deliberately — so
`swarm-authelia.nix` asserts at eval that no hive name ends with the suffix. The
existing duplicate-id assertion does not cover this: it fires only when both
`foo` and `foo-agent` are on the roster, and with `foo-agent` alone there is no
duplicate, just a hive quietly receiving its agents' grant.

A test written by analogy with `the_prefix_alone_names_no_hive` failed, correctly
— `hive--agent` is a hive named `-agent` under the hive parse, which this module
cannot rule out. It now asserts only the part this module owns: no empty hive
name is ever expanded into a subject.
2026-09-12 10:33:06 +02:00
atlas
fe9417ae52 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.
2026-09-12 10:33:06 +02:00
5 changed files with 399 additions and 13 deletions

View file

@ -181,9 +181,10 @@ namespace.
By default agents can only reach the host on 80/443 (+53 DNS), so a By default agents can only reach the host on 80/443 (+53 DNS), so a
host-side service on another port — for example a dev OTLP collector you want host-side service on another port — for example a dev OTLP collector you want
agents to reach directly — is unreachable. (hyperhive's own telemetry agents to reach directly — is unreachable. (hyperhive's own services
needs none of this: `otel.enable` opens its collector's port itself, and need none of this: a module that means to be reachable from agents opens
`otel.endpoint` is the _upstream_, which no agent ever dials. See its own port here, which `otel.enable` and `nats.enable` both do. Note
that `otel.endpoint` is the _upstream_, which no agent ever dials. See
`docs/scheduler/observability.md`.) `docs/scheduler/observability.md`.)
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each `services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each

View file

@ -143,6 +143,40 @@ let
accessTokenSignedResponseAlg = "RS256"; accessTokenSignedResponseAlg = "RS256";
}) hyperhiveCfg.swarm.hives; }) hyperhiveCfg.swarm.hives;
# The identity an agent container presents to the swarm's queue. One per
# HIVE, not per agent, and that is the load-bearing choice rather than a
# shortcut: agents are created at **runtime**, so anything minted per agent
# would make creating one a config change plus an authelia reload. Keyed on
# the hive, this list's length tracks the roster above it — deploy-time, like
# every other entry here.
#
# ⚠️ The cost, stated rather than discovered later: every agent on a hive
# presents the SAME client id, so the broker can tell *hives* apart and not
# *agents*. Deliberate and deferred — closing it requires the client list to
# stop being static, which is the same problem the users-database writer
# already solves for identities.
#
# 🏷️ The id EXTENDS the hive's own (`hive-<name>-agent`) rather than taking a
# prefix of its own (`agent-<name>`), because that reads as *the agent called
# `<name>`* — which is the one thing this identity does not carry. The cost is
# that the two ids are no longer distinguishable by prefix, so the responder's
# agent rule must be tried BEFORE its hive rule; `policy.rs` says so at the
# match site, and the assertion below covers the case that ordering cannot.
#
# Mirrors `hiveClients` field for field so the two stay comparable. The
# signing algorithm is not load-bearing here — this client's only consumer is
# the queue's auth-callout responder, which *introspects* rather than
# verifying offline — but it matches its sibling rather than inventing a
# second answer to a question nobody asked.
agentClients = lib.mapAttrsToList (name: _: {
id = "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}";
description = "HyperHive agents on hive ${name}";
kind = "machine";
redirectUris = [ ];
audience = [ "${cfg.hiveClientPrefix}${name}${cfg.agentClientSuffix}" ];
accessTokenSignedResponseAlg = "RS256";
}) hyperhiveCfg.swarm.hives;
# `swarm-authelia-bridge`'s own identity — distinct from # `swarm-authelia-bridge`'s own identity — distinct from
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A # `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
# resource server introspecting a token proves its OWN identity to the # resource server introspecting a token proves its OWN identity to the
@ -416,9 +450,21 @@ in
default = deployCfg.nats.enable; default = deployCfg.nats.enable;
defaultText = lib.literalExpression "services.hyperhive.deploy.nats.enable"; defaultText = lib.literalExpression "services.hyperhive.deploy.nats.enable";
description = '' description = ''
Mint one machine client per hive in Mint machine clients per hive in
{option}`services.hyperhive.swarm.hives`, so each hive can {option}`services.hyperhive.swarm.hives`, so each hive can
authenticate to swarm services as itself. authenticate to swarm services as itself: `hive-<name>` for the
hive's own daemons, and `hive-<name>-agent` for the agent containers
running on it.
Two clients rather than one because they are not the same
principal — a hive's daemons run on the host and an agent runs
in a container the host hands a credential to, so a swarm
service has to be able to grant them different things. It is
one client per *hive* on the agent side, 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 from each other,
tracked as a follow-up rather than papered over.
Defaults to whether the swarm message queue is enabled, because Defaults to whether the swarm message queue is enabled, because
that is the first service that needs a hive to prove who it is. that is the first service that needs a hive to prove who it is.
@ -667,6 +713,30 @@ in
''; '';
}; };
agentClientSuffix = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "-agent";
description = ''
Suffix appended to a hive's own client id to name the client its
*agent containers* present — agents on hive `alpha` all present
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha${config.services.hyperhive.swarm.authelia.agentClientSuffix}`.
Read-only for the same reason as `hiveClientPrefix`, and read by the
same consumer with the same failure mode: a split spelling denies
every agent as a timeout.
A suffix on the hive's id rather than a prefix of its own, because
`agent-alpha` reads as *the agent named alpha* — which is precisely
what this identity does not say.
One id per hive rather than per agent, because agents are created at
runtime and a per-agent client would make creating one a config
change plus a reload. The consequence is that this identity says
*which hive* an agent belongs to and never *which agent* — the
broker cannot tell two agents on one hive apart.
'';
};
machine = lib.mkOption { machine = lib.mkOption {
type = lib.types.str; type = lib.types.str;
readOnly = true; readOnly = true;
@ -828,7 +898,7 @@ in
# wherever this module is, so `oidc.clients` is never actually empty # wherever this module is, so `oidc.clients` is never actually empty
# — see `oidcEnabled`'s comment above. # — see `oidcEnabled`'s comment above.
services.hyperhive.swarm.authelia.oidc.clients = services.hyperhive.swarm.authelia.oidc.clients =
lib.optionals cfg.oidc.hiveIdentities hiveClients lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients)
++ [ bridgeClient ]; ++ [ bridgeClient ];
# A redirect URI on a machine client is not harmless-but-unused: it # A redirect URI on a machine client is not harmless-but-unused: it
@ -877,6 +947,38 @@ in
+ "services.hyperhive.swarm.hives; rename the hive or the " + "services.hyperhive.swarm.hives; rename the hive or the "
+ "colliding client."; + "colliding client.";
} }
{
# A hive whose name ENDS with the agent suffix mints an id the
# queue's responder reads as somebody else's agents:
# `hive-foo-agent` parses as *the agents of hive foo* before it
# parses as *the hive foo-agent*, because the agent rule is the
# more specific one and therefore runs first.
#
# ⚠️ NOT covered by the duplicate-id assertion above, and the gap is
# the interesting half. That one fires only when BOTH `foo` and
# `foo-agent` are on the roster, because only then are two clients
# actually named the same string. With `foo-agent` alone there is no
# duplicate and nothing to see — the hive simply receives its
# agents' grant instead of its own, and a NATS denial arrives as a
# timeout, so the symptom names nothing.
#
# Checkable here and nowhere downstream: the responder runs in a
# container with no view of the roster (`policy.rs`'s `hive_name`
# says why that is deliberate), so this is the last place that knows
# both the naming scheme and the set of names.
assertion =
!cfg.oidc.hiveIdentities
|| !lib.any (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives);
message =
"services.hyperhive.swarm.hives contains "
+ lib.concatMapStringsSep ", " (h: "'${h}'") (
lib.filter (h: lib.hasSuffix cfg.agentClientSuffix h) (lib.attrNames hyperhiveCfg.swarm.hives)
)
+ " — names ending with '${cfg.agentClientSuffix}', the suffix that "
+ "marks a hive's agent containers. Such a hive's own client id is "
+ "indistinguishable from another hive's agent client, and the "
+ "queue resolves it as the agents. Rename the hive.";
}
# The two obligations `authelia.bearer.authz` carries. Authelia # The two obligations `authelia.bearer.authz` carries. Authelia
# enforces both itself — but in its `preStart` validator, so a # enforces both itself — but in its `preStart` validator, so a
# violation produces a green `nixos-rebuild switch` and an authelia # violation produces a green `nixos-rebuild switch` and an authelia

View file

@ -233,6 +233,17 @@ in
TCP port the queue listens on. 4222 is upstream's default and TCP port the queue listens on. 4222 is upstream's default and
sits outside hyperhive's claimed ranges (dashboard 7000, forge sits outside hyperhive's claimed ranges (dashboard 7000, forge
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash). 3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
Contributed to
`services.hyperhive.network.exposeHostPorts`, which opens it on
the bridge interface only — so it is reachable from agent
containers and not from the outside world. An agent connects to
`nats://<bridgeIp>:<port>`; loopback inside a container is the
agent itself, not this host.
Unlike {option}`monitorPort` and {option}`metricsPort`, the
address is not what bounds who may use this port: the queue's
`auth_callout` refuses every client it cannot identify.
''; '';
}; };
@ -564,6 +575,26 @@ in
nats = "127.0.0.1:${toString cfg.metricsPort}"; nats = "127.0.0.1:${toString cfg.metricsPort}";
}; };
# Open the client port on the bridge, so agent containers can reach
# the queue. `privateNetwork = false` below means the server binds in
# the host netns, which is the precondition this option names — but
# sharing a netns is not reachability: the bridge interface is
# default-deny, so without this an agent's connect attempt is dropped
# by the firewall and looks exactly like every other NATS failure,
# a timeout.
#
# ⚠️ Bridge interface only — never the world. What makes it safe to
# open at all is that the queue is fail-closed: `auth_callout` admits
# nobody until the responder above answers for them, so an agent that
# reaches this port still has to present a token authelia vouches for.
#
# An agent connects to `nats://<bridgeIp>:<port>`, NOT to
# `nats://127.0.0.1:<port>` — inside a container loopback is the
# *agent*. Every url in this repo today is the loopback one and each
# is correct for its reader, because those readers share the host
# netns; an agent does not.
services.hyperhive.network.exposeHostPorts = [ cfg.port ];
containers.swarm-nats = { containers.swarm-nats = {
autoStart = true; autoStart = true;
ephemeral = false; ephemeral = false;
@ -730,14 +761,15 @@ in
# be the same string — which is why both come from one let. # be the same string — which is why both come from one let.
"--account ${lib.escapeShellArg clientAccount}" "--account ${lib.escapeShellArg clientAccount}"
"--introspection-url ${lib.escapeShellArg introspectionUrl}" "--introspection-url ${lib.escapeShellArg introspectionUrl}"
# Both of these name a principal some OTHER module mints, # Each of these names a principal some OTHER module mints,
# so both are read out of that module rather than spelled # so each is read out of that module rather than spelled
# again here — same argument as `--account` above, one # again here — same argument as `--account` above, one
# level wider. The responder denies a client id it does # level wider. The responder denies a client id it does
# not recognise, and a NATS denial arrives as a timeout, # not recognise, and a NATS denial arrives as a timeout,
# so a drift here is silent at the point of change and # so a drift here is silent at the point of change and
# misattributed at the point of failure. # misattributed at the point of failure.
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}" "--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
"--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}"
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}" "--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
]; ];
# Every credential arrives by `LoadCredential` and is named # Every credential arrives by `LoadCredential` and is named

View file

@ -82,6 +82,21 @@ struct Args {
#[arg(long, default_value = "hive-")] #[arg(long, default_value = "hive-")]
hive_client_prefix: String, hive_client_prefix: String,
/// Suffix that marks an agent container, appended to the hive's own client
/// id: `swarm-authelia.nix` mints one machine client per roster entry as
/// `hive-<name>-agent` — 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.
///
/// A suffix on the hive's id rather than a prefix of its own, because
/// `agent-<name>` reads as *the agent called `<name>`* — the one thing
/// this identity does not carry.
#[arg(long, default_value = "-agent")]
agent_client_suffix: String,
/// Client ids allowed to read every hive's status. Repeatable. The /// Client ids allowed to read every hive's status. Repeatable. The
/// default is the swarm controller, which is the only reader that exists. /// default is the swarm controller, which is the only reader that exists.
#[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])] #[arg(long = "reader-client", default_values_t = [String::from("swarm-controller")])]
@ -96,6 +111,18 @@ struct Args {
/// subject still lands inside that hive's own namespace. /// subject still lands inside that hive's own namespace.
#[arg(long = "hive-publish-subject")] #[arg(long = "hive-publish-subject")]
hive_publish_subjects: Vec<String>, hive_publish_subjects: Vec<String>,
/// Subjects an agent may publish to, with `{hive}` standing for the hive
/// its identity names — `hive-alpha-agent` expands it to `alpha`.
/// 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. /// Read a secret file and strip surrounding whitespace.
@ -140,9 +167,11 @@ async fn main() -> anyhow::Result<()> {
// silently over-broad grant is not. // silently over-broad grant is not.
let policy = policy::Policy::new( let policy = policy::Policy::new(
args.hive_client_prefix.clone(), args.hive_client_prefix.clone(),
args.agent_client_suffix.clone(),
swarm_queue_client::status::BUCKET.to_owned(), swarm_queue_client::status::BUCKET.to_owned(),
args.reader_clients.clone(), args.reader_clients.clone(),
args.hive_publish_subjects.clone(), args.hive_publish_subjects.clone(),
args.agent_publish_subjects.clone(),
)?; )?;
let http = reqwest::Client::new(); let http = reqwest::Client::new();
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?) let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)

View file

@ -40,19 +40,29 @@ pub struct Permissions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Policy { pub struct Policy {
hive_prefix: String, hive_prefix: String,
agent_suffix: String,
bucket: String, bucket: String,
readers: Vec<String>, readers: Vec<String>,
extra_hive_subjects: 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}"; const HIVE_PLACEHOLDER: &str = "{hive}";
impl Policy { impl Policy {
/// `hive_prefix` is the client-id prefix that marks a hive, `bucket` the /// `hive_prefix` is the client-id prefix that marks a hive and
/// KV bucket hives report status in, `readers` the client ids allowed to /// `agent_suffix` what a hive's agent containers carry **on top of** it —
/// read every hive's key, and `extra_hive_subjects` additional subjects a /// `hive-<name>` and `hive-<name>-agent`. `bucket` is the KV bucket hives
/// hive may publish to (with `{hive}` standing for its own name). /// 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).
///
/// The agent id is deliberately the hive's id extended, not a second
/// top-level prefix: `agent-<name>` reads as *the agent called `<name>`*,
/// which is the one thing this identity does not carry.
/// ///
/// # Errors /// # Errors
/// ///
@ -62,11 +72,18 @@ impl Policy {
/// whose only purpose is to provide one. Returning a `Result` rather than /// whose only purpose is to provide one. Returning a `Result` rather than
/// checking at the call site is deliberate: it makes an unscoped policy /// checking at the call site is deliberate: it makes an unscoped policy
/// unconstructible instead of merely unlikely. /// unconstructible instead of merely unlikely.
///
/// An empty `agent_suffix` is refused for the same reason: every hive id
/// would also parse as its own agent id, and 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( pub fn new(
hive_prefix: String, hive_prefix: String,
agent_suffix: String,
bucket: String, bucket: String,
readers: Vec<String>, readers: Vec<String>,
extra_hive_subjects: Vec<String>, extra_hive_subjects: Vec<String>,
extra_agent_subjects: Vec<String>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
if let Some(bad) = extra_hive_subjects if let Some(bad) = extra_hive_subjects
.iter() .iter()
@ -78,11 +95,41 @@ impl Policy {
a per-hive namespace" 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 these are two
// flags and nothing downstream compares them. An agent id is a hive id
// plus this suffix, so an empty one makes `strip_suffix` succeed on
// every hive id and the two principals become the same string. What
// that costs is not a parse error but a **grant**: whichever arm runs
// first answers for both, and a client that authenticates fine is
// handed more than it should have — the one failure this module must
// not have.
if agent_suffix.is_empty() {
anyhow::bail!(
"--agent-client-suffix is empty: an agent id is a hive id plus this suffix, \
so every hive would also parse as its own agents and one of the two would \
silently receive the other's grant"
);
}
Ok(Self { Ok(Self {
hive_prefix, hive_prefix,
agent_suffix,
bucket, bucket,
readers, readers,
extra_hive_subjects, extra_hive_subjects,
extra_agent_subjects,
}) })
} }
@ -92,6 +139,24 @@ impl Policy {
/// a connected client with no permissions still holds a slot and still /// a connected client with no permissions still holds a slot and still
/// looks admitted in the logs, which is a worse answer than a refusal. /// looks admitted in the logs, which is a worse answer than a refusal.
pub fn permissions(&self, client_id: &str) -> Option<Permissions> { pub fn permissions(&self, client_id: &str) -> Option<Permissions> {
// ⚠️ THE AGENT ARM RUNS FIRST, AND THE ORDER IS LOAD-BEARING. An agent
// id is a hive id with a suffix, so `hive-foo-agent` satisfies the hive
// arm too — it strips to the hive `foo-agent`. Hive-first would
// therefore hand every agent its hive's grant, including writing that
// hive's status key, and nothing would report it: the client
// authenticates, connects, and is simply able to do more than it
// should. Most specific wins, so the more specific test is the one
// that has to be asked first.
//
// 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 let Some(hive) = self.hive_name(client_id) { if let Some(hive) = self.hive_name(client_id) {
return Some(Permissions { return Some(Permissions {
publish: self.hive_subjects(hive), publish: self.hive_subjects(hive),
@ -134,6 +199,29 @@ impl Policy {
.filter(|name| !name.is_empty()) .filter(|name| !name.is_empty())
} }
/// The hive whose agents a client id names, when it names one.
///
/// [`Self::hive_name`]'s caveats all apply — this is that parse with the
/// suffix stripped as well, so `hive-foo-agent` answers `foo`. Two 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.
/// - A hive genuinely named `foo-agent` mints `hive-foo-agent` and is
/// therefore **indistinguishable from hive `foo`'s agents** right here.
/// Unguardable from inside this responder, which has no roster (see
/// [`Self::hive_name`]) — `swarm-authelia.nix` asserts it at eval, where
/// the roster is known, so the deployment fails to build rather than
/// minting two principals with one id.
fn agent_hive<'a>(&self, client_id: &'a str) -> Option<&'a str> {
client_id
.strip_prefix(&self.hive_prefix)?
.strip_suffix(&self.agent_suffix)
.filter(|name| !name.is_empty())
}
fn stream(&self) -> String { fn stream(&self) -> String {
format!("KV_{}", self.bucket) format!("KV_{}", self.bucket)
} }
@ -314,6 +402,19 @@ impl Policy {
subjects 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> { fn reader_subjects(&self) -> Vec<String> {
let stream = self.stream(); let stream = self.stream();
let mut subjects = Self::jetstream_minimum().to_vec(); let mut subjects = Self::jetstream_minimum().to_vec();
@ -419,13 +520,30 @@ mod tests {
fn policy() -> Policy { fn policy() -> Policy {
Policy::new( Policy::new(
"hive-".to_owned(), "hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(), "hive-status".to_owned(),
vec!["swarm-controller".to_owned()], vec!["swarm-controller".to_owned()],
Vec::new(), Vec::new(),
Vec::new(),
) )
.expect("the default policy is valid") .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] #[test]
fn the_reader_can_open_and_list_the_agent_status_bucket() { fn the_reader_can_open_and_list_the_agent_status_bucket() {
let subjects = policy().reader_subjects(); let subjects = policy().reader_subjects();
@ -520,9 +638,11 @@ mod tests {
// way to remove it - silently, and only in the deployment that set it. // way to remove it - silently, and only in the deployment that set it.
let err = Policy::new( let err = Policy::new(
"hive-".to_owned(), "hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.all".to_owned()], vec!["$SWARM.events.all".to_owned()],
Vec::new(),
) )
.expect_err("a subject shared by every hive must not be accepted"); .expect_err("a subject shared by every hive must not be accepted");
let msg = format!("{err}"); let msg = format!("{err}");
@ -538,9 +658,11 @@ mod tests {
// from the option being broken. // from the option being broken.
Policy::new( Policy::new(
"hive-".to_owned(), "hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()], vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
) )
.expect("a per-hive template is the shape this option is for"); .expect("a per-hive template is the shape this option is for");
} }
@ -944,13 +1066,113 @@ mod tests {
// another's events even though its status key is scoped. // another's events even though its status key is scoped.
let p = Policy::new( let p = Policy::new(
"hive-".to_owned(), "hive-".to_owned(),
"-agent".to_owned(),
"hive-status".to_owned(), "hive-status".to_owned(),
Vec::new(), Vec::new(),
vec!["$SWARM.events.{hive}.>".to_owned()], vec!["$SWARM.events.{hive}.>".to_owned()],
Vec::new(),
) )
.expect("a per-hive template is valid"); .expect("a per-hive template is valid");
let g = p.permissions("hive-alpha").expect("a hive is admitted"); let g = p.permissions("hive-alpha").expect("a hive is admitted");
assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned())); assert!(g.publish.contains(&"$SWARM.events.alpha.>".to_owned()));
assert!(!g.publish.iter().any(|s| s.contains("{hive}"))); 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("hive-alpha-agent").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("hive-alpha-agent")
.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() {
// 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.
//
// 🩸 This became a REGRESSION test when the id moved from `agent-<hive>`
// to `hive-<hive>-agent`: the agent id now satisfies the HIVE parse too
// (`hive-alpha-agent` strips to a hive named `alpha-agent`), so a
// hive-first arm order hands every agent its hive's grant, and nothing
// reports it — the client authenticates and is merely able to do more
// than it should. Together with the `assert_eq` in
// `an_agent_publishes_inside_its_own_hives_namespace` (which pins the
// subject to `alpha`, not `alpha-agent`) this is what holds the
// ordering in place.
let g = policy_with_agent_subject()
.permissions("hive-alpha-agent")
.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 an_empty_agent_suffix_is_refused() {
// 🩸 An agent id is a hive id plus the suffix, so an empty suffix makes
// `strip_suffix` succeed on every hive id: the two principals become
// one string, and whichever arm runs first answers for both. That is a
// GRANT, not a parse error, which is why it has to fail at
// construction.
Policy::new(
"hive-".to_owned(),
String::new(),
"hive-status".to_owned(),
Vec::new(),
Vec::new(),
Vec::new(),
)
.expect_err("an empty agent suffix must not construct");
}
#[test]
fn the_agent_suffix_alone_names_no_hive() {
// `hive--agent` is the prefix and the suffix with nothing between them.
// The agent arm must refuse it rather than expand `{hive}` to the empty
// string and hand out `$SWARM.term..>`.
//
// 🩸 Written first as `is_none()`, by analogy with
// `the_prefix_alone_names_no_hive`, and it FAILED — correctly. The
// analogy does not hold: under the hive parse the same id is a hive
// named `-agent`, which is a non-empty name this responder cannot rule
// out, because it has no roster. `swarm-authelia.nix` refuses such a
// name at eval, where the roster is known. So the assertion here is
// narrowed to the only part this module owns.
let g = policy_with_agent_subject().permissions("hive--agent");
assert!(
!g.iter()
.flat_map(|p| &p.publish)
.any(|s| s.contains("$SWARM.term..")),
"an empty hive name must never be expanded into a subject: {g:?}"
);
}
} }