openbao custody: group/content reads fail with 403 — diagnosis, discriminator, and hardening scope #528
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#528
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observed on files-bao (v0.6.187), 2026-09-14 ~00:15Z: opening any file in the space Fall 3 fails; the raw endpoint returns 500 with
{"error":"encrypt: openbao read groups/Fall 3: 403 Forbidden"}. Pod log shows every/api/v1/files/rawand/api/v1/preview/pdfin the session returning 500 (same 63-byte body). Visible symptoms on the phone: image preview opens to a broken-image "?", Edit shows the raw error JSON.Why some previews still "work": tile thumbnails and converted previews are served from the per-version disk caches, so anything previewed before the failure began keeps rendering. No fresh decrypt demonstrably succeeded in this session — which matters for the diagnosis below. The odt that opened fine was a cached conversion.
Code path (
internal/storage/encrypt/openbao.go):keysFor→fetch(ctx, "groups/Fall 3")→ GET{base}/v1/{mount}/data/{prefix}/groups/Fall 3withX-Vault-Token→ non-200 →encrypt: openbao read %s: %s. The keyset cache is memory-only, so every pod restart re-reads from OpenBao — and the dogfoods restarted ~10 times on 2026-09-13 for the #389 waves, which is why this surfaced now regardless of when the credential actually went bad.Two candidate root causes — OpenBao/Vault returns 403 for BOTH:
groups/*under the prefix (users/* granted, groups/* missed), and Fall 3 is simply the first group content opened since the last restart.10-second discriminator: open any file under
/homeon files-bao.openbao read users/nikola-test: 403 Forbidden) → token-wide → cause 1.Server-side check, whichever way it points:
bao token lookupon Cairn's token (ttl/expire_time), andbao token capabilities <token> <mount>/data/<prefix>/groups/Fall 3.Remediation (ops, likely): re-mint as an orphan periodic token (or a long/renewable TTL with renewal), policy
path "<mount>/data/<prefix>/*" { capabilities = ["create","read","update"] }(+ the matchingmetadatapath if used), update the instance config Secret, restart.Product hardening scope (this issue):
cairn_custody_errors_total) so aCairnCustodyFailingalert can fire —CairnDowncannot see this failure mode (instance healthy, content dead).url(rel)concatenates the space name unescaped. "Fall 3" survives via Go's URL re-encoding, but%,?or#in a space name would corrupt the request path — escape per segment (url.PathEscape).Root cause confirmed: the custody token expired — default 768h TTL, dead on schedule
Evidence chain:
/homeopen fails identically —{"error":"encrypt: openbao read users/nikola-test: 403 Forbidden"}→ token-wide, not a groups policy gap.openbao-0(nsopenbao) is 32 days old with 0 restarts — never resealed, so not the seal (sealed would 503 anyway).2026-09-13T23:25:27Z [INFO] expiration: revoked lease: lease_id=auth/token/create/h5e13255c…The expiration manager revoked a token minted via
auth/token/create. 32 days = the default 768h token TTL; the token was minted during the same setup session that started the pod. Cairn never renews (static bearer), so it died exactly one TTL later.Why it surfaced as "space Fall 3": the keyset cache is memory-only and the dogfoods restarted repeatedly on 2026-09-13 for the #389 waves; the first post-restart decrypt anyone attempted happened to be group content.
Runbook (operator — needs the root token, so this is Nikola's)
Then put the new token into the openbao dogfood's (untracked) config Secret in ns
cairnandkubectl -n cairn rollout restart deploy/cairn-openbao. Content in Fall 3 and /home should open immediately after (fresh decrypts; caches were never affected).Important caveat on
-period: a periodic token is renewable forever but must still be renewed within each period — it does not refresh on use. Until Cairn self-renews, this token dies again in 32 days, same as this one. That turns this issue's hardening list from nice-to-have into a fuse:cairn_custody_errors_totalmetric for aCairnCustodyFailingalert — this failure mode was invisible to every existing alert for ~an hour of active use.Happy to build the renewal + boot-check + metric as the next TDD run on request — it is self-contained (httptest OpenBao stub) and closes the fuse before day 32.
Hardening shipped — v0.6.189 + v0.6.190, alerts live
Ops side (Nikola): root token recovered, replacement minted as orphan periodic 768h, config Secret updated, instance restarted — content decrypts again.
Product side:
openbao_maint.go—LookupSelf/RenewSelf/SelfCheck(canary-404-is-healthy),renewAfterclamp [1m, 12h] at half TTL, process-global custody stats with stickyCustodyLastError(), fetch/put failures counted; obs gainsSetCustodyProvideremittingcairn_custody_errors_total/cairn_custody_token_ttl_seconds/cairn_custody_token_renewable(licence-gauge pattern).buildKeys(standalone crypto role); in-process instances build throughbuildEncryption's near-duplicate switch and got nothing. Caught because the deployed boot log lacked the self-check line; now wired on both paths.CairnCustodyFailing(critical, anyincrease(cairn_custody_errors_total[15m])) andCairnCustodyTokenExpiring(warning, TTL < 3d for 30m) in the cairn-alerts group.Live verification (cairn-openbao, v0.6.190 boot):
The renewal loop now keeps that sawtoothing — 2026-10-16 is a non-event. During an actual outage the lookup retries every minute at ERROR, pumping the counter, so the critical alert fires within ~20 minutes instead of never.
Remaining scope (keeping this open):
CustodyLastError()+ token TTL in the admin Encryption & keys card (needs the encryption payload + frontend card work).url(rel)path escaping per segment (%,?,#in space names).buildKeysandbuildEncryptionduplicate the recovery parsing and the whole custody switch — that duplication is precisely what let v0.6.189 wire the wrong door.v0.6.191 shipped (PR #537) — remaining scope done, closing.
custodyStatusobject inGET /api/v1/admin/encryptionsourced fromCustodyStats()/CustodyLastError()— counters and TTL only, the token never reaches the payload. i18n ×4.url(rel)now escapes each segment withurl.PathEscape, so a#,%or?in a user or space name can no longer corrupt the KV request path (a%previously made the URL unparseable outright).buildEncryptioncollapsed toencrypt.New(store, buildKeys(cfg, log))after verifying the two switches were token-identical — the duplicate is what hid the v0.6.189 wiring gap.Verified live on the dogfood after the bump:
version=v0.6.191,custody self-check ok token_ttl=767h25m55s renewable=true— i.e. the dedup preserved the maintenance wiring on the in-process path.Full incident record: root cause (default 768 h token TTL, revoked lease), operational fix (orphan periodic token), productized hardening (boot self-check + background renewal, v0.6.189/190), Prometheus alerts (CairnCustodyFailing, CairnCustodyTokenExpiring) in homelab-config, and this release's admin-card visibility + escaping + dedup.