net-kingdom/sso-mfa/k8s/keycape/bind-env-adapter.py
tegwick cbadd359ba NK-WP-0048 r5 adapters: OW-1.3-C1 attended change lane, RAPPS-1.3-C1 human actor, RAPPS-1.3-C2 handover observations
Lane key-cape-oidc-login -> keycape-attended-case-change in the register-client,
ensure-groups and bind-env descriptors; deliver-consumer-secret actor agent ->
human; verify_consumer reports a value-free, exactly-once probe record list bound
into the observation digest. Composition artifacts, type.yaml and admit/compose
tests are left for the orchestrator to regenerate.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
2026-10-17 11:59:14 +02:00

221 lines
11 KiB
Python

"""Unexercised, injection-only bind-env seam for Warden's v1 kernel (NK-WP-0048-T27).
Given an admitted oidc-browser-relying-party case (canonical 1.1 by default via the
maintained helper), add exactly one `env` entry to the `keycape` container of the
issuer Deployment: name from the helper, valueFrom.secretKeyRef to the delivered
secret *reference*. No CLI, kubectl, credential, or value handling lives here;
read/write are injected capabilities with compare-and-swap on resourceVersion.
Only an entry this process created, authenticated and still byte-identical, may
be rolled back. Owner publication/authentication and parent-session revocation
are mandatory injected capabilities; no evidence trust root is installed here.
"""
from __future__ import annotations
import copy
from dataclasses import asdict, replace
import importlib.util
import json
from pathlib import Path
import yaml
from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER
from warden.case_receipt_state import Outcome, Refused
ROOT = Path(__file__).resolve().parent
REFERENCE = 'net-kingdom/keycape-bind-env@v1'
# keycape-client.py loads portal-client-rollout.py at import, which loads
# openbao-client-config.py and register-user-engine-portal.py; all of them run.
SOURCE_FILES = ('bind-env-adapter.py', 'keycape-client.py',
'portal-client-rollout.py', 'openbao-client-config.py',
'register-user-engine-portal.py')
CREATED = 'keycape-env-binding'
_spec = importlib.util.spec_from_file_location('bind_env_owner_helper', ROOT / 'keycape-client.py')
helper = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(helper)
def _container(deployment):
if (deployment.get('kind') != 'Deployment'
or deployment.get('metadata', {}).get('name') != 'keycape'
or deployment.get('metadata', {}).get('namespace') != 'sso'):
raise Refused('issuer deployment sso/keycape required')
found = [c for c in deployment['spec']['template']['spec'].get('containers', [])
if c.get('name') == 'keycape']
if len(found) != 1:
raise Refused('exactly one keycape container required')
return found[0]
def binding_for_case(case, *, allow_unimplemented=False):
if allow_unimplemented:
raise Refused('allow-unimplemented admission is not supported')
client = helper.client_from_case(case)
if client.get('clientType') != 'confidential':
raise Refused('public client has no secret reference to bind')
return helper.env_binding(client)
def _check_reference_only(entry):
if 'value' in entry or set(entry) != {'name', 'valueFrom'} or set(entry['valueFrom']) != {'secretKeyRef'}:
raise Refused('reference-only env binding required')
def plan(deployment, binding):
"""Return (new_deployment, created). Never reads or emits a secret value."""
_check_reference_only(binding)
new = copy.deepcopy(deployment)
env = _container(new).setdefault('env', [])
same = [e for e in env if e.get('name') == binding['name']]
if len(same) > 1:
raise Refused('duplicate env name present')
if same:
if same[0] != binding:
raise Refused('env name already bound to a different source')
return new, False
env.append(copy.deepcopy(binding))
return new, True
def source_digest():
"""Pin adapter plus the complete local helper import closure, in filename order."""
composition = {'files': {name: digest((ROOT / name).read_bytes()) for name in SOURCE_FILES}}
return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode())
def descriptor():
return Descriptor('warden.case.execution.v1', 'bind-env', 'net-kingdom', REFERENCE,
source_digest(), 'human', 'keycape-attended-case-change', 'client_id', 'change',
(CREATED,), False, ('parent-session',))
def build(*, admitted: Invocation, case_source: bytes, expected_resource_version: str,
case_acceptance: str, read, write, publish, authenticate, revoke_session,
allow_unimplemented=False):
"""Bind one admitted case and observed revision; return a closed kernel Adapter.
read()->deployment dict; write(deployment, expected_rv)->new rv (CAS, raises
on conflict). publish(bytes)->identifier retains only the closed metadata
payload; authenticate(identifier, bytes)->True verifies owner custody;
revoke_session(invocation)->identifier revokes this attempt's parent session.
Process loss, readback ambiguity and publication/revocation failure require
owner reconciliation; none are converted to retry-safe failures.
"""
reviewed_source = source_digest()
desc = descriptor()
if type(admitted) is not Invocation or not all(
isinstance(value, str) and IDENTIFIER.fullmatch(value)
for value in (admitted.step, admitted.owner, admitted.target,
admitted.actor, admitted.principal, admitted.lane)):
raise Refused('bounded invocation required')
if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != (
desc.step, desc.owner, desc.actor, desc.lane):
raise Refused('bind-env invocation boundary differs')
if admitted.operation != 'admission' or admitted.attempt_id:
raise Refused('expected admission invocation')
if type(case_source) is not bytes or len(case_source) > 262144 or digest(case_source) != admitted.pins.case:
raise Refused('case source pin differs')
# Pins.adapter is the descriptor digest, not the owner source digest.
descriptor_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
if admitted.pins.adapter != digest(descriptor_bytes):
raise Refused('descriptor pin differs')
if not all(isinstance(x, str) and 0 < len(x) <= 192
for x in (expected_resource_version, case_acceptance)):
raise Refused('observed resourceVersion and acceptance required')
if digest(case_acceptance.encode()) != admitted.pins.acceptance:
raise Refused('acceptance evidence binding differs')
if not all(callable(x) for x in (read, write, publish, authenticate, revoke_session)):
raise Refused('read/write, owner evidence and revocation capabilities required')
with contained_output():
case = yaml.safe_load(case_source)
client = helper.client_from_case(case)
binding = binding_for_case(case, allow_unimplemented=allow_unimplemented)
if admitted.target != client['clientId']:
raise Refused('case target differs')
records = {}
creation = None
def bound(inv, operation):
if (type(inv) is not Invocation
or replace(inv, attempt_id='', operation='admission') != admitted
or inv.operation != operation or not IDENTIFIER.fullmatch(inv.attempt_id)):
raise Refused('attempt binding differs')
if source_digest() != reviewed_source:
raise Refused('owner source changed')
def add():
current = read()
before = current['metadata'].get('resourceVersion')
if before != expected_resource_version:
raise Refused('deployment revision differs from observed')
new, created = plan(current, binding)
if not created:
return Outcome.NO_CHANGE, (), before, before
after = write(new, before)
back = read()
if (not isinstance(after, str) or not 0 < len(after) <= 192
or back['metadata'].get('resourceVersion') != after or plan(back, binding)[1]):
raise Refused('readback ambiguous; owner reconciliation required')
return Outcome.SUCCESS, (CREATED,), before, after
def remove(created):
if created != (CREATED,) or creation is None or not verify(*creation):
raise Refused('no authenticated case-created binding')
created_rv = json.loads(records[creation[1].evidence][1])['after']
current = read()
before = current['metadata'].get('resourceVersion')
if before != created_rv:
raise Refused('deployment changed since creation; owner reconciliation required')
new = copy.deepcopy(current)
env = _container(new).get('env', [])
matches = [i for i, e in enumerate(env) if e == binding]
if len(matches) != 1 or [e for e in env if e.get('name') == binding['name']] != [binding]:
raise Refused('created binding not found unchanged')
del env[matches[0]]
after = write(new, before)
back = read()
if (not isinstance(after, str) or back['metadata'].get('resourceVersion') != after
or any(e.get('name') == binding['name'] for e in _container(back).get('env', []))):
raise Refused('rollback readback ambiguous; owner reconciliation required')
return Outcome.ROLLED_BACK, (), before, after
def execute(inv, operation, created=()):
nonlocal creation
try:
with contained_output():
bound(inv, 'run' if operation == 'add' else 'rollback')
outcome, objects, before, after = add() if operation == 'add' else remove(created)
finally:
# Even failure must end the parent session; failures propagate to the
# kernel as uncertain, never safe to replay.
with contained_output():
revocation = revoke_session(inv)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
payload = json.dumps({'schema': 'netkingdom.keycape-bind-env.attempt.v1',
'invocation': asdict(inv), 'source': reviewed_source, 'outcome': outcome.value,
'created': objects, 'env_name': binding['name'], 'before': before, 'after': after,
'values_emitted': False, 'session_revocation': revocation},
sort_keys=True, separators=(',', ':')).encode()
with contained_output():
evidence = publish(payload)
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
raise Refused('owner evidence identifier invalid or reused')
result = OwnerResult(outcome, evidence, objects, True, revocation,
attempt_id=inv.attempt_id, operation=inv.operation)
records[evidence] = (inv, payload, result)
if objects:
creation = (inv, result)
elif operation == 'remove':
creation = None
return result
def verify(inv, result):
record = records.get(result.evidence) if type(result) is OwnerResult else None
if record is None or record[0] != inv or record[2] != result:
return False
with contained_output():
return authenticate(result.evidence, record[1]) is True
return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'),
verify, lambda inv, created: execute(inv, 'remove', created))