diff --git a/docs/swarm/secrets.md b/docs/swarm/secrets.md index cc4ee358..e7a7b8f6 100644 --- a/docs/swarm/secrets.md +++ b/docs/swarm/secrets.md @@ -524,9 +524,12 @@ stream passthrough readers use on that host comes up with the store sealed. 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/*`. That shows +`swarm-operator-viewer`: `list` and `read` on `secret/metadata/*`, and +`create` and `update` on +`secret/data/swarm/services/review-classifier/anthropic-api-key`. That shows the key tree and each key's versions and timestamps, and no value: the store -refuses every `secret/data/` read. Anything more needs a token. +refuses every `secret/data/` read. The one write is the review classifier's API +key, which the operator fills in by hand. Anything more needs a token. | piece | written by | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 4847771c..7a0e1da5 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -243,7 +243,8 @@ identity: the host that runs the store has it, and a services host with the store elsewhere gets the classifier only once it holds a copy of the pair (see below). Set `deploy.review-classifier.enable` directly to override either way. -The operator writes the Anthropic key by hand, in the secret store's UI at +The operator writes the Anthropic key by hand, in the secret store's UI under +the OIDC login, at `secret/swarm/services/review-classifier/anthropic-api-key`, field `api_key`. Nothing writes it for you: until it exists the service runs and every run fails with `no API key…`. The service reads the key on every run, so writing or diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 664c1241..c1abda3e 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -577,13 +577,18 @@ let # classifier's reviews with it; no other principal's policy reaches it. classifierForgeTokenLeaf = "swarm/services/review-classifier/forge/token"; + # The review classifier's Anthropic API key. The operator writes it through + # the UI's OIDC login; the classifier reads it on every run. + classifierApiKeyLeaf = "swarm/services/review-classifier/anthropic-api-key"; + # The UI's OIDC client secret. The publisher writes it; the granter reads it # into the `oidc` auth method's config, which is the only copy bao uses. baoUiClientLeaf = "swarm/services/${cfg.ui.oidc.clientId}/oidc/client"; # What an OIDC login through the UI gets: the key tree and each key's KV - # metadata, never a value — KV v2 serves values under `data/`, which no - # stanza here names. The UI needs no `sys/` grant on top: + # metadata, and a write on the one leaf the operator fills by hand. Never a + # value: KV v2 serves values under `data/`, and the one `data/` stanza has + # no `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. operatorViewerPolicyName = "swarm-operator-viewer"; @@ -591,6 +596,9 @@ let path "${credentialMountPath}/metadata/*" { capabilities = ["list", "read"] } + path "${credentialMountPath}/data/${classifierApiKeyLeaf}" { + capabilities = ["create", "update"] + } ''; # authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the @@ -967,7 +975,7 @@ let name = "swarm-review-classifier"; cn = baoDeploy.reviewClassifierCommonName; policyText = - readStanza "${credentialMountPath}/data/swarm/services/review-classifier/anthropic-api-key" + readStanza "${credentialMountPath}/data/${classifierApiKeyLeaf}" + readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.review-classifier.clientId}/oidc/client"; } ]; diff --git a/nix/module-eval/bao-grants.nix b/nix/module-eval/bao-grants.nix index aa23817b..0a5b6df2 100644 --- a/nix/module-eval/bao-grants.nix +++ b/nix/module-eval/bao-grants.nix @@ -1836,10 +1836,11 @@ let # ── the UI's OIDC login ───────────────────────────────────────────────── { - # What an `admins` login through the UI holds: the key tree and KV - # metadata. Any `data/` path would hand a browser session every secret - # in the store, and any `sys/` one more than the UI needs. - name = "the operator viewer policy is list and read on KV metadata, and nothing under data/ or sys/"; + # What an `admins` login through the UI holds: the key tree, KV + # metadata, and a write on the one leaf 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 API key, and no data/ read or sys/"; ok = let viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script; @@ -1852,10 +1853,18 @@ let "read" ]; } + { + path = "secret/data/swarm/services/review-classifier/anthropic-api-key"; + caps = [ + "create" + "update" + ]; + } ] && 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.hasPrefix "sys/" g.path) viewer); + && !(lib.any (g: lib.hasPrefix "secret/data" g.path && lib.elem "read" g.caps) viewer) + && !(lib.any (g: lib.hasPrefix "sys/" g.path) viewer); } { # Route (a): enabling an auth method stays a bootstrap-token act. The