Compare commits
3 changed files with 33 additions and 297 deletions
|
|
@ -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,
|
authenticate until some role exists — this token is what breaks that cycle,
|
||||||
and it's the only step that needs the root token.
|
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
|
```bash
|
||||||
# The policy file, copied to wherever you run `bao`.
|
# Exactly the six grants the bootstrap unit needs, and nothing else. Each was
|
||||||
bao policy write swarm-bootstrap swarm-bao-bootstrap-policy.hcl
|
# derived with `bao <cmd> -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.
|
# 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
|
bao token create -policy=swarm-bootstrap -ttl=24h -orphan -display-name=swarm-bootstrap
|
||||||
|
|
|
||||||
|
|
@ -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 <cmd> -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, `<prefix>-<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"]
|
|
||||||
}
|
|
||||||
|
|
@ -94,125 +94,6 @@ let
|
||||||
"swarm-bao-otel-oidc"
|
"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 = [
|
cases = [
|
||||||
{
|
{
|
||||||
# Reads the rendered unit on the HOST, which is where the write happens:
|
# 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";
|
name = "a bootstrap token on a host that runs no store grants nothing";
|
||||||
ok = !(baoGrantNoStore.systemd.services ? swarm-bao-bootstrap-dir);
|
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
|
in
|
||||||
runGroup "bao-grants" cases
|
runGroup "bao-grants" cases
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue