Preserve writable OIDC fields on the loopback callback update.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

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
This commit is contained in:
codex 2026-09-15 02:23:22 +02:00
parent 7496d9fab5
commit 041f6bdc51
4 changed files with 185 additions and 27 deletions

View file

@ -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:]))