From e3c595857e2fb9861fce9ea4c89305dcea21f1b9 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 24 Sep 2026 15:53:53 +0200 Subject: [PATCH] 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. --- .../swarm-bao-bootstrap-policy.hcl | 136 +++++++++++++++ nix/module-eval/bao-grants.nix | 156 ++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 nix/host-modules/swarm-bao-bootstrap-policy.hcl diff --git a/nix/host-modules/swarm-bao-bootstrap-policy.hcl b/nix/host-modules/swarm-bao-bootstrap-policy.hcl new file mode 100644 index 00000000..6c40ea7c --- /dev/null +++ b/nix/host-modules/swarm-bao-bootstrap-policy.hcl @@ -0,0 +1,136 @@ +# The `swarm-bootstrap` policy: what the 24h bootstrap token may do, and +# nothing else. ../../docs/getting-started/setup.md has the operator write it +# with the root token; ./swarm-bao.nix's granting units then act with it. +# +# Each stanza was derived with `bao -output-policy`, which prints what a +# command requires without sending it. ../module-eval/bao-grants.nix reads +# this file and fails when a unit that uses the token calls a path it does not +# grant. The pki paths assume the default `servicesPkiMountPath` (`pki`) and +# `servicesPkiRoleName` (`swarm-services`). + +# swarm-bao-controller-policy: the controller's own policy and role. +path "sys/policies/acl/swarm-controller" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-controller" { + capabilities = ["create", "update"] +} + +# The auth mounts it creates. Reading `sys/auth` is how the unit checks, and +# `sudo` is what enabling one costs. +path "sys/auth" { + capabilities = ["read"] +} + +path "sys/auth/cert" { + capabilities = ["create", "update", "sudo"] +} + +path "sys/auth/approle" { + capabilities = ["create", "update", "sudo"] +} + +# The KV and PKI engines, checked the same way. Enabling a secrets engine does +# not ask for `sudo`. +path "sys/mounts" { + capabilities = ["read"] +} + +path "sys/mounts/secret" { + capabilities = ["create", "update"] +} + +path "sys/mounts/pki" { + capabilities = ["create", "update"] +} + +path "sys/mounts/pki/tune" { + capabilities = ["create", "update"] +} + +# The services root: generated once, read back on every run, and replaced +# only when it can no longer outlive a leaf. +path "pki/issuers" { + capabilities = ["list"] +} + +path "pki/cert/ca" { + capabilities = ["read"] +} + +path "pki/root" { + capabilities = ["delete", "sudo"] +} + +path "pki/root/generate/internal" { + capabilities = ["create", "update"] +} + +path "pki/roles/swarm-services" { + capabilities = ["create", "update"] +} + +# swarm-bao-secret-publisher-policy +path "sys/policies/acl/swarm-secret-publisher" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-secret-publisher" { + capabilities = ["create", "update"] +} + +# swarm-bao-matrix-ctl-policy +path "sys/policies/acl/swarm-matrix-ctl" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-matrix-ctl" { + capabilities = ["create", "update"] +} + +# swarm-bao-services-issuer-policy +path "sys/policies/acl/swarm-services-issuer" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-services-issuer" { + capabilities = ["create", "update"] +} + +# swarm-bao-grafana-oidc-policy +path "sys/policies/acl/swarm-grafana-oidc" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-grafana-oidc" { + capabilities = ["create", "update"] +} + +# swarm-bao-otel-oidc-policy +path "sys/policies/acl/swarm-otel-oidc" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-otel-oidc" { + capabilities = ["create", "update"] +} + +# swarm-bao-matrix-token-policy and swarm-bao-queue-agent-policy write one +# policy and role per hive, `-`, so these two are globs. Each +# stops at its own prefix. +path "sys/policies/acl/swarm-matrix-token-*" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-matrix-token-*" { + capabilities = ["create", "update"] +} + +path "sys/policies/acl/swarm-queue-agent-*" { + capabilities = ["create", "update"] +} + +path "auth/cert/certs/swarm-queue-agent-*" { + capabilities = ["create", "update"] +} diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 8e78acb3..55c4381e 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -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 -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