diff --git a/nix/host-modules/swarm-otel.nix b/nix/host-modules/swarm-otel.nix index baf64d13..99c2c37b 100644 --- a/nix/host-modules/swarm-otel.nix +++ b/nix/host-modules/swarm-otel.nix @@ -72,6 +72,14 @@ let # would be an eval error rather than a silently missing guard. reservedOwners = import ../reserved-names.nix; + # The second, narrower blacklist: words no hive name may CONTAIN, because + # they are fixed segments of identifiers built FROM a hive name + # (`hive-`, `hive--agent`). Substring rather than equality is + # the whole point — a hive called `foo-agent` collides in the composed + # string while colliding with nothing in the list above. That file explains + # why it is not merged into this one. + reservedFragments = import ../reserved-hive-fragments.nix; + # A published target is declared as ONE url, because that url is also the # audience its token is minted for — but prometheus wants the same fact in # three fields. Split it here rather than asking a service to state it @@ -685,6 +693,44 @@ in Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}. ''; } + { + # The substring half, and a genuinely different failure from the one + # above: these words are fixed SEGMENTS of identifiers composed from a + # hive name, so the collision is between two composed strings while + # the names themselves differ. `hive-foo-agent` is hive `foo-agent`'s + # own client id and hive `foo`'s AGENT client id at the same time; the + # queue's responder resolves it as the agents, so `foo-agent` silently + # receives an agent grant instead of its own — and a NATS denial + # reaches a client as a timeout, so nothing names the cause. + # + # Lives here rather than in `swarm-authelia.nix` (which composes those + # ids) because this module already owns the hive-roster check and a + # second site for "is this hive name legal" is how the two drift. + assertion = + !lib.any (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) ( + lib.attrNames hyperhiveCfg.swarm.hives + ); + message = '' + services.hyperhive.swarm.hives contains ${ + lib.concatMapStringsSep ", " (h: "'${h}'") ( + lib.filter (h: lib.any (frag: lib.hasInfix frag h) reservedFragments) ( + lib.attrNames hyperhiveCfg.swarm.hives + ) + ) + }, which CONTAIN a word the swarm composes identifiers from. + + Hive-scoped identifiers are built by joining a hive name with these + words — `hive-` for a hive, `hive--agent` for its agent + containers — so a name containing one produces an identifier that is + also somebody else's. The failure is a wrong grant, not an error: + the client authenticates and receives permissions meant for another + principal. + + Rename the hive. Forbidden as substrings: ${ + lib.concatMapStringsSep ", " (n: "'${n}'") reservedFragments + }. + ''; + } { # A published target that is not an `https://host/path` url. Without # this the split returns null and the failure surfaces as diff --git a/nix/reserved-hive-fragments.nix b/nix/reserved-hive-fragments.nix new file mode 100644 index 00000000..ebf95a8e --- /dev/null +++ b/nix/reserved-hive-fragments.nix @@ -0,0 +1,45 @@ +# Words no HIVE name may CONTAIN — a substring blacklist, and the second half +# of `./reserved-names.nix`. +# +# Two files rather than one list, because the two differ in every dimension +# that matters and folding them would silently widen one of them: +# +# | | `reserved-names.nix` | this file | +# |---|---|---| +# | subject | agent AND hive names | hive names only | +# | match | equality | substring | +# | why | the name IS a value some component emits | the word is a fixed SEGMENT of a derived identifier | +# +# ⚠️ That last row is the whole argument. `hive-` and `hive--agent` +# are built by composing a hive name with these words, so a hive called +# `foo-agent` produces an id another hive's AGENTS also produce — the composed +# strings collide even though the names do not. Equality cannot see that; only +# containment can. +# +# ⇒ And it is why the sender words (`operator`, `system`, `forge`, …) are NOT +# here: those are compared against a whole name, never embedded in one, so +# applying containment to them would refuse `forgeworks` for no failure that +# exists. `reserved-names.nix`'s own rule — an entry must name a real failure, +# not a word that looked risky — governs this file too. +# +# Read by `host-modules/swarm-otel.nix`, which owns the hive-roster assertion. +# Deliberately NOT exported to the Rust side: `HIVE_RESERVED_NAMES` answers +# "may an AGENT be called this", which stays an equality question. +[ + # `hive-` — every hive's own OIDC client id (`swarm-authelia.nix`'s + # `hiveClientPrefix`), and the string the queue's auth-callout responder + # strips to decide which hive a connection is. + "hive" + # `hive--agent` — the client its agent containers present + # (`agentClientSuffix`). A hive named `foo-agent` mints exactly the id hive + # `foo`'s agents do, and the responder resolves it as the agents: that hive + # silently receives an agent grant instead of its own, and a NATS denial + # arrives as a timeout, so nothing names the cause. + "agent" + # Already in `reserved-names.nix` as an exact match, listed again here for + # the wider rule: the swarm collector names components `/` with + # the hive name as owner, and a name merely CONTAINING it is not a collision + # today — but `swarm` is the one word this swarm composes identifiers from + # everywhere, so the operator's rule covers it and the two files agree. + "swarm" +] diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index f7fbb6b2..60ecefdf 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -25,6 +25,9 @@ # `from`/`to`, or a component name the collector builds pipelines from — not a # word that merely looked risky. A name in here that nothing emits is a refusal # with no failure behind it. +# +# ⚠️ Matched by EQUALITY. Words forbidden *inside* a hive name live in +# `./reserved-hive-fragments.nix` — read its header before merging the two. [ # ---- message-layer senders ------------------------------------------- # The human at the dashboard: a broker recipient (the T4LK box sends