From 98f2a94d824a4063386b87ac7a33c13bce0530c7 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 22:54:42 +0200 Subject: [PATCH] swarm-bao: write the secret publisher's policy and cert-auth role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sibling unit rather than more script in swarm-bao-controller-policy, because that unit's name is an operator-facing string: docs/getting-started/setup.md tells a reader to run `systemctl status swarm-bao-controller-policy`. Widening it to two principals makes the name wrong; renaming it makes the instruction wrong. `after` and not `requires`. The controller's unit creates the KV and cert-auth mounts this one writes into, so the ordering is real — but a failed oneshot still counts as finished, so `requires` would neither wait for its success nor re-run this unit when the sibling's own retry eventually lands. Ordering plus this unit's `Restart=on-failure` is what converges. Four module-eval cases, because the unit arrived with every claim about it in prose and the suite still reporting the same count: the grant is write-only and reaches the hive prefix alone (pinned as the whole capability list, since an added capability is what a presence check misses, with negative arms for the agent prefix, the bare swarm prefix and the policy path); it is ordered after the unit that creates the mounts; it renders on the host; and the control, that it does not render inside the store's container. Refs #3853 --- nix/host-modules/swarm-bao.nix | 53 ++++++++++++++++++++++++++++++++++ nix/module-eval.nix | 45 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index bede4e26..52590ec5 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -208,6 +208,7 @@ let # controller may create policies under that prefix, and a policy it can # rewrite is not a constraint on anything. secretPublisherPolicyName = "swarm-secret-publisher"; + secretPublisherCn = baoDeploy.secretPublisherCommonName; # One grant, and every narrowing in it is load-bearing. # @@ -1006,6 +1007,58 @@ in ''; }; + # A SIBLING rather than more script in the unit above, because that unit's + # name is an operator-facing string: ../../docs/getting-started/setup.md + # tells a reader to run `systemctl status swarm-bao-controller-policy`. + # Widening it to two principals would make the name wrong, and renaming it + # would make that instruction wrong. + # + # `after` and not `requires`: the unit above creates the KV and cert-auth + # mounts this one writes into, but a failed oneshot still counts as + # finished, so `requires` would neither wait for its success nor re-run + # this one when its own retry eventually lands. Ordering plus this unit's + # own retry is what actually converges. + systemd.services.swarm-bao-secret-publisher-policy = lib.mkIf haveBootstrapToken { + description = "write the swarm secret publisher's bao policy and cert-auth role"; + after = [ + "container@${cfg.machine}.service" + "swarm-bao-controller-policy.service" + ]; + wantedBy = [ "multi-user.target" ]; + path = [ + baoCli + pkgs.coreutils + ]; + unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile; + # Same unseal wait as its sibling above, for the reason stated there: + # under `seal = "shamir"` a human unseals by hand, which can take a day. + startLimitBurst = 2880; + startLimitIntervalSec = 90000; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + Restart = "on-failure"; + RestartSec = 30; + }; + script = '' + set -euo pipefail + + BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})" + export BAO_TOKEN + + printf '%s' ${lib.escapeShellArg secretPublisherPolicyText} | + bao policy write ${lib.escapeShellArg secretPublisherPolicyName} - + '' + + lib.optionalString (baoDeploy.clientCaFile != null) '' + + bao write auth/cert/certs/${lib.escapeShellArg secretPublisherPolicyName} \ + certificate=@${tlsDir}/client-ca.pem \ + allowed_common_names=${lib.escapeShellArg secretPublisherCn} \ + token_policies=${lib.escapeShellArg secretPublisherPolicyName} \ + display_name=${lib.escapeShellArg secretPublisherCn} + ''; + }; + containers.${cfg.machine} = { autoStart = true; ephemeral = false; diff --git a/nix/module-eval.nix b/nix/module-eval.nix index b408746a..94f6c3d5 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -841,6 +841,51 @@ let in lib.hasInfix "sys/policies/acl/hive-*" s && !(lib.hasInfix "sys/policies/acl/*" s); } + { + # Same host-side reasoning as the controller's granting unit above: the + # write needs a client certificate and the host is the side that has one. + name = "a store host with a placed bootstrap token renders the publisher's granting unit too"; + ok = + let + u = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy; + in + u.unitConfig.ConditionPathExists == "/run/secrets/bao-bootstrap.token" + && lib.hasInfix "swarm-secret-publisher" u.script; + } + { + # The control for the case above, and the same one the controller's unit + # has: rendered on the host means NOT rendered in the container, where it + # would have neither an identity nor a route to the store. + name = "the publisher's granting unit is not rendered inside the store's container"; + ok = + !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-secret-publisher-policy); + } + { + # The whole point of a second principal. `hives/` and not `swarm/`, so it + # cannot touch an agent's or a service's credentials; and no `read`, so a + # unit whose job is copying a file cannot recover what is already there. + # Pinned as the full capability list, because an added capability is + # exactly what a presence check misses. + name = "the publisher's grant is write-only and reaches the hive prefix alone"; + ok = + let + s = baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.script; + in + lib.hasInfix "path \"secret/data/swarm/hives/*\" {\n capabilities = [\"create\", \"update\"]" s + && !(lib.hasInfix "secret/data/swarm/agents" s) + && !(lib.hasInfix "secret/data/swarm/*" s) + && !(lib.hasInfix "sys/policies/acl" s); + } + { + # The ordering is load-bearing and invisible at runtime: the controller's + # unit creates the KV and cert-auth mounts this one writes into, so + # without it a cold boot races and fails with "route entry not found", + # which names neither unit. + name = "the publisher's granting unit is ordered after the one that creates the mounts"; + ok = lib.elem "swarm-bao-controller-policy.service" ( + baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after + ); + } { # The policy authorising this route lives in another file, and nothing # else relates the grants to the paths the code actually writes.