diff --git a/sso-mfa/k8s/keycape/bind-env-adapter.md b/sso-mfa/k8s/keycape/bind-env-adapter.md index df78b7e..3af06e2 100644 --- a/sso-mfa/k8s/keycape/bind-env-adapter.md +++ b/sso-mfa/k8s/keycape/bind-env-adapter.md @@ -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. diff --git a/sso-mfa/k8s/keycape/bind-env-adapter.py b/sso-mfa/k8s/keycape/bind-env-adapter.py index f1b2d53..2e61140 100644 --- a/sso-mfa/k8s/keycape/bind-env-adapter.py +++ b/sso-mfa/k8s/keycape/bind-env-adapter.py @@ -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)) diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.md b/sso-mfa/k8s/keycape/ensure-groups-adapter.md index 0453440..cdd5ba8 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.md +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.md @@ -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. diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.py b/sso-mfa/k8s/keycape/ensure-groups-adapter.py index e19124f..ebbee9a 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.py +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.py @@ -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): diff --git a/sso-mfa/k8s/keycape/keycape-registration-adapter.py b/sso-mfa/k8s/keycape/keycape-registration-adapter.py index ae59a62..3c51737 100644 --- a/sso-mfa/k8s/keycape/keycape-registration-adapter.py +++ b/sso-mfa/k8s/keycape/keycape-registration-adapter.py @@ -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) diff --git a/sso-mfa/k8s/keycape/registration-adapter.md b/sso-mfa/k8s/keycape/registration-adapter.md index 6f4a70f..8b0a834 100644 --- a/sso-mfa/k8s/keycape/registration-adapter.md +++ b/sso-mfa/k8s/keycape/registration-adapter.md @@ -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 diff --git a/sso-mfa/k8s/keycape/test_bind_env_adapter.py b/sso-mfa/k8s/keycape/test_bind_env_adapter.py index 0812acc..fe35626 100644 --- a/sso-mfa/k8s/keycape/test_bind_env_adapter.py +++ b/sso-mfa/k8s/keycape/test_bind_env_adapter.py @@ -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__': diff --git a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py index d6105c3..2e8e969 100644 --- a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py +++ b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py @@ -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') diff --git a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py index d9bb3c6..6402046 100644 --- a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py +++ b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py @@ -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']) diff --git a/sso-mfa/k8s/keycape/test_registration_original_intent_reconcile.py b/sso-mfa/k8s/keycape/test_registration_original_intent_reconcile.py index 30e9844..345ff3a 100644 --- a/sso-mfa/k8s/keycape/test_registration_original_intent_reconcile.py +++ b/sso-mfa/k8s/keycape/test_registration_original_intent_reconcile.py @@ -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):