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
219 lines
8 KiB
Python
Executable file
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:]))
|