swarm-bao: add a pki mount, role and issuing policy

First slice of the move off the hand-rolled swarm services sub-CA: the
store gains the three objects the eventual minter needs, and nothing
else.

- a `pki` secrets mount, enabled ask-first off the same `bao secrets
  list` snapshot the kv-v2 mount beside it uses, so a rebuild that finds
  it mounted does nothing;
- a `swarm-services` role on it, `allowed_domains` read straight out of
  `swarm.serviceDomains` — the same swarm-tier list swarm-ca.nix
  name-constrains its sub-CA to and hive-tls.nix carries as the leaf's
  SANs — with subdomains, localhost, globs and IP SANs all off;
- a `swarm-services-issuer` policy granting `update` on
  `pki/issue/swarm-services`.

Inert: the mount has no issuer generated into it, the role therefore
cannot issue, and no login role attaches the policy, so no token in the
swarm carries it. No consumer changes; swarm-ca.nix still mints the
services sub-CA exactly as before. Reversible with
`bao secrets disable pki`.

The cert-auth role that attaches the policy waits for the leaf carrying
its CN, which glue-bao-tls.nix mints — a later step.

Refs #4256
This commit is contained in:
atlas 2026-09-17 01:35:38 +02:00 committed by mara
commit 02f90b7bc6

View file

@ -243,6 +243,55 @@ let
# value on the reading side.
credentialMountPath = "secret";
# The PKI engine the swarm's *service* certificates are to be issued from,
# named once for the same reason `credentialMountPath` is: the role below,
# the grant that points at `pki/issue/`, and the `secrets enable` in the
# bootstrap unit all have to spell it the same way.
#
# ⚠️ NOT bao's own client-auth PKI. That one is ./glue-bao-tls.nix's
# self-signed CA under `/var/lib/swarm-bao-pki`, and it stays outside the
# store permanently: bao cannot issue the credential that opens bao.
#
# Nothing issues through this mount yet — see the role below.
servicesPkiMountPath = "pki";
# The role every swarm-service certificate will be issued through, and the
# whole narrowing of this mount. A role rather than a name-constrained
# sub-CA: the engine enforces the same "these names and no others" at issue
# time that ./swarm-ca.nix builds into x509 `nameConstraints` today, so a
# second CA in the chain would re-implement a check that already happens.
servicesPkiRoleName = "swarm-services";
# The names that role may issue for. Read out of the swarm-tier option
# rather than assembled here, exactly as ./swarm-ca.nix:45 and
# ./hive-tls.nix:16 read it — the thing that narrows what may be issued and
# the leaf that carries those names as SANs must agree, and a second module
# composing its own list is how they stop agreeing.
swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains;
# The identity that will ask this mount for a certificate, once something
# does. A THIRD principal rather than either existing one, for the reason
# `secretPublisherCommonName` gives for being a second: the controller may
# rewrite every hive's policy and login role, and a unit whose whole job is
# renewing one server certificate has no business holding that.
#
# `update` and not `create`: bao's issue endpoint is a POST to an existing
# path, which is `update` in ACL terms — `create` here would grant nothing
# and read as though it did.
#
# ⚠️ NOTHING IS ATTACHED TO THIS POLICY. A bao policy grants only through a
# token that carries it, so until a login role names it this is an inert
# declaration of intent — which is the point: the cert-auth role that
# attaches it needs a leaf carrying its CN, and minting that is
# ./glue-bao-tls.nix's job, one step further along than this one.
servicesIssuerPolicyName = "swarm-services-issuer";
servicesIssuerPolicyText = ''
path "${servicesPkiMountPath}/issue/${servicesPkiRoleName}" {
capabilities = ["update"]
}
'';
# Every listener serves the same identity: they differ in which address
# they answer on, not in who they are. Client verification is separate and
# optional — a store with no `clientCaFile` still serves TLS, it just does
@ -1016,6 +1065,66 @@ in
*'"${credentialMountPath}/"'*) ;;
*) bao secrets enable -path=${credentialMountPath} kv-v2 ;;
esac
# The PKI engine the swarm's service certificates are to come from.
# Asked rather than attempted for the same reason as the mount above:
# `secrets enable` errors on a path already in use, so a second
# rebuild would fail a unit that has nothing left to do.
#
# ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so
# the role below can be written but cannot yet issue. That is the
# whole shape of this step — the mount, its narrowing and its grant
# exist, and the minter that uses them arrives separately. Undoing it
# is `bao secrets disable ${servicesPkiMountPath}`.
case "$mounts" in
*'"${servicesPkiMountPath}/"'*) ;;
*) bao secrets enable -path=${servicesPkiMountPath} pki ;;
esac
# `bao write` is an upsert, so this re-asserts the role on every
# rebuild rather than failing on one that exists — same shape as the
# cert-auth roles below, and the reason the mount needs the `case`
# above while this does not.
#
# Every narrowing is load-bearing, and three of them turn OFF a
# default that is on:
# * `allow_subdomains` — the x509 constraint ./swarm-ca.nix writes
# permits a whole subtree per name; a swarm service is an exact
# hostname, so the role is the narrower of the two.
# * `allow_localhost` — bao's own default is `true`, which would
# make this role issue for a name nobody configured.
# * `allow_ip_sans` — the sub-CA excludes both IP subtrees
# explicitly (./swarm-ca.nix:260-276); an unconstrained name type
# is a name type this issuer is unconstrained for.
# `server_flag` alone because that is what today's leaf carries
# (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096
# because that is the key the current minter generates.
#
# An empty `swarm.serviceDomains` renders an empty `allowed_domains`,
# which with `allow_any_name=false` is a role that issues nothing —
# the same outcome as ./swarm-ca.nix's "nothing to issue for" exit,
# reached without a branch.
bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${servicesPkiRoleName}"} \
allowed_domains=${lib.escapeShellArg (lib.concatStringsSep "," swarmServiceDomains)} \
allow_bare_domains=true \
allow_subdomains=false \
allow_glob_domains=false \
allow_localhost=false \
allow_any_name=false \
allow_ip_sans=false \
enforce_hostnames=true \
server_flag=true \
client_flag=false \
key_type=rsa \
key_bits=4096
# Idempotent for the same reason the controller's policy above is: a
# rebuild re-asserts it. Written here rather than in a unit of its
# own because it attaches to nothing yet — the sibling-unit rule
# below is about a second *principal* that logs in, and this policy
# has no login role until the leaf carrying its CN exists.
printf '%s' ${lib.escapeShellArg servicesIssuerPolicyText} |
bao policy write ${lib.escapeShellArg servicesIssuerPolicyName} -
''
+ lib.optionalString (baoDeploy.clientCaFile != null) ''