swarm: guard hive names where the roster is declared, and reserve the cert subjects

The two hive-name guards lived in swarm-otel.nix, inside its
`config = lib.mkIf (… && deployCfg.swarm-otel.enable)`. A swarm running the
secret store and the controller but no collector therefore had no hive-name
check at all, while the names were still composed into OIDC client ids, bao
policies and cert-auth roles exactly the same way. They move to swarm.nix,
which declares `swarm.hives` and is unconditional. swarm-otel keeps the
assertion that its own entry is still in the shared list — that one is about
this module's stake in a file it no longer controls.

The equality guard also takes the store's cert-auth subjects now. Cert auth
trusts the CA, so `allowed_common_names` is the whole of what narrows a role
to one identity, and the same CA signs every hive's leaf with the hive's name
as its CN. A hive named after a role's subject presents a certificate that
role accepts, which for the controller is write access to every hive's
credentials and policies.

A list rather than the one string, because the next role added beside it
widens what a hive name must not collide with, and because the subject is an
option an operator sets — a literal deny entry covers the default and nothing
else.

Four module-eval cases, two of them controls. The fixture overrides the
subject to `ctl` on purpose: the default contains `swarm`, which the substring
guard catches whatever the new arm does, so a fixture using it could not tell
the two apart. The controls are that a legal roster trips neither guard, and
that all three fixtures really do have the collector disabled — without the
second, every case would pass while testing the arrangement they exist to
rule out.
This commit is contained in:
atlas 2026-09-11 22:20:00 +02:00 committed by mara
commit bb62bf1aa9
3 changed files with 129 additions and 31 deletions

View file

@ -57,15 +57,13 @@ let
# is asserted to CONTAIN `swarmTierName`, so a rename that dropped it from
# the file (or a `producerName` override the file was never updated for)
# would be an eval error rather than a silently missing guard.
# ⚠️ The guards themselves live in ./swarm.nix, which declares
# `swarm.hives` and is unconditional. They were here and gated on this
# module's own `enable`, so a swarm without the collector had no hive-name
# check at all. What stays here is the assertion below: this module's own
# entry must still be in that shared list.
reservedOwners = import ../reserved-names.nix;
# Words no hive name may CONTAIN, because identifiers are composed from a
# hive name. That file explains why it is not merged into the list above.
reservedFragments = import ../reserved-hive-fragments.nix;
nameGuards = import ./lib/name-guards.nix { inherit lib; };
hiveNames = lib.attrNames hyperhiveCfg.swarm.hives;
# 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
@ -775,30 +773,6 @@ in
Put it back, or give this module a different swarmTierName.
'';
}
(nameGuards.mustNotEqual {
option = "services.hyperhive.swarm.hives";
names = hiveNames;
reserved = reservedOwners;
why = ''
The collector names components `<kind>/<owner>` with the hive name
as owner, and `//` resolves the clash silently: the swarm tier's
parts win, that hive's pipeline and `hive=` stamp disappear, and it
keeps pushing into a route that goes nowhere. Rename the hive.
'';
})
(nameGuards.mustNotContain {
option = "services.hyperhive.swarm.hives";
names = hiveNames;
fragments = reservedFragments;
why = ''
Hive-scoped identifiers are composed from a hive name
`hive-<name>`, `hive-<name>-agent` so a name containing one of
these produces an identifier that is also somebody else's. The
failure is a wrong grant rather than an error: the client
authenticates and receives another principal's permissions, and a
NATS denial arrives as a timeout. Rename the hive.
'';
})
{
# A published target that is not an `https://host/path` url. Without
# this the split returns null and the failure surfaces as