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` 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.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 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 | | 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 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. 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`. `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 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 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. # classifier's reviews with it; no other principal's policy reaches it.
classifierForgeTokenLeaf = "swarm/services/review-classifier/forge/token"; 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 # 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. # into the `oidc` auth method's config, which is the only copy bao uses.
baoUiClientLeaf = "swarm/services/${cfg.ui.oidc.clientId}/oidc/client"; 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 # 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 # metadata, and a write on the one leaf the operator fills by hand. Never a
# stanza here names. The UI needs no `sys/` grant on top: # 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 # `sys/internal/ui/mounts` lists any mount the token holds a capability
# under, and the rest it calls is in the `default` policy. # under, and the rest it calls is in the `default` policy.
operatorViewerPolicyName = "swarm-operator-viewer"; operatorViewerPolicyName = "swarm-operator-viewer";
@ -591,6 +596,9 @@ let
path "${credentialMountPath}/metadata/*" { path "${credentialMountPath}/metadata/*" {
capabilities = ["list", "read"] capabilities = ["list", "read"]
} }
path "${credentialMountPath}/data/${classifierApiKeyLeaf}" {
capabilities = ["create", "update"]
}
''; '';
# authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the # authelia's operator group (`operatorGroup` in ./swarm-authelia.nix, the
@ -967,7 +975,7 @@ let
name = "swarm-review-classifier"; name = "swarm-review-classifier";
cn = baoDeploy.reviewClassifierCommonName; cn = baoDeploy.reviewClassifierCommonName;
policyText = 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"; + readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.review-classifier.clientId}/oidc/client";
} }
]; ];

View file

@ -1836,10 +1836,11 @@ let
# ── the UI's OIDC login ───────────────────────────────────────────────── # ── the UI's OIDC login ─────────────────────────────────────────────────
{ {
# What an `admins` login through the UI holds: the key tree and KV # What an `admins` login through the UI holds: the key tree, KV
# metadata. Any `data/` path would hand a browser session every secret # metadata, and a write on the one leaf the operator fills by hand.
# in the store, and any `sys/` one more than the UI needs. # A `read` under `data/` would hand a browser session a secret's
name = "the operator viewer policy is list and read on KV metadata, and nothing under data/ or sys/"; # 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 = ok =
let let
viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script; viewer = grantsIn baoGrantHere.systemd.services.swarm-bao-operator-viewer-policy.script;
@ -1852,10 +1853,18 @@ let
"read" "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/data/swarm/agents/x/queue" == null
&& grantFor viewer "secret/metadata/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 # Route (a): enabling an auth method stays a bootstrap-token act. The