256 lines
11 KiB
Nix
256 lines
11 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
|
|
;
|
|
|
|
# One host per guard. Each guard reports every offender in one message
|
|
# (`../host-modules/lib/name-guards.nix`), so one host carrying every
|
|
# colliding name still tells each name's arm apart: a name its arm misses
|
|
# is absent from the offenders line.
|
|
#
|
|
# Every fixture runs 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`.
|
|
|
|
# Every cert-auth subject an operator can override, set to a name with no
|
|
# reserved fragment and taken by a hive. `controllerCommonName`'s default
|
|
# (`swarm-controller`) contains `swarm` and is caught by the substring guard
|
|
# whatever the cert-auth arm does, so the default could not tell the two
|
|
# apart.
|
|
#
|
|
# The fixed subjects are a LIST, and a list with one consulted element and
|
|
# one dead one looks identical from the first element's case: each element
|
|
# needs its own name in the offenders line. matrix-ctl's grant names its
|
|
# leaves, not a whole prefix, so a hive that inherited a dead entry there
|
|
# would silently gain the ability to overwrite the swarm's matrix credential.
|
|
#
|
|
# 🩸 The two per-hive subjects have a different shape: those 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 — a hive reading another
|
|
# hive's queue credential, the exact widening the split exists to avoid. The
|
|
# collision is with the OTHER hive's role, hence `other` beside `qr-other`
|
|
# and `hl-other`.
|
|
hiveNamedAfterEverySubject = hive {
|
|
deploy.swarm-otel.enable = false;
|
|
deploy.bao = {
|
|
controllerCommonName = "ctl";
|
|
secretPublisherCommonName = "pubctl";
|
|
matrixCtlCommonName = "mintctl";
|
|
grafanaOidcCommonName = "gfctl";
|
|
otelOidcCommonName = "otctl";
|
|
forwarderOidcCommonName = "fwctl";
|
|
forgePublisherCommonName = "fpctl";
|
|
granterCommonName = "grctl";
|
|
queueAgentCommonNamePrefix = "qr";
|
|
hiveLeafCommonNamePrefix = "hl";
|
|
};
|
|
swarm.hives = {
|
|
ctl.domain = "ctl.t.local";
|
|
pubctl.domain = "p.t.local";
|
|
mintctl.domain = "m.t.local";
|
|
gfctl.domain = "g.t.local";
|
|
otctl.domain = "o.t.local";
|
|
fwctl.domain = "f.t.local";
|
|
fpctl.domain = "fp.t.local";
|
|
grctl.domain = "gr.t.local";
|
|
other.domain = "other.t.local";
|
|
qr-other.domain = "q.t.local";
|
|
hl-other.domain = "hl.t.local";
|
|
};
|
|
};
|
|
|
|
# The control for every refusal below: same shape, a roster nothing objects
|
|
# to. It carries both sides of the identifier rule's edges — 63 characters,
|
|
# and digits and hyphens in every position (the rule has no
|
|
# leading-character restriction).
|
|
hiveNamesAllLegal = hive {
|
|
deploy.swarm-otel.enable = false;
|
|
deploy.bao.controllerCommonName = "ctl";
|
|
swarm.hives.${lib.concatStrings (lib.replicate 63 "a")}.domain = "l.t.local";
|
|
swarm.hives."0-h-9".domain = "h.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; 64 characters is one over the cap.
|
|
hiveNamesNotIdents = hive {
|
|
deploy.swarm-otel.enable = false;
|
|
swarm.hives = {
|
|
"a/b".domain = "a.t.local";
|
|
"../x".domain = "x.t.local";
|
|
${lib.concatStrings (lib.replicate 64 "a")}.domain = "l.t.local";
|
|
};
|
|
};
|
|
|
|
# The first line of a guard's message: the problem and its offenders. The
|
|
# lines after it list the forbidden values, which name a subject whether or
|
|
# not any hive took it.
|
|
offendersLine = a: lib.head (lib.splitString "\n" a.message);
|
|
|
|
# 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;
|
|
|
|
# True when the equality guard names `name` as an offender.
|
|
equalityGuardRefuses =
|
|
h: name:
|
|
lib.any (
|
|
a:
|
|
!a.assertion
|
|
&& lib.hasInfix "has reserved name(s)" (offendersLine a)
|
|
&& lib.hasInfix "'${name}'" (offendersLine a)
|
|
) 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 hiveNamesNotIdents "a/b";
|
|
}
|
|
{
|
|
name = "a hive key that climbs out of its directory is refused, naming the key";
|
|
ok = identGuardRefuses hiveNamesNotIdents "../x";
|
|
}
|
|
{
|
|
name = "a hive key over 63 characters is refused";
|
|
ok = identGuardRefuses hiveNamesNotIdents (lib.concatStrings (lib.replicate 64 "a"));
|
|
}
|
|
{
|
|
# 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 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 = equalityGuardRefuses hiveNamedAfterEverySubject "ctl";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "pubctl";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "mintctl";
|
|
}
|
|
{
|
|
# 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 =
|
|
equalityGuardRefuses hiveNamedAfterEverySubject "gfctl"
|
|
&& equalityGuardRefuses hiveNamedAfterEverySubject "otctl";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "fwctl";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "fpctl";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "grctl";
|
|
}
|
|
{
|
|
# 🩸 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 = equalityGuardRefuses hiveNamedAfterEverySubject "qr-other";
|
|
}
|
|
{
|
|
# 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 = equalityGuardRefuses hiveNamedAfterEverySubject "hl-other";
|
|
}
|
|
{
|
|
# 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 =
|
|
!hiveNamedAfterEverySubject.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
|