Prepare the read-only attended session for four open custody questions
Four owner threads are each blocked on a fact only an authenticated read can establish: whether the legacy whynot-design npm path still exists, which field backs the authoritative lane, whether the two KeyCape approval policies match repo source after an activation that recorded policy_applied false, which netkingdom bound group claims already exist as input to CCR-2026-0019, and which fields the governed backup lane carries. Adds scripts/openbao_open_questions_session.py, which contains no mutating bao verb and emits only a mode-0600 metadata receipt, plus the run-book in docs/openbao-open-questions-session.md. Field-name resolution is a data read, so the runner returns sorted key names and no value reaches argv, disk or the receipt; that caveat is stated rather than glossed. Not run. The rapp-qonto rotation and the RPF-WP-0029 provider invalidation are explicitly out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLUjpv3ssxNRAEPPgLFnEB Assistant: claude-code Assistant-Model: opus Assistant-Process: 1275505@bnt-lap001 Assistant-Session: 97265baa-f08f-4032-b290-a1e2965a69c5
This commit is contained in:
parent
251456acd2
commit
f0c2fd58cd
2 changed files with 270 additions and 0 deletions
103
docs/openbao-open-questions-session.md
Normal file
103
docs/openbao-open-questions-session.md
Normal file
|
|
@ -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/<new-unique-name>.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.
|
||||||
167
scripts/openbao_open_questions_session.py
Executable file
167
scripts/openbao_open_questions_session.py
Executable file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue