diff --git a/openbao/auth/netkingdom-platform-admin-role.json b/openbao/auth/netkingdom-platform-admin-role.json index 6e32fdf..54e9fc2 100644 --- a/openbao/auth/netkingdom-platform-admin-role.json +++ b/openbao/auth/netkingdom-platform-admin-role.json @@ -14,6 +14,7 @@ "bound_claims_type": "string", "bound_subject": "", "claim_mappings": { + "/assurance/level": "assurance_level", "email": "email", "preferred_username": "username" }, diff --git a/scripts/openbao-platform-admin-aal2.sh b/scripts/openbao-platform-admin-aal2.sh new file mode 100755 index 0000000..8b945e1 --- /dev/null +++ b/scripts/openbao-platform-admin-aal2.sh @@ -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 diff --git a/scripts/openbao_platform_admin_aal2.py b/scripts/openbao_platform_admin_aal2.py new file mode 100755 index 0000000..7afe371 --- /dev/null +++ b/scripts/openbao_platform_admin_aal2.py @@ -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:])) diff --git a/tests/test_openbao_platform_admin_aal2.py b/tests/test_openbao_platform_admin_aal2.py new file mode 100644 index 0000000..45a4e25 --- /dev/null +++ b/tests/test_openbao_platform_admin_aal2.py @@ -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 diff --git a/workplans/RPF-WP-0065-netkingdom-role-claim-bindings.md b/workplans/RPF-WP-0065-netkingdom-role-claim-bindings.md index 16ff337..27b1ced 100644 --- a/workplans/RPF-WP-0065-netkingdom-role-claim-bindings.md +++ b/workplans/RPF-WP-0065-netkingdom-role-claim-bindings.md @@ -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 -- ` +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 `. +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)