net-kingdom/tools/service-binds/executor-lanes.py
tegwick 12c9b2df43 NK-WP-0056-T10: set cas_required by metadata write (platform-admin has no patch)
First attended setup stopped at cas_required: platform-admin holds
create/read/update/delete/list on platform/*, not patch, so 'kv metadata
patch' was denied. Policies were already applied; nothing after. Now a
metadata write naming only cas_required (KV v2 keeps unnamed fields; checked
on the pinned OpenBao image), guarded by a before/after comparison of
max_versions, delete_version_after and custom_metadata. Receipt of the
stopped attempt kept as evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5502@bnt-lap001
Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
2026-10-31 19:42:40 +01:00

257 lines
14 KiB
Python

#!/usr/bin/env python3
"""Attended OpenBao setup and proof for the bounded executor's two lanes (NK-WP-0056-T10, T06).
write CCR-2026-0058 policy only. Its Kubernetes role is window-scoped: created per admitted run,
never here. Also sets cas_required on the svc-authelia key.
journey CCR-2026-0059 policy and the standing read role for sso/nk-bind-executor.
setup: under both approved, sha256-bound CCRs (binding confirmed) and an attended platform-admin session:
check that the coding-agent fence denies both paths, apply both policies, set cas_required, create the
journey role, prove that the write role does not exist, and prove that the fence beats both policies.
verify: read back policies, cas_required and the role; log in as sso/nk-bind-executor (audience openbao) and prove
that the token reads only the journey path, with exactly its policy and at most 15m. Prove that a wrong
ServiceAccount, a wrong audience and a login to the write role are all denied.
Receipts hold closed booleans only. The journey values are read into memory to check their field names, and never
printed or written.
"""
import argparse
import hashlib
import importlib.util
import json
import os
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
PLATFORM = Path(os.environ.get('NK_PLATFORM_ROOT', REPO.parent / 'railiance-platform'))
sys.path.insert(0, str(PLATFORM / 'scripts'))
from state_hub_preflight_lane import bao, data, capabilities, revoke # noqa: E402
from repair_eso_kubernetes_auth import role_payload, check_role # noqa: E402
from telemetry_smtp_delivery import ensure # noqa: E402
SA, NS = 'nk-bind-executor', 'sso'
APPROVERS = {'platform-owner', 'net-kingdom-owner'}
BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary'
BINDS = 'platform/data/workloads/net-kingdom/directory-binds/'
LANES = {
'write': {
'ccr': 'CCR-2026-0058-net-kingdom-bind-executor-svc-authelia-write.yaml',
'request_type': 'workload-kv-write-next-version',
'kv': 'platform/workloads/net-kingdom/directory-binds/svc-authelia', 'fields': ['LDAP_BIND_PASSWORD'],
'policy': 'net-kingdom-bind-executor-svc-authelia-write',
'policy_text': ('path "platform/data/workloads/net-kingdom/directory-binds/svc-authelia" {\n'
' capabilities = ["read", "update"]\n allowed_parameters = {\n "data" = []\n'
' "options" = []\n }\n}\n\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n'
'path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n'),
},
'journey': {
'ccr': 'CCR-2026-0059-net-kingdom-bind-executor-syn-journey-read.yaml',
'request_type': 'workload-kv-read',
'kv': 'platform/workloads/net-kingdom/synthetic-journey/syn-journey', 'fields': ['PASSWORD', 'TOTP_SEED'],
'policy': 'net-kingdom-bind-executor-syn-journey-read',
'policy_text': ('path "platform/data/workloads/net-kingdom/synthetic-journey/syn-journey" '
'{ capabilities = ["read"] }\n'
'path "auth/token/lookup-self" { capabilities = ["read"] }\n'
'path "auth/token/revoke-self" { capabilities = ["update"] }\n'),
},
}
for _lane in LANES.values():
_lane.update(role=_lane['policy'], service_account=SA, namespace=NS,
data_path=_lane['kv'].replace('platform/', 'platform/data/', 1),
metadata_path=_lane['kv'].replace('platform/', 'platform/metadata/', 1))
CAS_KEY = 'platform/metadata/workloads/net-kingdom/directory-binds/svc-authelia'
KEPT_METADATA = ('max_versions', 'delete_version_after', 'custom_metadata')
class Refused(Exception):
pass
def load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module
def contract(name, expected):
lane = LANES[name]
path = PLATFORM / 'credential-change-requests' / lane['ccr']
if hashlib.sha256(path.read_bytes()).hexdigest() != expected: raise Refused(name + ':ccr_digest')
ccr, errors, _ = load('credential_change', PLATFORM / 'scripts/credential-change.py').validate_ccr(path)
if errors or ccr['status'] not in {'approved', 'applied', 'verified', 'active'}: raise Refused(name + ':ccr_status')
approved = {r['reviewer'] for r in ccr['review'].get('comments', []) if r.get('decision') == 'approved'}
if not APPROVERS <= approved: raise Refused(name + ':ccr_approval')
a = ccr['openbao']; auth = a['auth']
if (ccr['request_type'] != lane['request_type'] or a['kv_path'] != lane['kv'] or a['fields'] != lane['fields']
or a['policy_name'] != lane['policy'] or auth['role'] != lane['role'] or auth['policies'] != [lane['policy']]
or auth.get('method') != 'kubernetes' or auth.get('audience') != 'openbao'
or auth['bound_claims'] != {'service_account_names': [SA], 'service_account_namespaces': [NS]}
or auth.get('token_no_default_policy') is not True
or any(auth.get(k) != '15m' for k in ('ttl', 'token_max_ttl', 'token_explicit_max_ttl'))):
raise Refused(name + ':ccr_shape')
if auth.get('bound_claims_confirmed') is not True: raise Refused(name + ':binding_unconfirmed')
if (PLATFORM / a['policy_file']).read_text() != lane['policy_text']: raise Refused(name + ':policy_file')
return ccr
def attended_platform_admin():
ident = data(bao(['token', 'lookup', '-format=json']))['data']
if ('platform-admin' not in ident.get('policies', []) or 'root' in ident.get('policies', [])
or not ident.get('path', '').startswith('auth/netkingdom/')):
raise Refused('attended_platform_admin_required')
def fence_present():
current = data(bao(['read', '-format=json', BOUNDARY]))['data']['policy']
return all('path "' + p + '" { capabilities = ["deny"] }' in current
for lane in LANES.values() for p in (lane['data_path'], lane['metadata_path']))
def policy_check(lane):
def check(actual):
if actual.get('policy') != lane['policy_text']: raise Refused(lane['policy'] + ':policy_drift')
return check
def key_metadata():
return data(bao(['read', '-format=json', CAS_KEY]))['data']
def cas_required():
return key_metadata().get('cas_required') is True
def set_cas_required():
"""A metadata write that names only cas_required. platform-admin holds update, not patch, on platform/*.
KV v2 changes only the fields a write names (checked on the pinned OpenBao, 2026-10-31); the other settings
are compared before and after anyway."""
before = {k: key_metadata().get(k) for k in KEPT_METADATA}
bao(['write', CAS_KEY, '-'], payload={'cas_required': True})
after = key_metadata()
if {k: after.get(k) for k in KEPT_METADATA} != before: raise Refused('metadata_changed_beyond_cas_required')
def write_role_absent():
result = bao(['read', '-format=json', 'auth/kubernetes/role/' + LANES['write']['role']], allow_failure=True)
return result.returncode != 0 and b'No value found' in result.stdout + result.stderr
def contracts(digests):
return {name: contract(name, digests[name]) for name in LANES}
def setup(digests, receipt):
receipt['phase'] = 'contract'; ccrs = contracts(digests)
receipt['phase'] = 'identity'; attended_platform_admin()
receipt['phase'] = 'coding_agent_fence'
if not fence_present(): raise Refused('agent_fence_missing') # established by CCR-2026-0053 and -0057
receipt['phase'] = 'policies'
for lane in LANES.values():
ensure('sys/policies/acl/' + lane['policy'], {'policy': lane['policy_text']}, policy_check(lane))
receipt['phase'] = 'cas_required'
if not cas_required():
set_cas_required()
if not cas_required(): raise Refused('cas_required_not_set')
receipt['phase'] = 'journey_role'
journey = LANES['journey']
ensure('auth/kubernetes/role/' + journey['role'], role_payload(journey), lambda actual: check_role(actual, journey))
receipt['phase'] = 'write_role_absent'
if not write_role_absent(): raise Refused('write_role_exists_outside_a_window')
receipt['phase'] = 'fence_effective'
for lane in LANES.values():
child = data(bao(['token', 'create', '-format=json', '-policy=' + lane['policy'],
'-policy=agent-high-risk-boundary', '-no-default-policy', '-ttl=60s']))['auth']['client_token']
try:
caps = capabilities(child, [lane['data_path'], lane['metadata_path']])
if any(v != ['deny'] for v in caps.values()): raise Refused(lane['policy'] + ':fence_ineffective')
finally:
revoke(child)
receipt.update(phase='done', status='lanes_prepared', policies_applied=True, cas_required=True,
journey_role_created=True, write_role_absent=True, coding_agents_denied=True,
source_ccrs={n: c['id'] for n, c in ccrs.items()}, ccr_sha256=digests)
def kube(kubeconfig, args):
return subprocess.run(['kubectl', '--kubeconfig', kubeconfig, *args], check=True, capture_output=True).stdout
def verify(digests, kubeconfig, receipt):
receipt['phase'] = 'contract'; ccrs = contracts(digests)
receipt['phase'] = 'identity'; attended_platform_admin()
receipt['phase'] = 'read_back'
for lane in LANES.values():
policy_check(lane)(data(bao(['read', '-format=json', 'sys/policies/acl/' + lane['policy']]))['data'])
journey, write = LANES['journey'], LANES['write']
check_role(data(bao(['read', '-format=json', 'auth/kubernetes/role/' + journey['role']]))['data'], journey)
if not cas_required(): raise Refused('cas_required_not_set')
if not write_role_absent(): raise Refused('write_role_exists_outside_a_window')
if not fence_present(): raise Refused('agent_fence_missing')
def login(sa, audience, role, negative=False):
jwt = kube(kubeconfig, ['-n', NS, 'create', 'token', sa, '--audience=' + audience, '--duration=10m']).decode().strip()
result = bao(['write', '-format=json', 'auth/kubernetes/login', '-'],
payload={'role': role, 'jwt': jwt}, allow_failure=negative)
if negative:
if result.returncode == 0:
revoke(data(result)['auth']['client_token']); raise Refused('negative_login_accepted')
if b'403' not in result.stderr and b'400' not in result.stderr: raise Refused('negative_login_unclear')
return None
return data(result)['auth']
receipt['phase'] = 'journey_login'
auth = login(SA, 'openbao', journey['role']); token = auth['client_token']
try:
if (auth['token_policies'] != [journey['policy']] or auth.get('identity_policies')
or not 0 < auth['lease_duration'] <= 900):
raise Refused('journey_policy_or_ttl')
receipt['phase'] = 'journey_capabilities'
others = [BINDS + n for n in ('svc-authelia', 'svc-keycape', 'svc-privacyidea', 'svc-provisioner')]
paths = [journey['data_path'], journey['metadata_path'], write['metadata_path'], *others, 'auth/token/create']
caps = capabilities(token, paths)
if caps[paths[0]] != ['read'] or any(caps[p] != ['deny'] for p in paths[1:]):
raise Refused('journey_capabilities')
receipt['phase'] = 'journey_read'
native = data(bao(['read', '-format=json', journey['data_path']], token=token))['data']
if sorted(native['data']) != sorted(journey['fields']): raise Refused('journey_fields')
receipt['journey_kv_version'] = native['metadata']['version']
finally:
revoke(token)
receipt['phase'] = 'negative_logins'
login('default', 'openbao', journey['role'], True)
login(SA, 'wrong-audience', journey['role'], True)
login(SA, 'openbao', write['role'], True)
receipt.update(phase='done', status='lanes_verified', exact_policies_and_role=True, cas_required=True,
journey_reads_only_its_path=True, journey_lease_at_most_15m=True, write_role_absent=True,
wrong_service_account_denied=True, wrong_audience_denied=True, write_login_denied=True,
coding_agents_denied=True, temporary_token_revoked=True,
source_ccrs={n: c['id'] for n, c in ccrs.items()}, ccr_sha256=digests)
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument('action', choices=['setup', 'verify'])
p.add_argument('--write-ccr-sha256', required=True, help='sha256 of the CCR-2026-0058 file')
p.add_argument('--journey-ccr-sha256', required=True, help='sha256 of the CCR-2026-0059 file')
p.add_argument('--kubeconfig', help='admin kubeconfig; required for verify')
p.add_argument('--receipt', required=True)
a = p.parse_args()
if a.action == 'verify' and not a.kubeconfig: p.error('--kubeconfig is required for verify')
digests = {'write': a.write_ccr_sha256, 'journey': a.journey_ccr_sha256}
fd = os.open(a.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
r = {'status': 'failed', 'action': a.action, 'values_recorded': False,
'source_sha256': hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
try:
if a.action == 'setup': setup(digests, r)
else: verify(digests, a.kubeconfig, r)
except Exception as error:
r.update(status='failed', reason=str(error) if isinstance(error, Refused) else type(error).__name__)
finally:
with os.fdopen(fd, 'w') as out: json.dump(r, out, indent=2, sort_keys=True); out.write('\n')
print(json.dumps({k: r[k] for k in ('status', 'phase', 'reason') if k in r}))
return int(r['status'] == 'failed')
if __name__ == '__main__':
raise SystemExit(main())