Declare live platform-admin role and policies; add attended admin check
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s

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:
codex 2026-09-23 19:27:25 +02:00
parent 32ccb1acab
commit 3fbb610d1f
8 changed files with 525 additions and 0 deletions

View file

@ -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 <subcommand> [options] [args]"
],
"error": "exit_1",
"ok": false
}
},
"token": null
}

View file

@ -395,6 +395,21 @@ The browser callback URI must be present in both:
- KeyCape `openbao-admin` client redirect URIs; and - KeyCape `openbao-admin` client redirect URIs; and
- OpenBao `auth/netkingdom/role/platform-admin` `allowed_redirect_uris`. - 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 <new-file>`. Run it through
`scripts/openbao-attended-exec.py`. It only reads.
If the compatibility alias is kept enabled, also keep If the compatibility alias is kept enabled, also keep
`http://127.0.0.1:18200/ui/vault/auth/keycape/oidc/callback` in the `http://127.0.0.1:18200/ui/vault/auth/keycape/oidc/callback` in the
KeyCape client and `auth/keycape/role/platform-admin`. KeyCape client and `auth/keycape/role/platform-admin`.

View file

@ -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"
}

View file

@ -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.

View file

@ -45,3 +45,11 @@ path "secret/*" {
path "tenants/*" { path "tenants/*" {
capabilities = ["create", "read", "update", "delete", "list"] 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"]
}

View 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

View 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:]))

View file

@ -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'}