From 0f0282a43646edbb5743318bc923749af1c0cea2 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 14 Oct 2026 23:45:00 +0200 Subject: [PATCH] RPF-WP-0057: capabilities-self grant for the hub-writer caller and UI workload role choice (NK-WP-0051-T06) credential-change.py generates sys/capabilities-self (update) when openbao.token_capabilities_self is true. Proposed CCR-2026-0045 sets it for workload-kv-read-reuse-surface-runtime so the ops-mason proof stops calling sys/capabilities with a root token. The OpenBao login page keeps its platform-admin button and adds an allow-listed workload role choice. Repo only; nothing applied to OpenBao or the cluster. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 1041694@bnt-lap001 Assistant-Session: 7af0bb68-ac4a-4ce3-8639-24ddd84c0b52 --- ...-surface-hub-writer-capabilities-self.yaml | 121 ++++++++++++++++++ helm/openbao-ui-overlay/README.md | 9 +- helm/openbao-ui-overlay/login.css | 39 +++++- helm/openbao-ui-overlay/login.html | 9 ++ helm/openbao-ui-overlay/login.js | 67 ++++++++-- helm/openbao-ui-overlay/presets.json | 12 +- ...workload-kv-read-reuse-surface-runtime.hcl | 8 +- scripts/credential-change.py | 7 + scripts/openbao-verify-login-overlay.sh | 5 + tests/test_credential_change.py | 25 ++++ tests/test_openbao_ui_role_select.py | 95 ++++++++++++++ ...-WP-0057-sign-in-diagnosis-caller-proof.md | 84 ++++++++++++ 12 files changed, 462 insertions(+), 19 deletions(-) create mode 100644 credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml create mode 100644 tests/test_openbao_ui_role_select.py create mode 100644 workplans/RPF-WP-0057-sign-in-diagnosis-caller-proof.md diff --git a/credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml b/credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml new file mode 100644 index 0000000..2e9a261 --- /dev/null +++ b/credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml @@ -0,0 +1,121 @@ +id: CCR-2026-0045 +kind: credential-change-request +schema_version: 1 +request_type: workload-kv-read +title: Let the reuse-surface hub-writer caller query its own capabilities +status: proposed +created: '2026-10-14' +updated: '2026-10-14' +supersedes: CCR-2026-0044 +requester: + agent: ops-mason + reason: NK-WP-0051-T06 / MASON-WP-0012. The CCR-2026-0044 member proof called sys/capabilities + with a root or platform-admin token because the lane policy has no capabilities-self grant. + This request adds a generated sys/capabilities-self grant (update; the endpoint is a POST + and returns capability names only) so the lane token proves its own reach. Policy, role, + bound claims and token bounds are otherwise unchanged from CCR-2026-0044. Do not attach + workload-kv-read-reuse-surface-runtime to entity 4a1f663c-0621-0483-edd0-f852274f4b6b. No + secret value is read, written or rotated. +review: + required: true + required_approvers: + - platform-operator + - reuse-surface-owner + comments: + - at: '2026-10-14T21:00:00Z' + reviewer: claude + decision: filed_proposed + comment: Filed for NK-WP-0051-T06 (RPF-WP-0057). Repo policy file now carries the generated + sys/capabilities-self grant; the live policy is unchanged until approval and a delegated + apply. Bound claims are those confirmed under CCR-2026-0044 (groups=reuse-surface-hub-writers, + /assurance/level=aal2); this request does not change them. +target: + domain: financials + tenant: reuse + workload: reuse-surface + environment: production + purpose: Attended read of the existing hub writer token by one dedicated caller. +openbao: + mount: platform + kv_path: platform/workloads/reuse/reuse-surface/runtime-secrets + fields: + - REUSE_SURFACE_TOKEN + - REUSE_SURFACE_FORGEJO_WEBHOOK_SECRET + policy_name: workload-kv-read-reuse-surface-runtime + policy_file: openbao/policies/workload-kv-read-reuse-surface-runtime.hcl + metadata_read: true + token_self_lifecycle: true + auth: + method: oidc + mount: netkingdom + role: reuse-surface-hub-writer-workload-kv-read + allowed_redirect_uris: + - http://localhost:8250/oidc/callback + - http://127.0.0.1:8250/oidc/callback + - http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback + oidc_scopes: + - openid + - profile + - email + - groups + user_claim: sub + groups_claim: groups + bound_claims_type: string + bound_claims: + groups: + - reuse-surface-hub-writers + /assurance/level: aal2 + bound_claims_confirmed: true + policies: + - workload-kv-read-reuse-surface-runtime + ttl: 15m + token_max_ttl: 30m + token_explicit_max_ttl: 30m + token_no_default_policy: true + token_type: service + token_capabilities_self: true +access_frontdoor: + type: ops-warden + catalog_id: reuse-surface-hub-write-token + selector: reuse-surface federation hub write bearer token + command: After a passing member receipt, run warden access reuse-surface-hub-write-token --exec + --field REUSE_SURFACE_TOKEN -- with the unprinted lane token. Do not use + --fetch. + resolvable: false + readiness: pending-review +risk: + classification: high + notes: + - sys/capabilities-self reports capability names for paths the caller names. It returns no + secret value and grants no access. + - The platform-admin entity must not receive this policy. OpenBao merges entity policies into + every token for that entity. + - The ESO policy workload-kv-read-reuse-surface-runtime-eso stays on its Kubernetes role and + is unchanged. + - Until this apply, the live lane policy differs from openbao/policies/workload-kv-read-reuse-surface-runtime.hcl + by this one stanza; CCR-2026-0044 member proofs stay on the earlier operator-token path. +verification: + positive: + - A member token's policy set is exactly workload-kv-read-reuse-surface-runtime. + - That token's capabilities are read on the exact data path and the exact metadata path. + - The member token can call sys/capabilities-self on the five proof paths without any operator + or root token. + negative: + - The same token is deny on platform/data/workloads/reuse/reuse-surface, its metadata parent, + and platform/data/workloads/issue-core/issue-core/issue-core-runtime. + - A principal outside reuse-surface-hub-writers is refused by the role. + - Entity 4a1f663c-0621-0483-edd0-f852274f4b6b is not the member token's entity. + activation_conditions: + - platform-operator and reuse-surface-owner approve this request. + - Founder applies the policy through the delegated applier. No KV write, no role change beyond + re-asserting the CCR-2026-0044 role. + - ops-mason scripts/verify-reuse-surface-hub-writer.py member passes using capabilities-self + only, and non-member records the OpenBao refusal verbatim. Receipts stay mode 0600 with + no token or field value. + evidence: [] +lifecycle: + deactivate: Re-apply the CCR-2026-0044 policy body (drop the sys/capabilities-self stanza). + Leave the role, KV path and ESO lane in place. + rotate: unchanged from CCR-2026-0005. This request does not rotate field values. + compromised: detach the role, revoke outstanding tokens for the role, and follow the CCR-2026-0005 + compromise procedure if a field value was exposed. diff --git a/helm/openbao-ui-overlay/README.md b/helm/openbao-ui-overlay/README.md index 5d8cd68..a645611 100644 --- a/helm/openbao-ui-overlay/README.md +++ b/helm/openbao-ui-overlay/README.md @@ -5,6 +5,13 @@ Streamlines the browser login mask reached through the named single **Sign in with KeyCape** action. Namespace, auth method, mount path, and role are preset in `presets.json` and hidden by `overlay.css` / `overlay.js`. +The main button always uses role `platform-admin`. Below it, "Sign in with a +workload role" offers only the roles listed in `presets.json` `workloadRoles` +(no free-text role), for example `reuse-surface-hub-writer-workload-kv-read` +(NK-WP-0051-T06). Each listed role must allow the loopback UI callback. Use a +private window for a workload role so the platform-admin KeyCape session is +not reused. + ## Mechanism (T01 decision) OpenBao ships UI assets inside the container image. There is no supported API @@ -30,7 +37,7 @@ behaviour. | File | Purpose | | --- | --- | | `VERSION` | OpenBao image tag this overlay targets (`openbao-values.yaml`) | -| `presets.json` | Hidden login defaults (`netkingdom`, `platform-admin`, …) | +| `presets.json` | Hidden login defaults (`netkingdom`, `platform-admin`, …) and the `workloadRoles` allow-list | | `overlay.css` | Hide raw OpenBao login fields | | `overlay.js` | Apply presets, branding on post-login Ember pages | | `login.html` / `login.js` / `login.css` | Standalone KeyCape login at `/ui/vault/auth` | diff --git a/helm/openbao-ui-overlay/login.css b/helm/openbao-ui-overlay/login.css index d9880ca..8f17c9a 100644 --- a/helm/openbao-ui-overlay/login.css +++ b/helm/openbao-ui-overlay/login.css @@ -82,4 +82,41 @@ body { .login-error.is-visible { display: block; -} \ No newline at end of file +} +.login-workload { + margin-top: 1.25rem; + border-top: 1px solid var(--border); + padding-top: 1rem; + font-size: 0.9rem; +} + +.login-workload summary { + cursor: pointer; + color: var(--accent); + font-weight: 600; +} + +.login-workload p { + margin: 0.75rem 0; +} + +.login-workload label { + display: block; + margin-bottom: 0.35rem; + color: var(--muted); +} + +.login-workload select { + width: 100%; + padding: 0.55rem 0.6rem; + margin-bottom: 0.9rem; + border: 1px solid var(--border); + border-radius: 6px; + font: inherit; + background: var(--card); + color: var(--text); +} + +.login-button-secondary { + background: var(--muted); +} diff --git a/helm/openbao-ui-overlay/login.html b/helm/openbao-ui-overlay/login.html index 54a4a09..15ccd89 100644 --- a/helm/openbao-ui-overlay/login.html +++ b/helm/openbao-ui-overlay/login.html @@ -16,6 +16,15 @@ + diff --git a/helm/openbao-ui-overlay/login.js b/helm/openbao-ui-overlay/login.js index d629ad1..37ed7ab 100644 --- a/helm/openbao-ui-overlay/login.js +++ b/helm/openbao-ui-overlay/login.js @@ -21,9 +21,21 @@ } } - async function redirectToKeyCape(presets) { + // Only roles listed in presets.workloadRoles can be chosen; there is no + // free-text role. The main button always uses presets.role (platform-admin). + function workloadRoles(presets) { + const roles = Array.isArray(presets.workloadRoles) ? presets.workloadRoles : []; + return roles.filter( + (entry) => + entry && + typeof entry.role === "string" && + /^[a-z0-9][a-z0-9-]*$/.test(entry.role) && + entry.role !== (presets.role || "platform-admin") + ); + } + + async function redirectToKeyCape(presets, role) { const mount = presets.mount || "netkingdom"; - const role = presets.role || "platform-admin"; const redirectUri = `${window.location.origin}/ui/vault/auth/${mount}/oidc/callback`; const response = await fetch(`/v1/auth/${mount}/oidc/auth_url`, { @@ -67,18 +79,45 @@ if (!button) return; - button.addEventListener("click", async () => { - button.disabled = true; - try { - await redirectToKeyCape(presets); - } catch (error) { - button.disabled = false; - showError( - error instanceof Error - ? error.message - : "Sign-in failed. Contact your administrator." - ); - } + function bind(control, chooseRole) { + control.addEventListener("click", async () => { + control.disabled = true; + try { + await redirectToKeyCape(presets, chooseRole()); + } catch (error) { + control.disabled = false; + showError( + error instanceof Error + ? error.message + : "Sign-in failed. Contact your administrator." + ); + } + }); + } + + bind(button, () => presets.role || "platform-admin"); + + const roles = workloadRoles(presets); + const workload = document.getElementById("login-workload"); + const select = document.getElementById("login-workload-role"); + const workloadButton = document.getElementById("login-workload-submit"); + if (!roles.length || !workload || !select || !workloadButton) return; + + const summary = document.getElementById("login-workload-summary"); + const hint = document.getElementById("login-workload-hint"); + if (summary && presets.workloadLabel) summary.textContent = presets.workloadLabel; + if (hint && presets.workloadHint) hint.textContent = presets.workloadHint; + for (const entry of roles) { + const option = document.createElement("option"); + option.value = entry.role; + option.textContent = entry.label || entry.role; + select.appendChild(option); + } + workload.hidden = false; + bind(workloadButton, () => { + const chosen = roles.find((entry) => entry.role === select.value); + if (!chosen) throw new Error("Choose a listed workload role."); + return chosen.role; }); } diff --git a/helm/openbao-ui-overlay/presets.json b/helm/openbao-ui-overlay/presets.json index 9aa265c..59f82d7 100644 --- a/helm/openbao-ui-overlay/presets.json +++ b/helm/openbao-ui-overlay/presets.json @@ -5,5 +5,13 @@ "role": "platform-admin", "title": "Sign in with KeyCape", "signInLabel": "Sign in with KeyCape", - "banner": "Platform operators authenticate through KeyCape at kc.coulomb.social." -} \ No newline at end of file + "banner": "Platform operators authenticate through KeyCape at kc.coulomb.social.", + "workloadRoles": [ + { + "role": "reuse-surface-hub-writer-workload-kv-read", + "label": "reuse-surface hub writer (workload read)" + } + ], + "workloadLabel": "Sign in with a workload role", + "workloadHint": "A workload role needs its own person. Open this page in a private window, or sign out of KeyCape first, so the platform-admin session is not reused." +} diff --git a/openbao/policies/workload-kv-read-reuse-surface-runtime.hcl b/openbao/policies/workload-kv-read-reuse-surface-runtime.hcl index e2dbec6..c042392 100644 --- a/openbao/policies/workload-kv-read-reuse-surface-runtime.hcl +++ b/openbao/policies/workload-kv-read-reuse-surface-runtime.hcl @@ -1,4 +1,6 @@ # CCR-2026-0044 adds lookup-self and revoke-self for the dedicated caller. +# CCR-2026-0045 (NK-WP-0051-T06) adds capabilities-self so the caller proves +# its own reach without an operator or root token. # Do not attach this policy to entity 4a1f663c-0621-0483-edd0-f852274f4b6b. path "platform/data/workloads/reuse/reuse-surface/runtime-secrets" { @@ -15,4 +17,8 @@ path "auth/token/lookup-self" { path "auth/token/revoke-self" { capabilities = ["update"] -} \ No newline at end of file +} + +path "sys/capabilities-self" { + capabilities = ["update"] +} diff --git a/scripts/credential-change.py b/scripts/credential-change.py index e12b41f..e75baa6 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -253,6 +253,8 @@ def validate_workload_kv_read(ccr: dict[str, Any], errors: list[str], warnings: errors.append("openbao.token_self_lifecycle must be boolean") if "token_revoke_self" in openbao and not isinstance(openbao["token_revoke_self"], bool): errors.append("openbao.token_revoke_self must be boolean") + if "token_capabilities_self" in openbao and not isinstance(openbao["token_capabilities_self"], bool): + errors.append("openbao.token_capabilities_self must be boolean") if "kv_metadata" in openbao: validate_kv_metadata(openbao["kv_metadata"], errors) fields = [str(field) for field in require_list(openbao.get("fields"), "openbao.fields", errors)] @@ -621,6 +623,11 @@ def generated_policy_hcl(ccr: dict[str, Any]) -> str: '\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n') elif openbao.get("token_revoke_self", False): body += '\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n' + if openbao.get("token_capabilities_self", False): + # NK-WP-0051-T06: the caller proves its own reach. capabilities-self is a + # POST, so the grant is "update"; it reveals capabilities, never values. + body += ('\npath "sys/capabilities-self" {\n' + ' capabilities = ["update"]\n}\n') return body diff --git a/scripts/openbao-verify-login-overlay.sh b/scripts/openbao-verify-login-overlay.sh index 4b23e6f..cfca2a8 100755 --- a/scripts/openbao-verify-login-overlay.sh +++ b/scripts/openbao-verify-login-overlay.sh @@ -113,6 +113,11 @@ require_pattern \ "$presets_json" \ '"role"[[:space:]]*:[[:space:]]*"platform-admin"' +require_pattern \ + "presets.json offers the workload role selection (NK-WP-0051-T06)" \ + "$presets_json" \ + '"workloadRoles"' + require_pattern \ "overlay.css hides namespace picker" \ "$overlay_css" \ diff --git a/tests/test_credential_change.py b/tests/test_credential_change.py index 3e21cc8..b6c78a8 100644 --- a/tests/test_credential_change.py +++ b/tests/test_credential_change.py @@ -683,6 +683,31 @@ class CredentialChangeTests(unittest.TestCase): self.assertTrue(ccr["openbao"]["auth"]["bound_claims_confirmed"]) self.assertEqual(ccr["review"]["comments"][-1]["decision"], "binding_confirmed") + def test_hub_writer_caller_gains_generated_capabilities_self(self) -> None: + # NK-WP-0051-T06: the proof no longer needs sys/capabilities with a root token. + path = REPO_DIR / "credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml" + ccr, errors, _warnings = credential_change.validate_ccr(path) + self.assertEqual(errors, []) + body = credential_change.generated_policy_hcl(ccr) + self.assertIn('path "sys/capabilities-self" {\n capabilities = ["update"]\n}', body) + self.assertNotIn('path "sys/capabilities" {', body) + self.assertNotIn("*", body) + self.assertEqual(credential_change.policy_artifact_diff(ccr)["status"], "matches") + self.assertEqual(credential_change.applier_policy_violations(ccr), []) + self.assertEqual(ccr["openbao"]["auth"]["policies"], ["workload-kv-read-reuse-surface-runtime"]) + without = json.loads(json.dumps(ccr)) + without["openbao"]["token_capabilities_self"] = False + self.assertNotIn("capabilities-self", credential_change.generated_policy_hcl(without)) + + def test_token_capabilities_self_must_be_boolean(self) -> None: + path = REPO_DIR / "credential-change-requests/CCR-2026-0045-reuse-surface-hub-writer-capabilities-self.yaml" + with tempfile.TemporaryDirectory() as tmp: + bad = Path(tmp) / "bad.yaml" + bad.write_text(path.read_text(encoding="utf-8").replace( + "token_capabilities_self: true", "token_capabilities_self: 'yes'"), encoding="utf-8") + _ccr, errors, _warnings = credential_change.validate_ccr(bad) + self.assertIn("openbao.token_capabilities_self must be boolean", errors) + def test_generated_policy_is_narrow(self) -> None: ccr, _errors, _warnings = credential_change.validate_ccr(self.sample) policy = credential_change.generated_policy_hcl(ccr) diff --git a/tests/test_openbao_ui_role_select.py b/tests/test_openbao_ui_role_select.py new file mode 100644 index 0000000..b9c34cf --- /dev/null +++ b/tests/test_openbao_ui_role_select.py @@ -0,0 +1,95 @@ +"""NK-WP-0051-T06: the OpenBao login page can choose the workload role. + +The main button stays role platform-admin. Workload roles come only from the +presets allow-list. The script runs in node against a minimal DOM stub. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parents[1] +OVERLAY = REPO_DIR / "helm/openbao-ui-overlay" +WORKLOAD_ROLE = "reuse-surface-hub-writer-workload-kv-read" + +HARNESS = r""" +const fs = require("fs"); +const [script, presetsPath, clickId, selectValue] = process.argv.slice(1); +const presets = JSON.parse(fs.readFileSync(presetsPath, "utf8")); +presets.workloadRoles = (presets.workloadRoles || []).concat([{ role: "Bad Role!" }]); +const els = {}; +function el(id) { + const node = { id, hidden: false, disabled: false, textContent: "", value: "", + children: [], listeners: {}, classList: { add() {} }, + appendChild(c) { this.children.push(c); if (!this.value) this.value = c.value; }, + addEventListener(t, f) { this.listeners[t] = f; } }; + els[id] = node; return node; +} +["login-title","login-banner","login-submit","login-error","login-workload", + "login-workload-summary","login-workload-hint","login-workload-role","login-workload-submit"].forEach(el); +els["login-workload"].hidden = true; +const posted = []; +global.document = { getElementById: (id) => els[id] || null, + createElement: () => ({ value: "", textContent: "" }) }; +global.window = { location: { origin: "http://127.0.0.1:18200", assign() {} } }; +global.fetch = async (url, opts) => { + if (url.endsWith("presets.json")) return { ok: true, json: async () => presets }; + posted.push({ url, body: JSON.parse(opts.body) }); + return { ok: true, json: async () => ({ data: { auth_url: "https://kc.example/authorize" } }) }; +}; +eval(fs.readFileSync(script, "utf8")); +setTimeout(async () => { + if (selectValue) els["login-workload-role"].value = selectValue; + await els[clickId].listeners.click(); + console.log(JSON.stringify({ posted, + options: els["login-workload-role"].children.map((c) => c.value), + workloadHidden: els["login-workload"].hidden, + error: els["login-error"].textContent })); +}, 20); +""" + + +@unittest.skipUnless(shutil.which("node"), "node is not installed") +class OpenBaoUiRoleSelectTests(unittest.TestCase): + def run_page(self, click_id: str, select_value: str = "") -> dict: + proc = subprocess.run( + ["node", "-e", HARNESS, str(OVERLAY / "login.js"), str(OVERLAY / "presets.json"), + click_id, select_value], + capture_output=True, text=True, check=True, timeout=30, + ) + return json.loads(proc.stdout.strip().splitlines()[-1]) + + def test_presets_keep_platform_admin_and_list_workload_role(self) -> None: + presets = json.loads((OVERLAY / "presets.json").read_text(encoding="utf-8")) + self.assertEqual(presets["role"], "platform-admin") + self.assertEqual(presets["mount"], "netkingdom") + self.assertIn(WORKLOAD_ROLE, [entry["role"] for entry in presets["workloadRoles"]]) + + def test_main_button_stays_platform_admin(self) -> None: + result = self.run_page("login-submit") + self.assertEqual(len(result["posted"]), 1) + self.assertEqual(result["posted"][0]["url"], "/v1/auth/netkingdom/oidc/auth_url") + self.assertEqual(result["posted"][0]["body"]["role"], "platform-admin") + self.assertEqual( + result["posted"][0]["body"]["redirect_uri"], + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback", + ) + + def test_workload_button_uses_selected_listed_role(self) -> None: + result = self.run_page("login-workload-submit", WORKLOAD_ROLE) + self.assertFalse(result["workloadHidden"]) + self.assertEqual(result["options"], [WORKLOAD_ROLE]) + self.assertEqual(result["posted"][0]["body"]["role"], WORKLOAD_ROLE) + + def test_unlisted_role_is_refused(self) -> None: + result = self.run_page("login-workload-submit", "platform-admin") + self.assertEqual(result["posted"], []) + self.assertIn("listed workload role", result["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/RPF-WP-0057-sign-in-diagnosis-caller-proof.md b/workplans/RPF-WP-0057-sign-in-diagnosis-caller-proof.md new file mode 100644 index 0000000..25d9081 --- /dev/null +++ b/workplans/RPF-WP-0057-sign-in-diagnosis-caller-proof.md @@ -0,0 +1,84 @@ +--- +id: RPF-WP-0057 +type: workplan +title: "Let the hub-writer caller prove its own reach and pick the workload role in the OpenBao UI" +domain: financials +repo: railiance-platform +status: active +owner: claude +topic_slug: railiance +created: "2026-10-14" +updated: "2026-10-14" +--- + +Demand: NetKingdom NK-WP-0051-T06 (`net-kingdom/workplans/NK-WP-0051-sign-in-diagnosis.md`). +Contract: `net-kingdom/docs/sign-in-diagnosis.md`. Paired ops-mason work: +MASON-WP-0012. Background: MASON-WP-0011 and CCR-2026-0044. + +## Boundaries + +- Repo changes and tests only. Nothing is applied to OpenBao or the cluster. +- `ops-mason-survey` is not touched. +- `workload-kv-read-reuse-surface-runtime` is not attached to entity + `4a1f663c-0621-0483-edd0-f852274f4b6b`. +- No `verbose_oidc_logging`. No token, code, email or OTP in any file or log. + +## Generate a capabilities-self grant for the lane policy + +```task +id: RPF-WP-0057-T01 +status: done +priority: high +``` + +`scripts/credential-change.py` gains the boolean `openbao.token_capabilities_self`. +When true, the generated policy adds `sys/capabilities-self` with `update` +(the endpoint is a POST; it returns capability names only). Proposed +CCR-2026-0045 sets it for the reuse-surface hub-writer caller and supersedes +CCR-2026-0044 once applied. `openbao/policies/workload-kv-read-reuse-surface-runtime.hcl` +matches the generated body. Tests in `tests/test_credential_change.py`. + +Done 2026-10-14 in the repo. The live policy is unchanged. + +## Apply CCR-2026-0045 + +```task +id: RPF-WP-0057-T02 +status: wait +depends_on: [RPF-WP-0057-T01] +priority: high +``` + +Needs platform-operator and reuse-surface-owner approval, then the founder's +delegated metadata apply (`credential-change.py applier-apply CCR-2026-0045`). +No KV write. Afterwards ops-mason re-runs its member and non-member proofs +with capabilities-self only, and this CCR records that evidence. + +## Let the OpenBao UI choose the workload role + +```task +id: RPF-WP-0057-T03 +status: done +priority: medium +``` + +`helm/openbao-ui-overlay/login.{html,js,css}` and `presets.json`: the main +button stays role `platform-admin`; a "Sign in with a workload role" section +offers only the roles in `presets.json` `workloadRoles` +(`reuse-surface-hub-writer-workload-kv-read`). Test: +`tests/test_openbao_ui_role_select.py` (node harness). + +Done 2026-10-14 in the repo. The live overlay is unchanged. + +## Deploy the overlay + +```task +id: RPF-WP-0057-T04 +status: wait +depends_on: [RPF-WP-0057-T03] +priority: medium +``` + +Operator runs `make openbao-overlay-apply` then +`make openbao-verify-login-overlay`, and one attended browser login per role +(the workload role in a private window).