swarm-bao: give the store forwarder's OIDC reader its own bao identity

`swarm-bao-forwarder-oidc` fetches the store container's collector secret,
one path, and was the last reader still logging in with
`deploy.bao.clientCertFile`: the hive's own leaf, whose policy reads every
agent's credentials, the hive's tree and every service's OIDC secret. The
four-way split gave grafana's and the swarm collector's readers leaves of
their own and left this one behind.

It now holds `forwarder-oidc.pem`, minted by `swarm-bao-pki`, and logs in
under the `swarm-forwarder-oidc` cert-auth role, whose policy reads
`secret/data/swarm/services/<store forwarder client id>/oidc/client` and
nothing else. The role is written by `swarm-bao-forwarder-oidc-policy`
from the bootstrap token, which gains the two grants that unit calls, and
the reader is ordered after it. The subject is reserved as a hive name. A
store host whose pair is null is refused at eval rather than falling back
to the hive's leaf.

The hive's own role and `client.pem` are untouched; nothing is revoked.
This commit is contained in:
atlas 2026-09-24 17:25:34 +02:00 • committed by mara
commit 92e1909caf
9 changed files with 229 additions and 47 deletions

View file

@ -44,6 +44,9 @@ let
swarm-bao-otel-oidc =
deployCfg.swarm-otel.enable
&& havePair baoDeploy.otelOidcClientCertFile baoDeploy.otelOidcClientKeyFile;
# ./swarm-bao.nix: its block is gated on the store, which `orderAfterPolicy`
# already checks.
swarm-bao-forwarder-oidc = hyperhiveCfg.enable;
# ./swarm-nats.nix: the queue's TLS leaf, not a secret, but the same wait.
swarm-bao-nats-tls = deployCfg.nats.enable;
};

View file

@ -103,6 +103,10 @@ in
grafanaOidcClientKeyFile = lib.mkDefault "${pkiDir}/grafana-oidc-key.pem";
otelOidcClientCertFile = lib.mkDefault "${pkiDir}/otel-oidc.pem";
otelOidcClientKeyFile = lib.mkDefault "${pkiDir}/otel-oidc-key.pem";
# The fifth, the store forwarder's: the reader the four-way split left
# on `client.pem`.
forwarderOidcClientCertFile = lib.mkDefault "${pkiDir}/forwarder-oidc.pem";
forwarderOidcClientKeyFile = lib.mkDefault "${pkiDir}/forwarder-oidc-key.pem";
};
# Idempotent on ABSENCE, never on content. Re-issuing the CA invalidates
@ -219,6 +223,9 @@ in
[ -s ${pkiDir}/otel-oidc.pem ] || ${signLeaf} ${pkiDir} otel-oidc \
${lib.escapeShellArg deployCfg.bao.otelOidcCommonName} "" clientAuth
[ -s ${pkiDir}/forwarder-oidc.pem ] || ${signLeaf} ${pkiDir} forwarder-oidc \
${lib.escapeShellArg deployCfg.bao.forwarderOidcCommonName} "" clientAuth
# The identity a hive presents to ask the store's `pki` mount for the
# swarm-services certificate its gateway serves. Minted here like the
# three above, and the reason is the sharpest of the four: this leaf

View file

@ -116,6 +116,15 @@ path "auth/cert/certs/swarm-otel-oidc" {
capabilities = ["create", "update"]
}
# swarm-bao-forwarder-oidc-policy
path "sys/policies/acl/swarm-forwarder-oidc" {
capabilities = ["create", "update"]
}
path "auth/cert/certs/swarm-forwarder-oidc" {
capabilities = ["create", "update"]
}
# swarm-bao-nats-tls-policy: the queue's own pki role, beside
# `swarm-services` above, and its policy and login role.
path "pki/roles/swarm-nats" {

View file

@ -471,6 +471,17 @@ let
}
];
# The fifth, and the one the four-way split left on the hive's leaf: the
# store's own forwarder. Its path is `forwarderStoreSecretPath` below with
# KV v2's `data/` inserted.
forwarderOidcReaders = [
{
name = "swarm-forwarder-oidc";
cn = baoDeploy.forwarderOidcCommonName;
policyText = readStanza "${credentialMountPath}/data/swarm/services/${cfg.otel.clientId}/oidc/client";
}
];
# The role name IS the policy name, as for the three service principals
# above: the role attaches the policy by spelling it identically, and one
# string for both objects removes the way they drift apart.
@ -703,15 +714,15 @@ let
# The forwarder's own credential, and the three names it takes on the way
# in. The shape is ./swarm-otel.nix's `swarm-bao-otel-oidc` — the unit that
# already reads an OIDC client secret out of the store with this host's
# certificate and lands it in a collector's container — because that route
# is proven and there is no second one worth inventing.
# already reads an OIDC client secret out of the store with a leaf of its
# own and lands it in a collector's container — because that route is
# proven and there is no second one worth inventing.
#
# Where the publisher on authelia's host leaves it. The `services` segment
# is `swarm-secret-client`'s `path::Kind::Service`: a swarm service's client
# is registered once for the whole swarm, so its secret is one value. The
# prefix is also exactly what a hive certificate's read grant covers, so a
# path outside it answers 403 however correct it looks.
# `swarm-forwarder-oidc` role's grant is this one path, so any other answers
# 403 however correct it looks.
forwarderStoreSecretPath = "secret/swarm/services/${cfg.otel.clientId}/oidc/client";
# At rest in the container's tree, written by the host unit below — under
# /var/lib rather than /run, because a secret that evaporates on reboot
@ -1213,6 +1224,23 @@ in
'';
};
forwarderOidcCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-bao-forwarder-oidc";
example = "swarm-bao-forwarder-oidc.svc";
description = ''
Subject the store's `swarm-forwarder-oidc` cert-auth role accepts — the
identity `swarm-bao-forwarder-oidc`, the unit that fetches the store's
own forwarder's OIDC client secret, presents. Its grant is one path,
`swarm/services/<forwarder client id>/oidc/client`, and read only.
A **fifth** identity rather than reuse of
{option}`services.hyperhive.deploy.bao.otelOidcCommonName`: the two
read different clients' secrets, and the store's forwarder is not
entitled to the swarm collector's.
'';
};
matrixTokenClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -1318,6 +1346,30 @@ in
'';
};
forwarderOidcClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/forwarder-oidc.pem";
description = ''
Certificate the unit that fetches the store's own forwarder's OIDC
client secret presents to the store. Its subject must be
{option}`services.hyperhive.deploy.bao.forwarderOidcCommonName`.
⚠️ Not the hive's own leaf, for the reason
{option}`services.hyperhive.deploy.bao.matrixTokenClientCertFile` gives.
'';
};
forwarderOidcClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/forwarder-oidc-key.pem";
description = ''
Private key for
{option}`services.hyperhive.deploy.bao.forwarderOidcClientCertFile`.
'';
};
serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
@ -1511,6 +1563,28 @@ in
something set these back to null.
'';
}
{
# Refused rather than degraded: `swarm-bao-forwarder-oidc` renders
# wherever the store does and has no mode without a secret, and the
# only fallback left would be the hive's leaf — the union grant this
# pair exists to end.
assertion =
baoDeploy.forwarderOidcClientCertFile != null && baoDeploy.forwarderOidcClientKeyFile != null;
message = ''
The swarm secret store runs on this host, so its forwarder needs a
client identity of its own: set both
services.hyperhive.deploy.bao.forwarderOidcClientCertFile
services.hyperhive.deploy.bao.forwarderOidcClientKeyFile
swarm-bao-forwarder-oidc.service fetches the store forwarder's OIDC
client secret out of the store with them. A hive that runs the store
normally gets both from ./glue-bao-tls.nix.
⚠️ Not deploy.bao.clientCertFile. That one is the hive's, and its
grant reads every secret in the store; this role reads one path.
'';
}
];
# The name every reader dials, made resolvable where the store runs.
@ -1557,6 +1631,7 @@ in
"swarm-bao-queue-agent-policy"
"swarm-bao-grafana-oidc-policy"
"swarm-bao-otel-oidc-policy"
"swarm-bao-forwarder-oidc-policy"
"swarm-bao-services-issuer-policy"
"swarm-bao-nats-tls-policy"
];
@ -1618,14 +1693,14 @@ in
# reads a store in the container NEXT DOOR; this one reads the store in
# the very container it is delivering into, so "before" is a wait on a
# process that cannot start until this finishes. There is no bootstrap
# cycle behind it — the identity used here is this host's static
# `swarm-bao-pki` certificate, not anything the store mints — only an
# ordering one, and the cost is bounded: the file is under /var/lib, so
# it survives reboots and only a FIRST boot has the collector starting
# before it exists. `LoadCredential` refuses to start a unit whose
# source is missing, so that boot is a collector that restarts, says so
# each time, and comes up the moment this lands. Loud and self-healing
# rather than silently exporting without a credential.
# cycle behind it — the identity used here is the static
# `forwarder-oidc.pem` leaf from `swarm-bao-pki`, not anything the store
# issues — only an ordering one, and the cost is bounded: the file is
# under /var/lib, so it survives reboots and only a FIRST boot has the
# collector starting before it exists. `LoadCredential` refuses to start
# a unit whose source is missing, so that boot is a collector that
# restarts, says so each time, and comes up the moment this lands. Loud
# and self-healing rather than silently exporting without a credential.
systemd.services.swarm-bao-forwarder-oidc = {
description = "fetch the secret store forwarder's OIDC client secret from the store";
after = [
@ -1656,8 +1731,11 @@ in
};
environment = {
BAO_ADDR = "https://${cfg.domain}:${toString cfg.port}";
BAO_CLIENT_CERT = baoDeploy.clientCertFile;
BAO_CLIENT_KEY = baoDeploy.clientKeyFile;
# 🩸 Its OWN leaf, not `clientCertFile`: the hive's grant reads every
# agent's credential and every service's OIDC secret, and this unit
# needs one path. The pair is asserted set above.
BAO_CLIENT_CERT = baoDeploy.forwarderOidcClientCertFile;
BAO_CLIENT_KEY = baoDeploy.forwarderOidcClientKeyFile;
}
# Absent means the system trust store, which is what a deployment with
# a real CA wants and what a self-signed one must not be left with.
@ -1680,7 +1758,7 @@ in
# this unit's `path` does not carry — `-token-only` answers on
# stdout and skips the helper on both sides.
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to the swarm secret store with this host's certificate." >&2
echo "could not log in to the swarm secret store with the forwarder's own certificate." >&2
cat "$err" >&2
exit 1
fi
@ -2206,6 +2284,8 @@ in
systemd.services.swarm-bao-otel-oidc-policy = readerPolicyUnit "write the collector's OIDC-secret-reader bao policy and cert-auth role" otelOidcReaders;
systemd.services.swarm-bao-forwarder-oidc-policy = readerPolicyUnit "write the store forwarder's OIDC-secret-reader bao policy and cert-auth role" forwarderOidcReaders;
# A FOURTH sibling, same shape and same reasons as the two above. This
# one is what turns `swarm-services-issuer` from a declaration into a
# grant: a bao policy reaches nothing until a login role hands it to a

View file

@ -49,6 +49,7 @@ let
deployCfg.bao.matrixCtlCommonName
deployCfg.bao.grafanaOidcCommonName
deployCfg.bao.otelOidcCommonName
deployCfg.bao.forwarderOidcCommonName
deployCfg.bao.servicesIssuerCommonName
deployCfg.bao.natsCommonName
]