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
This commit is contained in:
parent
32ccb1acab
commit
3fbb610d1f
8 changed files with 525 additions and 0 deletions
4
scripts/openbao-platform-admin-check.sh
Executable file
4
scripts/openbao-platform-admin-check.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
# Silent child for the governed attended login; results go to the receipt only.
|
||||
set -euo pipefail
|
||||
exec python3 "$(dirname "$0")/openbao_platform_admin_check.py" "$@" >/dev/null 2>&1
|
||||
219
scripts/openbao_platform_admin_check.py
Executable file
219
scripts/openbao_platform_admin_check.py
Executable file
|
|
@ -0,0 +1,219 @@
|
|||
#!/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:]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue