Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/nix/module-eval/name-guards.nix
atlas 7ee6e776a3 swarm: refuse swarm.hives keys that are not valid hive identifiers
A hive key becomes a certificate subject and a directory name, but only the
reserved-name and fragment guards looked at it, so `a/b` or `../x` evaluated
and misbehaved later. Assert at eval time that every key is 1-63 characters
of [a-z0-9-], the rule `Ident` applies in hive-types, and name the offending
keys in the message.
2026-10-12 21:45:50 +02:00

329 lines
13 KiB
Nix

# `checks.module-eval-name-guards` — see ./lib.nix for the shared
# rationale (why this suite exists, naming convention, "evaluates
# not executes").
{
pkgs,
lib,
self,
nixosSystem,
}:
let
inherit
(import ./lib.nix {
inherit
pkgs
lib
self
nixosSystem
;
})
hive
runGroup
;
# The hive-name guards, with the collector explicitly OFF. That is the whole
# property: the guards live where `swarm.hives` is declared, so they run in a
# deployment that has a secret store and no collector, rather than being
# assertions inside swarm-otel's own `mkIf`.
#
# ⚠️ `controllerCommonName` is overridden to a name containing NO reserved
# fragment. Its default (`swarm-controller`) contains `swarm` and is caught
# by the substring guard whatever the cert-auth arm does — so a fixture using
# the default could not tell the two apart, and the arm under test would pass
# on the neighbour's work.
hiveNamedAfterCertSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.controllerCommonName = "ctl";
swarm.hives.ctl.domain = "ctl.t.local";
};
# The control for both arms below: same shape, a roster nothing objects to.
hiveNamesAllLegal = hive {
deploy.swarm-otel.enable = false;
deploy.bao.controllerCommonName = "ctl";
};
# The reserved subjects are a LIST, and a list with one consulted element and
# one dead one looks identical from the first element's case. This fixture
# collides with the SECOND, leaving the controller's at its default.
hiveNamedAfterPublisherSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.secretPublisherCommonName = "pubctl";
swarm.hives.pubctl.domain = "p.t.local";
};
# The THIRD element of the same list, colliding on its own so neither of the
# two above can carry it. matrix-ctl's grant is one path rather than a whole
# prefix, which is exactly why a dead entry here would be easy to miss: a
# hive that inherited it would not obviously break anything, it would
# silently gain the ability to overwrite the swarm's matrix credential.
hiveNamedAfterMatrixCtlSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.matrixCtlCommonName = "mintctl";
swarm.hives.mintctl.domain = "m.t.local";
};
# The two OIDC-secret readers' subjects, fixed strings like the three above.
hiveNamedAfterGrafanaOidcSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.grafanaOidcCommonName = "gfctl";
swarm.hives.gfctl.domain = "g.t.local";
};
hiveNamedAfterOtelOidcSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.otelOidcCommonName = "otctl";
swarm.hives.otctl.domain = "o.t.local";
};
# The store forwarder's OIDC reader, the fifth fixed subject.
hiveNamedAfterForwarderOidcSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.forwarderOidcCommonName = "fwctl";
swarm.hives.fwctl.domain = "f.t.local";
};
# The forge publisher's, the one writer of the controller's forge token.
hiveNamedAfterForgePublisherSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.forgePublisherCommonName = "fpctl";
swarm.hives.fpctl.domain = "fp.t.local";
};
# The granter's, the one subject whose role may write every `swarm-*` grant.
hiveNamedAfterGranterSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.granterCommonName = "grctl";
swarm.hives.grctl.domain = "gr.t.local";
};
# 🩸 A different shape from every fixture above: the queue-credential roles
# are written PER HIVE, so the subject a hive must not
# be is `<prefix>-<some hive's name>` rather than the prefix itself. Reserving
# only the prefix would leave the composed spelling free, and a hive taking it
# would present a leaf the other hive's role accepts — which is a hive reading
# another hive's queue credential, the exact widening the split exists to
# avoid.
#
# Two hives here, not one: the collision is with the OTHER hive's role.
hiveNamedAfterPerHiveReaderSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.queueAgentCommonNamePrefix = "qr";
swarm.hives.other.domain = "o.t.local";
swarm.hives.qr-other.domain = "q.t.local";
};
# The per-hive gateway-leaf subject, composed the same way.
hiveNamedAfterHiveLeafSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.hiveLeafCommonNamePrefix = "hl";
swarm.hives.other.domain = "o.t.local";
swarm.hives.hl-other.domain = "hl.t.local";
};
hiveNameWithComposedWord = hive {
deploy.swarm-otel.enable = false;
swarm.hives."h1-agent".domain = "a.t.local";
};
# The key characters are checked by the same module, so these run with the
# collector off too. `a/b` adds a certificate-subject component; `../x`
# leaves a directory named after the hive.
hiveNameWithSlash = hive {
deploy.swarm-otel.enable = false;
swarm.hives."a/b".domain = "a.t.local";
};
hiveNameWithParentDir = hive {
deploy.swarm-otel.enable = false;
swarm.hives."../x".domain = "x.t.local";
};
# Both sides of the length cap: 63 characters passes, 64 does not.
hiveNameAtLengthCap = hive {
deploy.swarm-otel.enable = false;
swarm.hives.${lib.concatStrings (lib.replicate 63 "a")}.domain = "l.t.local";
};
hiveNameOverLengthCap = hive {
deploy.swarm-otel.enable = false;
swarm.hives.${lib.concatStrings (lib.replicate 64 "a")}.domain = "l.t.local";
};
# Digits and hyphens in every position: the rule has no leading-character
# restriction.
hiveNameWithDigitsAndHyphens = hive {
deploy.swarm-otel.enable = false;
swarm.hives."0-h-9".domain = "h.t.local";
};
# Markers from `lib/name-guards.nix`'s `problem` strings. Matching the
# problem rather than the `why` prose keeps the messages rewordable.
equalityGuardFired =
h: lib.any (a: !a.assertion && lib.hasInfix "has reserved name(s)" a.message) h.assertions;
fragmentGuardFired =
h:
lib.any (
a: !a.assertion && lib.hasInfix "has name(s) containing a reserved word" a.message
) h.assertions;
# True when the identifier guard refuses `key`.
identGuardRefuses =
h: key:
lib.any (
a:
!a.assertion && lib.hasInfix "not valid identifiers" a.message && lib.hasInfix "'${key}'" a.message
) h.assertions;
identGuardFired =
h: lib.any (a: !a.assertion && lib.hasInfix "not valid identifiers" a.message) h.assertions;
cases = [
{
name = "a hive key containing a slash is refused, naming the key";
ok = identGuardRefuses hiveNameWithSlash "a/b";
}
{
name = "a hive key that climbs out of its directory is refused, naming the key";
ok = identGuardRefuses hiveNameWithParentDir "../x";
}
{
name = "a hive key over 63 characters is refused";
ok = identGuardFired hiveNameOverLengthCap;
}
{
# Controls: without them an identifier guard that refuses everything
# passes the three cases above.
name = "63-character and digit-and-hyphen hive keys pass the identifier guard";
ok =
!(identGuardFired hiveNameAtLengthCap)
&& !(identGuardFired hiveNameWithDigitsAndHyphens)
&& !(identGuardFired hiveNamesAllLegal);
}
{
# `ctl` is in no deny list — it is reserved *because it is the subject a
# cert-auth role accepts*, which is a value an operator sets, so a
# literal deny entry could never have covered it.
name = "a hive named after a cert-auth subject is refused, with the collector off";
ok =
equalityGuardFired hiveNamedAfterCertSubject
&& lib.any (a: !a.assertion && lib.hasInfix "'ctl'" a.message) hiveNamedAfterCertSubject.assertions;
}
{
# Every cert-auth subject is reserved, not just the first one in the
# list. Without this case the second element could be dead and the case
# above would still pass.
name = "a hive named after the secret publisher's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterPublisherSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
) hiveNamedAfterPublisherSubject.assertions;
}
{
# And the third, for the reason the second one's comment gives one list
# element earlier. `certAuthCns` is where a role added beside the others
# has to register itself, and nothing but a case per element notices when
# one forgets.
name = "a hive named after matrix-ctl's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterMatrixCtlSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
) hiveNamedAfterMatrixCtlSubject.assertions;
}
{
# The fourth and fifth, for the reason the case above gives: `certAuthCns`
# is where a role added beside the others registers itself, and nothing
# but a case per element notices when one forgets. These two are the
# subjects of the readers that fetch Grafana's and the collector's OIDC
# client secrets.
name = "a hive named after either OIDC-secret reader's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterGrafanaOidcSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'gfctl'" a.message
) hiveNamedAfterGrafanaOidcSubject.assertions
&& equalityGuardFired hiveNamedAfterOtelOidcSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'otctl'" a.message
) hiveNamedAfterOtelOidcSubject.assertions;
}
{
# And the store forwarder's, for the same reason one element later.
name = "a hive named after the store forwarder's OIDC-reader subject is refused too";
ok =
equalityGuardFired hiveNamedAfterForwarderOidcSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'fwctl'" a.message
) hiveNamedAfterForwarderOidcSubject.assertions;
}
{
# And the forge publisher's: a hive holding a leaf it accepts could
# replace the token the controller provisions every forge object with.
name = "a hive named after the forge publisher's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterForgePublisherSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'fpctl'" a.message
) hiveNamedAfterForgePublisherSubject.assertions;
}
{
# And the granter's, whose role is root-equivalent: a hive holding a leaf
# it accepts could grant itself anything.
name = "a hive named after the bao granter's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterGranterSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'grctl'" a.message
) hiveNamedAfterGranterSubject.assertions;
}
{
# 🩸 The per-hive half, and the one a prefix-only reservation would miss:
# the role is `<prefix>-<hive>`, so the reserved string has to be composed
# against every declared hive. Here hive `qr-other` collides with the role
# written for hive `other` — a leaf that reads a credential belonging to a
# hive that is not it.
name = "a hive named after another hive's per-hive reader subject is refused";
ok =
equalityGuardFired hiveNamedAfterPerHiveReaderSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'qr-other'" a.message
) hiveNamedAfterPerHiveReaderSubject.assertions;
}
{
# A hive taking this spelling would present a leaf hive `other`'s
# gateway-leaf role accepts, and could ask the store for that hive's
# certificate. The control is the legal roster below.
name = "a hive named after another hive's gateway-leaf subject is refused";
ok =
equalityGuardFired hiveNamedAfterHiveLeafSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'hl-other'" a.message
) hiveNamedAfterHiveLeafSubject.assertions;
}
{
# Without this the case above proves nothing: an arm that fires for every
# roster is not a guard, and `hives` is non-empty in both fixtures.
name = "a legal hive roster trips neither name guard";
ok = !(equalityGuardFired hiveNamesAllLegal) && !(fragmentGuardFired hiveNamesAllLegal);
}
{
# The substring guard has to work with the collector off too.
# `h1-agent` mints exactly the client id hive `h1`'s agents present.
name = "a hive name containing a composed-identifier word is refused, with the collector off";
ok = fragmentGuardFired hiveNameWithComposedWord;
}
{
# ⚠️ The control that makes "with the collector off" mean anything. If a
# fixture silently had swarm-otel enabled, all three cases above would
# pass while testing the arrangement they exist to rule out.
name = "the guard fixtures really do have the collector disabled";
ok =
!hiveNamedAfterCertSubject.services.hyperhive.deploy.swarm-otel.enable
&& !hiveNamesAllLegal.services.hyperhive.deploy.swarm-otel.enable
&& !hiveNameWithComposedWord.services.hyperhive.deploy.swarm-otel.enable;
}
];
in
runGroup "name-guards" cases