#!/usr/bin/env python3 """Silent contained check of the platform-admin role, policy and self-revocation. Read-only by default. Writes one non-secret receipt: the live role configuration, the live policy compared with openbao/policies/platform-admin.hcl, the session token's policy set (never its id or accessor), and the effective capabilities on auth/token/revoke-self and auth/token/lookup-self. With --apply-policy it additionally rewrites the live platform-admin policy from the repo file, only when the live text differs, and verifies on readback. """ from datetime import datetime, timezone import hashlib import json import os from pathlib import Path import re import subprocess import sys ROLE = 'auth/netkingdom/role/platform-admin' POLICY = 'platform-admin' POLICY_FILE = Path(__file__).resolve().parents[1] / 'openbao/policies/platform-admin.hcl' SELF_PATHS = ('auth/token/revoke-self', 'auth/token/lookup-self') NEEDED = {'auth/token/revoke-self': 'update', 'auth/token/lookup-self': 'read'} ROLE_FIELDS = ( 'role_type', 'user_claim', 'groups_claim', 'bound_claims', 'bound_claims_type', 'bound_audiences', 'bound_subject', 'claim_mappings', 'oidc_scopes', 'allowed_redirect_uris', 'token_policies', 'policies', 'token_no_default_policy', 'token_ttl', 'token_max_ttl', 'token_explicit_max_ttl', 'token_type', 'token_period', 'token_num_uses', 'token_bound_cidrs', 'ttl', 'max_ttl', ) TOKENISH = re.compile(r'\b[a-z]{1,4}\.[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{24,}') TOKEN_FIELDS = ('policies', 'identity_policies', 'ttl', 'creation_ttl', 'type', 'path') 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 bao_json(command, *args): """Run `bao -format=json `; flags precede positionals.""" result = subprocess.run(['bao', *command.split(), '-format=json', *args], capture_output=True, check=True, timeout=30) return json.loads(result.stdout) def digest(text): return hashlib.sha256(text.strip().encode('utf-8')).hexdigest() def read_role(): data = bao_json('read', ROLE)['data'] return {key: data[key] for key in ROLE_FIELDS if key in data} def read_policy(): return bao_json('read', 'sys/policy/' + POLICY)['data']['rules'] def read_rules(name): return bao_json('read', 'sys/policy/' + name)['data']['rules'] def read_attached_rules(role): names = sorted(set(role.get('token_policies') or role.get('policies') or []) | {'default'}) return {name: read_rules(name) for name in names} def read_token(): # `bao token lookup` rejects -format placement in some CLI builds; the API path does not. data = bao_json('read', 'auth/token/lookup-self')['data'] return {key: data.get(key) for key in TOKEN_FIELDS} def read_capabilities(): data = bao_json('write', 'sys/capabilities-self', 'paths=' + ','.join(SELF_PATHS))['data'] return {path: sorted(data.get(path, [])) for path in SELF_PATHS} def apply_policy(): subprocess.run(['bao', 'policy', 'write', POLICY, str(POLICY_FILE)], capture_output=True, check=True, timeout=30) def assess(role, rules, token, caps): declared = POLICY_FILE.read_text(encoding='utf-8') policies = set(token.get('policies') or []) | set(token.get('identity_policies') or []) missing = sorted(path for path, cap in NEEDED.items() if cap not in caps[path] and 'root' not in caps[path]) return { 'role': role, 'policy': { 'live_sha256': digest(rules), 'declared_sha256': digest(declared), 'matches_declared': digest(rules) == digest(declared), }, 'token': {**token, 'has_default_policy': 'default' in policies}, 'self_capabilities': caps, 'self_capabilities_missing': missing, 'self_revocation_permitted': not missing, } 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-platform-admin-check.v1', 'observed_at': datetime.now(timezone.utc).isoformat(), 'role_path': ROLE, 'policy_name': POLICY, '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): return 'bao_policy_write_failed' if 'policy' in error.cmd else 'bao_read_failed' return 'contained_operation_failed' def error_summary(error): """Keep only bao's status and error lines; they carry no token material.""" if not isinstance(error, subprocess.CalledProcessError): return {'error': type(error).__name__} text = (error.stderr or b'').decode('utf-8', 'replace') lines = [line.strip() for line in text.splitlines() if line.strip().startswith(('Code:', '* ', 'URL:'))] if not lines: # local CLI error: first line only, token-shaped strings redacted first = next((line.strip() for line in text.splitlines() if line.strip()), '') lines = [TOKENISH.sub('[redacted]', first)[:200]] if first else [] return {'error': 'exit_%s' % error.returncode, 'detail': lines[:4]} def step(steps, name, func): try: value = func() steps[name] = {'ok': True} return value except Exception as error: # record and continue; one run should say everything steps[name] = {'ok': False, **error_summary(error)} return None def collect(): steps = {} role = step(steps, 'read_role', read_role) rules = step(steps, 'read_policy', read_policy) token = step(steps, 'token_lookup_self', read_token) caps = step(steps, 'capabilities_self', read_capabilities) result = {'steps': steps} if role is not None: result['attached_policy_rules'] = step(steps, 'read_attached_rules', lambda: read_attached_rules(role)) if None not in (role, rules, token, caps): result.update(assess(role, rules, token, caps)) else: result.update({'role': role, 'token': token, 'self_capabilities': caps, 'policy': None if rules is None else { 'live_sha256': digest(rules), 'declared_sha256': digest(POLICY_FILE.read_text(encoding='utf-8')), 'matches_declared': digest(rules) == digest(POLICY_FILE.read_text(encoding='utf-8'))}}) return result def run(apply=False): result = collect() changed = False policy = result.get('policy') if apply and policy and not policy['matches_declared']: apply_policy() changed = True result = collect() if not (result.get('policy') or {}).get('matches_declared'): raise Refused('readback_policy_mismatch') return result, changed def parse(argv): receipt, apply, args = None, False, list(argv) while args: if args[0] == '--receipt' and len(args) > 1: receipt, args = args[1], args[2:] elif args[0] == '--apply-policy': apply, args = True, args[1:] else: raise SystemExit(2) if not receipt: raise SystemExit(2) return receipt, apply def main(argv): receipt, apply = parse(argv) try: require_attended() result, changed = run(apply) complete = all(item['ok'] for item in result['steps'].values()) write_receipt(receipt, 'checked' if complete else 'partial', applied=apply, changed=changed, **result) return 0 except Exception as error: try: write_receipt(receipt, classify(error), applied=apply) except Exception: pass return 1 if __name__ == '__main__': raise SystemExit(main(sys.argv[1:]))