Watch
0
0
Fork
You've already forked hyperhive
0

swarm-bao: agent certificates issued by a store-generated agent CA

An agent's store identity was signed in swarm-controller's memory by a CA
a controller-host unit generated on disk, and the listener never trusted
that CA. Agent leaves now come from the store itself: a `pki-agents` PKI
mount whose root openbao generates internally, so the agent CA's key
never exists outside the store.

- swarm-bao-agent-pki (new, store host, as the bao granter): enables and
  tunes the mount, generates the root once (guarded on an empty issuer
  list, no replace branch), upserts the `swarm-agent` role (client
  certificates named `hive-agent-*` only, 90 days), caches the CA at
  /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle.
- The listener's tls_client_ca_file is a new listener-client-ca.pem
  (client-ca.pem, then the agent CA). Host cert-auth roles still pin
  client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs
  composes the same bundle before openbao starts.
- openbao reads tls_client_ca_file only at start, so when the bundle
  changed after openbao started, swarm-bao-agent-pki restarts
  openbao.service in the container; under `seal = "shamir"` it prints
  the step instead. Once swarm-bao-certs has a cached CA, later boots
  start openbao with it and do not restart.
- The controller policy gains exactly `update` on
  pki-agents/issue/swarm-agent. mint_and_verify now asks that role for
  the leaf (the store generates the key), writes the agent's cert-auth
  role pinning the issuing CA bao returned, and writes the agent's
  policy as render_agent alone: the hive-shared queue credential stanza
  is gone.
- deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the
  other pki role names); swarm-controller gets
  SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options.

Deleted: swarm-controller-agent-ca and its options (agentCaFile,
agentCaKeyFile), env, LoadCredential entries and assertion;
agent_identity's Authority, rcgen signing and validity window; the
rcgen and time dependencies of swarm-controller (rcgen leaves the
workspace); policy::render_agent_with_queue and its tests. The CN-prefix
assertion policy.rs said was owed is not: agent and host roles pin
different CAs.

Migration is re-creating each agent after deploy; that overwrites the
stale role and policy.

Closes #4756
This commit is contained in:
atlas 2026-09-27 19:30:38 +02:00
commit 6170e74a31
16 changed files with 894 additions and 925 deletions

View file

@ -57,6 +57,12 @@ let
deploy.bao.natsPkiRoleName = "queue";
};
# An agent pki role the granter's `roles/swarm-*` does not reach.
baoGranterOddAgentRole = hive {
deploy.bao.enable = true;
deploy.bao.agentPkiRoleName = "agent";
};
# The store and the token, with no CA to trust. `mkForce` because the PKI
# glue supplies one by default here — this is the deployment that brings its
# own certificates and has not named the authority yet, in which nothing can
@ -145,7 +151,7 @@ let
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
) baoGrantWithConsumers.systemd.services;
# The ten units that write a `swarm-*` grant, by name, for the discovery
# The eleven units that write a `swarm-*` grant, by name, for the discovery
# control below.
grantingUnitNames = [
"swarm-bao-controller-policy"
@ -158,6 +164,7 @@ let
"swarm-bao-forwarder-oidc-policy"
"swarm-bao-services-issuer-policy"
"swarm-bao-nats-tls-policy"
"swarm-bao-agent-pki"
];
# Comment lines dropped first: both the HCL and the scripts explain
@ -741,24 +748,24 @@ let
}
{
# A store host without the granter's pair writes its grants some other
# way, so none of the ten units may exist. Without this arm
# way, so none of the eleven units may exist. Without this arm
# `lib.mkIf haveGranter` could be dropped from any of them and every other
# case here would still pass.
name = "without the granter's pair none of the ten granting units render";
name = "without the granter's pair none of the eleven granting units render";
ok =
let
s = baoGranterOptOut.systemd.services;
in
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
# The control: the same store with the pair renders all ten.
# The control: the same store with the pair renders all eleven.
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
}
{
# 🩸 What replaced the silent skip. With no bootstrap token the ten still
# 🩸 What replaced the silent skip. With no bootstrap token the eleven still
# render, and a refused granter fails them with the step that fixes it.
# A store host that never named a token is told to name one, since the
# unit that sets the granter up renders only where it has.
name = "a store host without a bootstrap token renders the ten, each failing loudly with the one-time step";
name = "a store host without a bootstrap token renders the eleven, each failing loudly with the one-time step";
ok =
let
s = baoGranterNoToken.systemd.services;
@ -1122,8 +1129,8 @@ let
}
{
# What makes the case above mean something: discovery by the granter's
# certificate reaches all ten units, and each yields calls.
name = "the granter-policy check sees all ten granting units, and parses calls from each";
# certificate reaches all eleven units, and each yields calls.
name = "the granter-policy check sees all eleven granting units, and parses calls from each";
ok =
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
@ -1163,6 +1170,96 @@ let
]
&& grantFor bootstrapGrants "sys/policies/acl/swarm-controller" == null;
}
{
# The controller's whole reach on any PKI mount: one role's issue
# endpoint. It cannot rewrite the role, sign a CSR of its choosing or
# touch the issuer, so the role's narrowing is the narrowing.
name = "the controller's only PKI grant is update on the agent role's issue path";
ok =
let
cg = grantsIn baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
in
lib.filter (g: lib.hasInfix "pki" g.path) cg == [
{
path = "pki-agents/issue/swarm-agent";
caps = [ "update" ];
}
]
&& lib.all (p: grantFor cg p == null) [
"pki-agents/roles/swarm-agent"
"pki-agents/sign/swarm-agent"
"pki-agents/sign-verbatim/swarm-agent"
"pki-agents/issue/swarm-other"
"pki-agents/root/generate/internal"
"pki-agents/issuer/default"
"pki-agents/config/urls"
"pki-agents/keys"
"pki/issue/swarm-services"
"sys/mounts/pki-agents"
];
}
{
# The engine refuses any name outside the glob, which is what keeps a
# host CN out of the controller's reach. Exactly one unit writes a role
# on the agent mount, so no second role widens it.
name = "the agent PKI role issues client certificates named hive-agent-* and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-agent-pki.script;
roleWriters = lib.filter (u: matches "bao write '?pki-agents/roles/" (u.script or "") != [ ]) (
lib.attrValues baoGrantHere.systemd.services
);
in
lib.all (t: lib.hasInfix t s) [
"allowed_domains='hive-agent-*'"
"allow_glob_domains=true"
"allow_bare_domains=false"
"allow_subdomains=false"
"allow_wildcard_certificates=false"
"allow_localhost=false"
"allow_any_name=false"
"allow_ip_sans=false"
"server_flag=false"
"client_flag=true"
"code_signing_flag=false"
"email_protection_flag=false"
"ttl=2160h"
"max_ttl=2160h"
]
&& lib.length roleWriters == 1;
}
{
# Every agent's role pins this root by value: generated once, after the
# tune that stops bao clamping it, and never deleted.
name = "the agent root is generated once, after the tune, as a leaf-only CA, and nothing deletes it";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-agent-pki.script;
tune = "bao secrets tune -max-lease-ttl=262800h pki-agents";
generate = "pki-agents/root/generate/internal";
before =
a: b:
lib.stringLength (lib.head (lib.splitString b s))
> lib.stringLength (lib.head (lib.splitString a s));
in
lib.length (lib.splitString generate s) == 2
&& lib.hasInfix tune s
&& before tune generate
&& lib.hasInfix "max_path_length=0" s
&& lib.hasInfix "elif [ \"$issuers\" = '{}' ]; then" s
&& !(lib.hasInfix "bao delete" s)
&& grantFor granterGrants "pki-agents/root" == null;
}
{
# The granter writes pki roles through `roles/swarm-*` only.
name = "an agent pki role name outside swarm-* is refused, naming the option";
ok =
let
names = a: lib.hasInfix "services.hyperhive.deploy.bao.agentPkiRoleName" a.message;
in
lib.any (a: !a.assertion && names a) baoGranterOddAgentRole.assertions
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.assertions);
}
];
in
runGroup "bao-grants" cases