RPF-WP-0065-T04: rebuild founder runbook for the silent warden lane
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:
parent
87a488cc66
commit
bedab6f5ab
5 changed files with 523 additions and 30 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"bound_claims_type": "string",
|
||||
"bound_subject": "",
|
||||
"claim_mappings": {
|
||||
"/assurance/level": "assurance_level",
|
||||
"email": "email",
|
||||
"preferred_username": "username"
|
||||
},
|
||||
|
|
|
|||
4
scripts/openbao-platform-admin-aal2.sh
Executable file
4
scripts/openbao-platform-admin-aal2.sh
Executable 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
|
||||
244
scripts/openbao_platform_admin_aal2.py
Executable file
244
scripts/openbao_platform_admin_aal2.py
Executable 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:]))
|
||||
202
tests/test_openbao_platform_admin_aal2.py
Normal file
202
tests/test_openbao_platform_admin_aal2.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue