diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index 00d1d73e..89350563 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 35bd3544..743047cb 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 9e6df0ea..8078501d 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 9a5f25d3..85057868 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 2f2319df..be8be0bd 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"