railiance-platform/scripts/openbao_platform_admin_check.py
codex 3fbb610d1f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Declare live platform-admin role and policies; add attended admin check
Answers the-custodian 641673a4. An attended read-only check (receipt
docs/evidence/2026-09-23-openbao-platform-admin-check.json) found:
- live platform-admin policy = repo file + reins/* (ops-mason, 2026-07-27);
  repo now matches live (sha256 0ca5b821...). No live write.
- role also attaches operator-custody (undeclared); declared here.
- role declared as openbao/auth/netkingdom-platform-admin-role.json.
- default policy is attached and grants revoke-self/lookup-self, so the
  suspected missing grant is not the cause of warden's unconfirmed revocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 150322@bnt-lap001
Assistant-Session: 16a7b788-374e-4915-a1df-fc87ffd9a5e4
2026-09-23 19:27:25 +02:00

219 lines
8 KiB
Python
Executable file

#!/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 <command...> -format=json <args...>`; 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:]))