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:
parent
5cd7f866f4
commit
6170e74a31
16 changed files with 894 additions and 925 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue