Watch
0
0
Fork
You've already forked hyperhive
0

bao: let the UI's admins login write the review classifier's API key

The docs tell the operator to write
`secret/swarm/services/review-classifier/anthropic-api-key` by hand in
the bao UI, but the UI's OIDC login for authelia's `admins` group lands
on `swarm-operator-viewer`, which is list+read on `secret/metadata/*`
and nothing under `secret/data/`. bao refuses the write, so an admin
can't set the key and every classifier run fails with "no API key".

The viewer policy gains `create` and `update` on that one leaf, no
`read`: a UI session still never sees a secret value. The leaf is a
named binding, shared with the classifier's reader policy.

module-eval pins the viewer's two stanzas and that no `data/` grant
carries `read`. docs/swarm/secrets.md and services.md describe the
write.

Closes #5148
This commit is contained in:
atlas 2026-10-12 18:17:28 +02:00
commit 133d5578d2
4 changed files with 32 additions and 11 deletions

View file

@ -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 |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |

View file

@ -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

View file

@ -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";
}
];

View file

@ -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