Merge NK-WP-0048 T30 prerequisites (T24 1.2 path, T27 Warden wrapping) and T15 reconcile source

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
This commit is contained in:
tegwick 2026-10-17 08:03:12 +02:00
commit 65559efa2f
10 changed files with 467 additions and 150 deletions

View file

@ -14,13 +14,27 @@ bound to anything else (including a literal), is refused.
and `allow_unimplemented` are refused.
- `read`/`write` are injected capabilities; `write` is compare-and-swap on the
observed `resourceVersion`. No kubectl, credential or secret value is used.
- `build(...)` returns a closed Warden v1 `Adapter` (T13 pattern). It pins the
owner source digest over `bind-env-adapter.py` + `keycape-client.py`, the
descriptor digest (`Pins.adapter`), case source and acceptance digests, and the
admitted invocation (step `bind-env`, actor `human`, lane `key-cape-oidc-login`,
created object `keycape-env-binding`, not retry-safe, `parent-session` scope).
Every attempt is rebound to that invocation and re-checks source drift.
- `publish`, `authenticate` and `revoke_session` are mandatory injected
capabilities. The parent session is revoked in `finally`, also on refusal; a
missing revocation or invalid/reused evidence identifier is never success.
Evidence payload is closed metadata (`netkingdom.keycape-bind-env.attempt.v1`:
invocation, source, outcome, env name, before/after revision) with no values.
- An identical existing binding is `no-change` and is not rollbackable.
- Rollback removes only the entry this process created, and only while the
- Rollback removes only the entry this process created, whose evidence still
authenticates (a fresh process has no creation authority), and only while the
Deployment is still at the revision that creation produced; otherwise owner
reconciliation is required.
Tests: `python3 -m pytest -q test_bind_env_adapter.py` (synthetic only).
Tests: `PYTHONPATH=~/ops-warden/src ~/ops-warden/.venv/bin/python -m pytest -q
test_bind_env_adapter.py` (synthetic only).
Not supplied: Warden `Adapter`/descriptor wrapping and evidence publication (see
T13 `keycape-registration-adapter.py`), NetKingdom runtime owner source review
before pinning, and any native action. Those remain separate gates.
Not supplied: kernel-level admission/dispatch tests with fixture trust (as in T13),
owner confirmation of the proposed step/lane/schema/object names, NetKingdom
runtime owner source review before pinning, the real owner evidence and
revocation capabilities, and any native action. Those remain separate gates.

View file

@ -1,29 +1,36 @@
"""Unexercised source candidate: bind one issuer env reference, never a value (NK-WP-0048-T27).
"""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, still byte-identical, may be rolled back.
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'
SOURCE_FILES = ('bind-env-adapter.py', 'keycape-client.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)
class Refused(Exception):
pass
def _container(deployment):
if (deployment.get('kind') != 'Deployment'
or deployment.get('metadata', {}).get('name') != 'keycape'
@ -66,45 +73,145 @@ def plan(deployment, binding):
return new, True
def build(*, case, expected_resource_version, read, write, allow_unimplemented=False):
"""read()->deployment dict; write(deployment, expected_rv)->new rv (CAS, raises on conflict)."""
binding = binding_for_case(case, allow_unimplemented=allow_unimplemented)
if not (callable(read) and callable(write)):
raise Refused('read/write capabilities required')
if not isinstance(expected_resource_version, str) or not expected_resource_version:
raise Refused('observed resourceVersion required')
state = {'created_rv': None}
def source_digest():
"""Pin adapter plus its 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 run():
def descriptor():
return Descriptor('warden.case.execution.v1', 'bind-env', 'net-kingdom', REFERENCE,
source_digest(), 'human', 'key-cape-oidc-login', '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()
rv = current['metadata'].get('resourceVersion')
if rv != expected_resource_version:
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', 'created': (), 'values_emitted': False}
after = write(new, rv)
return Outcome.NO_CHANGE, (), before, before
after = write(new, before)
back = read()
if back['metadata'].get('resourceVersion') != after or plan(back, binding)[1]:
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')
state['created_rv'] = after
return {'outcome': 'success', 'created': (binding['name'],), 'values_emitted': False}
return Outcome.SUCCESS, (CREATED,), before, after
def rollback():
if state['created_rv'] is None:
raise Refused('no case-created binding to roll back')
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()
rv = current['metadata'].get('resourceVersion')
if rv != state['created_rv']:
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)['env']
env = _container(new).get('env', [])
matches = [i for i, e in enumerate(env) if e == binding]
if len(matches) != 1:
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]]
write(new, rv)
state['created_rv'] = None
return {'outcome': 'rolled-back', 'created': (), 'values_emitted': False}
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
return REFERENCE, run, rollback
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))

View file

@ -13,9 +13,19 @@ unexercised against any live system and is not pinned, admitted or reviewed.
must name this case pin, the reviewed adapter source digest and the admitted
principal. A missing journal, a local SQLite journal or a differing binding is
refused before any call.
- The case must be canonical `oidc-browser-relying-party@1.1`. `access_groups`
and `initial_members` are parsed exactly, and members outside `access_groups`
are refused.
- By default the case must be canonical `oidc-browser-relying-party@1.1`.
`oidc-browser-relying-party@1.2` is accepted only when `build` is called with
an explicit `version_root='1.2'` (as in `handover-adapter.py`); an unknown root
or a case type that differs from the selected root is refused. The 1.2
revision leaves the ensure-groups step and its `access_groups` /
`initial_members` parameters unchanged, so both roots share one parser.
`access_groups` and `initial_members` are parsed exactly, and members outside
`access_groups` are refused. This is a source path only; it admits nothing.
- The journal binding is computed by the adapter's public `group_binding`,
which a test pins byte-identical to the provisioner's private
`LLDAPProvisioner._group_binding`; the adapter no longer calls the private
method. Promoting it to a public provisioner API is an identity-provisioner
owner change, not made here.
- The idempotency key and correlation id are derived from the case pin, so
every attempt resumes the same journal receipt. A replay returns `no_change`
and never recreates removed state.

View file

@ -11,6 +11,7 @@ Owner source review and every native action remain separate gates.
from __future__ import annotations
from dataclasses import asdict, replace
import hashlib
import json
import sys
from pathlib import Path
@ -28,6 +29,10 @@ SOURCE_FILES = (('sso-mfa/k8s/keycape', 'ensure-groups-adapter.py'),
('identity-provisioner', 'group_provenance_postgres.py'))
REFERENCE = 'net-kingdom/ensure-groups@v1'
CASE_TYPE = 'oidc-browser-relying-party@1.1' # canonical 1.1 stays the default
# 1.2 is reachable only by explicit version-root selection, never by default.
# Its ensure-groups step and access_groups/initial_members parameters are
# unchanged from 1.1, so the same request parsing applies to both.
SELECTABLE_TYPES = {'1.1': CASE_TYPE, '1.2': 'oidc-browser-relying-party@1.2'}
CREATED = ('service-group', 'service-group-membership')
if str(PROVISIONER) not in sys.path:
@ -48,10 +53,22 @@ def descriptor():
CREATED, False, ())
def _request(case):
def group_binding(groups, members, correlation_id) -> str:
"""Public form of the provisioner's exact ensure binding (journal key binding).
Must stay byte-identical to identity-provisioner's Provisioner._group_binding;
the test suite pins that equality so drift fails closed in review.
"""
canonical = {'ensure_correlation_id': correlation_id, 'groups': sorted(groups),
'members': {g: sorted(members.get(g, [])) for g in sorted(groups)}}
return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def _request(case, version_root='1.1'):
spec = case.get('spec') if isinstance(case, dict) else None
if not isinstance(spec, dict) or spec.get('type') != CASE_TYPE:
raise Refused('canonical oidc-browser-relying-party@1.1 case required')
expected = SELECTABLE_TYPES.get(version_root) if isinstance(version_root, str) else None
if expected is None or not isinstance(spec, dict) or spec.get('type') != expected:
raise Refused('case type differs from selected version root')
params = spec.get('parameters')
if not isinstance(params, dict) or not isinstance(params.get('access_groups'), str):
raise Refused('access_groups required')
@ -71,7 +88,7 @@ def _request(case):
def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate,
delete_created_if_empty: bool = False):
delete_created_if_empty: bool = False, version_root: str = '1.1'):
"""Bind one admitted case to the owner journal; return a closed kernel Adapter.
provisioner exposes ensure_service_groups/revoke_service_groups and carries
@ -80,6 +97,9 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
The idempotency key and correlation derive from the case pin, so every
attempt resumes the one journal receipt and never recreates removed state.
version_root selects the accepted case type: '1.1' (default, canonical) or
'1.2' only when passed explicitly. Unknown roots are refused.
Rollback reverses only journal-confirmed creations; preexisting/shared
groups and memberships are preserved. Ambiguity propagates as uncertain.
"""
@ -111,13 +131,15 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
raise Refused('owner group operations required')
if not all(callable(x) for x in (publish, authenticate)) or type(delete_created_if_empty) is not bool:
raise Refused('owner evidence capabilities required')
if version_root not in SELECTABLE_TYPES:
raise Refused('unknown version root')
with contained_output():
client_id, groups, members = _request(yaml.safe_load(case_source))
client_id, groups, members = _request(yaml.safe_load(case_source), version_root)
if client_id != admitted.target:
raise Refused('case target differs')
key = 'warden-ensure:' + admitted.pins.case
request = {'groups': groups, 'members': members, 'correlation_id': admitted.pins.case}
journal_binding = provisioner._group_binding(groups, members, admitted.pins.case)
journal_binding = group_binding(groups, members, admitted.pins.case)
records = {}
def check_attempt(inv, operation):

View file

@ -85,7 +85,7 @@ def descriptor(check_config_digest):
def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
expected_resource_version: str, case_acceptance: str,
check_config: Path, check_config_digest: str, publish, authenticate,
revoke_session):
revoke_session, holder=None, observe=None):
"""Bind one admitted case and observed revision; return a closed kernel Adapter.
publish(bytes)->identifier durably retains only the closed metadata payload.
@ -94,6 +94,14 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
session; Warden independently authenticates it using its own evidence verifier.
These callbacks are capabilities, never command strings or case parameters.
holder(original_attempt_id)->[(identifier, bytes)] and observe()->secret are
optional read-only capabilities, injected together or not at all. With them
the Adapter gains reconcile_original: it confirms a creation only from one
authenticated original created-only record whose post-creation revision and
exact client entry still hold. It never adds or removes; anything missing,
ambiguous or changed raises so Warden keeps the step uncertain. Choosing the
real authenticated holder is an owner gate outside this source.
Only a verified creation in this process may be rolled back. Process loss,
write/readback ambiguity, publication/revocation failure and source drift
require owner reconciliation; none are converted to retry-safe failures.
@ -125,6 +133,9 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
raise Refused('acceptance evidence binding differs')
if not all(callable(x) for x in (publish, authenticate, revoke_session)):
raise Refused('owner evidence and revocation capabilities required')
if (holder is None) != (observe is None) or not all(
x is None or callable(x) for x in (holder, observe)):
raise Refused('original-attempt holder and readback must be injected together')
with contained_output():
case = yaml.safe_load(case_source)
client = helper.client_from_case(case)
@ -213,5 +224,67 @@ def build(*, admitted: Invocation, case_source: bytes, expected_uid: str,
with contained_output():
return authenticate(result.evidence, record[1]) is True
def contained(fn, *args):
# Callback output is discarded; refusals below stay this adapter's own.
with contained_output():
return fn(*args)
def reconcile_original(ctx):
"""Read-only confirmation of a restarted/lost-receipt add; never replays or undoes."""
o, attempt = ctx.original.invocation, ctx.attempt
try:
if (o.operation != 'run' or attempt.operation != 'reconcile'
or o.attempt_id == attempt.attempt_id
or not IDENTIFIER.fullmatch(o.attempt_id)
or not IDENTIFIER.fullmatch(attempt.attempt_id)):
raise Refused('original intent not separate from reconcile attempt')
for inv in (o, attempt):
if replace(inv, attempt_id='', operation='admission') != admitted:
raise Refused('invocation differs from admitted case')
if ctx.original.source != reviewed_source or source_digest(check_config_digest) != reviewed_source:
raise Refused('original source differs')
try:
found = list(contained(holder, o.attempt_id))
except Exception:
raise Refused('original-attempt holder unavailable') from None
if len(found) != 1:
raise Refused('original record missing or ambiguous')
ref, raw = found[0]
if type(raw) is not bytes or contained(authenticate, ref, raw) is not True:
raise Refused('original record unauthenticated')
record = json.loads(raw)
if (record.get('schema') != 'netkingdom.keycape-registration.attempt.v1'
or record.get('invocation') != json.loads(json.dumps(asdict(o)))
or record.get('source') != reviewed_source):
raise Refused('original record belongs to another attempt/case/source/principal')
if record.get('outcome') != Outcome.SUCCESS.value or record.get('created') != ['keycape-client']:
raise Refused('original record is not created-only provenance')
secret = contained(observe)
meta = secret['metadata']
if {'uid': meta['uid'], 'resource_version': meta['resourceVersion']} != record['after']:
raise Refused('native revision changed since creation')
if contained(helper.replacement, secret, client)[1] is not False:
raise Refused('exact client entry absent')
finally:
with contained_output():
revocation = revoke_session(attempt)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
payload = json.dumps({'schema': 'netkingdom.keycape-registration.reconcile.v1',
'invocation': asdict(attempt), 'original': ref, 'source': reviewed_source,
'outcome': Outcome.SUCCESS.value, 'created': ('keycape-client',),
'after': record['after'], '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.SUCCESS, evidence, ('keycape-client',), True, revocation,
attempt_id=attempt.attempt_id, operation='reconcile')
# Recorded for verify only; never a creation this process may roll back.
records[evidence] = (attempt, payload, result)
return result
return Adapter(REFERENCE, reviewed_source, lambda inv: execute(inv, 'add'),
verify, lambda inv, created: execute(inv, 'remove', created))
verify, lambda inv, created: execute(inv, 'remove', created),
reconcile_original=reconcile_original if holder is not None else None)

View file

@ -59,6 +59,25 @@ published producer source pins apply unchanged through actual BrowserInputs.
This is a source compatibility fixture, not acknowledgment, owner provenance,
normal native admission or deployment acceptance.
## Original-intent reconciliation (NK-WP-0048-T15 source)
`build(..., holder=, observe=)` takes two optional read-only capabilities,
injected together or not at all. With them the Adapter installs
`reconcile_original` for Warden's `ReconcileContext` (ops-warden `a45cf47`): the
original `run` intent must be separate from the `reconcile` attempt and both must
match the admitted case and reviewed source; `holder(original_attempt_id)` must
return exactly one record that `authenticate` accepts, naming that invocation and
source with a created-only `success`; and `observe()` must show the recorded
post-creation uid/resourceVersion with the exact client entry. The parent session
is revoked either way, and a `netkingdom.keycape-registration.reconcile.v1`
record is published; `verify` accepts that result like any other owner record.
It never adds or removes: holder outage, missing/ambiguous/unauthenticated or
foreign records, a no-change original, and changed revision or entry all raise,
so Warden keeps the step uncertain. Rollback after restart stays refused, since a
reconciled creation is not a creation this process verified, until an owner
rollback contract exists. Which authenticated holder backs `holder` is an owner
gate; the tests use an in-memory stand-in only.
Remaining work belongs in live work records before T12 closure:
- Warden owns global evidence admission and exact native evidence/session

View file

@ -1,8 +1,15 @@
"""Synthetic-only tests for the T27 bind-env candidate; no cluster, no values."""
import copy
from dataclasses import asdict, replace
import json
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
from warden.case_run import Invocation, OwnerResult, digest
from warden.case_receipt_state import Outcome, Pins, Refused
import yaml
ROOT = Path(__file__).parent
spec = importlib.util.spec_from_file_location('bind_env_adapter', ROOT / 'bind-env-adapter.py')
@ -38,65 +45,151 @@ class Store:
class BindEnvTests(unittest.TestCase):
def setUp(self):
self.case = yaml.safe_dump(CASE).encode()
desc_bytes = json.dumps(asdict(a.descriptor()), sort_keys=True, separators=(',', ':')).encode()
pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes),
digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet'))
self.admitted = Invocation('bind-env', 'net-kingdom', 'forgejo', 'human',
'founder', 'key-cape-oidc-login', pins)
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
self.undo = replace(self.admitted, attempt_id='attempt-two', operation='rollback')
self.evidence, self.revocations = {}, []
def publish(self, payload):
self.assertNotIn(b'synthetic', payload)
ref = 'owner-evidence:attempt-' + str(len(self.evidence))
self.evidence[ref] = payload
return ref
def revoke(self, inv):
self.revocations.append(inv)
return 'revocation:' + inv.attempt_id
def adapter(self, store, **kw):
args = dict(admitted=self.admitted, case_source=self.case, expected_resource_version='1',
case_acceptance='case-acceptance:test', read=store.read, write=store.write,
publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p,
revoke_session=self.revoke)
args.update(kw)
return a.build(**args)
def env(self, store):
return store.d['spec']['template']['spec']['containers'][0]['env']
def test_binding_is_reference_only(self):
b = a.binding_for_case(CASE)
self.assertEqual(b['name'], 'KEYCAPE_FORGEJO_CLIENT_SECRET')
self.assertEqual(b['valueFrom']['secretKeyRef'], {'name': 'keycape-forgejo-client', 'key': 'client-secret'})
self.assertNotIn('value', b)
def test_run_then_created_only_rollback(self):
def test_run_verify_then_created_only_rollback(self):
s = Store(deployment())
ref, run, rollback = a.build(case=CASE, expected_resource_version='1', read=s.read, write=s.write)
self.assertEqual(ref, a.REFERENCE)
r = run()
self.assertEqual((r['outcome'], r['created'], r['values_emitted']), ('success', ('KEYCAPE_FORGEJO_CLIENT_SECRET',), False))
self.assertEqual(len(s.d['spec']['template']['spec']['containers'][0]['env']), 2)
self.assertEqual(rollback()['outcome'], 'rolled-back')
self.assertEqual(s.d['spec']['template']['spec']['containers'][0]['env'], deployment()['spec']['template']['spec']['containers'][0]['env'])
with self.assertRaises(a.Refused):
rollback()
ad = self.adapter(s)
self.assertEqual((ad.reference, ad.source), (a.REFERENCE, a.source_digest()))
r = ad.invoke(self.inv)
self.assertEqual((r.outcome, r.created, r.session_revocation_evidence), (Outcome.SUCCESS, (a.CREATED,), 'revocation:attempt-one'))
self.assertTrue(ad.verify(self.inv, r))
payload = json.loads(self.evidence[r.evidence])
self.assertEqual((payload['before'], payload['after'], payload['values_emitted']), ('1', '2', False))
self.assertEqual(len(self.env(s)), 2)
u = ad.rollback(self.undo, (a.CREATED,))
self.assertEqual(u.outcome, Outcome.ROLLED_BACK)
self.assertEqual(self.env(s), deployment()['spec']['template']['spec']['containers'][0]['env'])
self.assertEqual(self.revocations, [self.inv, self.undo])
with self.assertRaises(Refused):
ad.rollback(replace(self.undo, attempt_id='attempt-three'), (a.CREATED,))
self.assertEqual(len(self.revocations), 3)
def test_existing_identical_binding_is_no_change_and_not_rollbackable(self):
s = Store(deployment([a.binding_for_case(CASE)]))
_, run, rollback = a.build(case=CASE, expected_resource_version='1', read=s.read, write=s.write)
self.assertEqual(run()['outcome'], 'no-change')
ad = self.adapter(s)
self.assertEqual(ad.invoke(self.inv).outcome, Outcome.NO_CHANGE)
self.assertEqual(s.writes, [])
with self.assertRaises(a.Refused):
rollback()
with self.assertRaises(Refused):
ad.rollback(self.undo, (a.CREATED,))
def test_refusals(self):
def test_refusals_still_revoke_session(self):
other = dict(a.binding_for_case(CASE), valueFrom={'secretKeyRef': {'name': 'x', 'key': 'client-secret'}})
literal = {'name': 'KEYCAPE_FORGEJO_CLIENT_SECRET', 'value': 'synthetic'}
for env in ([other], [literal]):
s = Store(deployment(env))
_, run, _ = a.build(case=CASE, expected_resource_version='1', read=s.read, write=s.write)
with self.assertRaises(a.Refused):
run()
with self.assertRaises(Refused):
self.adapter(s).invoke(self.inv)
self.assertEqual(s.writes, [])
s = Store(deployment())
_, run, _ = a.build(case=CASE, expected_resource_version='0', read=s.read, write=s.write)
with self.assertRaises(a.Refused):
run()
with self.assertRaises(a.Refused):
a.build(case=CASE, expected_resource_version='1', read=s.read, write=s.write, allow_unimplemented=True)
with self.assertRaises(Refused):
self.adapter(s, expected_resource_version='0').invoke(self.inv)
self.assertEqual((s.writes, len(self.revocations), self.evidence), ([], 3, {}))
public = {'spec': {'type': 'oidc-browser-relying-party@1.1',
'parameters': dict(f.FORGEJO_PARAMS, client_type='public', mfa_required='false')}}
with self.assertRaises(a.Refused):
with self.assertRaises(Refused):
a.binding_for_case(public)
with self.assertRaises(Exception):
a.binding_for_case({'spec': {'type': 'other@1', 'parameters': f.FORGEJO_PARAMS}})
with self.assertRaises(a.Refused):
with self.assertRaises(Refused):
a.plan(dict(deployment(), metadata={'name': 'keycape', 'namespace': 'other'}), a.binding_for_case(CASE))
with self.assertRaises(a.Refused):
with self.assertRaises(Refused):
a.plan(deployment(), dict(a.binding_for_case(CASE), value='synthetic'))
def test_build_pins_and_capabilities_required(self):
s = Store(deployment())
pins = self.admitted.pins
for kw in (dict(allow_unimplemented=True),
dict(case_source=self.case + b' '),
dict(case_acceptance='case-acceptance:other'),
dict(admitted=replace(self.admitted, pins=replace(pins, adapter=digest(b'x')))),
dict(admitted=replace(self.admitted, step='register-client')),
dict(admitted=replace(self.admitted, target='other')),
dict(admitted=self.inv),
dict(publish=None), dict(revoke_session='cmd'), dict(write=None)):
with self.assertRaises(Exception, msg=kw):
self.adapter(s, **kw)
def test_rebinding_and_source_drift_refused_before_write(self):
s = Store(deployment())
ad = self.adapter(s)
with self.assertRaises(Refused):
ad.invoke(replace(self.inv, principal='someone-else'))
with patch.object(a, 'source_digest', return_value=digest(b'drift')):
with self.assertRaises(Refused):
ad.invoke(self.inv)
self.assertEqual(s.writes, [])
self.assertEqual(len(self.revocations), 2)
def test_tampered_or_unauthenticated_evidence_never_authorizes_undo(self):
s = Store(deployment())
ad = self.adapter(s)
r = ad.invoke(self.inv)
self.assertFalse(ad.verify(self.undo, r))
self.assertFalse(ad.verify(self.inv, replace(r, evidence='owner-evidence:forged')))
self.evidence[r.evidence] = b'tampered'
self.assertFalse(ad.verify(self.inv, r))
with self.assertRaises(Refused):
ad.rollback(self.undo, (a.CREATED,))
self.assertEqual(len(self.env(s)), 2)
def test_process_loss_does_not_reconstruct_created_authority(self):
s = Store(deployment())
self.adapter(s).invoke(self.inv)
with self.assertRaises(Refused):
self.adapter(s, expected_resource_version='2').rollback(self.undo, (a.CREATED,))
self.assertEqual(len(self.env(s)), 2)
def test_revocation_or_publication_failure_is_never_success(self):
for kw in (dict(revoke_session=lambda inv: None), dict(publish=lambda p: 'bad id!')):
s = Store(deployment())
with self.assertRaises(Refused):
self.adapter(s, **kw).invoke(self.inv)
def test_rollback_refused_after_drift(self):
s = Store(deployment())
_, run, rollback = a.build(case=CASE, expected_resource_version='1', read=s.read, write=s.write)
run()
ad = self.adapter(s)
ad.invoke(self.inv)
s.d['metadata']['resourceVersion'] = '9'
with self.assertRaises(a.Refused):
rollback()
with self.assertRaises(Refused):
ad.rollback(self.undo, (a.CREATED,))
self.assertEqual(len(self.env(s)), 2)
if __name__ == '__main__':

View file

@ -152,6 +152,35 @@ class EnsureGroupsTests(unittest.TestCase):
with self.assertRaises(Refused):
a.build(**{**self.kw, 'case_source': case, 'admitted': replace(self.admitted, pins=pins)})
def _rebind(self, kind):
case = yaml.safe_dump({'spec': {'type': kind, 'parameters': {
'client_id': 'forgejo', 'access_groups': 'forgejo-users,forgejo-admins',
'initial_members': 'forgejo-users=alice'}}}).encode()
self.prov.group_provenance = self.make_journal(case_digest=digest(case))
return {**self.kw, 'case_source': case,
'admitted': replace(self.admitted, pins=replace(self.admitted.pins, case=digest(case)))}
def test_explicit_version_root_selects_1_2(self):
kw = self._rebind('oidc-browser-relying-party@1.2')
with self.assertRaises(Refused):
a.build(**kw) # default stays canonical 1.1
adapter = a.build(**kw, version_root='1.2')
result = adapter.invoke(replace(kw['admitted'], attempt_id='attempt-one', operation='run'))
self.assertEqual(result.outcome, Outcome.SUCCESS)
self.assertEqual(self.prov.calls[0][1]['members'], {'forgejo-users': ['alice']})
def test_version_root_must_match_case_type_and_be_known(self):
with self.assertRaises(Refused):
a.build(**self.kw, version_root='1.2') # 1.1 case under 1.2 root
for root in ('2.0', '', None, 1.2):
with self.assertRaises(Refused):
a.build(**self.kw, version_root=root)
def test_public_group_binding_matches_provisioner(self):
groups, members = ['b-group', 'a-group'], {'a-group': ['bob', 'alice']}
self.assertEqual(a.group_binding(groups, members, 'corr'),
LLDAPProvisioner._group_binding(groups, members, 'corr'))
def test_rollback_refuses_unconfirmed_or_foreign_objects(self):
adapter = a.build(**self.kw)
rb_inv = replace(self.inv, operation='rollback')

View file

@ -80,6 +80,15 @@ class RegistrationTests(unittest.TestCase):
return copy.deepcopy(self.secret)
raise AssertionError('unexpected tooling operation')
def test_original_reconcile_capabilities_are_optional_but_paired(self):
self.assertIsNone(a.build(**self.kw).reconcile_original)
for extra in ({'holder': lambda attempt: []}, {'observe': lambda: {}},
{'holder': 'cmd', 'observe': lambda: {}}):
with self.subTest(extra=sorted(extra)), self.assertRaisesRegex(Refused, 'together'):
a.build(**dict(self.kw, **extra))
adapter = a.build(**dict(self.kw, holder=lambda attempt: [], observe=lambda: {}))
self.assertTrue(callable(adapter.reconcile_original))
def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
adapter = a.build(**self.kw)
original = copy.deepcopy(self.secret['data'])

View file

@ -2,7 +2,8 @@
adapter against Warden's original-intent interface (ops-warden a45cf47:
OriginalIntent, Journal.original_intent, Adapter.reconcile_original).
Fixture-only. The original-attempt holder below is the test's own in-memory
T15 source: reconciliation now runs through the adapter's own
reconcile_original (build(holder=..., observe=...)). The original-attempt holder below is the test's own in-memory
evidence store standing in for an owner-authenticated holder; Warden's intent
row is Warden's dispatch record, not created-only provenance. Selecting the
actual authenticated holder remains a T15 owner gate. Synthetic Kubernetes
@ -27,74 +28,18 @@ spec.loader.exec_module(base)
a = base.a
def original_reconciler(adapter, admitted, client, *, holder, authenticate,
observe, publish, revoke_session, reasons):
"""Fixture owner reconciler: read-only, never adds, never removes.
def capture_reasons(adapter, reasons):
"""Fixture-only: Warden contains the owner's refusal, so keep the message."""
reconcile = adapter.reconcile_original
holder(original_attempt_id) -> [(evidence_ref, payload_bytes)] is the
authenticated original-attempt readback; observe() -> current secret.
Confirms creation only from one authenticated original created-only record
whose post-creation uid/resourceVersion and exact client entry still hold.
Anything else raises, so Warden keeps the step uncertain.
"""
confirmed = {}
def reconcile(ctx):
def wrapped(ctx):
try:
return confirm(ctx)
except Exception as exc: # Warden contains the message; keep it for fixtures
return reconcile(ctx)
except Exception as exc:
reasons.append(f'{type(exc).__name__}: {exc}')
raise
def confirm(ctx):
o, attempt = ctx.original.invocation, ctx.attempt
try:
if (o.operation != 'run' or attempt.operation != 'reconcile'
or o.attempt_id == attempt.attempt_id
or not IDENTIFIER.fullmatch(o.attempt_id)):
raise Refused('original intent not separate from reconcile attempt')
for inv in (o, attempt):
if replace(inv, attempt_id='', operation='admission') != admitted:
raise Refused('invocation differs from admitted case')
if ctx.original.source != adapter.source:
raise Refused('original source differs')
try:
records = holder(o.attempt_id)
except Exception:
raise Refused('original-attempt holder unavailable') from None
if len(records) != 1:
raise Refused('original record missing or ambiguous')
ref, raw = records[0]
if authenticate(ref, raw) is not True:
raise Refused('original record unauthenticated')
record = json.loads(raw)
if (record.get('schema') != 'netkingdom.keycape-registration.attempt.v1'
or record.get('invocation') != json.loads(json.dumps(base.asdict(o)))
or record.get('source') != adapter.source):
raise Refused('original record belongs to another attempt/case/source/principal')
if record.get('outcome') != Outcome.SUCCESS.value or record.get('created') != ['keycape-client']:
raise Refused('original record is not created-only provenance')
secret = observe()
meta = secret['metadata']
if {'uid': meta['uid'], 'resource_version': meta['resourceVersion']} != record['after']:
raise Refused('native revision changed since creation')
if a.helper.replacement(secret, client)[1] is not False:
raise Refused('exact client entry absent')
finally:
revocation = revoke_session(attempt)
evidence = publish(json.dumps({'schema': 'netkingdom.keycape-registration.reconcile.v1',
'original': ref, 'attempt': attempt.attempt_id}, sort_keys=True).encode())
result = OwnerResult(Outcome.SUCCESS, evidence, ('keycape-client',), True, revocation,
attempt_id=attempt.attempt_id, operation='reconcile')
confirmed[evidence] = (attempt, result)
return result
def verify(inv, result):
if confirmed.get(getattr(result, 'evidence', None)) == (inv, result):
return True
return adapter.verify(inv, result)
return replace(adapter, reconcile_original=reconcile, verify=verify)
return replace(adapter, reconcile_original=wrapped)
class OriginalIntentReconcileTests(unittest.TestCase):
@ -116,14 +61,10 @@ class OriginalIntentReconcileTests(unittest.TestCase):
"""Fresh process: new journal handle, new adapter (empty records), readmission."""
executor.journal.db.close()
self.reasons = []
# The adapter's own reconcile_original path, given test-only holder/readback.
self.kw = dict(self.kw, holder=self.holder, observe=lambda: copy.deepcopy(self.secret))
fresh, admitted = self.kernel_fixture()
adapter = fresh.adapters[a.REFERENCE]
client = a.helper.client_from_case(base.yaml.safe_load(self.case_source(admitted)))
fresh.adapters[a.REFERENCE] = original_reconciler(
adapter, admitted[1], client, holder=self.holder,
authenticate=lambda ref, raw: self.evidence.get(ref) == raw,
observe=lambda: copy.deepcopy(self.secret), publish=self.publish,
revoke_session=self.revoke, reasons=self.reasons)
fresh.adapters[a.REFERENCE] = capture_reasons(fresh.adapters[a.REFERENCE], self.reasons)
return fresh, admitted
def case_source(self, admitted):