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
This commit is contained in:
atlas 2026-09-20 13:42:17 +02:00 • committed by mara
commit f778122f5a
28 changed files with 1566 additions and 320 deletions

View file

@ -236,6 +236,35 @@ let
}
'';
# The identity the matrix container's minter presents. Named outside `hive-*`
# for the reason its two siblings above give — the controller may rewrite
# every policy under that prefix, and a policy it can rewrite constrains
# nothing.
matrixMinterPolicyName = "swarm-matrix-minter";
matrixMinterCn = baoDeploy.matrixMinterCommonName;
# ONE path, and every narrowing in it is load-bearing.
#
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
# written by the caller — the same trap as the two grants above.
#
# Not `swarm/services/*` like the publisher's: this principal produces
# exactly one secret, the `@hive:` account's access token, and a
# homeserver is not entitled to overwrite Grafana's OIDC client. The path is
# spelled to the leaf for that reason, not for tidiness.
#
# `read` as well as write, unlike either sibling, and it is what makes "and
# only once" mechanical: the minter's first act is to read this path back and
# stop if something is there, so without the capability every container
# restart would mint a second access token and invalidate the hive's. A read
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
matrixMinterPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" {
capabilities = ["create", "update", "read"]
}
'';
# The KV v2 engine the controller writes agent credentials through. Named
# once because the grant above and the `secrets enable` in the bootstrap unit
# have to agree: a policy pointing at a mount nobody created is precisely the
@ -659,6 +688,29 @@ in
'';
};
matrixMinterCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-matrix-minter";
example = "swarm-matrix-minter.svc";
description = ''
Subject the store's matrix-minter cert-auth role accepts — the
identity the oneshot inside the matrix container presents when it
publishes the `@hive:` account's access token.
A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that
credential itself. The point of the whole arrangement is that the
process holding the appservice token is not the hive, so handing it
the hive's own leaf — which reads every secret in the store — would
give the separation away in one line.
⚠️ Same collision as its siblings, and the same answer: ./swarm.nix
feeds this value into the guard on
{option}`services.hyperhive.swarm.hives`, so a hive named after it
fails evaluation rather than silently receiving the minter's grant.
'';
};
serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -1222,6 +1274,54 @@ in
'';
};
# A THIRD sibling, for the reason the second one's comment gives: these
# unit names are operator-facing strings, and the grant written here
# belongs to a principal neither of the other two names. Same `after`
# rather than `requires`, and for the same stated reason — the first unit
# creates the mounts this one writes into, but a failed oneshot still
# counts as finished, so only ordering plus this unit's own retry
# converges.
systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken {
description = "write the swarm matrix minter's bao policy and cert-auth role";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
];
unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile;
# Same unseal wait as its two siblings above, for the reason stated
# there: under `seal = "shamir"` a human unseals by hand.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
export BAO_TOKEN
printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} |
bao policy write ${lib.escapeShellArg matrixMinterPolicyName} -
''
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \
certificate=@${tlsDir}/client-ca.pem \
allowed_common_names=${lib.escapeShellArg matrixMinterCn} \
token_policies=${lib.escapeShellArg matrixMinterPolicyName} \
display_name=${lib.escapeShellArg matrixMinterCn}
'';
};
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;