The swarm gets an appservice identity of its own, separate from each hive's
`hyperhive` registration. `swarm-matrix-ctl appservice render` mints its
tokens inside the matrix container when they are absent and renders the
registration tuwunel loads; `appservice publish` writes its as_token to
`swarm/controller/swarm-controller/matrix/appservice-token`, the one kind no
hive's policy grants.
The homeserver calls move out of swarm-matrix-ctl into swarm-matrix-client,
with a `whoami`, so swarm-controller can mint agents' accounts through the
same pinned device id instead of a copy of them.
A swarm runs one homeserver and every hive on it logged in as the same
`@hive:` localpart, holding the same access token out of one swarm-wide
store path. That is one matrix identity for N hives: the homeserver
cannot attribute an action to the hive that took it, and revoking one
hive's standing revokes every hive's.
Three changes, and the third is the one that makes the other two real:
- **The localpart carries the hive's name** (`hive-<hive>`), derived in
one place, `swarm_secret_client::matrix::hive_localpart`.
`hive-matrix.nix` renders the same string as the appservice
registration's `sender_localpart`, so the shared account stops being
created rather than merely stops being used.
- **The store path is templated by hive**, not a constant. The
"a swarm runs one homeserver, so this is a constant rather than a
parameter" rationale went with it; it stopped holding the moment two
hives shared the homeserver it describes.
- **The path moved out from under the grant every hive has.** It sat at
`swarm/services/matrix/sender-token`, inside the
`secret/data/swarm/services/*` read stanza `policy::render` gives every
hive. It now sits under that hive's own stanza,
`secret/data/swarm/hives/<hive>/*`, which interpolates the reader's
name — so a hive reads its own token and is refused another's. The
policy renderer itself is unchanged: narrowing the `services/*` grant
would break the OIDC-secret read it exists for, and moving the
credential is what this needed instead. A policy test walks the
rendered stanzas and asserts none of hive alpha's covers hive beta's
sender token, so a later stanza that widened it fails here.
`swarm-matrix-ctl` takes a new required `MATRIX_MINT_HIVE` and writes
that hive's path; its store grant in `swarm-bao.nix` follows, scoped to
one hive's leaf via the new `deploy.bao.matrixCtlHiveName` (defaulting to
this host's `hiveName`) rather than a `hives/*` wildcard, which would
hand the matrix container every hive's token back.
Migration: no outage at deploy. `ensure_hive_user` short-circuits on the
local token file, so a hive keeps running on what it has; with no such
file it reads the new per-hive path, finds nothing, and falls through to
the existing register-or-appservice-login ladder against its own
localpart — which needs only the per-hive `as_token` on local disk. The
old shared object is read by nothing afterwards. Rooms do not follow the
identity, and that is the one operator step; both ways out are written
into `docs/integrations/matrix.md`.
No admin standing is granted to the per-hive accounts: `admin_execute`
stays empty and the assertion pinning it is untouched.
The store path and every identifier around it called this an admin
token. It is not one: of ~15 hive-c0re call sites only two need
homeserver admin, and the homeserver no longer promotes the account at
boot, so the name overstated both what the credential is and what it may
do.
Renaming it to the account was not enough either. "The `@hive:` token"
reads as the token of a hive user, and no such user is provisioned —
`@hive:<server_name>` is the appservice registration's own
`sender_localpart`, an account the homeserver creates for itself when it
loads the registration.
So it is the **sender token**: the matrix appservice sender account's
access token, at `swarm/services/matrix/sender-token`. The name says
what it authenticates as rather than what it may do, which is the part
that was wrong.
The path has one constructor, and the bao grant, the grant assertion and
three unit tests pin its literal independently — so a half-finished
rename fails a check rather than leaving the minter and its readers
disagreeing at runtime. `tracing` messages are renamed with the code, so
the journal reads the way the source does.
The host-side file keeps its name (`matrix/access-token`): it carried no
admin framing, and renaming it would orphan the file on every deployed
hive for nothing.
`docs/tools/hivectl-cli.md` is regenerated from the clap tree.
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
Nothing reads it any more: hive-c0re creates accounts as the hive's
appservice, so the mint, the host file, the bind mount, the
`LoadCredential` entry and tuwunel's `registration_token_file` all go.
⚠️ `allow_registration` has to go to `false` in the same change, and not
as hardening. tuwunel refuses to START when registration is allowed with
no token configured — it demands
`yes_i_am_very_very_sure_…_open_registration_…` instead — so dropping the
token and leaving the flag true is not a lax homeserver, it is one that
does not boot. The flag is checked only for requests arriving without an
appservice token, so hive-c0re provisions exactly as before and everyone
else is refused outright.
The swarm secret store keeps its role, repointed at the credential that
replaced the token (`swarm/hives/<hive>/matrix/appservice-token`). Its
unit now also re-runs hive-matrix's own registration renderer after
writing the file: the token is half an agreement, and a registration
still naming the previous value authenticates nobody. The renderer is
shared through an internal option rather than copied, so the
registration's shape has one home.
Both spellings of `registrationTokenFile` become
`mkRemovedOptionModule` with a message naming what replaced them. A hive
that never set the option — the default — is unaffected; one that pinned
it fails to evaluate with instructions instead of a silent no-op.
An upgraded hive needs no intervention: the activation script has both
halves in place before the homeserver restarts, existing agents keep the
tokens their devices already hold, and the old token file is left on
disk read by nothing. docs/integrations/matrix.md spells the path out.
Refs #4402
`swarm-bao-matrix-token` reads `secret/swarm/matrix/registration-token`
and is refused with `Code: 403 — permission denied`, measured on this
host at 16:17:49Z after a successful cert login.
path.rs makes every swarm path `swarm/<kind>/<name>/…` where Kind is a
closed set of four: agents, hives, services, controller. `matrix` sits
where a kind belongs, so policy.rs's read document — which emits exactly
`swarm/agents/*` and `swarm/hives/<hive>/*` — cannot cover it. The
module's own doc predicted this: "a misspelled kind is a 403 at provision
time rather than anything a compiler sees".
Moves the token to `swarm/hives/<hive>/matrix/registration-token`, built
through principal_prefix(Kind::Hive, …) like its per-hive sibling
queue::agent_client_path. The policy is untouched: render already grants
that prefix. mara chose this over widening the namespace.
The nix reader interpolates hyperhiveCfg.hiveName, with the no-fallback
reasoning glue-bao-tls.nix already gives at its own use of it.
path.rs's MOUNT doc justified itself by citing the old literal, which
this commit deletes; rewritten to cite the nix reader instead.
Scope: this makes the read reachable, not the value present. Nothing
writes that path yet, and a 403 says nothing about presence — the two
are separate findings and only the first is fixed here. No migration:
nothing ever wrote the old path and no read ever succeeded.
Gate: cargo fmt 0, cargo test -p swarm-secret-client 0 — 32 passed
against a 29-passed baseline with the change stashed, so the three new
tests are accounted for rather than assumed.
Refs #4308
The crate had a single path convention and it was per-agent:
`swarm/agents/<agent>/matrix/<account>`. The secrets still to move into the
store do not fit it — one belongs to a hive, one to a swarm service, one to
the controller itself — so each would have picked its own shape, and each
would have been a separate grant to get wrong.
mara ruled the scheme on the epic: `swarm/<kind>/<name>/<secret>`, over
`agents`, `hives`, `services` and `controller`. This lands it.
`Kind` is an enum rather than free strings for one reason: the store's grant
is written in nix and cannot be reached from Rust, so a misspelled kind is a
403 at provision time and not a compile error. `Kind::ALL` lets a test
enumerate the set instead of restating it, which is what makes adding a kind
a deliberate edit rather than an accidental grant.
Note `Kind` sits beside `checked_segment`'s existing `kind` argument, which
means something else entirely — the label of the name being validated. They
are not the same concept and should not be merged.
Nothing about the rendered policy changes. `policy::render` still grants read
on the agent kind alone; the other kinds are absent on purpose, because what a
hive may read of its own kind is a boundary question and not a consequence of
the namespace growing. The controller's write grant likewise stays scoped to
`agents/` — it widens when a path outside it gains a writer, not when the
kinds are declared.
Verified: `cargo test -p swarm-secret-client` 23 passed, 0 failed. The two
tests pinning the rendered strings (`the_document_grants_read_over_the_whole_agent_prefix`
and matrix's path assertion) still assert the same literals they did before,
which is what shows this is a faithful port rather than a reshape. `nix fmt`
710 emitted, 10 formatted, 0 changed; the three scripts/check-*.sh lints pass
with the change staged. No reference to the removed `path::AGENT_PREFIX`
survives in the crate or in nix — checked with a scoped pattern, because the
unqualified name also belongs to hive-host-sock's container prefix and greps
for it are answering a different question.
mara, reviewing the previous commit: "the field is specific to matrix, why
add it to the general struct". She is right, and the answer is that there was
no general struct — `Credential` had one consumer, the crate's only path
builder was `matrix_account`, and `value` is pinned by
`glue-matrix-bao-token.nix`, a matrix unit. It was matrix's throughout,
wearing a general name; adding `homeserver` is what made that visible.
`client` now moves whatever type a caller names and decodes nothing itself.
That is forwarding rather than machinery: `vaultrs::kv2::read`/`set` are
already generic over the payload.
The matrix agreement moves to its own module holding both halves — where a
credential lives (`account_path`, was `path::matrix_account`) and what the
object at that path holds. `path` keeps only what every path obeys, so a
second kind of swarm secret becomes a module beside `matrix` rather than
another optional field on a struct it shares. argus raised the same collision
from the other direction on #4092: two mutually-exclusive `Option`s modelling
one concept is the failure mode this forecloses.
`checked_segment` stays public in `path`: hive-priv builds an on-disk path
from the same names and must accept the same charset.
Behaviour is unchanged. The compatibility properties move with the struct —
`Option` is what lets a pre-`homeserver` stored object decode, and
`skip_serializing_if` is what keeps a token-only object free of
`"homeserver":null` for that nix reader.
Refs #3726