diff --git a/docs/openbao-open-questions-session.md b/docs/openbao-open-questions-session.md new file mode 100644 index 0000000..dc41d76 --- /dev/null +++ b/docs/openbao-open-questions-session.md @@ -0,0 +1,103 @@ +# Attended OpenBao session — four open custody questions + +One founder-attended session that settles the questions currently blocking four +owner threads. Everything in it is read-only against OpenBao. It provisions +nothing, approves nothing, and rotates nothing. + +Prepared 2026-09-09 under RPF-WP-0035. Not yet run. + +## Why a session rather than a reply + +Four owner threads are each blocked on a fact that only an authenticated read can +establish, and each has been answered so far with "I will not guess": + +| Question | Thread | What is undecidable from repo source | +| --- | --- | --- | +| Q1 | secrets-engine `546403e4` | Whether the legacy path `secret/coulomb/whynot-design/npm/publish` still exists and holds a real token | +| Q2 | secrets-engine `546403e4` | Which field backs the authoritative npm lane. My CCR says `NPM_AUTH_TOKEN`; secrets-engine says `npm_token`. Field names are not visible in KV v2 metadata | +| Q3 | internal | The 2026-09-09 activation receipt records `policy_applied: false` for both KeyCape approval lanes while `role_applied: true`. Reads verified, so the policies must exist — but existence and repo agreement were never confirmed | +| Q4 | CCR-2026-0019 | Which netkingdom OIDC roles and bound group claims already exist, as input to the operator group claim that lane is missing | +| Q5 | risk-nexus `ee702ac9` | Which fields the governed backup lane actually carries, as RPF-WP-0029-T02 context | + +## Authority and containment + +Authority is the governed `openbao-platform-admin-login` lane: `founder_required`, +attended OIDC through `bao login -method=oidc -path=netkingdom role=platform-admin`. +The owner command is never run directly. + +```sh +scripts/openbao-attended-exec.py -- \ + /usr/bin/python3 /home/worsch/railiance-platform/scripts/openbao_open_questions_session.py \ + --receipt /tmp/.json +``` + +`scripts/openbao-attended-exec.py` supplies the WSL browser launcher and then +`exec`s into `warden access openbao-platform-admin-login --exec`, so Warden keeps +the envelope: it captures both streams, owns the temporary token helper and +self-revokes when the command returns. Use a receipt path that does not exist — +the writer opens it `O_EXCL` at mode 0600 and refuses to overwrite. + +The runner prints nothing. Its entire output is the receipt. + +## What the runner reads, and the one honest caveat + +| Step | Call | Emitted | +| --- | --- | --- | +| Q1 | `secrets list`, then `kv metadata get` on the legacy path if the mount exists | mount present, path present, current version, created/updated time | +| Q2 | `kv get` on `platform/workloads/coulomb/whynot-design/npm-publish` | **sorted field names only**, plus KV version | +| Q3 | `policy read` for both KeyCape approval policies | present, matches-repo-source, sha256 of each normalized text | +| Q4 | `list auth/netkingdom/role`, then `read` each | role names, bound claims, groups/user claim, token policies, TTL | +| Q5 | `kv get` on `platform/workloads/railiance/backup/offsite-lane` | **sorted field names only**, plus KV version | + +**The caveat, stated plainly:** Q2 and Q5 are data reads. Field names cannot be +obtained from KV v2 metadata, so the value transits the runner's memory. It is +never printed, never passed on argv, never written to disk, and never placed in +the receipt — `field_names()` returns key names and the values go out of scope +with the function. If that is not acceptable for the backup lane in particular, +drop Q5: it is context for RISK-F-0010, not a dependency of it. + +For Q5 the receipt records `predecessor_material_recorded: false`, and no value, +fingerprint, length or shape of any predecessor credential is captured — which is +the constraint risk-nexus set. + +## What each answer unblocks + +- **Q1 + Q2 → secrets-engine.** A pointer plus a field name closes their custody + question. If Q2 returns `npm_token`, my CCR-2026-0001 is wrong about the field + and I correct it; if it returns `NPM_AUTH_TOKEN`, their catalog entry is the one + that moves, as a reviewed lane change carrying its own approval. If Q1 finds a + populated legacy path, that is a stale duplicate holding a real token and + becomes its own disposition item — do not delete it inside this session. +- **Q3 → internal.** Either the policies match the repo, and the activation's + `policy_applied: false` meant "no change needed", or they drift and that is a + finding to fix under RPF-WP-0035 before anyone treats the verifier lanes as + clean. +- **Q4 → CCR-2026-0019.** Existing bound claims show what the estate already uses + for operator lanes. This is **input, not confirmation** — the authorized group + claim is NetKingdom's and KeyCape's to state, and the receipt says so in-line so + a later reader cannot mistake the listing for an approval. +- **Q5 → risk-nexus.** Confirms the governed lane is populated with the expected + field names, supporting the fail-closed argument already made on that thread. + +## Explicitly out of scope + +- **The rapp-qonto KeyCape client rotation** (ops-warden `c1aafba4`). It is a + two-custodian CAS write with a service restart and needs its own reviewed + request first. Do not fold it into a read-only session because the operator + happens to be authenticated. +- **Predecessor share invalidation** for RPF-WP-0029-T02. That is a provider-side + action in the Nextcloud UI, not an OpenBao operation, and it needs the provider + owner. It can share the same sitting, but it is a separate step with a separate + receipt. +- **Any write, seed, rotation or policy apply.** The runner contains no mutating + verb; if a question turns out to need one, it comes back as a request. +- **Client-side reads for the approval clients.** CCR-2026-0019/0020 remain + in_flight; this session does not advance them beyond Q4's input. + +## After the session + +1. Store the receipt under `docs/evidence/` with the run date, values absent. +2. Reply to secrets-engine (Q1/Q2), risk-nexus (Q5) and, if Q3 drifts, record the + finding against RPF-WP-0035. +3. If Q1 finds a populated legacy path, open a disposition item for it rather + than acting on it in the moment. diff --git a/scripts/openbao_open_questions_session.py b/scripts/openbao_open_questions_session.py new file mode 100755 index 0000000..5b3f06d --- /dev/null +++ b/scripts/openbao_open_questions_session.py @@ -0,0 +1,167 @@ +"""Read-only, silent owner session for the four open custody questions. + +Runs inside Warden's attended login envelope. Reads no secret value into the +receipt: where a data read is unavoidable (field-name resolution), only sorted +key names leave this process. Applies nothing, writes nothing to OpenBao, and +records no approval. Warden owns the temporary token helper and self-revokes +after this command returns. + +Questions settled: + Q1 Does legacy mount/path secret/coulomb/whynot-design/npm/publish exist? + Q2 Which field name backs the authoritative npm lane, NPM_AUTH_TOKEN or + npm_token? (secrets-engine message 546403e4) + Q3 Do the two KeyCape approval policies live in OpenBao and match the repo + source? (activation receipt recorded policy_applied false) + Q4 Which netkingdom OIDC roles and bound group claims already exist, as + non-binding input to CCR-2026-0019? + Q5 Governed backup lane field presence, as RPF-WP-0029-T02 / RISK-F-0010 + context. Never the value, fingerprint, length or shape. +""" +import argparse +import hashlib +import json +import os +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +LEGACY_MOUNT = 'secret/' +LEGACY_PATH = 'secret/coulomb/whynot-design/npm/publish' +NPM_PATH = 'platform/workloads/coulomb/whynot-design/npm-publish' +BACKUP_PATH = 'platform/workloads/railiance/backup/offsite-lane' +POLICIES = { + 'workload-kv-read-keycape-secrets-engine-approval': + 'openbao/policies/workload-kv-read-keycape-secrets-engine-approval.hcl', + 'workload-kv-read-keycape-approval-engine-operator': + 'openbao/policies/workload-kv-read-keycape-approval-engine-operator.hcl', +} +FIELD_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]{1,64}$') + + +def bao(*args, timeout=20): + """Run one bao command. Returns (returncode, parsed-json-or-None).""" + result = subprocess.run(['bao', *args], capture_output=True, text=True, timeout=timeout) + if result.returncode: + return result.returncode, None + try: + return 0, json.loads(result.stdout) + except json.JSONDecodeError: + return 0, None + + +def field_names(payload): + """Sorted key names only. A value never leaves this function.""" + data = ((payload or {}).get('data') or {}).get('data') or {} + names = sorted(str(k) for k in data.keys()) + if any(not FIELD_NAME_RE.match(n) for n in names): + raise ValueError('unexpected field-name shape; refusing to record') + return names + + +def kv_version(payload): + meta = ((payload or {}).get('data') or {}).get('metadata') or {} + return meta.get('version') + + +def normalized_policy(text): + lines = [line.strip() for line in text.splitlines()] + return '\n'.join(line for line in lines if line) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--receipt', type=Path, required=True) + args = parser.parse_args() + + receipt = { + 'schema': 'platform.openbao-open-questions-session.v1', + 'observed_at': datetime.now(timezone.utc).isoformat(), + 'operation': 'read-only observation', + 'credential_values_emitted': False, + 'openbao_mutations': 0, + 'questions': ['Q1', 'Q2', 'Q3', 'Q4', 'Q5'], + } + + # Q1 - legacy mount and path existence. + rc, mounts = bao('secrets', 'list', '-format=json') + legacy_mount_present = bool(mounts and LEGACY_MOUNT in mounts) + q1 = {'legacy_mount_present': legacy_mount_present, 'legacy_path': LEGACY_PATH} + if legacy_mount_present: + rc, meta = bao('kv', 'metadata', 'get', '-format=json', LEGACY_PATH) + q1['legacy_path_present'] = rc == 0 + if rc == 0 and meta: + data = meta.get('data') or {} + q1['current_version'] = data.get('current_version') + q1['created_time'] = data.get('created_time') + q1['updated_time'] = data.get('updated_time') + else: + q1['legacy_path_present'] = False + receipt['q1_legacy_npm_path'] = q1 + + # Q2 - authoritative npm lane field names. + rc, payload = bao('kv', 'get', '-format=json', NPM_PATH) + receipt['q2_npm_lane'] = { + 'path': NPM_PATH, + 'readable': rc == 0, + 'field_names': field_names(payload) if rc == 0 else None, + 'kv_version': kv_version(payload) if rc == 0 else None, + 'values_recorded': False, + } + + # Q3 - KeyCape approval policy presence and drift. + q3 = {} + for name, rel in POLICIES.items(): + rc, payload = bao('policy', 'read', '-format=json', name) + entry = {'present': rc == 0} + if rc == 0: + live = normalized_policy(((payload or {}).get('data') or {}).get('policy', '')) + source = normalized_policy((REPO / rel).read_text()) + entry['matches_repo_source'] = live == source + entry['live_sha256'] = hashlib.sha256(live.encode()).hexdigest() + entry['source_sha256'] = hashlib.sha256(source.encode()).hexdigest() + q3[name] = entry + receipt['q3_keycape_policies'] = q3 + + # Q4 - existing netkingdom OIDC roles and bound claims (non-secret config). + rc, listing = bao('list', '-format=json', 'auth/netkingdom/role') + roles = {} + if rc == 0 and isinstance(listing, list): + for role in listing: + rc, payload = bao('read', '-format=json', f'auth/netkingdom/role/{role}') + if rc: + continue + data = (payload or {}).get('data') or {} + roles[str(role)] = { + 'bound_claims': data.get('bound_claims'), + 'groups_claim': data.get('groups_claim'), + 'user_claim': data.get('user_claim'), + 'token_policies': data.get('token_policies'), + 'token_ttl': data.get('token_ttl'), + } + receipt['q4_netkingdom_roles'] = { + 'listed': rc == 0, + 'roles': roles, + 'note': 'input only; the authorized operator group claim is confirmed by ' + 'NetKingdom/KeyCape, not inferred from this listing', + } + + # Q5 - governed backup lane field presence. + rc, payload = bao('kv', 'get', '-format=json', BACKUP_PATH) + receipt['q5_backup_lane'] = { + 'path': BACKUP_PATH, + 'readable': rc == 0, + 'field_names': field_names(payload) if rc == 0 else None, + 'kv_version': kv_version(payload) if rc == 0 else None, + 'predecessor_material_recorded': False, + } + + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(receipt, stream, indent=2) + stream.write('\n') + + +if __name__ == '__main__': + main()