hyperhive/swarm-secret-client
Repository files (latest commit first)
Filename Latest commit message Latest commit date
atlas 4aa982cc2a swarm-grafana: deliver the OIDC client secret through the secret store
Grafana's OIDC client secret only existed where authelia did. One
`ssoLocal` gate — `grafana.enable && authelia.enable` — decided the
client registration, the minted secret's delivery and the whole
`auth.generic_oauth` block, so a swarm whose authelia runs on another
host got Grafana with no SSO wiring at all. The local login form is
disabled unconditionally, so that is no way in.

Split the one gate into the two questions it was conflating:

- `ssoConfigured` — does this SWARM have an identity provider
  (`swarm.authelia.url`, which is swarm-wide and whose own description
  makes null mean "no SSO configured"). With a delivery route present
  this is what emits Grafana's OIDC block.
- `ssoLocal` — is authelia on THIS host, now spelled as the forge and
  matrix modules spell it. It decides only which unit delivers the
  secret.

Where authelia is elsewhere, `swarm-bao-grafana-oidc.service` reads the
secret from the swarm secret store, shaped after
glue-queue-agent-credential.nix: cert login fails loudly because a retry
fixes every state it fails on, the read degrades quietly because no
retry turns "no value there" into a value, and nothing writes a
stand-in. The producer is the publisher that already runs on authelia's
host, which gains the swarm's service clients beside the per-hive ones
at `swarm/services/<id>/oidc/client` — with the write grant in
swarm-bao.nix and the hive read grant in `policy::render` to match.

Registration moved to glue-grafana-oidc-client.nix. It has to be
declared where authelia's config is rendered, and swarm-grafana.nix's
config block hangs off this host running Grafana.

Two judgement calls stated rather than buried: a hive's read policy now
grants the whole `services` prefix, because a service's path names the
service and nothing swarm-wide records which hive runs it (cost recorded
in docs/trust-boundary/security.md); and the client is registered on any
authelia host, because no swarm-wide "this swarm has a Grafana" fact
exists to gate it on.

Refs #4234

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 19:57:28 +02:00
..
src swarm-grafana: deliver the OIDC client secret through the secret store 2026-09-13 19:57:28 +02:00
Cargo.toml swarm-secret-client: derive Kind's segment strings via strum instead of a hand-written match 2026-09-12 00:06:31 +02:00
README.md swarm-secret-client: one module per kind of secret, not one struct 2026-09-08 15:53:50 +02:00

swarm-secret-client

Reading and writing a swarm credential in the secret store, over vaultrs. The HTTP is that crate's job. What this one owns is the agreements both ends of the store have to state identically: where a credential lives, which field its bytes are in, and how this deployment's environment becomes a logged-in client.

The surrounding picture — which secret is minted where, and why delivery is a copy rather than a bind mount — is docs/swarm/secrets.md.

Why a crate and not a module per binary

The store has two Rust ends and they are peers: the swarm controller writes a credential, a hive reads it. Neither is senior to the other, so a path formatted at each call site is an agreement with no owner — it holds right up until one side is edited alone, and then it fails as a missing key rather than as a mismatch.

The third end is what settles it. nix/host-modules/glue-matrix-bao-token.nix reads the store with bao kv get -field=value. That reader is a shell line in a nix module: it cannot be renamed by the same refactor as a Rust struct, and no Rust test reaches it. So the field name is pinned by a test against the serialised literal rather than left to the struct definition.

The identity is a certificate, and the role is the hive's name

Authentication is the store's cert auth method. nix/host-modules/glue-bao-tls.nix mints the client certificate with its CN set to the hive's name, because a cert-auth role matches on the CN. So cert_role is not a free choice for the caller: a hive passes its own name, and the policy attached to that role is what scopes what it may read.

The listener's tls_require_and_verify_client_cert is a different thing and not a substitute. It decides who may open a connection; it says nothing about who the connection belongs to, and a store with the option set and no cert mount configured refuses every login made here. That refusal is what Error::Vault out of connect means.

Configuration

Settings::from_env reads BAO_ADDR, BAO_CLIENT_CERT, BAO_CLIENT_KEY, and the optional BAO_CACERT.

The BAO_ spellings are read explicitly rather than left to vaultrs. Its own defaults look for VAULT_ADDR / VAULT_CLIENT_CERT / VAULT_CLIENT_KEY, which no unit in this tree sets. Falling through to them builds a client with no identity at all, and that surfaces as a TLS handshake failure — a place that names neither the variable nor the reason.

Empty is as absent as unset. systemd renders an unset nix option as Environment=BAO_CACERT=, so empty is the shape a missing value arrives in.

The certificate and key are paths, not values, and are read at connect time — same rule as every other credential in this tree, for the same reason: a value in a nix expression is rendered into the world-readable store.

Reading the environment is separate from connecting (Settings::from_lookup) because every one of those failures is a misconfiguration an operator has to read an error about, and none of them needs a reachable store to happen.

Names that arrive from elsewhere

matrix::account_path is fallible, which for a string formatter needs saying: its segments are an agent name from the topology and an account name from that agent's own config. A / turns one agent's segment into another agent's directory and .. walks out of the prefix entirely, so the charset it accepts is deliberately narrower than what the store would.

One module per kind of secret

client moves whatever type a caller names; it decodes nothing itself. What a stored object holds is stated in the module that also builds its path — matrix today, and a second kind of swarm secret gets a module beside it.

The split is deliberate. A single shared struct that grows one field per consumer ends up carrying, on every path, a field only one path's reader has ever heard of; and the two things a kind of secret must pin — where it lives and what is in it — are one agreement that reads worse split across modules.

What this crate does not do

It has no opinion on what a caller may read. That is the policy attached to the cert role, and it lives in the store.

It holds the token minted at login and renews nothing. A handle is built per credential, so the login is the cheap part of a rare operation — a caller that wanted to keep one alive across a token's lifetime would need more than this.