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:
commit
65559efa2f
10 changed files with 467 additions and 150 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue