`swarm/agents/<agent>/bao-mtls` did not exist, and neither did any
per-agent identity at the secret store: `policy::agent_object_name`,
`render_agent` and `render_agent_with_queue` had been written and never
called outside their own tests. An agent's only "per-agent" secret today
is read under the HIVE's certificate, through a wide grant on
`swarm/agents/*` — so "per-agent" was presentational.
The swarm now mints the certificate, so no hive ever needs the capability
to mint one. `swarm-controller` is the service that does it: it already
logs in to the store, and its existing grant already covers exactly the
three objects written here (`create/update` on
`secret/data/swarm/agents/*`, `sys/policies/acl/hive-*` and
`auth/cert/certs/hive-*`). No new bao grant, and nothing co-located — a
cert-auth role pins its authority by value, per role, so the controller
issues from its own CA on its own host and pins that CA in the role it
writes. No existing role changes.
The mint node does not report success on a write. After publishing it
connects again, with the leaf it just issued and under the role it just
wrote, and reads the path back — so the policy, the role, the common name
and the leaf are exercised in production on every agent creation. A
certificate this code mints that the role this code writes will not accept
turns the job node red at creation time instead of surfacing later as an
agent container that cannot start.
`TriggerDeploy` gains an `after_any` edge on the mint, not `after_ok`: a
hive cannot pass down a certificate the swarm has not published, but a
host with no authority configured must still create agents exactly as it
does today.
The private key is generated in memory and never written to disk on the
controller — `SecretStore::connect_with_identity` takes the PEM the minter
is already holding, so nothing is written out purely to be logged in with.
Refs #4137
The agreement half of delivering the agent queue principal's client secret
through the store. No producer yet, so nothing writes this path — the unit
that does lands in the same PR, with the write grant it needs.
queue.rs is the sibling matrix.rs prescribes for a second kind of secret
rather than another field on a shared struct. Keyed per HIVE, not per agent:
the queue identity is minted once per hive at deploy time and says which hive
an agent belongs to, never which agent.
The client id rides with the secret for matrix.rs's stated reason — a
credential has to be reconstructable from the store alone, and deriving
`hive-<name>-agent` on the reading side is the split spelling the authelia
module warns denies every agent as a timeout.
policy.rs's render() takes the hive name now and emits a second, narrow
stanza for that hive's own path. The agent stanza is untouched: an agent's
path does not name its hive, so narrowing it still needs the enumeration
docs/trust-boundary/security.md rejects. A hive path does name its principal,
so scoping it costs nothing and drifts nowhere.
every_hive_gets_a_byte_identical_document is replaced rather than deleted.
Its surviving half is that the text is a function of the deploy-time name
alone, so a re-emission cannot drift; the new arms are that one hive's
document cannot reach another's path, and that a name which could close the
stanza is refused — live again now that a name reaches the document text.
Refs #3853
`write_policy` called `vaultrs::sys::policy::set`, which targets
`sys/policy/<name>` — the deprecated alias the store gates as a path of
its own. Every grant in this tree names `sys/policies/acl/hive-*`, so a
correctly-scoped controller was refused with a 403 and no hive read
policy has ever been written, on any deployment. The doc comment three
lines above the call already named the modern path; the code is what
moves to meet it.
vaultrs has no endpoint for that path (`grep policies/acl` over 0.8.0:
zero hits, against 8 for `sys/policy`), so this defines one over its own
endpoint machinery — which keeps the client's token header and `/v1`
prefix middleware rather than re-deriving them.
The alternative was to widen the grant to cover the legacy path. This
way needs no policy change at all: the deployed grant already permits
the write, so no store is re-bootstrapped and nothing is entrenched on
an alias upstream has deprecated.
Two tests pin the path and the body shape. The reason this survived
deployment is that nothing in the tree ever asserted either one.
Closes#4177.
A hive holds an mTLS pair and a policy naming what it may read, and still
cannot log in: nothing creates the role that maps its certificate to that
policy. The one pre-shared credential in the system therefore buys no
access.
Minting happens here rather than in nix, which was the first plan. Nix
mints from the store's own container, and that path is gated on the
bootstrap token -- so onboarding a hive later would mean placing the one
genuinely pre-shared secret again. Doing it from the controller costs a
public certificate authority as an input and makes the bootstrap token
one-time.
A startup pass, not a hook: the hive list is loaded once and a config
change means a redeploy, so the roles are as static as the list. Only the
policy is derived from something that moves.
The subject is the hive's name because glue-bao-tls.nix mints a hive's
client leaf with its name as the CN, and cert auth matches on that.
Per-hive failures are logged and skipped, matching the queue, bridge and
forge connects above it: a controller whose store is unreachable still
serves everything else, and the next start retries.
Not covered by a test: ensure_hive_roles is IO from end to end, and the
seam that would make it assertable is the one the read-grant sink already
has. Said here rather than implied by a green suite.
The renderer beside this produces a hive's read policy; nothing could put
one in the store. `read`/`write` are KV v2 verbs and a policy is not a
secret, so this is a second endpoint rather than another path.
A whole-document write, because the store has no merge verb: the caller
renders from the current agent set, so a stanza missing from the render
is a grant that is meant to be gone.
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
A delivered matrix account needs two things: the token and the homeserver it
belongs to. Only the token was stored, so the homeserver had to ride on the
queue notice — and a notice is not persistence. Re-delivering a credential
(agent moved, hive re-provisioned, token rotated) has to reconstruct it from
somewhere, and there is nowhere; keeping it separately at swarm level would be
a second store for one logical object, free to drift from the first.
So `Credential` grows a `homeserver` field and `read`/`write` carry the whole
object rather than a bare string.
`value` keeps its name. `nix/host-modules/glue-matrix-bao-token.nix` reads the
store with `bao kv get -field=value` and is the only nix reader of it, checked
rather than assumed — so this had to be an addition, never a rename.
Two compatibility properties, both of which fail silently if broken:
KV2 keeps every prior version, so objects written before this field existed
are still decoded by this type. What tolerates their absence is the field
being `Option` — a bare `String` would not fail as a migration, every stored
credential would become unreadable at once. The new test pins that, with a
presence control so the arm is about absence being tolerated rather than the
field being ignored.
`skip_serializing_if` keeps a token-only credential serialising to exactly the
bytes the previous version wrote, with no `homeserver` key rather than a null,
which is what that nix reader would otherwise trip over. The existing test
pinning `{"value":"t"}` proves it and became the control for free.
Mutation testing earned its place here: `#[serde(default)]` was in the first
draft and its comment claimed it was what made old objects decode. Dropping it
changed nothing — serde already decodes a missing field to `None` for an
optional type — so the attribute was redundant and the comment was wrong about
its own mechanism. Both removed rather than left to mislead the next reader.
The delivery half needed no change: `write_agent_matrix_token` already took a
homeserver and already wrote the `matrix-account-<name>.json` sidecar beside
the token. `deliver` simply stops passing `None`. A credential stored without
one still works exactly as before — no sidecar, and the account needs a
configured entry.
Refs #3726
mara ruled (a) on #3726: a thin workspace crate over `vaultrs` rather than
keeping bao access in nix and having each end trigger units. The HTTP is the
SDK's job; what this crate owns is the things the controller and a hive must
say *identically*, and which have no other home because neither end is senior
to the other.
Three such agreements:
`path::matrix_account` builds where a credential lives. It is fallible rather
than a `format!`, because both names reach it from elsewhere -- the agent name
from the topology, the account name from an agent's own config -- and a `/` or
`..` in either does not produce a malformed path, it produces a valid path to
a *different agent's* secret. The charset mirrors the KV bucket-name rule.
`Credential`'s `value` field is not a free choice: glue-matrix-bao-token.nix
reads the store with `bao kv get -field=value`, so the name is load-bearing
for a consumer no Rust test can reach. A test pins the serialised shape.
`client::Settings` reads BAO_ADDR / BAO_CLIENT_CERT / BAO_CLIENT_KEY /
BAO_CACERT explicitly instead of letting vaultrs fall through to its own
defaults, which look for VAULT_ADDR / VAULT_CLIENT_CERT / VAULT_CLIENT_KEY.
Every unit in this tree sets the BAO_ spellings, so the defaults would yield a
client with no identity at all -- surfacing as a TLS handshake failure, which
names neither the missing variable nor the reason.
The env read is split from the connect so every misconfiguration arm is
testable without a reachable store and without touching process-global env.
Dependency impact, measured against the lock at forge/main rather than assumed:
native-tls 0 -> 0, openssl-sys 0 -> 0, one reqwest (0.13.4) which vaultrs
shares, and 10 new crates that are all derive/proc-macro helpers.
Refs #3726