From 041f6bdc512fc5a2eedef8eddf9374a506d026bb Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 15 Sep 2026 02:23:22 +0200 Subject: [PATCH] Preserve writable OIDC fields on the loopback callback update. The second attended attempt spawned the owner command, then failed closed. Stop posting the entire role read-back and requiring exact dict equality. Write a metadata receipt so the next failure has a class, not silence. Assistant: grok Assistant-Session: 01a0a23b-3bf0-7341-b4e5-9dc05f72573a --- docs/openbao-public-listener-transition.md | 10 +- scripts/openbao_operator_loopback_callback.py | 149 +++++++++++++++--- ...test_openbao_operator_loopback_callback.py | 43 ++++- ...PF-WP-0025-openbao-operator-only-access.md | 10 ++ 4 files changed, 185 insertions(+), 27 deletions(-) diff --git a/docs/openbao-public-listener-transition.md b/docs/openbao-public-listener-transition.md index cfddb96..505c8c1 100644 --- a/docs/openbao-public-listener-transition.md +++ b/docs/openbao-public-listener-transition.md @@ -37,10 +37,16 @@ silent and Warden self-revokes the attended session: warden plan \ "attended OpenBao platform administration to add the exact operator-tunneled OIDC callback to auth/netkingdom/role/platform-admin" \ --json -python3 scripts/openbao-attended-exec.py -- \ - /home/worsch/railiance-platform/scripts/openbao-apply-operator-loopback-callback.sh +python3 /home/worsch/railiance-platform/scripts/openbao-attended-exec.py -- \ + /home/worsch/railiance-platform/scripts/openbao-apply-operator-loopback-callback.sh \ + --receipt /tmp/openbao-loopback-callback.json ``` +The child stays silent. Metadata-only status lands in the receipt: `applied`, +`already_present`, or a failure class. Do not reuse a receipt path that already +exists. The helper writes only known OIDC role fields so a live read-back blob +cannot fail the update. + The plan must return `founder_required` and select `openbao-platform-admin-login`. The owner command must be an absolute path: Warden's contained child inherits the caller's cwd, so a relative `scripts/...` diff --git a/scripts/openbao_operator_loopback_callback.py b/scripts/openbao_operator_loopback_callback.py index 0fa368b..930cfa3 100644 --- a/scripts/openbao_operator_loopback_callback.py +++ b/scripts/openbao_operator_loopback_callback.py @@ -1,49 +1,154 @@ #!/usr/bin/env python3 """Silent contained callback update; preserve the existing administrator role.""" import json +import os +from pathlib import Path import subprocess import sys +import tempfile ROLE = 'auth/netkingdom/role/platform-admin' CALLBACK = 'http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback' +WRITABLE = ( + 'role_type', 'user_claim', 'user_claim_json_pointer', 'groups_claim', + 'bound_claims', 'bound_claims_type', 'bound_audiences', 'bound_subject', + 'claim_mappings', 'oidc_scopes', 'allowed_redirect_uris', + 'clock_skew_leeway', 'expiration_leeway', 'not_before_leeway', 'max_age', + 'verbose_oidc_logging', 'token_ttl', 'token_max_ttl', 'token_explicit_max_ttl', + 'token_policies', 'token_bound_cidrs', 'token_no_default_policy', + 'token_num_uses', 'token_period', 'token_type', 'policies', 'ttl', 'max_ttl', + 'period', 'num_uses', +) +PRESERVED = ( + 'role_type', 'user_claim', 'groups_claim', 'bound_claims', 'claim_mappings', + 'oidc_scopes', 'token_policies', 'policies', 'token_ttl', 'ttl', +) + + +class Refused(Exception): + pass + + +def require_attended(): + if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'): + raise Refused('attended_envelope_required') + + +def payload(role): + return {key: role[key] for key in WRITABLE if key in role and role[key] is not None} + + +def preserved(role): + return {key: role.get(key) for key in PRESERVED} def read_role(): result = subprocess.run(['bao', 'read', '-format=json', ROLE], capture_output=True, check=True, timeout=30) role = json.loads(result.stdout)['data'] + policies = role.get('token_policies') or role.get('policies') or [] if (role.get('role_type') != 'oidc' - or 'platform-admin' not in role.get('token_policies', role.get('policies', [])) + or 'platform-admin' not in policies or not isinstance(role.get('allowed_redirect_uris'), list) or not all(isinstance(uri, str) for uri in role['allowed_redirect_uris'])): - raise ValueError('unexpected role') + raise Refused('unexpected_role') return role -def update(read=read_role, write=None): +def write_role(desired): + handle = tempfile.NamedTemporaryFile('w', encoding='utf-8', delete=False) + try: + json.dump(desired, handle) + handle.close() + subprocess.run(['bao', 'write', ROLE, '@' + handle.name], + capture_output=True, check=True, timeout=30) + finally: + Path(handle.name).unlink(missing_ok=True) + + +def update(read=read_role, write=write_role): original = read() if CALLBACK in original['allowed_redirect_uris']: return False - desired = dict(original, allowed_redirect_uris=original['allowed_redirect_uris'] + [CALLBACK]) - if read() != original: - raise ValueError('role changed before write') - # The endpoint has no CAS: this detects observed drift, not an atomic lock. - if write is None: - subprocess.run(['bao', 'write', ROLE, '-'], input=json.dumps(desired).encode(), - capture_output=True, check=True, timeout=30) - else: - write(desired) - if read() != desired: - raise ValueError('role readback differs') + desired = dict(payload(original), + allowed_redirect_uris=original['allowed_redirect_uris'] + [CALLBACK]) + if preserved(read()) != preserved(original): + raise Refused('role_changed_before_write') + write(desired) + after = read() + if CALLBACK not in after.get('allowed_redirect_uris', []): + raise Refused('readback_callback_missing') + if preserved(after) != preserved(original): + raise Refused('readback_settings_changed') + if set(original['allowed_redirect_uris']) - set(after['allowed_redirect_uris']): + raise Refused('readback_existing_callback_dropped') return True -if __name__ == '__main__': +def write_receipt(path, status, **extra): + from datetime import datetime, timezone + path = Path(path) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + fd = os.open(path, flags, 0o600) + body = { + 'schema': 'railiance-platform.openbao-loopback-callback.v1', + 'observed_at': datetime.now(timezone.utc).isoformat(), + 'role': ROLE, 'callback': CALLBACK, 'status': status, + '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 classify(error): + if isinstance(error, Refused): + return str(error) + if isinstance(error, subprocess.CalledProcessError): + command = error.cmd[1] if isinstance(error.cmd, list) and len(error.cmd) > 1 else '' + return 'bao_write_failed' if command == 'write' else 'bao_read_failed' + return 'contained_operation_failed' + + +def parse(argv): + receipt = None + check_only = False + args = list(argv) + while args: + if args[0] == '--receipt': + if len(args) < 2: + raise SystemExit(2) + receipt = args[1] + args = args[2:] + elif args[0] == '--check-only': + check_only = True + args = args[1:] + else: + raise SystemExit(2) + return receipt, check_only + + +def main(argv): + receipt, check_only = parse(argv) try: - if sys.argv[1:] == ['--check-only']: - sys.exit(0 if CALLBACK in read_role()['allowed_redirect_uris'] else 3) - if sys.argv[1:]: - sys.exit(2) - update() - except Exception: - sys.exit(1) + if check_only: + present = CALLBACK in read_role()['allowed_redirect_uris'] + if receipt: + write_receipt(receipt, 'present' if present else 'absent') + return 0 if present else 3 + require_attended() + changed = update() + if receipt: + write_receipt(receipt, 'applied' if changed else 'already_present', changed=changed) + return 0 + except Exception as error: + if receipt: + try: + write_receipt(receipt, classify(error)) + except Exception: + pass + return 1 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_openbao_operator_loopback_callback.py b/tests/test_openbao_operator_loopback_callback.py index 2ba0d45..5f7d975 100644 --- a/tests/test_openbao_operator_loopback_callback.py +++ b/tests/test_openbao_operator_loopback_callback.py @@ -29,14 +29,36 @@ def test_preserves_all_other_settings(): assert len(writes) == 1 +def test_write_payload_omits_read_only_fields(): + current = dict(role(), request_id='synthetic', lease_duration=0) + writes = [] + def write(value): + writes.append(value) + current.update(value) + assert m.update(lambda: copy.deepcopy(current), write) + assert 'request_id' not in writes[0] + assert 'lease_duration' not in writes[0] + assert writes[0]['allowed_redirect_uris'][-1] == m.CALLBACK + + +def test_readback_allows_normalized_extra_fields(): + current = role() + def read(): + return dict(current, lease_duration=0) + def write(value): + current.update(value) + assert m.update(read, write) + assert m.CALLBACK in current['allowed_redirect_uris'] + + def test_observed_concurrent_change_prevents_write(): reads = iter([role(), dict(role(), token_ttl=300)]) - with pytest.raises(ValueError): + with pytest.raises(m.Refused, match='role_changed_before_write'): m.update(lambda: next(reads), lambda _: pytest.fail('must not write')) def test_failed_readback_is_not_success(): - with pytest.raises(ValueError): + with pytest.raises(m.Refused, match='readback_callback_missing'): m.update(role, lambda _: None) @@ -46,10 +68,25 @@ def test_unexpected_live_role_refused(monkeypatch, mutation): import subprocess payload = dict(role(), **mutation) monkeypatch.setattr(m.subprocess, 'run', lambda *a, **kw: subprocess.CompletedProcess(a, 0, json.dumps({'data': payload}).encode())) - with pytest.raises(ValueError): + with pytest.raises(m.Refused, match='unexpected_role'): m.read_role() +def test_receipt_records_apply_and_failure(tmp_path, monkeypatch): + import json + receipt = tmp_path / 'loopback.json' + monkeypatch.setattr(m, 'require_attended', lambda: None) + monkeypatch.setattr(m, 'update', lambda: True) + assert m.main(['--receipt', str(receipt)]) == 0 + assert json.loads(receipt.read_text())['status'] == 'applied' + failed = tmp_path / 'failed.json' + def boom(): + raise m.Refused('bao_write_failed') + monkeypatch.setattr(m, 'update', boom) + assert m.main(['--receipt', str(failed)]) == 1 + assert json.loads(failed.read_text())['status'] == 'bao_write_failed' + + def test_attended_wrapper_requires_absolute_existing_executable(tmp_path, monkeypatch): import os spec = importlib.util.spec_from_file_location( diff --git a/workplans/RPF-WP-0025-openbao-operator-only-access.md b/workplans/RPF-WP-0025-openbao-operator-only-access.md index d186a11..9c8bad1 100644 --- a/workplans/RPF-WP-0025-openbao-operator-only-access.md +++ b/workplans/RPF-WP-0025-openbao-operator-only-access.md @@ -121,3 +121,13 @@ the session was revoked and private storage cleaned. No role write, Ingress change, or retained helper. Retry only through `python3 scripts/openbao-attended-exec.py --` and the absolute owner command. Do not reuse the failed relative-path attempt as callback evidence. + +A second attended attempt used the absolute owner command. Login reached a +helper-backed session and the child started, then Warden reported +`attended command failed closed because it returned a failure or unexpected +output; the login session was revoked`. That string means the silent helper +exited non-zero. The helper previously posted the entire role read-back and +required exact dict equality; that path was never live-proven and can fail on +read-only/normalized fields. No retained helper. Retry uses the same envelope +with `--receipt` and a field-preserving write. Do not retract Ingress from this +failed attempt.