From 3fbb610d1f35b3c1a06d1fe756b629aac11524a5 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 23 Sep 2026 19:27:25 +0200 Subject: [PATCH] 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 Assistant: claude-code Assistant-Model: opus Assistant-Process: 150322@bnt-lap001 Assistant-Session: 16a7b788-374e-4915-a1df-fc87ffd9a5e4 --- ...26-09-23-openbao-platform-admin-check.json | 97 ++++++++ docs/openbao.md | 15 ++ .../auth/netkingdom-platform-admin-role.json | 47 ++++ openbao/policies/operator-custody.hcl | 30 +++ openbao/policies/platform-admin.hcl | 8 + scripts/openbao-platform-admin-check.sh | 4 + scripts/openbao_platform_admin_check.py | 219 ++++++++++++++++++ tests/test_openbao_platform_admin_check.py | 105 +++++++++ 8 files changed, 525 insertions(+) create mode 100644 docs/evidence/2026-09-23-openbao-platform-admin-check.json create mode 100644 openbao/auth/netkingdom-platform-admin-role.json create mode 100644 openbao/policies/operator-custody.hcl create mode 100755 scripts/openbao-platform-admin-check.sh create mode 100755 scripts/openbao_platform_admin_check.py create mode 100644 tests/test_openbao_platform_admin_check.py diff --git a/docs/evidence/2026-09-23-openbao-platform-admin-check.json b/docs/evidence/2026-09-23-openbao-platform-admin-check.json new file mode 100644 index 0000000..5dc05dd --- /dev/null +++ b/docs/evidence/2026-09-23-openbao-platform-admin-check.json @@ -0,0 +1,97 @@ +{ + "applied": false, + "attached_policy_sha256": { + "default": "cf5e073df536011f977880d58be6a32acb04cfcd195364241f2d99f6601c2e3f", + "operator-custody": "84eeabeeb11c15e2f8940ed23d6ecc434a3299405fd70ea9c05d959f925970f7", + "platform-admin": "0ca5b821666bdf262bb0742a0031a4f0f9f3d315ef85e951d6b9ce20e7563394" + }, + "changed": false, + "credential_values_emitted": false, + "observed_at": "2026-09-23T17:25:18.820429+00:00", + "policy": { + "declared_sha256": "43a53b00fab59fc62ad586d81f89f36293669c2f8ccedbfb1e3d70618d9af767", + "live_sha256": "0ca5b821666bdf262bb0742a0031a4f0f9f3d315ef85e951d6b9ce20e7563394", + "matches_declared": false + }, + "policy_name": "platform-admin", + "role": { + "allowed_redirect_uris": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", + "https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback", + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" + ], + "bound_audiences": [], + "bound_claims": { + "groups": [ + "net-kingdom-admins" + ] + }, + "bound_claims_type": "string", + "bound_subject": "", + "claim_mappings": { + "email": "email", + "preferred_username": "username" + }, + "groups_claim": "groups", + "oidc_scopes": [ + "openid", + "profile", + "email", + "groups" + ], + "policies": [ + "platform-admin", + "operator-custody" + ], + "role_type": "oidc", + "token_bound_cidrs": [], + "token_explicit_max_ttl": 0, + "token_max_ttl": 0, + "token_no_default_policy": false, + "token_num_uses": 0, + "token_period": 0, + "token_policies": [ + "platform-admin", + "operator-custody" + ], + "token_ttl": 3600, + "token_type": "default", + "ttl": 3600, + "user_claim": "sub" + }, + "role_path": "auth/netkingdom/role/platform-admin", + "schema": "railiance-platform.openbao-platform-admin-check.v1", + "self_capabilities": { + "auth/token/lookup-self": [ + "read" + ], + "auth/token/revoke-self": [ + "update" + ] + }, + "status": "partial", + "steps": { + "capabilities_self": { + "ok": true + }, + "read_attached_rules": { + "ok": true + }, + "read_policy": { + "ok": true + }, + "read_role": { + "ok": true + }, + "token_lookup_self": { + "detail": [ + "Usage: bao token [options] [args]" + ], + "error": "exit_1", + "ok": false + } + }, + "token": null +} diff --git a/docs/openbao.md b/docs/openbao.md index a684a2e..f5f42c7 100644 --- a/docs/openbao.md +++ b/docs/openbao.md @@ -395,6 +395,21 @@ The browser callback URI must be present in both: - KeyCape `openbao-admin` client redirect URIs; and - OpenBao `auth/netkingdom/role/platform-admin` `allowed_redirect_uris`. +Declared state of the administrator login (verified live 2026-09-23, +`docs/evidence/2026-09-23-openbao-platform-admin-check.json`): + +- role `auth/netkingdom/role/platform-admin`: + `openbao/auth/netkingdom-platform-admin-role.json`. It is written by + net-kingdom `sso-mfa/k8s/keycape/configure-openbao-oidc.sh`, which lacks + `operator-custody` and still lists retired `bao.coulomb.social` callbacks. +- policies `openbao/policies/platform-admin.hcl` and + `openbao/policies/operator-custody.hcl`, plus the built-in `default` + (`token_no_default_policy: false`). `default` grants `auth/token/revoke-self` + and `lookup-self`, so an attended session can always revoke itself. +- Re-check with the silent attended helper + `scripts/openbao-platform-admin-check.sh --receipt `. Run it through + `scripts/openbao-attended-exec.py`. It only reads. + If the compatibility alias is kept enabled, also keep `http://127.0.0.1:18200/ui/vault/auth/keycape/oidc/callback` in the KeyCape client and `auth/keycape/role/platform-admin`. diff --git a/openbao/auth/netkingdom-platform-admin-role.json b/openbao/auth/netkingdom-platform-admin-role.json new file mode 100644 index 0000000..1f51828 --- /dev/null +++ b/openbao/auth/netkingdom-platform-admin-role.json @@ -0,0 +1,47 @@ +{ + "allowed_redirect_uris": [ + "http://localhost:8250/oidc/callback", + "http://127.0.0.1:8250/oidc/callback", + "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", + "https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback", + "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback" + ], + "bound_audiences": [], + "bound_claims": { + "groups": [ + "net-kingdom-admins" + ] + }, + "bound_claims_type": "string", + "bound_subject": "", + "claim_mappings": { + "email": "email", + "preferred_username": "username" + }, + "groups_claim": "groups", + "oidc_scopes": [ + "openid", + "profile", + "email", + "groups" + ], + "policies": [ + "platform-admin", + "operator-custody" + ], + "role_type": "oidc", + "token_bound_cidrs": [], + "token_explicit_max_ttl": 0, + "token_max_ttl": 0, + "token_no_default_policy": false, + "token_num_uses": 0, + "token_period": 0, + "token_policies": [ + "platform-admin", + "operator-custody" + ], + "token_ttl": 3600, + "token_type": "default", + "ttl": 3600, + "user_claim": "sub" +} diff --git a/openbao/policies/operator-custody.hcl b/openbao/policies/operator-custody.hcl new file mode 100644 index 0000000..e6cae26 --- /dev/null +++ b/openbao/policies/operator-custody.hcl @@ -0,0 +1,30 @@ +# operator-custody — human operators' own credentials. +# +# Distinct from platform-admin on purpose. platform-admin lists the mounts an +# administrator operates *on*; this is the one mount an administrator keeps +# things *in*. Separating them means custody can move independently of +# administration — which is exactly what the S6 trust stage (two-of-three +# independent recovery control) requires, and it cannot be done later if the +# grant is buried inside platform-admin. +# +# Holds: service account passwords, provider PATs, LLDAP admin bind, +# privacyIDEA pi-admin, machine identities. See +# net-kingdom/docs/platform-root-custody.md, "Operator Credential Custody". +# +# Does NOT hold, and must never: platform-root's own password, unseal shares, +# the break-glass password. Those live in the offline packet — putting them +# here is a circular dependency, since you would need this store to reach the +# credential that opens it. + +path "operators/data/*" { + capabilities = ["create", "read", "update", "list"] +} + +path "operators/metadata/*" { + capabilities = ["read", "list"] +} + +# Delete is deliberately absent. A credential is retired by writing its +# successor — KV v2 keeps the prior version, which is the history that did not +# exist when the LLDAP predecessor was overwritten on 2026-08-27 and lost. +# Destroying a version is a separate, deliberate act under a broader identity. diff --git a/openbao/policies/platform-admin.hcl b/openbao/policies/platform-admin.hcl index 27db5a2..6491964 100644 --- a/openbao/policies/platform-admin.hcl +++ b/openbao/policies/platform-admin.hcl @@ -45,3 +45,11 @@ path "secret/*" { path "tenants/*" { capabilities = ["create", "read", "update", "delete", "list"] } + +# reins/ -- per-rein credential secrets (glas-harness family), mount added +# 2026-07-27 by ops-mason (MASON-WP-0001-T05). Added here so platform-admin +# can complete paste-once-provision into any rein's path, matching every +# other KV mount already listed in this policy. +path "reins/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} diff --git a/scripts/openbao-platform-admin-check.sh b/scripts/openbao-platform-admin-check.sh new file mode 100755 index 0000000..523611d --- /dev/null +++ b/scripts/openbao-platform-admin-check.sh @@ -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 diff --git a/scripts/openbao_platform_admin_check.py b/scripts/openbao_platform_admin_check.py new file mode 100755 index 0000000..eccfeb8 --- /dev/null +++ b/scripts/openbao_platform_admin_check.py @@ -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 -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:])) diff --git a/tests/test_openbao_platform_admin_check.py b/tests/test_openbao_platform_admin_check.py new file mode 100644 index 0000000..26ef774 --- /dev/null +++ b/tests/test_openbao_platform_admin_check.py @@ -0,0 +1,105 @@ +import importlib.util +import json +from pathlib import Path + +import pytest + +spec = importlib.util.spec_from_file_location( + 'check', Path(__file__).resolve().parents[1] / 'scripts/openbao_platform_admin_check.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +DECLARED = m.POLICY_FILE.read_text(encoding='utf-8') +FULL = {p: ['create', 'delete', 'list', 'read', 'sudo', 'update'] for p in m.SELF_PATHS} + + +def token(**extra): + return dict({'policies': ['default', 'platform-admin'], 'identity_policies': None, + 'ttl': 3500, 'creation_ttl': 3600, 'type': 'service', + 'path': 'auth/netkingdom/oidc/callback'}, **extra) + + +def test_matching_policy_and_capabilities_pass(): + result = m.assess({'role_type': 'oidc'}, DECLARED + '\n', token(), FULL) + assert result['policy']['matches_declared'] + assert result['self_revocation_permitted'] + assert result['token']['has_default_policy'] + + +def test_missing_self_capabilities_are_reported(): + caps = {'auth/token/revoke-self': ['deny'], 'auth/token/lookup-self': ['read']} + result = m.assess({}, 'path "x" {}', token(policies=['platform-admin']), caps) + assert not result['policy']['matches_declared'] + assert result['self_capabilities_missing'] == ['auth/token/revoke-self'] + assert not result['token']['has_default_policy'] + + +def test_bao_json_places_format_flag_before_positionals(monkeypatch): + seen = [] + + class Done: + stdout = b'{"data": {}}' + + monkeypatch.setattr(m.subprocess, 'run', lambda cmd, **kw: seen.append(cmd) or Done()) + m.bao_json('read', 'auth/token/lookup-self') + m.bao_json('write', 'sys/capabilities-self', 'paths=a,b') + assert seen == [['bao', 'read', '-format=json', 'auth/token/lookup-self'], + ['bao', 'write', '-format=json', 'sys/capabilities-self', 'paths=a,b']] + + +def test_token_id_is_never_kept(monkeypatch): + monkeypatch.setattr(m, 'bao_json', lambda *a: {'data': {'id': 'SECRET', 'accessor': 'ACC', 'policies': ['default']}}) + kept = m.read_token() + assert 'SECRET' not in json.dumps(kept) and 'ACC' not in json.dumps(kept) + + +def test_apply_only_when_drifted_and_verified(monkeypatch): + live = {'rules': 'path "old" {}'} + writes = [] + monkeypatch.setattr(m, 'read_role', lambda: {}) + monkeypatch.setattr(m, 'read_policy', lambda: live['rules']) + monkeypatch.setattr(m, 'read_token', token) + monkeypatch.setattr(m, 'read_capabilities', lambda: FULL) + monkeypatch.setattr(m, 'apply_policy', lambda: writes.append(1) or live.update(rules=DECLARED)) + result, changed = m.run(apply=True) + assert changed and writes == [1] and result['policy']['matches_declared'] + _, changed = m.run(apply=True) + assert not changed and writes == [1] + + +def test_refuses_outside_attended_envelope(tmp_path, monkeypatch): + monkeypatch.setenv('HOME', str(tmp_path)) + receipt = tmp_path / 'r.json' + assert m.main(['--receipt', str(receipt)]) == 1 + assert json.loads(receipt.read_text())['status'] == 'attended_envelope_required' + + +def test_failed_step_is_recorded_and_others_continue(monkeypatch): + def denied(): + raise m.subprocess.CalledProcessError( + 2, ['bao'], stderr=b'Error\nURL: GET http://127.0.0.1:18200/v1/auth/token/lookup-self\nCode: 403. Errors:\n\n* permission denied\n') + monkeypatch.setattr(m, 'read_role', lambda: {'role_type': 'oidc'}) + monkeypatch.setattr(m, 'read_policy', lambda: DECLARED) + monkeypatch.setattr(m, 'read_token', denied) + monkeypatch.setattr(m, 'read_capabilities', lambda: FULL) + result, changed = m.run() + assert not changed + assert result['steps']['token_lookup_self'] == { + 'ok': False, 'error': 'exit_2', + 'detail': ['URL: GET http://127.0.0.1:18200/v1/auth/token/lookup-self', + 'Code: 403. Errors:', '* permission denied']} + assert result['steps']['read_role']['ok'] and result['policy']['matches_declared'] + + +def test_local_error_line_is_kept_with_token_shapes_redacted(): + error = m.subprocess.CalledProcessError( + 1, ['bao'], stderr=b'error looking up token hvs.CAESIabcdefghijklmnopqrstu: bad\n') + detail = m.error_summary(error)['detail'] + assert detail == ['error looking up token [redacted]: bad'] + + +def test_attached_rules_include_default(monkeypatch): + monkeypatch.setattr(m, 'read_rules', lambda name: 'rules-' + name) + rules = m.read_attached_rules({'token_policies': ['platform-admin', 'operator-custody']}) + assert rules == {'default': 'rules-default', 'operator-custody': 'rules-operator-custody', + 'platform-admin': 'rules-platform-admin'}