diff --git a/docs/credential-governance-break-glass.md b/docs/credential-governance-break-glass.md index 9262258..240e34c 100644 --- a/docs/credential-governance-break-glass.md +++ b/docs/credential-governance-break-glass.md @@ -37,6 +37,11 @@ Hard lockdown is not an agent command. The irreversible hold point is the platform owner's attended seal action. All prerequisites below must be true before that hold point; repository access alone grants no authority to cross it. +Route planning must select `openbao-shamir-recovery-ceremony` and return one +`founder_required` approval act. A result that selects `openbao-api-key`, asks +for paste-once provisioning, or offers any raw-value transport is a routing +failure and must not be executed. + ## Pre-seal hold point - An approved, bounded window names the accountable platform driver and a diff --git a/registry/routing/catalog.yaml b/registry/routing/catalog.yaml index 07b2c42..f4c5382 100644 --- a/registry/routing/catalog.yaml +++ b/registry/routing/catalog.yaml @@ -171,6 +171,29 @@ entries: exec_capable: true lane: login + - id: openbao-shamir-recovery-ceremony + title: Attended OpenBao Shamir seal and unseal recovery ceremony + # A ceremony pointer, not a credential-value lane. Approval coordinates + # existing out-of-band custodians; Warden never requests or transports a share. + risk: high + workload_ref: + applicability: not-applicable + reason: "Attended platform trust-root ceremony; no workload credential is retrieved." + need_keywords: [openbao, shamir, seal, unseal, sealed, emergency, recovery, break-glass, quorum, share, shares, custodian, ceremony, raft, snapshot, provider-console, abort, attended] + owner_repo: railiance-platform + subsystem: OpenBao operator recovery + warden_executes: false + wiki_ref: wiki/playbooks/openbao-shamir-recovery-ceremony.md#worker-checklist + canon_ref: railiance-platform/docs/railiance01-coordinated-reboot.md + reviewed: "2026-08-22" + status: active + delegation: + mode: native + intended_owner: railiance-platform + reviewed: "2026-08-22" + verified: source-read + lane: ceremony + - id: whynot-design-npm-publish title: whynot-design npm publish token (@whynot/design → coulomb Gitea registry) # Publish rights to the package registry — a leaked token is a supply-chain write, not a read (WARDEN-WP-0032-T05). diff --git a/src/warden/plan.py b/src/warden/plan.py index 639ccfc..dd0d7dd 100644 --- a/src/warden/plan.py +++ b/src/warden/plan.py @@ -185,6 +185,16 @@ def _founder_for_entry(entry: RouteEntry, need: str, domain: Optional[str]) -> F "desk_hint": f"warden desk --from-plan (act=oidc_login) or: {expanded.fetch_command}", }, ) + if entry.lane == "ceremony": + return FounderAct( + kind="approve", + summary=f"Attended owner ceremony approval required for {entry.id}", + details={ + "lane_id": entry.id, + "wiki_ref": entry.wiki_ref, + "desk_hint": "warden desk --act approve --lane " + entry.id, + }, + ) if _APPROVAL_SIGNS.search(need): return FounderAct( kind="approve", @@ -293,8 +303,9 @@ def build_plan( domain=domain, ) - # Login lanes always need a human identity act - if entry.lane == "login": + # Login and ceremony lanes always need a human act. A ceremony is a pure + # owner pointer: it must never fall through to secret paste-once mechanics. + if entry.lane in ("login", "ceremony"): act = _founder_for_entry(entry, need, domain) return AccessPlan( need=need, @@ -308,7 +319,11 @@ def build_plan( founder_act=act, catalog=freshness, candidates=candidates, - reasons=["login lane requires interactive founder/operator identity act"], + reasons=[ + "login lane requires interactive founder/operator identity act" + if entry.lane == "login" + else "ceremony lane requires attended owner approval" + ], actor=actor, domain=domain, ) diff --git a/src/warden/routing/catalog.py b/src/warden/routing/catalog.py index aca8eaf..c04f7d7 100644 --- a/src/warden/routing/catalog.py +++ b/src/warden/routing/catalog.py @@ -69,7 +69,7 @@ _REQUIRED_FIELDS = ( "workload_ref", ) _VALID_STATUS = ("active", "draft") -_VALID_LANES = ("secret", "login") +_VALID_LANES = ("secret", "login", "ceremony") _VALID_ROTATION_METHODS = ("rotate", "re-establish") # Default review cadence for a catalog pointer — "is this still the right owner diff --git a/src/warden/routing/models.py b/src/warden/routing/models.py index c9c348e..2485c69 100644 --- a/src/warden/routing/models.py +++ b/src/warden/routing/models.py @@ -174,6 +174,8 @@ class RouteEntry: # "login" — interactive auth bootstrap (OIDC/MFA). No secret-read gate (you have # no identity yet), no caller-auth precheck (the point is to get one), # run interactively as the caller; warden never captures the token. + # "ceremony" — attended owner operation such as Shamir seal/unseal. It is a + # pointer plus approval boundary, never an executable access lane. lane: str = "secret" # Owner-native exec front door (WP-0019). When `exec_owner` is set, that subsystem # (e.g. secrets-engine) provides the PRIMARY way to run a secret-backed command; the diff --git a/tests/test_plan.py b/tests/test_plan.py index 447d487..7f6b7aa 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -80,6 +80,23 @@ def test_plan_first_time_openbao_database_admin_uses_platform_admin_login(): ) +def test_plan_openbao_shamir_recovery_uses_approval_ceremony_not_secret_provision(): + plan = build_plan( + "coordinate one attended production OpenBao emergency seal/unseal drill " + "with a fresh encrypted off-host Raft snapshot receipt, verified " + "provider-console access, two-of-three Shamir custodian quorum, named " + "driver and abort operator, without exposing credential values" + ) + assert plan.verdict == "founder_required" + assert plan.lane_id == "openbao-shamir-recovery-ceremony" + assert plan.founder_act is not None + assert plan.founder_act.kind == "approve" + assert "openbao-shamir-recovery-ceremony" in plan.founder_act.details["desk_hint"] + assert "paste_once" not in plan.founder_act.details["desk_hint"] + assert all("warden access" not in item for item in plan.commands) + assert any("openbao-shamir-recovery-ceremony" in item for item in plan.commands) + + def test_plan_unroutable(): # Zero keyword overlap with catalog (avoid tokens like secret/key/token) plan = build_plan("xyzzy-plugh-fnord-qqq-zzzz") diff --git a/tests/test_routing.py b/tests/test_routing.py index 1ad3498..209e54c 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -170,6 +170,18 @@ def test_platform_admin_login_lane_is_exact_and_non_value_bearing(): assert entry.workload_ref.resolution == "not-applicable" +def test_openbao_recovery_ceremony_is_non_value_bearing_owner_pointer(): + entry = load_catalog(_repo_catalog()).get("openbao-shamir-recovery-ceremony") + assert entry.lane == "ceremony" + assert entry.risk == "high" + assert entry.owner_repo == "railiance-platform" + assert entry.warden_executes is False + assert entry.exec_capable is False + assert entry.has_handoff is False + assert entry.vends_secret is False + assert entry.workload_ref.resolution == "not-applicable" + + def test_find_exact_id_wins_over_keyword_collision(): catalog = load_catalog(_repo_catalog()) # "npm" alone collides with openbao-api-key; the exact id must resolve uniquely. diff --git a/wiki/CredentialRouting.md b/wiki/CredentialRouting.md index 3b86c42..a9b04bf 100644 --- a/wiki/CredentialRouting.md +++ b/wiki/CredentialRouting.md @@ -91,6 +91,7 @@ lists the interim set — WARDEN-WP-0030). | `ops-warden-warden-sign-token` | "railiance-platform broker owns the `warden-sign` lease — use `credential exec`" | `railiance-platform/scripts/credential.py exec --grant ops-warden/warden-sign` (see playbook) | | `openbao-api-key` | "OpenBao owns this — here is the path/command shape" | Call OpenBao directly, or use `warden access --fetch/--exec` as yourself when the lane is `exec_capable` | | `openbao-platform-admin-login` | "This is an attended OpenBao administration identity act, not workload secret retrieval" | Use KeyCape-backed OIDC/MFA at `auth/netkingdom`, role `platform-admin`; never substitute a workload role or root token | +| `openbao-shamir-recovery-ceremony` | "This is an attended trust-root ceremony, not secret provisioning" | Obtain one explicit approval and follow the railiance-platform recovery checklist with the existing out-of-band custodians; never send shares through Warden | | `flex-auth-policy-check` | "flex-auth decides — here is the policy doc" | Query flex-auth / embed the PEP | | `key-cape-oidc-login` | "key-cape / Keycloak owns identity" | Authenticate via IAM Profile, or use the `warden access` login lane as yourself | | `ops-bridge-tunnel` | "ops-bridge owns transport — supply a `cert_command`" | Open the tunnel with ops-bridge | diff --git a/wiki/playbooks/openbao-shamir-recovery-ceremony.md b/wiki/playbooks/openbao-shamir-recovery-ceremony.md new file mode 100644 index 0000000..8aac70b --- /dev/null +++ b/wiki/playbooks/openbao-shamir-recovery-ceremony.md @@ -0,0 +1,37 @@ +# OpenBao Shamir recovery ceremony + +This lane routes an attended production seal/unseal or trust-root recovery need +to `railiance-platform`. It does not retrieve, provision, proxy, or transport an +unseal share, recovery value, OpenBao token, snapshot, or console credential. + +## Worker checklist + +1. Plan the need before drafting an operator step: + + ```bash + warden plan "attended OpenBao Shamir emergency seal/unseal recovery ceremony" --json + ``` + + The result must select `openbao-shamir-recovery-ceremony`, return + `founder_required`, and name one `approve` act. If it selects + `openbao-api-key`, proposes paste-once provisioning, or requests a raw share, + stop without executing the proposed act. + +2. Treat approval as coordination, not secret delivery. The platform owner uses + the existing out-of-band share custody and provider-console paths. Warden + Desk must never receive a share or console credential. + +3. Follow the authoritative owner checklist in + `railiance-platform/docs/railiance01-coordinated-reboot.md` and the OpenBao + recovery section of `railiance-platform/docs/openbao.md`. The local consumer + boundary and evidence requirements are in + `docs/credential-governance-break-glass.md`. + +4. Stop before the live hold point unless the owner preflight reports + `ready_for_live_execution: true` and the operator gives an explicit go/no-go + for that bounded window. + +Allowed records are non-secret approval ids, role attestations, timestamps, +seal-state booleans, hashes, and verification outcomes. Never place shares, +tokens, credentials, Secret data, or decrypted snapshots in Git, State Hub, +logs, shell history, or chat. diff --git a/workplans/WARDEN-WP-0027-credential-governance-lockdown.md b/workplans/WARDEN-WP-0027-credential-governance-lockdown.md index ffe2354..3a15705 100644 --- a/workplans/WARDEN-WP-0027-credential-governance-lockdown.md +++ b/workplans/WARDEN-WP-0027-credential-governance-lockdown.md @@ -145,6 +145,21 @@ that boundary and still would not recover a sealed OpenBao. A separately approved ops-bridge unattended-signing design may re-evaluate the narrow AppRole under its own workplan; that is service access, not recovery authority. +**Read-only readiness 2026-08-22.** The owner `node-reboot` preflight passed +every automated check without observing a secret value: node/k3s, platform-pg +and continuous archiving, a 16.37-hour successful backup, initialized/unsealed +OpenBao with `shares=3` and `threshold=2`, ESO stores/projections, and the +reviewed audit-core digest were healthy. `ready_for_live_execution` correctly +remained false because no approved window, current encrypted off-host snapshot +receipt, provider-console attestation, quorum attestation, owner-ack flag, or +named abort operator was supplied. + +That preflight also exposed a Warden safety defect: the ceremony request matched +the generic `openbao-api-key` template and proposed paste-once provisioning. +`openbao-shamir-recovery-ceremony` now distinguishes the owner-operated approval +ceremony from secret retrieval. It is a non-value-bearing pointer and can never +offer `--fetch`, `--exec`, `--out`, `--wrap`, or paste-once share transport. + ## Task: Tamper-evident policy governance + reconcile ```task