From 7f25af3cb15c264941195bec11dc1ff03d94307d Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 24 Sep 2026 00:49:39 +0200 Subject: [PATCH] Add attended prune of retired bao.coulomb.social callbacks from platform-admin Read-preserve-write via the reviewed loopback helper; keeps the tunnel callback and every other setting; also captures non-secret OIDC mount config. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 150322@bnt-lap001 Assistant-Session: 16a7b788-374e-4915-a1df-fc87ffd9a5e4 --- .../openbao-platform-admin-callback-prune.sh | 4 + .../openbao_platform_admin_callback_prune.py | 104 ++++++++++++++++++ ...t_openbao_platform_admin_callback_prune.py | 58 ++++++++++ 3 files changed, 166 insertions(+) create mode 100755 scripts/openbao-platform-admin-callback-prune.sh create mode 100755 scripts/openbao_platform_admin_callback_prune.py create mode 100644 tests/test_openbao_platform_admin_callback_prune.py diff --git a/scripts/openbao-platform-admin-callback-prune.sh b/scripts/openbao-platform-admin-callback-prune.sh new file mode 100755 index 0000000..9ca9a4b --- /dev/null +++ b/scripts/openbao-platform-admin-callback-prune.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Silent child for the governed attended login; results go to the receipt only. +set -euo pipefail +exec python3 "$(dirname "$0")/openbao_platform_admin_callback_prune.py" "$@" >/dev/null 2>&1 diff --git a/scripts/openbao_platform_admin_callback_prune.py b/scripts/openbao_platform_admin_callback_prune.py new file mode 100755 index 0000000..97a9426 --- /dev/null +++ b/scripts/openbao_platform_admin_callback_prune.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Silent contained removal of the retired public callbacks from platform-admin. + +Removes exactly the two bao.coulomb.social callbacks retired by RPF-WP-0025-T03 +from auth/netkingdom/role/platform-admin. Every other role setting is kept, +using the reviewed read/payload/write helpers of the loopback-callback script. +Refuses unless the tunnel callback is present and stays present. Writes +nothing if the retired callbacks are already gone. One non-secret receipt; no +output. +""" +from datetime import datetime, timezone +import importlib.util +import json +import os +from pathlib import Path +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 +KEEP = loopback.CALLBACK # http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback +RETIRED = ( + 'https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback', + 'https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback', +) +Refused = loopback.Refused +MOUNTS = ('netkingdom', 'keycape') +CONFIG_FIELDS = ('oidc_discovery_url', 'oidc_client_id', 'default_role', 'bound_issuer', + 'oidc_response_mode', 'oidc_response_types', 'jwt_supported_algs', + 'provider_config', 'namespace_in_state') + + +def read_mount_configs(): + """Non-secret OIDC mount settings; OpenBao never returns oidc_client_secret.""" + import subprocess + configs = {} + for mount in MOUNTS: + try: + result = subprocess.run(['bao', 'read', '-format=json', 'auth/%s/config' % mount], + capture_output=True, check=True, timeout=30) + data = json.loads(result.stdout)['data'] + configs[mount] = {key: data[key] for key in CONFIG_FIELDS if key in data} + except Exception: + configs[mount] = {'error': 'read_failed'} + return configs + + +def prune(read=loopback.read_role, write=loopback.write_role): + original = read() + uris = original['allowed_redirect_uris'] + if KEEP not in uris: + raise Refused('tunnel_callback_missing') + remaining = [uri for uri in uris if uri not in RETIRED] + if remaining == uris: + return False, uris + desired = dict(loopback.payload(original), allowed_redirect_uris=remaining) + if loopback.preserved(read()) != loopback.preserved(original): + raise Refused('role_changed_before_write') + write(desired) + after = read() + if sorted(after.get('allowed_redirect_uris', [])) != sorted(remaining): + raise Refused('readback_callbacks_mismatch') + if loopback.preserved(after) != loopback.preserved(original): + raise Refused('readback_settings_changed') + return True, after['allowed_redirect_uris'] + + +def write_receipt(path, status, **extra): + fd = os.open(Path(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + body = { + 'schema': 'railiance-platform.openbao-callback-prune.v1', + 'observed_at': datetime.now(timezone.utc).isoformat(), + 'role': ROLE, 'retired': list(RETIRED), '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 main(argv): + if len(argv) != 2 or argv[0] != '--receipt': + return 2 + receipt = argv[1] + try: + loopback.require_attended() + changed, uris = prune() + write_receipt(receipt, 'pruned' if changed else 'already_pruned', + changed=changed, allowed_redirect_uris=uris, + mount_configs=read_mount_configs()) + return 0 + except Exception as error: + try: + write_receipt(receipt, loopback.classify(error), changed=False) + except Exception: + pass + return 1 + + +if __name__ == '__main__': + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_openbao_platform_admin_callback_prune.py b/tests/test_openbao_platform_admin_callback_prune.py new file mode 100644 index 0000000..73299e9 --- /dev/null +++ b/tests/test_openbao_platform_admin_callback_prune.py @@ -0,0 +1,58 @@ +import copy +import importlib.util +import json +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + 'prune', Path(__file__).resolve().parents[1] / 'scripts/openbao_platform_admin_callback_prune.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +DECLARED = json.loads((Path(__file__).resolve().parents[1] / 'openbao/auth/netkingdom-platform-admin-role.json').read_text()) + + +def live(role): + state = {'role': copy.deepcopy(role)} + writes = [] + + def write(value): + writes.append(value) + state['role'].update(value) + return state, writes, (lambda: copy.deepcopy(state['role'])), write + + +def test_prunes_only_the_two_retired_callbacks_and_keeps_settings(): + role = dict(DECLARED, allowed_redirect_uris=list(DECLARED['allowed_redirect_uris']) + list(m.RETIRED) + if not set(m.RETIRED) <= set(DECLARED['allowed_redirect_uris']) else list(DECLARED['allowed_redirect_uris'])) + state, writes, read, write = live(role) + changed, uris = m.prune(read, write) + assert changed and len(writes) == 1 + assert not set(m.RETIRED) & set(uris) + assert m.KEEP in uris and 'http://localhost:8250/oidc/callback' in uris + assert state['role']['token_policies'] == ['platform-admin', 'operator-custody'] + assert state['role']['bound_claims'] == {'groups': ['net-kingdom-admins']} + + +def test_idempotent_when_already_pruned(): + role = dict(DECLARED, allowed_redirect_uris=[u for u in DECLARED['allowed_redirect_uris'] if u not in m.RETIRED]) + _, writes, read, write = live(role) + assert m.prune(read, write)[0] is False and writes == [] + + +def test_refuses_without_tunnel_callback(): + role = dict(DECLARED, allowed_redirect_uris=list(m.RETIRED)) + _, writes, read, write = live(role) + with pytest.raises(m.Refused, match='tunnel_callback_missing'): + m.prune(read, write) + assert writes == [] + + +def test_refuses_when_readback_drops_other_settings(): + role = copy.deepcopy(DECLARED) + calls = iter([copy.deepcopy(role), copy.deepcopy(role), + dict(copy.deepcopy(role), token_ttl=60, + allowed_redirect_uris=[u for u in role['allowed_redirect_uris'] if u not in m.RETIRED])]) + with pytest.raises(m.Refused, match='readback_settings_changed'): + m.prune(lambda: next(calls), lambda value: None)