From 525cbe0355cd40690fc8140d485a8a7a129dbabd Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 29 Oct 2026 00:09:12 +0100 Subject: [PATCH] count readers, not writers, of the pull token in the cache's roster pin --- nix/module-eval/bao-grants.nix | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 85057868..c1b43d95 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -316,6 +316,15 @@ let best: g: if best == null || lib.stringLength g.path > lib.stringLength best.path then g else best ) null globs; + # Whether the policy text in `text` lets its holder read `secret/data/swarm/`, + # by `grantFor`'s rule, so a glob that reaches the leaf counts and a write does not. + grantReads = + leaf: text: + let + g = grantFor (grantsIn text) "secret/data/swarm/${leaf}"; + in + g != null && lib.elem "read" g.caps; + ungranted = grants: units: lib.concatLists ( @@ -2139,20 +2148,42 @@ let { # The signing key is the cache's alone; the push token is also the # builder's, and is read there. The pull token is also the controller's, - # which only reads it (pinned above). - name = "no granting unit but the cache's names its signing key, and only it and the controller name its pull token"; + # which only reads it (pinned above). The pull token's value is what + # the roster guards, so it counts the units that may read it: the + # operator's write-only grant on it, which replaces the token and + # exposes nothing, stays outside. + name = "no granting unit but the cache's names its signing key, and only it and the controller read its pull token"; ok = lib.filter ( u: lib.hasInfix "services/attic/jwt-secret" baoGrantHere.systemd.services.${u}.script ) grantingUnitNames == [ "swarm-bao-attic-policy" ] && lib.filter ( - u: lib.hasInfix "services/attic/pull-token" baoGrantHere.systemd.services.${u}.script + u: grantReads "services/attic/pull-token" baoGrantHere.systemd.services.${u}.script ) grantingUnitNames == [ "swarm-bao-controller-policy" "swarm-bao-attic-policy" ]; } + { + # Control for the property above: `grantReads` tells a read from a write + # on the same path, and the operator's policy does name the pull token. + name = "a grant that only writes the pull token is not a read of it, and one that reads it is"; + ok = + let + stanza = caps: '' + path "secret/data/swarm/services/attic/pull-token" { + capabilities = [${caps}] + } + ''; + viewer = baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script; + in + !(grantReads "services/attic/pull-token" (stanza ''"create", "update"'')) + && grantReads "services/attic/pull-token" (stanza ''"read"'') + && grantReads "services/attic/pull-token" (stanza ''"create", "read"'') + && lib.hasInfix "services/attic/pull-token" viewer + && !(grantReads "services/attic/pull-token" viewer); + } { name = "the PKI unit signs the cache's leaf under its own subject"; ok =