swarm-bao: keep the bootstrap policy in one file, checked against the units using it (#4698)

setup.md's copy of the swarm-bootstrap policy still granted only the
controller's first six paths, while eight units now act with the
bootstrap token. The policy moves to
nix/host-modules/swarm-bao-bootstrap-policy.hcl, now covering every path
those units call. module-eval-bao-grants reads that file and fails
when a unit whose script uses the token calls a path the file does not
grant.
This commit is contained in:
atlas 2026-09-24 15:53:53 +02:00
commit e3c595857e
2 changed files with 292 additions and 0 deletions

View file

@ -94,6 +94,125 @@ let
"swarm-bao-otel-oidc"
];
# What the bootstrap token may do, read from the file the operator writes it
# from (../../docs/getting-started/setup.md points there), against what the
# units holding that token actually call. The units are found by the token
# path in their script rather than by name, so a new one is checked without
# anyone listing it here.
bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
bootstrapUnits = lib.filterAttrs (
_: u: lib.hasInfix bootstrapTokenFile u.script
) baoGrantWithConsumers.systemd.services;
# Comment lines dropped first: both the HCL and the scripts explain
# themselves in prose that names paths and `bao` commands.
codeLines =
text: lib.filter (l: builtins.match "[[:space:]]*#.*" l == null) (lib.splitString "\n" text);
bootstrapPolicyText = lib.concatStringsSep "\n" (
codeLines (builtins.readFile ../host-modules/swarm-bao-bootstrap-policy.hcl)
);
matches = re: text: lib.filter lib.isList (builtins.split re text);
bootstrapGrants =
map
(m: {
path = lib.elemAt m 0;
caps = map lib.head (matches ''"([a-z]+)"'' (lib.elemAt m 1));
})
(
matches ''path "([^"]+)"[[:space:]]*[{][[:space:]]*capabilities[[:space:]]*=[[:space:]]*[[]([a-z", ]*)'' bootstrapPolicyText
);
# One `bao …` invocation → the path and capabilities it needs, as
# `bao <cmd> -output-policy` reports them. Path-specific `sudo` (bao's
# root-protected paths, e.g. `pki/root` for a delete) does not follow from
# the verb, so only `auth enable` is checked for it. A verb not listed here
# needs a path no grant has, so it fails the case until it is taught.
baoCallNeeds =
words:
let
flags = lib.filter (lib.hasPrefix "-") words;
args = lib.filter (w: !(lib.hasPrefix "-" w) && w != "\\") words;
a = i: if i < lib.length args then lib.elemAt args i else "";
need = path: caps: {
inherit path caps;
call = lib.concatStringsSep " " words;
};
cu = [
"create"
"update"
];
in
if a 0 == "policy" && a 1 == "write" then
need "sys/policies/acl/${a 2}" cu
else if a 0 == "secrets" && a 1 == "list" then
need "sys/mounts" [ "read" ]
else if a 0 == "secrets" && a 1 == "enable" then
need "sys/mounts/${lib.removePrefix "-path=" (lib.findFirst (lib.hasPrefix "-path=") "-path=${a 2}" flags)}" cu
else if a 0 == "secrets" && a 1 == "tune" then
need "sys/mounts/${a 2}/tune" cu
else if a 0 == "auth" && a 1 == "list" then
need "sys/auth" [ "read" ]
else if a 0 == "auth" && a 1 == "enable" then
need "sys/auth/${a 2}" (cu ++ [ "sudo" ])
else if a 0 == "write" then
need (a 1) cu
else if a 0 == "read" then
need (a 1) [ "read" ]
else if a 0 == "list" then
need (a 1) [ "list" ]
else if a 0 == "delete" then
need (a 1) [ "delete" ]
else
need "unrecognised call" [ ];
baoCalls =
script:
map
(
inv:
baoCallNeeds (lib.filter (w: w != "") (lib.splitString " " (lib.replaceStrings [ "'" ] [ "" ] inv)))
)
(
lib.concatMap (l: map (m: lib.elemAt m 1) (matches "(^[[:space:]]*|[$][(]|[)] )bao ([^|;)]*)" l)) (
codeLines script
)
);
# bao's own rule: an exact path wins, otherwise the longest glob prefix.
bootstrapGrantFor =
path:
let
exact = lib.filter (g: g.path == path) bootstrapGrants;
globs = lib.filter (
g: lib.hasSuffix "*" g.path && lib.hasPrefix (lib.removeSuffix "*" g.path) path
) bootstrapGrants;
in
if exact != [ ] then
lib.head exact
else
lib.foldl' (
best: g: if best == null || lib.stringLength g.path > lib.stringLength best.path then g else best
) null globs;
bootstrapUngranted = lib.concatLists (
lib.mapAttrsToList (
unit: u:
map (n: "${unit}: `bao ${n.call}` needs ${n.path} [${toString n.caps}]") (
lib.filter (
n:
let
g = bootstrapGrantFor n.path;
in
g == null || !(lib.all (c: lib.elem c g.caps) n.caps)
) (baoCalls u.script)
)
) bootstrapUnits
);
cases = [
{
# Reads the rendered unit on the HOST, which is where the write happens:
@ -627,6 +746,43 @@ let
name = "a bootstrap token on a host that runs no store grants nothing";
ok = !(baoGrantNoStore.systemd.services ? swarm-bao-bootstrap-dir);
}
{
# The drift this case exists to stop: setup.md's copy of the policy
# stayed at the controller's first six grants while seven more units
# started using the token. Failing names every ungranted call.
name =
"every bao call a bootstrap-token unit makes is granted by swarm-bao-bootstrap-policy.hcl"
+ lib.optionalString (bootstrapUngranted != [ ]) (
": " + lib.concatStringsSep "; " bootstrapUngranted
);
ok = bootstrapUngranted == [ ];
}
{
# What makes the case above mean something: discovery by token path
# reaches every unit that uses the token today, and each yields calls.
name = "the bootstrap-policy check sees all eight units that use the token, and parses calls from each";
ok =
lib.all (n: bootstrapUnits ? ${n}) [
"swarm-bao-controller-policy"
"swarm-bao-secret-publisher-policy"
"swarm-bao-matrix-ctl-policy"
"swarm-bao-matrix-token-policy"
"swarm-bao-queue-agent-policy"
"swarm-bao-grafana-oidc-policy"
"swarm-bao-otel-oidc-policy"
"swarm-bao-services-issuer-policy"
]
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues bootstrapUnits);
}
{
# And the grants side: a stanza the parser skipped would read as a
# grant that is not there.
name = "every path stanza in swarm-bao-bootstrap-policy.hcl parses";
ok =
bootstrapGrants != [ ]
&& lib.length bootstrapGrants == lib.length (matches ''path "'' bootstrapPolicyText)
&& lib.all (g: g.caps != [ ]) bootstrapGrants;
}
];
in
runGroup "bao-grants" cases