railiance-platform/scripts/openbao_platform_admin_check.py

220 lines
8 KiB
Python
Raw Normal View History

#!/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:]))