diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 025ba9cd..a81b4d07 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -94,14 +94,12 @@ Put the token's value at `services.hyperhive.deploy.bao.bootstrapTokenFile` store's container reads it, writes the `swarm-controller` policy, enables the cert auth method, and creates the `swarm-controller` role that attaches the two. -⚠️ **None of this has been run against a live store.** Nothing in the tree has -ever authenticated to OpenBao, so treat the block above as derived rather than -exercised — the grants come from `-output-policy`, not from a swarm that came -up on them. - -⚠️ **The role it creates has nothing to present a certificate for yet.** No -deployment shape mints a leaf whose CN reads `swarm-controller`, so until one -does, the role stays provisioning that waits for a consumer. +⚠️ **This has been exercised once, and it did not go all the way through.** The +first real provision against a live store returned 403 (hyperhive#4124), so +treat the block above as derived-then-partly-tested rather than proven: the +grants still come from `-output-policy`, not from a swarm that came up on them. +What that 403 does _not_ tell you is whether the login or the write was +refused — `bao read auth/cert/certs/swarm-controller` separates the two. **Delete the file once that has run.** The unit skips when it's absent, so a host that has finished bootstrapping stops carrying the credential — and the diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 16f067a7..2155118b 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -168,6 +168,19 @@ let # `writeText`: a store path puts the grants behind a hash where # ../module-eval.nix cannot read them, and a heredoc would make the HCL's # indentation a function of this file's. + # + # The last grant is a different kind from the others: they let the controller + # bootstrap hives, this lets it write an agent's credentials. Two things about + # it do not read as they look. + # + # `secret/data/` is KV v2's ACL prefix, not part of the path the code passes: + # `swarm-secret-client` writes `swarm/agents//...` under mount + # `secret`, and the engine inserts `data/`. Matching the code's spelling + # literally would grant nothing. + # + # Write-only on purpose. The controller mints these; nothing in its job reads + # one back, and a read capability here would let it recover every agent's + # credentials rather than merely replace them. controllerPolicyText = '' path "auth/cert/certs/hive-*" { capabilities = ["create", "update", "read", "delete"] @@ -184,6 +197,10 @@ let path "sys/policies/acl" { capabilities = ["list"] } + + path "secret/data/swarm/agents/*" { + capabilities = ["create", "update"] + } ''; # Every listener serves the same identity: they differ in which address diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 76ee0295..5b19eb96 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -528,6 +528,34 @@ let in lib.hasInfix "sys/policies/acl/hive-*" s && !(lib.hasInfix "sys/policies/acl/*" s); } + { + # The policy authorising this route lives in another file, and nothing + # else relates the grants to the paths the code actually writes. + # + # `secret/data/` is KV v2's ACL prefix; `swarm/agents` is + # `swarm_secret_client::path::AGENT_PREFIX`, whose value that crate + # pins in its own test. + name = "the controller may write agent credentials, and only under the agent prefix"; + ok = + let + s = baoGrantHere.containers.swarm-bao.config.systemd.services.swarm-bao-controller-policy.script; + in + lib.hasInfix "secret/data/swarm/agents/*" s + && !(lib.hasInfix "secret/data/*" s) + && !(lib.hasInfix "path \"secret/*\"" s); + } + { + # Write-only is the property, not an accident of how it was typed: a + # `read` here would let the controller recover every agent's credentials + # instead of only replacing them. Pinned as the whole capability list, + # because an added capability is exactly what a presence check misses. + name = "the controller's grant on agent credentials is write-only"; + ok = + let + s = baoGrantHere.containers.swarm-bao.config.systemd.services.swarm-bao-controller-policy.script; + in + lib.hasInfix "path \"secret/data/swarm/agents/*\" {\n capabilities = [\"create\", \"update\"]" s; + } { # The policy above grants paths under a mount nothing else creates, so # the unit that writes the policy has to create it too — otherwise every diff --git a/swarm-secret-client/src/path.rs b/swarm-secret-client/src/path.rs index 345ca0ed..0bf04309 100644 --- a/swarm-secret-client/src/path.rs +++ b/swarm-secret-client/src/path.rs @@ -98,4 +98,15 @@ mod tests { "got {e:?}" ); } + + #[test] + fn the_bao_policy_grants_exactly_these_two_values() { + // Renaming either constant is a silent 403 at provision time, not a + // compile error: the controller's grant spells them out in + // `nix/host-modules/swarm-bao.nix` (`controllerPolicyText`), which no + // Rust change can reach. Editing here means editing there, and + // `nix/module-eval.nix` asserts the other side of the same pair. + assert_eq!(MOUNT, "secret"); + assert_eq!(AGENT_PREFIX, "swarm/agents"); + } }