From 0775c2bfa41af236301155bb5a69bbf088bdf823 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 16 Oct 2026 21:42:33 +0200 Subject: [PATCH] feat(identity): prepare fixed attended registration adapter Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786 --- .../keycape/keycape-registration-adapter.py | 180 +++++++++++ sso-mfa/k8s/keycape/registration-adapter.md | 59 ++++ .../test_keycape_registration_adapter.py | 289 ++++++++++++++++++ workplans/NK-WP-0048-security-case-types.md | 122 +++++++- 4 files changed, 635 insertions(+), 15 deletions(-) create mode 100644 sso-mfa/k8s/keycape/keycape-registration-adapter.py create mode 100644 sso-mfa/k8s/keycape/registration-adapter.md create mode 100644 sso-mfa/k8s/keycape/test_keycape_registration_adapter.py diff --git a/sso-mfa/k8s/keycape/keycape-registration-adapter.py b/sso-mfa/k8s/keycape/keycape-registration-adapter.py new file mode 100644 index 0000000..995dd26 --- /dev/null +++ b/sso-mfa/k8s/keycape/keycape-registration-adapter.py @@ -0,0 +1,180 @@ +"""Unexercised, injection-only attended registration seam for Warden's v1 kernel. + +No CLI, credentials, evidence trust root or live registration is installed here. +Owner publication/authentication and parent-session revocation are mandatory +injected capabilities. The kernel must independently admit both evidence kinds. +""" +from __future__ import annotations + +import argparse +from dataclasses import asdict, replace +import json +import importlib.util +from pathlib import Path + +import yaml + +from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER, DIGEST +from warden.case_receipt_state import Outcome, Refused + +ROOT = Path(__file__).resolve().parent +SOURCE_FILES = ('keycape-registration-adapter.py', 'keycape-client.py', + 'portal-client-rollout.py', 'openbao-client-config.py', + 'register-user-engine-portal.py') +REFERENCE = 'net-kingdom/keycape-registration@v1' + +# Importing the maintained helper performs no tooling operation. +_spec = importlib.util.spec_from_file_location('registration_owner_helper', ROOT / 'keycape-client.py') +helper = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(helper) + + +def source_digest(check_config_digest): + """Pin adapter plus the complete local helper import closure, in filename order.""" + if not isinstance(check_config_digest, str) or not DIGEST.fullmatch(check_config_digest): + raise Refused('exact reviewed checker digest required') + composition = {'files': {name: digest((ROOT / name).read_bytes()) for name in SOURCE_FILES}, + 'check_config_binary': check_config_digest} + return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode()) + + +def descriptor(check_config_digest): + return Descriptor('warden.case.execution.v1', 'register-client', 'net-kingdom', + REFERENCE, source_digest(check_config_digest), 'human', 'key-cape-oidc-login', + 'client_id', 'change', ('keycape-client',), False, + ('parent-session',)) + + +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): + """Bind one admitted case and observed revision; return a closed kernel Adapter. + + publish(bytes)->identifier durably retains only the closed metadata payload. + authenticate(identifier, bytes)->True must verify independent owner custody. + revoke_session(invocation)->identifier must revoke this attempt's parent + session; Warden independently authenticates it using its own evidence verifier. + These callbacks are capabilities, never command strings or case parameters. + + 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. + """ + reviewed_source = source_digest(check_config_digest) + desc = descriptor(check_config_digest) + 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 desc.adapter_source != reviewed_source: + raise Refused('owner source changed during binding') + if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != ( + desc.step, desc.owner, desc.actor, desc.lane): + raise Refused('registration 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_uid, expected_resource_version, case_acceptance)): + raise Refused('bounded attended binding required') + if digest(case_acceptance.encode()) != admitted.pins.acceptance: + 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') + with contained_output(): + case = yaml.safe_load(case_source) + client = helper.client_from_case(case) + if admitted.target != client['clientId']: + raise Refused('case target differs') + check_config = Path(check_config) + records = {} + creation = None + + def binding(inv, operation): + if (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(check_config_digest) != reviewed_source: + raise Refused('owner source changed') + if digest(check_config.read_bytes()) != check_config_digest: + raise Refused('check-config binary changed') + + def execute(inv, operation, created=()): + nonlocal creation + try: + with contained_output(): + binding(inv, 'run' if operation == 'add' else 'rollback') + uid, revision = expected_uid, expected_resource_version + if operation == 'remove': + if (created != ('keycape-client',) or creation is None + or not verify(*creation)): + raise Refused('no authenticated case-created client') + original_payload = json.loads(records[creation[1].evidence][1]) + uid, revision = original_payload['after']['uid'], original_payload['after']['resource_version'] + args = argparse.Namespace(mode='apply', operation=operation, + expected_uid=uid, expected_resource_version=revision, + case_acceptance=case_acceptance, check_config=str(check_config)) + receipt = helper.run(args, client) + # The helper has completed CAS, whole-data readback and preservation. + # Never retain check-config diagnostics or arbitrary helper fields. + if not (receipt['schema'] == 'netkingdom.keycape-client.v1' + and receipt['target'] == 'sso/keycape-config' + and receipt['client_id'] == admitted.target + and receipt['mode'] == 'apply' and receipt['operation'] == operation + and receipt['values_emitted'] is False + and type(receipt['changed']) is bool + and receipt['unrelated_config_bytes_preserved'] is True + and receipt['other_secret_data_unchanged'] is True + and receipt['before'] == {'uid': uid, 'resource_version': revision} + and receipt['after']['uid'] == uid + and receipt['check_config']['proposed']['valid'] is True): + raise Refused('owner readback contract differs') + after = receipt['after'] + if (set(after) != {'uid', 'resource_version'} or + not all(isinstance(v, str) and 0 < len(v) <= 192 for v in after.values())): + raise Refused('owner revision contract differs') + changed = receipt['changed'] + outcome = (Outcome.ROLLED_BACK if operation == 'remove' else + Outcome.SUCCESS if changed else Outcome.NO_CHANGE) + objects = ('keycape-client',) if operation == 'add' and changed else () + finally: + # Even helper failure must end the parent session. Failures propagate + # contained to the kernel and become 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-registration.attempt.v1', + 'invocation': asdict(inv), 'source': reviewed_source, + 'outcome': outcome.value, 'created': objects, 'before': receipt['before'], + 'after': 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, evidence, objects, True, revocation, + attempt_id=inv.attempt_id, operation=inv.operation) + records[evidence] = (inv, payload, result) + if operation == 'add' and changed: + 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/registration-adapter.md b/sso-mfa/k8s/keycape/registration-adapter.md new file mode 100644 index 0000000..1ace794 --- /dev/null +++ b/sso-mfa/k8s/keycape/registration-adapter.md @@ -0,0 +1,59 @@ +# Attended registration adapter candidate + +Exercise status: unexercised + +This is a source-only, injection-only adapter for the existing Warden closed +kernel. It installs no command, runtime dispatcher, credential provider, evidence +trust root or new service. NetKingdom type/case status and bytes remain unchanged. +The generated descriptor is a proposal; it is not owner acknowledgment. + +`keycape-registration-adapter.py` wraps maintained `keycape-client.run` in apply +mode. A fixed invocation binds principal, human actor, lane, case/type/descriptor/ +catalog/acceptance/run-sheet digests and target. The descriptor pins this adapter +and its complete local helper import closure together with the exact reviewed +`check-config` executable digest. Changing that digest changes the descriptor +source pin and descriptor bytes, requiring independently reviewed admission. +The fixed executable is rehashed before each attempt. The caller constructs the +proposed invocation from exact source inputs, installs the fixed adapter once, +and calls normal `Executor.admit`; independently derived admission must match. +No callable replacement after admission is required or supported by this recipe. + +The operation uses existing cluster/issuer checks, byte-preserving merge, +Deployment secret-env prerequisite, config validation, UID/resourceVersion CAS +and whole-data readback. Only closed metadata is passed to the injected durable +owner publisher. The owner authenticator must independently authenticate those +exact bytes. The session-revocation capability must end the same attempt's +parent session; Warden separately verifies its exact revocation evidence. +These capabilities are owner-selected callables, never case-supplied commands. +No implementation of these native capabilities is supplied or implied here. + +An identical existing client returns no change and creates no rollback authority. +Removal requires this process's independently authenticated successful creation +receipt, exact created-object label, unchanged postcreation UID/resourceVersion, +and the original exact client record. External drift, a lost readback, receipt +publication/revocation failure or process loss requires owner reconciliation. +No uncertainty is labeled retry-safe. No automatic reconciliation is installed. +Rollback after restart intentionally remains unavailable until an owner supplies +independently authenticated durable creation provenance and reviews recovery. + +The source tests execute actual maintained helper CAS/readback against synthetic +Kubernetes objects. Three checks exercise actual Warden admission/run/rollback +and uncertainty transitions with synthetic owner trust. Warden d97652a's exact +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. + +Remaining work belongs in live work records before T12 closure: + +- Warden owns global evidence admission and exact native evidence/session + capabilities. Published d97652a refreshes BrowserInputs producer pins for + NetKingdom 015694ee; that source assent does not attest this new adapter. +- NetKingdom owns separately reviewed descriptor/type pin selection, the env + binding/config/restart steps, and group ensure/revoke provenance. Current group + ensure does not report newly added memberships; current revoke can remove + memberships that predate the case. Wrapping these as reversible creation would + be unsound. Preserve them until a reviewed created-only contract exists. +- Platform owns structural/mint/broker and secret delivery evidence; Apps owns + actual consumer login and group enforcement. No assent is supplied here. +- Attended use still requires existing owner authorization, current caller + identity, native source/config/image pins, native receipts and cleanup proof. diff --git a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py new file mode 100644 index 0000000..98ed1ce --- /dev/null +++ b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py @@ -0,0 +1,289 @@ +"""Actual helper/CAS execution against synthetic Kubernetes objects only.""" +from dataclasses import asdict, replace +import copy +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from warden.case_run import Invocation, digest +from warden.case_receipt_state import Outcome, Pins, Refused +import yaml + +ROOT = Path(__file__).parent +spec = importlib.util.spec_from_file_location('registration_adapter', ROOT / 'keycape-registration-adapter.py') +a = importlib.util.module_from_spec(spec) +spec.loader.exec_module(a) +spec = importlib.util.spec_from_file_location('registration_fixture', ROOT / 'test_keycape_client.py') +f = importlib.util.module_from_spec(spec) +spec.loader.exec_module(f) + + +class RegistrationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.binary = Path(self.temp.name) / 'keycape' + self.binary.write_bytes(b'synthetic checker binary') + self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1', + 'parameters': f.FORGEJO_PARAMS}}).encode() + desc = a.descriptor(digest(self.binary.read_bytes())) + self.desc_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes), + digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet')) + self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human', + 'founder', 'key-cape-oidc-login', pins) + self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') + self.secret, self.raw = f.fixture() + self.effects = [] + self.evidence = {} + self.revocations = [] + self.kw = dict(admitted=self.admitted, case_source=self.case, + expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test', + check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()), + publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload, + revoke_session=self.revoke) + self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True}) + self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube) + self.check_patch.start() + self.kube_patch.start() + self.addCleanup(self.check_patch.stop) + self.addCleanup(self.kube_patch.stop) + self.addCleanup(self.temp.cleanup) + + def publish(self, payload): + ref = 'owner-evidence:attempt-' + str(len(self.evidence)) + self.evidence[ref] = payload + return ref + + def revoke(self, inv): + self.revocations.append(inv) + return 'session-revoked:' + inv.operation + ':' + inv.attempt_id + + def kube(self, args, payload=None): + if args[:3] == ['get', 'namespace', 'kube-system']: + return {'metadata': {'uid': a.helper.lane.CLUSTER_UID}} + if args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + return copy.deepcopy(self.secret) + if args[:5] == ['-n', 'sso', 'get', 'deployment', 'keycape']: + return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape', + 'env': [a.helper.env_binding(f.FORGEJO)]}]}}}} + if args[:5] == ['-n', 'sso', 'patch', 'secret', 'keycape-config']: + self.assertEqual(payload[:2], [ + {'op': 'test', 'path': '/metadata/uid', 'value': self.secret['metadata']['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': self.secret['metadata']['resourceVersion']}]) + self.effects.append(copy.deepcopy(payload)) + self.secret['data']['config.yaml'] = payload[2]['value'] + self.secret['metadata']['resourceVersion'] = str(int(self.secret['metadata']['resourceVersion']) + 1) + return copy.deepcopy(self.secret) + raise AssertionError('unexpected tooling operation') + + def test_actual_add_and_created_only_rollback_preserve_original_bytes(self): + adapter = a.build(**self.kw) + original = copy.deepcopy(self.secret['data']) + result = adapter.invoke(self.inv) + self.assertEqual(result.outcome, Outcome.SUCCESS) + self.assertEqual(result.created, ('keycape-client',)) + self.assertTrue(adapter.verify(self.inv, result)) + undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created) + self.assertEqual(undone.outcome, Outcome.ROLLED_BACK) + self.assertEqual(self.secret['data'], original) + self.assertEqual(len(self.effects), 2) + self.assertEqual(len(self.revocations), 2) + for payload in self.evidence.values(): + self.assertNotIn(b'synthetic-marker', payload) + self.assertNotIn(b'config.yaml', payload) + self.assertNotIn(b'signing-key', payload) + + def test_identical_client_is_no_change_and_cannot_be_removed(self): + self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO]) + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + self.assertEqual(result.outcome, Outcome.NO_CHANGE) + self.assertEqual(result.created, ()) + with self.assertRaises(Refused): + adapter.rollback(replace(self.inv, operation='rollback'), ('keycape-client',)) + self.assertFalse(self.effects) + + def test_existing_drift_refused_and_session_revoked(self): + self.secret, _ = f.fixture(f.EXISTING + [dict(f.FORGEJO, displayName='Drift')]) + with self.assertRaises(Refused): + a.build(**self.kw).invoke(self.inv) + self.assertFalse(self.effects) + self.assertEqual(self.revocations, [self.inv]) + + def test_invocation_rebinding_refused_before_tooling(self): + for changes in ({'target': 'other'}, {'principal': 'other'}, {'actor': 'agent'}, + {'lane': 'other'}, {'operation': 'rollback'}, {'attempt_id': ''}, + {'pins': replace(self.inv.pins, case=digest(b'other'))}): + with self.subTest(changes=changes), self.assertRaises(Refused): + a.build(**self.kw).invoke(replace(self.inv, **changes)) + self.assertFalse(self.effects) + + def test_initial_case_descriptor_and_target_pins_are_required(self): + for changes in ({'case_source': b'{}'}, {'case_acceptance': 'other'}, {'admitted': replace(self.admitted, target='other')}, + {'admitted': replace(self.admitted, actor='agent')}, + {'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'wrong')))}): + with self.subTest(changes=changes), self.assertRaises(Refused): + a.build(**dict(self.kw, **changes)) + self.assertFalse(self.effects) + + def test_binary_or_source_drift_refused_before_tooling(self): + adapter = a.build(**self.kw) + self.binary.write_bytes(b'changed') + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.binary.write_bytes(b'synthetic checker binary') + with patch.object(a, 'source_digest', return_value=digest(b'changed')): + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertFalse(self.effects) + + def test_substituting_caller_selected_checker_under_same_descriptor_is_refused(self): + substitute = Path(self.temp.name) / 'other-checker' + substitute.write_bytes(b'caller-selected executable') + with self.assertRaisesRegex(Refused, 'descriptor pin differs'): + a.build(**dict(self.kw, check_config=substitute, + check_config_digest=digest(substitute.read_bytes()))) + self.assertFalse(self.effects) + + def test_stale_revision_refuses_mutation(self): + adapter = a.build(**self.kw) + self.secret['metadata']['resourceVersion'] = '8' + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertFalse(self.effects) + + def test_rollback_requires_exact_provenance_and_postcreation_revision(self): + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + rollback = replace(self.inv, attempt_id='attempt-two', operation='rollback') + for created in ((), ('other',), ('keycape-client', 'other')): + with self.subTest(created=created), self.assertRaises(Refused): + adapter.rollback(rollback, created) + self.secret['metadata']['resourceVersion'] = '9' + with self.assertRaises(Refused): + adapter.rollback(rollback, result.created) + self.assertEqual(len(self.effects), 1) + + def test_missing_or_tampered_evidence_never_verifies_or_authorizes_undo(self): + adapter = a.build(**self.kw) + result = adapter.invoke(self.inv) + self.assertFalse(adapter.verify(replace(self.inv, attempt_id='other'), result)) + self.assertFalse(adapter.verify(self.inv, replace(result, revoked=False))) + self.evidence[result.evidence] = b'forged' + self.assertFalse(adapter.verify(self.inv, result)) + with self.assertRaises(Refused): + adapter.rollback(replace(self.inv, operation='rollback'), result.created) + self.assertEqual(len(self.effects), 1) + + def test_process_loss_does_not_reconstruct_created_authority(self): + result = a.build(**self.kw).invoke(self.inv) + fresh = a.build(**self.kw) + with self.assertRaises(Refused): + fresh.rollback(replace(self.inv, operation='rollback'), result.created) + self.assertIsNone(fresh.reconcile) + self.assertEqual(len(self.effects), 1) + + def test_failed_readback_is_uncertain_and_never_provides_creation(self): + original_kube = self.kube + def lost(args, payload=None): + if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + raise RuntimeError('synthetic sensitive detail') + return original_kube(args, payload) + with patch.object(a.helper.lane, 'kubectl', side_effect=lost): + adapter = a.build(**self.kw) + with self.assertRaisesRegex(Refused, '^owner callback failed$'): + adapter.invoke(self.inv) + self.assertEqual(len(self.effects), 1) + self.assertFalse(self.evidence) + self.assertEqual(self.revocations, [self.inv]) + + def test_revocation_or_publication_failure_cannot_produce_verified_success(self): + for capability in ('publish', 'revoke_session'): + self.secret, _ = f.fixture() + with self.subTest(capability=capability): + kw = dict(self.kw, **{capability: lambda *args: (_ for _ in ()).throw(RuntimeError('private'))}) + adapter = a.build(**kw) + with self.assertRaisesRegex(Refused, '^owner callback failed$'): + adapter.invoke(self.inv) + self.assertFalse(self.evidence) + + def kernel_fixture(self): + # Fixture-only proposed type/case; canonical files are never edited. + # Warden's published producer pins apply unchanged. Owner trust and + # proposed descriptor/type/case are fixture-only, never native admission. + import warden.case_inputs as inputs + from warden.case_run import Executor, Journal + root = ROOT.parents[2] + case = yaml.safe_load((root / 'security-cases/forgejo-netkingdom-oidc.yaml').read_bytes()) + case['spec']['status'] = 'materialized' + case_source = yaml.safe_dump(case).encode() + typ = yaml.safe_load((root / 'security-case-types/oidc-browser-relying-party/type.yaml').read_bytes()) + typ['spec']['execution_descriptors'] = {'register-client': digest(self.desc_bytes)} + type_source = yaml.safe_dump(typ).encode() + catalog = b'fixture-only-catalog' + sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human', + 'founder', 'key-cape-oidc-login', 'fixture-acceptance', 'fixture-materialization', + 'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode() + pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes), + digest(catalog), digest(b'fixture-acceptance'), digest(sheet)) + invocation = replace(self.admitted, pins=pins) + adapter = a.build(**dict(self.kw, admitted=invocation, case_source=case_source, + case_acceptance='fixture-acceptance')) + def fixture_verifier(purpose, source, evidence): + if purpose.startswith('session-revocation:'): + _, lane, operation, attempt = purpose.split(':') + return source == digest(case_source) and lane == invocation.lane and evidence == ( + 'session-revoked:' + operation + ':' + attempt) + return evidence in {'fixture-acceptance', 'fixture-materialization', + 'fixture-descriptor', 'fixture-session'} + executor = Executor(Journal(Path(self.temp.name) / 'private'), + {adapter.reference: adapter}, fixture_verifier, browser_inputs=inputs.BrowserInputs(root)) + admitted = executor.admit(case_source, type_source, self.desc_bytes, catalog, + principal='founder', actor='human', lane=invocation.lane, + acceptance_evidence='fixture-acceptance', materialization_evidence='fixture-materialization', + descriptor_evidence='fixture-descriptor', session_evidence='fixture-session') + self.assertEqual(admitted[1], invocation) + return executor, admitted + + def test_actual_kernel_admission_dispatch_and_rollback_with_fixture_only_trust(self): + executor, admitted = self.kernel_fixture() + result = executor.run(*admitted) + self.assertEqual(result.last.outcome, Outcome.SUCCESS) + self.assertEqual(result.last.created, ('keycape-client',)) + undone = executor.rollback(*admitted) + self.assertEqual(undone.last.outcome, Outcome.ROLLED_BACK) + self.assertEqual(len(self.effects), 2) + self.assertEqual(len(self.revocations), 2) + + def test_kernel_lost_readback_retains_uncertain_and_refuses_replay_and_undo(self): + executor, admitted = self.kernel_fixture() + original_kube = self.kube + def lost(args, payload=None): + if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']: + raise RuntimeError('synthetic sensitive detail') + return original_kube(args, payload) + with patch.object(a.helper.lane, 'kubectl', side_effect=lost): + result = executor.run(*admitted) + self.assertEqual(result.last.outcome, Outcome.UNCERTAIN) + self.assertFalse(result.last.created) + with self.assertRaises(Refused): + executor.run(*admitted, retry=True) + with self.assertRaises(Refused): + executor.rollback(*admitted) + self.assertEqual(len(self.effects), 1) + self.assertEqual(len(self.revocations), 1) + + def test_kernel_external_revision_change_retains_uncertain_rollback(self): + executor, admitted = self.kernel_fixture() + self.assertEqual(executor.run(*admitted).last.outcome, Outcome.SUCCESS) + self.secret['metadata']['resourceVersion'] = '9' + self.assertEqual(executor.rollback(*admitted).last.outcome, Outcome.ROLLBACK_UNCERTAIN) + self.assertEqual(len(self.effects), 1) + self.assertEqual(len(self.revocations), 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 72f1a9b..b4f9848 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -323,7 +323,7 @@ the rationale. The type is `acknowledged`; it becomes `implemented` with T06. ```task id: NK-WP-0048-T06 status: wait -depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, MASON-WP-0010-T03, WARDEN-WP-0044-T03] +depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, NK-WP-0048-T09, NK-WP-0048-T12, NK-WP-0048-T13, NK-WP-0048-T14, NK-WP-0048-T15, NK-WP-0048-T16, MASON-WP-0010-T03, WARDEN-WP-0044-T03] blocking_reason: "Type1.2 architecture has a confirmed source-only founder acknowledgment; canonical1.1 remains unchanged/default and the1.2 reference case stays proposed. Needs explicit version selection/reference-source integration without normal admission bypass, closed owner adapters plus global/native receipt verifier (WARDEN-WP-0044-T03), independently verified six-object structural materialization and CCR42 claims/created-only provenance, exact broker/case/source reconciliation, then separately authorized native mint/delivery and attended registry/consumer verification. Source acknowledgment, a bundle or local tests do not prove implemented/materialized/live status." priority: high state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c" @@ -592,6 +592,7 @@ prerequisites, with normal admission still refusing unimplemented types. id: NK-WP-0048-T10 status: done priority: high +state_hub_task_id: "4b06ca97-c1e4-5680-bf22-ab75ab1646a4" ``` Completed 2026-10-16 in published source `c31d21ba`. The read-only @@ -608,6 +609,7 @@ previously described under T06; it does not duplicate native acceptance credit. id: NK-WP-0048-T11 status: done priority: high +state_hub_task_id: "ef2a0119-f59e-5e45-9519-115da9df7b85" ``` Completed 2026-10-16. Reproduced the JSON schema rejecting a valid @@ -626,19 +628,109 @@ publication receipt: Custodian id: NK-WP-0048-T12 status: todo priority: high +state_hub_task_id: "7ce4eee3-cd47-55a9-bfa5-4f1835d4d6fd" ``` -Owner: NetKingdom. Next action: wrap the existing contained `keycape-client.py` -registration/config/env and identity-provisioner ensure/revoke entry points in -bounded owner adapters or receipt-only attended steps for Warden's closed kernel. -Reuse the existing caller/session boundary, CAS/readback and status-only outputs; -never acquire user-engine's runtime identity or expose registry/client secrets. -Completion requires exact adapter/source descriptors, positive and refusal source -checks, uncertainty/created-only rollback behavior and a separate owner review of -the proposed machine seam. Source preparation can begin without credentials. -Warden independently owns global provenance/admission integration; Platform owns -mint/broker and native structural receipts; Apps owns consumer delivery/group -acceptance. Their assent is not supplied by this task. Pinning descriptors into -an acknowledged type remains a separately reviewed type-source change. Actual -attended execution still requires its existing owner authorization and native -receipts. T06 retains all native/reference lifecycle acceptance scope. +Owner: NetKingdom. Retained scope: prepare bounded attended Deployment env, +config verification and restart/readiness steps around the existing helpers. +Next action: identify exact current entry points and their revision/readback +contracts, then implement fixed source-pinned callbacks or receipt-only attended +steps. Completion requires positive/refusal checks, exact target/source pins and +uncertainty behavior without acquiring user-engine runtime identity or exposing +registry/client secrets. Existing caller/session, cluster/issuer, CAS/readback +and status-only output boundaries remain mandatory. + +2026-10-16 task refinement: the original broad adapter scope is split into T13 +(completed registration source candidate), T14 (group creation provenance), T15 +(durable registration recovery) and T16 (descriptor/artifact selection and owner +review). This preserves T12's task ID and Hub UUID while giving each remaining +outcome an owner and completion gate. T06 now names every source dependency; +its native/reference lifecycle acceptance scope remains unchanged. Completion +of any source task supplies no Warden, Platform or Apps assent and no native +execution authority. + +## T13 — Prepare the fixed registration adapter source candidate + +```task +id: NK-WP-0048-T13 +status: done +priority: high +``` + +Completed 2026-10-16, source implementation and synthetic fixture proof only. +Added an injection-only adapter around actual `keycape-client.run`: fixed +case/principal/human/lane/source/acceptance binding, existing CAS/readback, +metadata-only owner evidence and per-attempt parent-session revocation boundary. +The descriptor source composition pins the exact checker executable digest +alongside adapter/helper source. Creation-only rollback authenticates its +creation receipt and refuses changed postcreation UID/resourceVersion; +pre-existing clients confer no rollback authority. Missing readback, evidence, +revocation or process-local creation provenance cannot become retry-safe success. + +Evidence: Custodian `docs/changes/netkingdom-target-2026-10-16/browser-adapter-next/` +R3 candidate, explicit patch and postimages, retained R1 rejected custody finding +and R2 revision, and 36 passing source checks (16 new, 20 existing). Three checks +use actual Warden kernel admission/dispatch/rollback with published Warden +`d97652a3bb5fa733b063d71a1b74068efcccf572` producer pins unchanged. Owner trust, +descriptor/type/case and Kubernetes objects remain synthetic fixtures. This +records a completed source deliverable, not independent review, publication, +type acknowledgment, exercised procedure, identity admission or native browser +acceptance. Independent root source review: Custodian browser-adapter-next/coordinator-review-r3.json. Source integration and normal publication/synchronization receipts: Custodian browser-adapter-next/canonical-integration.json. These receipts supply no other-owner assent or native authority. + +## T14 — Preserve created-only provenance for group ensure and revoke + +```task +id: NK-WP-0048-T14 +status: todo +priority: high +``` + +Owner: NetKingdom. Next action: extend or wrap the existing identity-provisioner +ensure/revoke contract with independently retained prestate and exact newly +created groups/memberships before exposing reversible owner callbacks. Current +ensure returns no membership-creation inventory; current revoke may remove a +membership that predated this case. Completion requires actual helper-backed +positive/refusal fixtures, all validation before mutation, per-attempt evidence +and revocation, readback, ambiguity handling, and rollback that preserves every +pre-existing/shared group and membership. No existing endpoint or service token +is treated as new authorization. + +## T15 — Define authenticated recovery for lost registration process state + +```task +id: NK-WP-0048-T15 +status: todo +priority: high +``` + +Owner: NetKingdom for registration state, with Warden owning kernel evidence +admission. Next action: select the exact existing owner evidence holder/readback +contract and define authenticated lookup for the original creation attempt. +Completion requires restart/lost-receipt fixtures that bind case/source/principal, +original created-only provenance and current native revision before any undo; +uncertain outcomes remain non-replayable. The T13 adapter deliberately refuses +post-restart rollback and installs no reconciliation callback. A new broker or +unverified JSON receipt is not a recovery contract. Actual evidence-holder +acceptance and attended recovery remain separately authorized native work. + +## T16 — Select and review the actual machine descriptor composition + +```task +id: NK-WP-0048-T16 +status: todo +priority: high +``` + +Owner: NetKingdom for descriptor/type source and exact checker artifact; Warden +independently owns global provenance/admission. Next action: identify the exact +reviewed checker executable artifact and native owner evidence/session callback +contracts, then assemble the complete step descriptor set and a proposed explicit +type version selection. T13's retained descriptor pins a synthetic checker only. +Completion requires separate owner source review of exact machine descriptors, +artifact/source/config pins, normal type/version admission and independent +evidence-verification boundaries. Warden d97652a refreshes producer validator +pins for NetKingdom 015694ee; it does not attest T13's new adapter. Pinning any +machine descriptor into an acknowledged type is a separately reviewed source +change. Platform owns structural/mint/broker/delivery evidence; Apps owns actual +consumer login/group enforcement. Neither their assent nor native acceptance is +supplied by this task.