hyperhive/nix/module-eval/name-guards.nix
atlas e9cec0da21 swarm-bao: write every swarm-* grant as a bao granter, not with a 24h token
Every unit that writes a bao policy or cert-auth role ran only while the
operator-placed bootstrap token existed, and skipped silently otherwise.
The token lives 24h, so on any real swarm a PR adding or changing a grant
deployed with its unit skipped, and each one needed a manual token refresh
(plus a root `bao policy write` when it added a path).

A `bao-granter` principal now writes them. Its leaf is minted by
swarm-bao-pki on the store host (0600 root, never copied off it), and its
policy covers `swarm-*` policies, `swarm-*` cert-auth roles and
`pki/roles/swarm-*` by glob, plus the mount and services-root paths the
controller's unit already used. All ten granting units
(controller, secret-publisher, matrix-ctl, matrix-token, queue-agent,
grafana-oidc, otel-oidc, forwarder-oidc, services-issuer, nats-tls) log in
with it instead of reading the token. They keep the 2880 x 30s retry, now
require swarm-bao-pki, and when the store refuses the granter they fail
and print the one-time step instead of skipping.

swarm-bao-granter-role is the one unit left on the token. It enables the
auth mounts (moved out of the controller's unit) and writes the granter's
own policy and role. The bootstrap policy is renamed `bao-bootstrap` and
shrinks to those five stanzas; it is shipped at
/etc/hyperhive/bao-bootstrap-policy.hcl. The old name `swarm-bootstrap`
matched the granter's own `swarm-*` glob.

The granter's CN joins certAuthCns, so no hive can be named into its role.
An assertion keeps both pki role names under `swarm-`. With no client CA
the granting units no longer render, and a warning says so.

module-eval pins the granter's policy stanza by stanza, what it cannot
reach, that every call a granting unit makes is granted, and that only
swarm-bao-granter-role reads the token.

Refs #4704
2026-09-27 22:57:46 +02:00

230 lines
9.5 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 — which used to skip
# them entirely, because they were 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 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 matrix-token and
# 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";
};
hiveNameWithComposedWord = hive {
deploy.swarm-otel.enable = false;
swarm.hives."h1-agent".domain = "a.t.local";
};
# Markers from `lib/name-guards.nix`'s two `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;
cases = [
{
# `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 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;
}
{
# 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 came along in the move and has to still work.
# `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