RPF-WP-0065-T04: rebuild founder runbook for the silent warden lane
All checks were successful
CCR intake gate / gate (push) Successful in 12s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The warden openbao-platform-admin-login lane runs one silent child per login,
fails closed on any output and self-revokes; the old runbook printed output
and rolled back from a standing session that cannot exist.

- scripts/openbao-platform-admin-aal2.sh (+ .py): prepare (backup + assurance
  claim mapping), apply (gated on this login's assurance_level=aal2, readback
  of bound_claims), verify, rollback from the backup receipt via a fresh lane
  login, rollback-body for the docs/openbao.md break-glass path. 0600 O_EXCL
  receipts, value-free; no stdout/stderr.
- Role declaration maps /assurance/level to token metadata assurance_level.
- tests/test_openbao_platform_admin_aal2.py: fake bao end-to-end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 4515@bnt-lap001
Assistant-Session: 0bf96127-6e04-41be-bfda-d11717c3f354
This commit is contained in:
codex 2026-10-27 22:46:31 +01:00
parent 87a488cc66
commit bedab6f5ab
5 changed files with 523 additions and 30 deletions

View file

@ -14,6 +14,7 @@
"bound_claims_type": "string",
"bound_subject": "",
"claim_mappings": {
"/assurance/level": "assurance_level",
"email": "email",
"preferred_username": "username"
},

View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Silent child for the governed attended login (RPF-WP-0065-T04); results go to the receipt only.
set -euo pipefail
exec python3 "$(dirname "$0")/openbao_platform_admin_aal2.py" "$@" >/dev/null 2>&1

View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Silent contained aal2 rollout for auth/netkingdom/role/platform-admin (RPF-WP-0065-T04).
Each attended mode runs as the one reviewed child of
`warden access openbao-platform-admin-login --exec -- ...`, writes exactly one
non-secret 0600 receipt (O_EXCL) to the caller-given path and prints nothing.
Any refusal or mismatch exits 1 after recording a status in the receipt.
prepare --receipt R back up the live role into R, then add only the
claim mapping /assurance/level -> assurance_level
(no new binding; logins are not restricted yet)
apply --receipt R gate: this login's token metadata must show
role=platform-admin and assurance_level=aal2;
then write the declared role and read it back
verify --receipt R role equals the declaration and this fresh
login is aal2 with platform-admin + operator-custody
rollback --backup B --receipt R restore the role recorded by `prepare` in B
rollback-body --backup B --out F local only (no bao): write the backed-up role as a
bao write body for the docs/openbao.md break-glass path
Only role configuration (no secrets in an OIDC role) and the token's policy set,
role name and assurance level are recorded: never a token, accessor, email or username.
"""
from datetime import datetime, timezone
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
HERE = Path(__file__).resolve().parent
_spec = importlib.util.spec_from_file_location('loopback', HERE / 'openbao_operator_loopback_callback.py')
loopback = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(loopback)
ROLE = loopback.ROLE
Refused = loopback.Refused
DECLARED_FILE = HERE.parent / 'openbao/auth/netkingdom-platform-admin-role.json'
EXPECTED_BOUND = {'groups': ['net-kingdom-admins'], '/assurance/level': 'aal2'}
MAPPING = ('/assurance/level', 'assurance_level')
REQUIRED_POLICIES = {'platform-admin', 'operator-custody'}
SCHEMA = 'railiance-platform.openbao-platform-admin-aal2.v1'
LEGACY_ALIASES = {'policies': 'token_policies', 'ttl': 'token_ttl'}
def declared():
return json.loads(DECLARED_FILE.read_text(encoding='utf-8'))
def norm(value):
if isinstance(value, list):
return sorted(norm(item) for item in value) if all(isinstance(i, str) for i in value) else [norm(i) for i in value]
if isinstance(value, dict):
return {key: norm(item) for key, item in value.items()}
return value
def drift(want, live):
"""Keys of `want` whose live value differs. Legacy aliases may be folded by OpenBao."""
out = []
for key, value in want.items():
if key in live:
if norm(live[key]) != norm(value):
out.append(key)
elif key in LEGACY_ALIASES and norm(live.get(LEGACY_ALIASES[key])) == norm(value):
continue
elif value not in ([], {}, '', 0, None, False):
out.append(key)
return sorted(out)
def bound_ok(role):
return norm(role.get('bound_claims')) == norm(EXPECTED_BOUND)
def mapped(role):
return (role.get('claim_mappings') or {}).get(MAPPING[0]) == MAPPING[1]
def token_facts():
"""Value-free view of this session: policies, role name, assurance level."""
result = subprocess.run(['bao', 'read', '-format=json', 'auth/token/lookup-self'],
capture_output=True, check=True, timeout=30)
data = json.loads(result.stdout)['data']
meta = data.get('meta') or {}
policies = set(data.get('policies') or []) | set(data.get('identity_policies') or [])
return {'policies': sorted(policies), 'role': meta.get('role'),
'assurance_level': meta.get(MAPPING[1]), 'path': data.get('path')}
def gate(facts):
if facts['role'] != 'platform-admin':
raise Refused('gate_wrong_role')
if facts['assurance_level'] != 'aal2':
raise Refused('gate_login_not_aal2')
def prepare(read=loopback.read_role, write=loopback.write_role):
original = read()
if mapped(original):
return original, False
mappings = dict(original.get('claim_mappings') or {}, **{MAPPING[0]: MAPPING[1]})
write(dict(loopback.payload(original), claim_mappings=mappings))
after = read()
if not mapped(after):
raise Refused('readback_mapping_missing')
rest = {k: v for k, v in loopback.payload(original).items() if k != 'claim_mappings'}
if drift(rest, after):
raise Refused('readback_settings_changed')
return original, True
def apply(read=loopback.read_role, write=loopback.write_role, facts=token_facts):
before = read()
if not mapped(before):
raise Refused('not_prepared')
observed = facts()
gate(observed)
want = declared()
if not bound_ok(want) or not mapped(want):
raise Refused('declaration_unexpected')
changed = bool(drift(want, before))
if changed:
write(dict(loopback.payload(before), **want))
after = read()
if drift(want, after) or not bound_ok(after):
raise Refused('readback_mismatch')
return before, observed, changed, after
def verify(read=loopback.read_role, facts=token_facts):
role = read()
observed = facts()
if drift(declared(), role) or not bound_ok(role):
raise Refused('role_not_declared')
gate(observed)
if not REQUIRED_POLICIES <= set(observed['policies']):
raise Refused('token_policies_missing')
return role, observed
def load_backup(path):
body = json.loads(Path(path).read_text(encoding='utf-8'))
role = body.get('backup_role')
if body.get('schema') != SCHEMA or body.get('mode') != 'prepare' or not isinstance(role, dict):
raise Refused('backup_receipt_invalid')
if role.get('role_type') != 'oidc' or bound_ok(role):
raise Refused('backup_role_unexpected')
return role
def rollback_body(backup):
body = loopback.payload(backup)
body.setdefault('claim_mappings', {})
return body
def rollback(backup, read=loopback.read_role, write=loopback.write_role):
want = rollback_body(backup)
write(want)
after = read()
if drift(want, after):
raise Refused('readback_mismatch')
return after
def write_receipt(path, mode, status, **extra):
fd = os.open(Path(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
body = {
'schema': SCHEMA, 'mode': mode, 'status': status, 'role_path': ROLE,
'observed_at': datetime.now(timezone.utc).isoformat(),
'expected_bound_claims': EXPECTED_BOUND,
'credential_values_emitted': False, **extra,
}
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
json.dump(body, handle, indent=2, sort_keys=True)
handle.write('\n')
def parse(argv):
if not argv:
raise SystemExit(2)
mode, opts, args = argv[0], {}, list(argv[1:])
while args:
if args[0] in ('--receipt', '--backup', '--out') and len(args) > 1:
opts[args[0][2:]] = args[1]
args = args[2:]
else:
raise SystemExit(2)
need = {'prepare': {'receipt'}, 'apply': {'receipt'}, 'verify': {'receipt'},
'rollback': {'receipt', 'backup'}, 'rollback-body': {'backup', 'out'}}
if mode not in need or set(opts) != need[mode]:
raise SystemExit(2)
return mode, opts
def run(mode, opts):
if mode == 'prepare':
original, changed = prepare()
return 'prepared' if changed else 'already_prepared', {
'backup_role': loopback.payload(original), 'changed': changed}
if mode == 'apply':
before, observed, changed, after = apply()
return 'applied' if changed else 'already_applied', {
'before_role': loopback.payload(before), 'gate_token': observed,
'changed': changed, 'bound_claims': after.get('bound_claims')}
if mode == 'verify':
role, observed = verify()
return 'verified', {'token': observed, 'bound_claims': role.get('bound_claims')}
after = rollback(load_backup(opts['backup']))
return 'rolled_back', {'bound_claims': after.get('bound_claims'),
'claim_mappings': after.get('claim_mappings')}
def main(argv):
mode, opts = parse(argv)
if mode == 'rollback-body':
try:
body = rollback_body(load_backup(opts['backup']))
fd = os.open(Path(opts['out']), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
json.dump(body, handle, indent=2, sort_keys=True)
handle.write('\n')
return 0
except Exception:
return 1
if Path(opts['receipt']).exists():
return 1 # never touch OpenBao when the evidence could not be written
try:
loopback.require_attended()
status, extra = run(mode, opts)
write_receipt(opts['receipt'], mode, status, **extra)
return 0
except Exception as error:
try:
write_receipt(opts['receipt'], mode, loopback.classify(error))
except Exception:
pass
return 1
if __name__ == '__main__':
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,202 @@
"""RPF-WP-0065-T04 aal2 rollout script against a fake bao. No live access."""
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
import pytest
ROOT = Path(__file__).resolve().parents[1]
WRAPPER = ROOT / 'scripts/openbao-platform-admin-aal2.sh'
DECLARED = json.loads((ROOT / 'openbao/auth/netkingdom-platform-admin-role.json').read_text())
EXPECTED = {'groups': ['net-kingdom-admins'], '/assurance/level': 'aal2'}
# The live role before T04: group binding only, no assurance mapping.
LIVE = {k: v for k, v in DECLARED.items()}
LIVE['bound_claims'] = {'groups': ['net-kingdom-admins']}
LIVE['claim_mappings'] = {'email': 'email', 'preferred_username': 'username'}
LIVE['max_age'] = 0
FAKE_BAO = r'''#!/usr/bin/env python3
import json, os, sys
state_path = os.environ['FAKE_BAO_STATE']
state = json.load(open(state_path))
state.setdefault('calls', []).append(sys.argv[1:])
args = [a for a in sys.argv[1:] if a != '-format=json']
def save():
json.dump(state, open(state_path, 'w'))
if state.get('fail') in args:
save(); sys.stderr.write('Code: 403. Errors:\n* permission denied\n'); sys.exit(2)
if args[:2] == ['read', 'auth/token/lookup-self']:
save(); print(json.dumps({'data': {'id': 'hvs.SECRETTOKENVALUE00000', 'accessor': 'ACCESSORVALUE',
'policies': ['default', 'operator-custody', 'platform-admin'], 'path': 'auth/netkingdom/oidc/callback',
'meta': state['meta']}})); sys.exit(0)
if args[:2] == ['read', 'auth/netkingdom/role/platform-admin']:
save(); print(json.dumps({'data': state['role']})); sys.exit(0)
if args[:2] == ['write', 'auth/netkingdom/role/platform-admin']:
body = json.load(open(args[2][1:]))
if state.get('ignore_bound'):
body.pop('bound_claims', None)
state['role'].update(body); state['writes'] = state.get('writes', 0) + 1
save(); print('Success! Data written to: auth/netkingdom/role/platform-admin'); sys.exit(0)
save(); sys.stderr.write('unsupported\n'); sys.exit(1)
'''
@pytest.fixture
def env(tmp_path):
home = tmp_path / '.warden-attended-login' / 'session'
home.mkdir(parents=True)
bindir = tmp_path / 'bin'
bindir.mkdir()
bao = bindir / 'bao'
bao.write_text(FAKE_BAO)
bao.chmod(bao.stat().st_mode | stat.S_IEXEC)
state = tmp_path / 'state.json'
state.write_text(json.dumps({'role': LIVE, 'meta': {'role': 'platform-admin', 'email': 'x@example.org',
'username': 'admin'}}))
base = {k: v for k, v in os.environ.items() if k not in ('BAO_TOKEN', 'VAULT_TOKEN')}
base.update(HOME=str(home), PATH='%s:%s' % (bindir, os.environ['PATH']), FAKE_BAO_STATE=str(state))
return tmp_path, state, base
def run(env, *args):
_, _, base = env
proc = subprocess.run([str(WRAPPER), *args], capture_output=True, env=base)
assert proc.stdout == b'' and proc.stderr == b'' # warden fails closed on any output
return proc.returncode
def st(env):
return json.loads(env[1].read_text())
def patch(env, **values):
state = st(env)
state.update(values)
env[1].write_text(json.dumps(state))
def receipt(path):
assert stat.S_IMODE(path.stat().st_mode) == 0o600
text = path.read_text()
for value in ('SECRETTOKENVALUE', 'ACCESSORVALUE', 'x@example.org'):
assert value not in text
return json.loads(text)
def test_declaration_maps_assurance_and_binds_aal2():
assert DECLARED['bound_claims'] == EXPECTED
assert DECLARED['claim_mappings']['/assurance/level'] == 'assurance_level'
def test_full_rollout_prepare_apply_verify_is_silent(env):
tmp = env[0]
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
p = receipt(tmp / 'p.json')
assert p['status'] == 'prepared' and p['backup_role']['bound_claims'] == {'groups': ['net-kingdom-admins']}
role = st(env)['role']
assert role['bound_claims'] == {'groups': ['net-kingdom-admins']} # prepare does not restrict logins
assert role['claim_mappings']['/assurance/level'] == 'assurance_level'
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'})
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 0
a = receipt(tmp / 'a.json')
assert a['status'] == 'applied' and a['bound_claims'] == EXPECTED
assert a['gate_token']['assurance_level'] == 'aal2'
assert st(env)['role']['bound_claims'] == EXPECTED
assert run(env, 'verify', '--receipt', str(tmp / 'v.json')) == 0
assert receipt(tmp / 'v.json')['status'] == 'verified'
assert all(call[:2] != ['token', 'revoke'] for call in st(env)['calls']) # warden revokes, not us
def test_apply_refused_without_aal2_login_and_writes_nothing(env):
tmp = env[0]
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
writes = st(env)['writes']
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal1'})
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 1
assert receipt(tmp / 'a.json')['status'] == 'gate_login_not_aal2'
assert st(env)['writes'] == writes and st(env)['role']['bound_claims'] == {'groups': ['net-kingdom-admins']}
def test_apply_refused_before_prepare(env):
tmp = env[0]
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'})
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 1
assert receipt(tmp / 'a.json')['status'] == 'not_prepared'
assert 'writes' not in st(env)
def test_apply_readback_mismatch_exits_nonzero(env):
tmp = env[0]
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'}, ignore_bound=True)
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 1
assert receipt(tmp / 'a.json')['status'] == 'readback_mismatch'
def test_verify_fails_on_unapplied_role(env):
tmp = env[0]
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'})
assert run(env, 'verify', '--receipt', str(tmp / 'v.json')) == 1
assert receipt(tmp / 'v.json')['status'] == 'role_not_declared'
def test_rollback_restores_backup_with_fresh_login(env):
tmp = env[0]
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'})
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 0
assert run(env, 'rollback', '--backup', str(tmp / 'p.json'), '--receipt', str(tmp / 'r.json')) == 0
r = receipt(tmp / 'r.json')
assert r['status'] == 'rolled_back' and r['bound_claims'] == {'groups': ['net-kingdom-admins']}
assert '/assurance/level' not in st(env)['role']['claim_mappings']
def test_rollback_refuses_a_non_backup_receipt(env):
tmp = env[0]
patch(env, meta={'role': 'platform-admin', 'assurance_level': 'aal2'})
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
assert run(env, 'apply', '--receipt', str(tmp / 'a.json')) == 0
assert run(env, 'rollback', '--backup', str(tmp / 'a.json'), '--receipt', str(tmp / 'r.json')) == 1
assert receipt(tmp / 'r.json')['status'] == 'backup_receipt_invalid'
def test_rollback_body_is_local_and_private(env, monkeypatch):
tmp = env[0]
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 0
calls = len(st(env)['calls'])
_, _, base = env
base = dict(base, HOME=str(tmp)) # outside the envelope on purpose
proc = subprocess.run([str(WRAPPER), 'rollback-body', '--backup', str(tmp / 'p.json'),
'--out', str(tmp / 'body.json')], capture_output=True, env=base)
assert proc.returncode == 0 and proc.stdout == proc.stderr == b''
body = receipt(tmp / 'body.json')
assert body['bound_claims'] == {'groups': ['net-kingdom-admins']}
assert len(st(env)['calls']) == calls
def test_refuses_outside_attended_envelope(env):
tmp = env[0]
_, _, base = env
proc = subprocess.run([str(WRAPPER), 'prepare', '--receipt', str(tmp / 'p.json')],
capture_output=True, env=dict(base, HOME=str(tmp)))
assert proc.returncode == 1 and proc.stdout == proc.stderr == b''
assert receipt(tmp / 'p.json')['status'] == 'attended_envelope_required'
def test_existing_receipt_is_never_overwritten(env):
tmp = env[0]
(tmp / 'p.json').write_text('{}')
assert run(env, 'prepare', '--receipt', str(tmp / 'p.json')) == 1
assert (tmp / 'p.json').read_text() == '{}'
assert 'calls' not in st(env)
def test_bad_arguments_exit_2(env):
assert run(env, 'apply') == 2
assert run(env, 'destroy', '--receipt', '/dev/null') == 2

View file

@ -8,7 +8,7 @@ status: proposed
flavor: implementation
owner: claude
created: "2026-10-24"
updated: "2026-10-24"
updated: "2026-10-27"
related:
- NK-WP-0054
- RPF-WP-0022
@ -43,7 +43,8 @@ Live evidence: KeyCape `docs/evidence/2026-10-07-secrets-engine-openbao-issuance
records the founder's OIDC login through `openbao-admin` under bound `/assurance/level aal2`
(role `credential-broker-...` family, same client and mount). Conclusion: verified from source and
prior live evidence; one residual risk is that this exact role has not been exercised with the claim,
so the rollout below keeps an open admin session and a one-command rollback.
so the rollout below gates the apply on a machine-checked aal2 login and keeps a fresh-login
rollback plus the break-glass path.
## Tasks
@ -90,7 +91,9 @@ status: wait
priority: high
state_hub_task_id: "abd78db2-03ad-5942-a9ef-12bbada54b28"
```
**Founder live step: apply platform-admin aal2 (needs the founder).** See runbook below.
**Founder live step: apply platform-admin aal2 (needs the founder).** See runbook below, rebuilt
2026-10-27 for the silent warden lane (`scripts/openbao-platform-admin-aal2.sh`). The declaration
now also maps `/assurance/level` to token metadata `assurance_level` for the pre-apply gate.
```task
id: RPF-WP-0065-T05
@ -103,44 +106,83 @@ Then the founder applies the role and later removes `service`.
## Runbook for the founder (T04)
Preconditions: you are on the workstation with the `openbao-ui-railiance01` tunnel up
(`bridge status`), in the repo checkout, and you are a `net-kingdom-admins` member who completes MFA.
Role JSON contains no secrets. Keep one existing admin session open in another terminal
until step 4 passes.
Rebuilt 2026-10-27 for the warden lane. `warden access openbao-platform-admin-login --exec -- <cmd>`
runs one reviewed command after one OIDC/MFA login, fails closed on any child output (even
whitespace) and self-revokes the token on every path. There is no interactive or standing session,
so every step below is its own login, every child is silent, and rollback never assumes an open
terminal. The previous runbook (bao read/write printing output, rollback from a still-open session)
cannot work in this lane and is retired; the founder's `--exec -- bash` attempt changed nothing.
The child is `scripts/openbao-platform-admin-aal2.sh` (Python: `scripts/openbao_platform_admin_aal2.py`,
tests `tests/test_openbao_platform_admin_aal2.py`). It reuses the role read/payload/write helpers
of `openbao_operator_loopback_callback.py`, prints nothing, writes one `0600` receipt per run
(refuses an existing path before touching OpenBao) and exits non-zero on any refusal or mismatch.
Receipts hold role configuration and the session's policy set, role name and assurance level only;
never a token, accessor, email or username. `scripts/openbao-platform-admin-check.sh` remains the
read-only policy/self-revocation check; it does not compare bound claims, so it is optional here.
**Why three logins.** OpenBao token metadata carries no assurance level today, so the only
value-free proof that the founder's `admin` login is `aal2` needs a claim mapping first:
`prepare` backs up the live role and adds only `claim_mappings["/assurance/level"] = "assurance_level"`
(no new binding, nobody is locked out). `apply` then refuses unless *its own* login's token metadata
shows `role=platform-admin` and `assurance_level=aal2`, and only then writes the declared role and
checks the readback, including `bound_claims == {"groups":["net-kingdom-admins"],"/assurance/level":"aal2"}`.
`verify` proves a fresh login is accepted under the new role. In parallel, the KeyCape sign-in
diagnosis page for `openbao-admin` (NK-WP-0051-T02) shows `assurance` for each login: it must read
`aal2` at steps 2 and 3. A reused KeyCape/Authelia session can be `aal1` even after earlier MFA; if
the page shows `aal1`, step 2 refuses with `gate_login_not_aal2` and nothing changes; report to
KeyCape and do not pin MFA on the client without its own go.
Preconditions: workstation, `openbao-ui-railiance01` tunnel up (`bridge status`), browser opener
working, you are `admin` in `net-kingdom-admins` and complete the one-time code at every login.
```bash
set +x; umask 077; unset BAO_TOKEN VAULT_TOKEN
cd ~/railiance-platform
python3 -m pytest -q tests/test_netkingdom_role_files.py tests/test_openbao_platform_admin_callback_prune.py
# 1 backup of the live role (rollback source)
warden access openbao-platform-admin-login --exec -- \
bao read -format=json auth/netkingdom/role/platform-admin > ~/.local/state/railiance/platform-admin-role.before.json
# 2 apply
warden access openbao-platform-admin-login --exec -- \
bao write auth/netkingdom/role/platform-admin @openbao/auth/netkingdom-platform-admin-role.json
# 3 read back
warden access openbao-platform-admin-login --exec -- \
bao read -format=json auth/netkingdom/role/platform-admin | jq '.data.bound_claims'
# expect {"groups":["net-kingdom-admins"],"/assurance/level":"aal2"}
python3 -m pytest -q tests/test_openbao_platform_admin_aal2.py tests/test_netkingdom_role_files.py
R=~/.local/state/railiance/rpf-wp-0065-t04; mkdir -p -m 700 "$R"
S="$PWD/scripts/openbao-platform-admin-aal2.sh"
# 1 back up the live role and add the assurance mapping (no new restriction)
warden access openbao-platform-admin-login --exec -- "$S" prepare --receipt "$R/1-prepare.json"
# 2 gate + apply: refuses unless this login is aal2 (diagnosis page must show aal2)
warden access openbao-platform-admin-login --exec -- "$S" apply --receipt "$R/2-apply.json"
# 3 fresh login under the new role: aal2, platform-admin + operator-custody, role == declaration
warden access openbao-platform-admin-login --exec -- "$S" verify --receipt "$R/3-verify.json"
jq -r '.mode + " " + .status' "$R"/*.json # prepare prepared / apply applied / verify verified
```
Step 4, prove a fresh MFA login still works: in a new terminal run
`warden access openbao-platform-admin-login --exec -- bao token lookup -format=json | jq '.data.policies'`
and confirm `platform-admin` and `operator-custody`. Only then close the old session.
Optionally record a receipt with `scripts/openbao-platform-admin-check.sh --receipt <new-file>`.
Each `warden` call must exit 0. On a non-zero exit read only the receipt's `status` (`jq -r .status`)
before anything else; codes 11-14 do not prove that nothing changed. Step 1 or 2 refused: the role
has at most the harmless mapping; fix the cause and rerun with a new receipt name.
Rollback (if step 4 fails, from the still-open session):
**Rollback A, login still works** (step 3 failed with warden exit 11, e.g. `role_not_declared`,
`token_policies_missing`): restore the backed-up role with a fresh lane login.
```bash
# previous declaration = the file as of the parent of this workplan's commit
git show 2126457^:openbao/auth/netkingdom-platform-admin-role.json > ~/.local/state/railiance/pa-role.prev.json
grep -c assurance ~/.local/state/railiance/pa-role.prev.json # expect 0
bao write auth/netkingdom/role/platform-admin @$HOME/.local/state/railiance/pa-role.prev.json
warden access openbao-platform-admin-login --exec -- \
"$S" rollback --backup "$R/1-prepare.json" --receipt "$R/4-rollback.json"
jq -r .status "$R/4-rollback.json" # rolled_back
```
If no session is open, use the break-glass recovery path in `docs/openbao.md`.
If the login fails because the token lacks `assurance.level`, check the sign-in diagnosis page of
`openbao-admin` (NK-WP-0051-T02) and report to KeyCape; do not pin MFA on the client without its own go.
**Rollback B, login itself is refused** (step 3 exits 10 `login_failed`, or OpenBao names the bound
claim): no OpenBao session can be obtained, so use the break-glass operator path of
`docs/openbao.md` (`kubectl exec` with the offline-escrowed initial token, "Bootstrap Ceremony"
step 7). The role body is derived locally from the step 1 backup and contains no secret. The
break-glass token is typed at a hidden prompt inside the pod, never in argv, history, a file or a receipt.
```bash
"$S" rollback-body --backup "$R/1-prepare.json" --out "$R/rollback-body.json"
kubectl exec -i -n openbao openbao-0 -- sh -c 'cat > /tmp/pa-rollback.json' < "$R/rollback-body.json"
kubectl exec -it -n openbao openbao-0 -- sh -c 'printf "break-glass token: "; stty -echo; read -r BAO_TOKEN; stty echo; echo; export BAO_TOKEN; bao write auth/netkingdom/role/platform-admin @/tmp/pa-rollback.json >/dev/null && echo rolled-back; rm -f /tmp/pa-rollback.json'
```
Then confirm logins work with a lane `verify` under a new receipt name: warden exit 11 with
`role_not_declared` (not exit 10) is the expected result after rollback. If the initial token was
revoked rather than escrowed, this is a break-glass event under `docs/openbao.md`
(custody owners, recovery shares): stop and raise it, do not improvise. Record the outcome as a
progress event; receipts stay in `$R`, never in Git.
## Rollout order for the coding-agent role (T05)