hyperhive/nix/module-eval/name-guards.nix
atlas f778122f5a matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender
account, so "mint it once" is a property of the thing being minted
rather than something a lock has to enforce. That is what makes this
account the one to move first: no trigger route, no controller change
and no agent list — a boot-time oneshot beside tuwunel is the whole
mechanism.

`swarm-matrix-minter` runs inside `containers.hive-matrix`, which
already holds the appservice token: the rendered registration is bound
in read-only because that is how tuwunel is handed it. What the
container lacked was an identity of its own, so this adds one — a leaf
from the store's CA with a grant of exactly one path, not the hive's
leaf, which reads every secret in the store.

Both ends of the credential ship here. The minter reads the path it
publishes to before it touches the homeserver, and returning on a
non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user`
reads the same path, authenticating with the hive name already in
`HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login
ladder stays as the fallback for a store that is empty, unconfigured or
unreachable, which is every swarm deployed before this — so nothing
needs backfilling and nothing breaks if the rest of the sequence never
lands.

The credential is not an admin credential, and is not named like one.
It is the access token of the appservice registration's own
`sender_localpart` — `@hive:<server_name>`, an account the homeserver
creates for itself when it loads the registration. The store path is
`swarm/services/matrix/sender-token`, the host path is
`matrix/access-token`, and the homeserver no longer runs an
`admin_execute` promotion for that account at boot. Everything the hive
provisions with it — the Space, the chat room, their hierarchy and join
rules, the invites — rides on being the creator of those rooms at power
level 100, not on homeserver admin; there is no Synapse admin API here
to need, tuwunel has none.

Two operations do need an admin *sender* and therefore stop working:
`hivectl matrix promote-user` and `hivectl matrix reset-password`, both
`!admin …` messages into `#admins:<server>`, plus the password-reset
recovery path that an agent with a lost password file falls back to.
They are swarm-level operations and are left failing loudly rather than
served by an over-privileged token every other call site would also
carry. The sweep's own admin-rights check and self-repair go with them:
an account that is deliberately not an admin has nothing to check.

`ephemeral = false` stays, and hive root can still read the container's
filesystem. Accepted: what this buys is identity separation — no hive
*process* holds or reads the appservice token — not physical isolation.

Refs #4345
2026-09-20 22:07:16 +02:00

138 lines
5.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. The minter'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.
hiveNamedAfterMinterSubject = hive {
deploy.swarm-otel.enable = false;
deploy.bao.matrixMinterCommonName = "mintctl";
swarm.hives.mintctl.domain = "m.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 the matrix minter's subject is refused too";
ok =
equalityGuardFired hiveNamedAfterMinterSubject
&& lib.any (
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
) hiveNamedAfterMinterSubject.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