diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 5a6c685e..2225529e 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -78,12 +78,40 @@ to grant itself anything. Cert auth answers a _role_, so nothing can authenticate until some role exists — this token is what breaks that cycle, and it's the only step that needs the root token. -The policy is `nix/host-modules/swarm-bao-bootstrap-policy.hcl` in this -repository, and CI fails when a unit using the token needs a path it lacks. - ```bash -# The policy file, copied to wherever you run `bao`. -bao policy write swarm-bootstrap swarm-bao-bootstrap-policy.hcl +# Exactly the six grants the bootstrap unit needs, and nothing else. Each was +# derived with `bao -output-policy`, which prints what a command requires +# without running it. +bao policy write swarm-bootstrap - <<'EOF' +path "sys/policies/acl/swarm-controller" { + capabilities = ["create", "update"] +} + +# Cert auth is a mount, and nothing has created it yet: 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 "auth/cert/certs/swarm-controller" { + capabilities = ["create", "update"] +} + +# The KV engine the controller writes agent credentials through — also absent +# on a fresh store, and checked the same way. No `sudo` here, unlike the auth +# mount above: enabling a secrets engine does not ask for it. +path "sys/mounts" { + capabilities = ["read"] +} + +path "sys/mounts/secret" { + capabilities = ["create", "update"] +} +EOF # A token holding it. `-orphan` so it outlives the session that made it. bao token create -policy=swarm-bootstrap -ttl=24h -orphan -display-name=swarm-bootstrap diff --git a/nix/host-modules/swarm-bao-bootstrap-policy.hcl b/nix/host-modules/swarm-bao-bootstrap-policy.hcl deleted file mode 100644 index 6c40ea7c..00000000 --- a/nix/host-modules/swarm-bao-bootstrap-policy.hcl +++ /dev/null @@ -1,136 +0,0 @@ -# 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 55c4381e..8e78acb3 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -94,125 +94,6 @@ 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: @@ -746,43 +627,6 @@ 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