From f22f4ee6ac11ca01f7cca8c5c99e40d6f86ae231 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 28 Oct 2026 23:46:58 +0100 Subject: [PATCH 1/3] let the operator's oidc login write the cache's tokens and facts --- docs/swarm/builder.md | 8 +++---- docs/swarm/credentials.md | 16 +++++++++----- nix/host-modules/swarm-bao.nix | 20 +++++++++++++----- nix/module-eval/bao-grants.nix | 38 +++++++++++++++++++++++++++++++++- nix/script-tests/attic-init.sh | 20 ++++++++++++++++++ 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index 00d1d73eb..893505636 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -355,8 +355,8 @@ principal and create what's missing. A unit creates each secret only while the store holds none, so a second start mints nothing. Both tokens last ten years and nothing renews them. To replace one, -write the new token to its path in the store: nothing mints over a value that -exists. A new signing key invalidates both tokens. +write the new token to its path in the store, in its UI under the OIDC login: +nothing mints over a value that exists. A new signing key invalidates both tokens. The unit creates the address, name and key once and never updates them. After you change `swarm.attic.domain`, or after the server's database goes and the @@ -387,8 +387,8 @@ the swarm at an existing tenant: `services.hyperhive.swarm.attic.cache` to the cache's name on it. Set both options on every host that reads them. 2. Seed the push token in the swarm secret store at - `secret/swarm/services/attic/push-token`, field `value`. Use the store's UI, - or `bao kv put` with `value=-` and the token on stdin, so the token is in no + `secret/swarm/services/attic/push-token`, field `value`. Use the store's UI under + the OIDC login, or `bao kv put` with `value=-` and the token on stdin, so the token is in no command line. The token needs push rights on that cache. Until it exists, every push fails and builds stay green with `cached` false. Seed the hives' pull token the same way at diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 35bd35445..743047cb8 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -556,11 +556,17 @@ The OIDC tab logs in through authelia as the client `swarm-bao-ui` (`swarm.bao.ui.oidc.clientId`). An `admins` member gets a token under `swarm-operator-viewer`: `list` and `read` on `secret/metadata/*`, and `create` and `update` on -`secret/data/swarm/services/review-classifier/anthropic-api-key` and -`secret/data/swarm/services/review-classifier/acp-provider`. That shows the key -tree and each key's versions and timestamps, and no value: the store refuses -every `secret/data/` read. The two writes are the review classifier's keys, -which the operator fills in by hand. Anything more needs a token. +`secret/data/swarm/services/review-classifier/anthropic-api-key`, +`secret/data/swarm/services/review-classifier/acp-provider` and the cache's five +leaves under `secret/data/swarm/services/attic/` (`push-token`, `pull-token`, +`url`, `cache` and `public-key`). That shows the key tree and each key's +versions and timestamps, and no value: the store refuses every `secret/data/` +read. The writes are the leaves the operator fills in by hand: the review +classifier's keys, and the cache's tokens and facts, which configure an external +cache or replace the in-swarm cache's own. The cache's host units create those +leaves only while the store holds none, so a saved value survives their next run +and every deploy. The cache's signing key, `attic/jwt-secret`, isn't writable here. +Anything more needs a token. | piece | written by | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 9e6df0ea0..8078501d1 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -777,18 +777,28 @@ let # `read`. The UI needs no `sys/` grant on top: # `sys/internal/ui/mounts` lists any mount the token holds a capability # under, and the rest it calls is in the `default` policy. + # + # The cache's five leaves are where an external cache is configured, and + # where the operator replaces the in-swarm cache's tokens; its host units + # create them only while none is stored. `atticJwtLeaf` is left out: a new + # signing key invalidates both tokens. + operatorWritableLeaves = [ + classifierApiKeyLeaf + classifierAcpKeyLeaf + atticPushTokenLeaf + ] + ++ atticFactLeaves; operatorViewerPolicyName = "swarm-operator-viewer"; operatorViewerPolicyText = '' path "${credentialMountPath}/metadata/*" { capabilities = ["list", "read"] } - path "${credentialMountPath}/data/${classifierApiKeyLeaf}" { + '' + + lib.concatMapStrings (leaf: '' + path "${credentialMountPath}/data/${leaf}" { capabilities = ["create", "update"] } - path "${credentialMountPath}/data/${classifierAcpKeyLeaf}" { - capabilities = ["create", "update"] - } - ''; + '') operatorWritableLeaves; # authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the # group its rule for the UI's vhost admits) → the viewer policy. authelia diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index 9a5f25d39..85057868a 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -2634,7 +2634,7 @@ let # metadata, and a write on the leaves the operator fills by hand. # A `read` under `data/` would hand a browser session a secret's # value, and any `sys/` path more than the UI needs. - name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's two keys, and no data/ read or sys/"; + name = "the operator viewer policy is list and read on KV metadata, a write on the classifier's two keys and the cache's five, and no data/ read, attic signing key or sys/"; ok = let viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script; @@ -2661,7 +2661,43 @@ let "update" ]; } + { + path = "secret/data/swarm/services/attic/push-token"; + caps = [ + "create" + "update" + ]; + } + { + path = "secret/data/swarm/services/attic/pull-token"; + caps = [ + "create" + "update" + ]; + } + { + path = "secret/data/swarm/services/attic/url"; + caps = [ + "create" + "update" + ]; + } + { + path = "secret/data/swarm/services/attic/cache"; + caps = [ + "create" + "update" + ]; + } + { + path = "secret/data/swarm/services/attic/public-key"; + caps = [ + "create" + "update" + ]; + } ] + && grantFor viewer "secret/data/swarm/services/attic/jwt-secret" == null && grantFor viewer "secret/data/swarm/agents/x/queue" == null && grantFor viewer "secret/metadata/swarm/agents/x/queue" != null && !(lib.any (g: lib.hasPrefix "secret/data" g.path && lib.elem "read" g.caps) viewer) diff --git a/nix/script-tests/attic-init.sh b/nix/script-tests/attic-init.sh index 2f2319dfc..be8be0bd2 100644 --- a/nix/script-tests/attic-init.sh +++ b/nix/script-tests/attic-init.sh @@ -207,6 +207,26 @@ expect "pull token unchanged" "$(stored $PULL)" "$pull" expect "push token unchanged" "$(stored $PUSH)" "$push" expect "address, name and key unchanged" "$(stored $URL)$(stored $CACHE)$(stored $KEY)" "http://cache.examplegridgrid:FAKEKEY" +case="values the operator stored over the minted ones are kept" +for leaf in $PULL $PUSH $URL $CACHE $KEY; do + printf '%s' "operator-$leaf" >"$dir/store/$leaf" +done +: >"$calls" +run "$PROVISION" +expect "exit status" $? 0 +expect "no token minted" "$(n '^atticadm')" 0 +expect "nothing written" "$(n '^bao put')" 0 +expect "the operator's push token is still stored" "$(stored $PUSH)" "operator-$PUSH" +expect "the operator's pull token is still stored" "$(stored $PULL)" "operator-$PULL" +expect "the operator's address, name and key are still stored" \ + "$(stored $URL)$(stored $CACHE)$(stored $KEY)" "operator-${URL}operator-${CACHE}operator-${KEY}" +expect "the cache lookup used the operator's pull token" "$(n "^attic cache info local:grid | token=operator-$PULL")" 1 +printf '%s' "$pull" >"$dir/store/$PULL" +printf '%s' "$push" >"$dir/store/$PUSH" +printf '%s' http://cache.example >"$dir/store/$URL" +printf '%s' grid >"$dir/store/$CACHE" +printf '%s' grid:FAKEKEY >"$dir/store/$KEY" + case="an empty server gets its cache back with the stored tokens" rm -f "$dir/cache-exists" : >"$calls" From 7d7886910478ac5616bdc9376cd2a60b6abdc8f0 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 28 Oct 2026 23:55:40 +0100 Subject: [PATCH 2/3] name the ui path for replacing the cache's leaves --- docs/swarm/credentials.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 743047cb8..1421b0f5a 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -568,6 +568,13 @@ leaves only while the store holds none, so a saved value survives their next run and every deploy. The cache's signing key, `attic/jwt-secret`, isn't writable here. Anything more needs a token. +To replace one of the cache's leaves in the UI, open `secret` > `swarm` > +`services` > `attic`, pick the leaf and choose **Create new version**. The +form starts empty because the login can't read the current value: type the +key `value` and the new value, and save. A new version replaces the whole +secret, so `value` is the only key to enter. `bao kv put` with the same login +works too. + | piece | written by | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | authelia client, with redirect `swarm.bao.ui.oidc.redirectUri` | `glue-bao-ui-oidc-client.nix`, wherever authelia runs | From 525cbe0355cd40690fc8140d485a8a7a129dbabd Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 29 Oct 2026 00:09:12 +0100 Subject: [PATCH 3/3] 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 85057868a..c1b43d95e 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 =