From eba6e1fdbb2d368093cb2effd4d798f393e0482e Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 13 Oct 2026 23:39:58 +0200 Subject: [PATCH] security: bind Playwright activation and recovery to exact issuer state Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2 --- docs/playwright-registration.md | 67 +++++ sso-mfa/k8s/keycape/playwright-activation.py | 230 ++++++++++++++++++ .../k8s/keycape/test_playwright_activation.py | 221 +++++++++++++++++ ...P-0050-playwright-consumer-registration.md | 19 +- 4 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 sso-mfa/k8s/keycape/playwright-activation.py create mode 100644 sso-mfa/k8s/keycape/test_playwright_activation.py diff --git a/docs/playwright-registration.md b/docs/playwright-registration.md index b82abc5..fd42e57 100644 --- a/docs/playwright-registration.md +++ b/docs/playwright-registration.md @@ -97,6 +97,73 @@ through the transport owner. No immediate global revocation is claimed. ## Verification and completion +### Guarded activation patch preparation + +`sso-mfa/k8s/keycape/playwright-activation.py` now implements the private proposal, +ordered patch preparation and readback checks for the existing protected owner +path. It has no CLI or Kubernetes connection. `build` takes original Secret and +Deployment API objects, the complete identity-reference inventory and the exact +reviewed source revision. Its result contains the private original registry and +must remain inside that execution path. Only `summary` is a public receipt. +Source revision and digest bindings do not constitute an approval or custody grant. + +The adapter admits one replica, `Recreate`, the pinned immutable KeyCape image, +the expected registry mount and explicit environment bindings. It preserves +unrelated settings and refuses differing bindings. `check_registry` pins the +native verifier's binary digest, checks both configurations using their exact +environment-variable names and policy-store mode, passes private input on stdin, +clears ambient environment overrides and contains diagnostics. The operator must +separately establish verifier compatibility with the deployed image; a locally +built verifier is not production artifact admission. + +The source deployment cannot atomically update its registry and environment: +an environment edit starts a rollout, and pending authorization codes are +process-local. The prepared sequence therefore requires an explicit exclusive +maintenance window with a planned authentication interruption: + +1. `stop`: bind the original object UIDs, resource versions, data and complete + Deployment spec; require the original single replica Ready; scale to zero. +2. Wait for the controller to observe that generation with zero replicas and + for **every** pod consuming `keycape-config` to disappear. Supply an unfiltered, + complete `sso` PodList; terminating pods still count. Direct/projected volumes, + env references and init/ephemeral containers are checked. +3. `registry`: conditionally write the exact proposed registry data while stopped. +4. `bindings`: conditionally add the agent environment bindings while still stopped. +5. `resume`: after fresh matching registry/spec and quiescence checks, restore one + replica. Then the protected executor must verify generation, exact pod/image, + readiness, real issuance and existing human/service login. Scaling is not that + acceptance proof. + +Fresh reads of **both** objects precede every `propose_step`. Each patch tests +the target UID, resourceVersion and complete data/spec before replacement. Use +`verify_response` on the API response and subsequent fresh GET, retaining each +sanitized receipt. Refuse and stop on any mismatch or unknown outcome; no automatic +retry or stale whole-registry replay. Kubernetes offers no cross-object transaction, +so concurrent issuer writers must be excluded by the owner maintenance procedure. + +Rollback uses the retained original proposal, never a newly inferred baseline: +`rollback-stop`, observed quiescence, `rollback-registry`, `rollback-bindings`, +`rollback-resume`, then the same native readiness/login verification. Exact +original/candidate matching preserves registrations and bindings that predated +this proposal. Any unrelated intervening data/spec change refuses rollback for +owner reconciliation. No completed changes means no rollback outage. Retain +private recovery state after failure; the adapter neither writes it to disk nor +destroys credentials. Token expiry and connection-termination limits above remain. + +Before wiring this into live execution, T02 still requires real identity/custody +references, the protected executor and recovery-state custody, cluster/current +image pinning, the exact supervisor disposition, and source/GitOps reconciliation. +Do not invoke the generic browser helper with a fabricated `--case-acceptance`. + +Activation tests use the independent `jsonpatch` Python package to apply RFC 6902 +patches to synthetic API objects (test prerequisites: PyYAML and jsonpatch). +They check ordered/partial recovery, created-only preservation, stale writes, +quiescence, revision/readback refusal and verifier containment. Controller state +is simulated; neither these tests nor native `check-config` replace live API and +issuer acceptance. + +### Registration and consumer checks + Python tests exercise preservation, add/no-op/remove, lifetime bounds, identity conflicts, distinct credentials and contained invalid-input output. The native Go fixture `tests/playwright_registration_native_test.go` is copied into a diff --git a/sso-mfa/k8s/keycape/playwright-activation.py b/sso-mfa/k8s/keycape/playwright-activation.py new file mode 100644 index 0000000..ebda16d --- /dev/null +++ b/sso-mfa/k8s/keycape/playwright-activation.py @@ -0,0 +1,230 @@ +"""Private, digest-bound Playwright registry/Deployment patch preparation. + +No CLI or Kubernetes client: the existing protected owner execution path must +provide fresh objects, an independently approved proposal digest, admitted +custody, exclusive maintenance, and readiness/login verification. Proposal and +patch bodies contain private registry data; never log or serialize them to chat. +Only summary() is an output receipt. This library grants no execution authority. +""" +from __future__ import annotations + +import base64 +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import re +import subprocess + +spec = importlib.util.spec_from_file_location('playwright_registration', Path(__file__).with_name('playwright-registration.py')) +registration = importlib.util.module_from_spec(spec) +spec.loader.exec_module(registration) +require = registration.require +Refused = registration.Refused + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':'), + allow_nan=False).encode()).hexdigest() + + +def target(obj, kind, name): + require(obj.get('kind') == kind and obj.get('metadata', {}).get('name') == name + and obj['metadata'].get('namespace') == 'sso', 'unexpected_target') + require(not obj['metadata'].get('deletionTimestamp') and + not obj['metadata'].get('ownerReferences'), 'target_not_independently_available') + return registration.registry.pin.safe_metadata(obj) + + +def container(specification): + items = specification['template']['spec']['containers'] + matches = [item for item in items if item.get('name') == 'keycape'] + require(len(matches) == 1, 'keycape_container_required') + return matches[0] + + +def stopped(specification): + result = copy.deepcopy(specification) + result['replicas'] = 0 + return result + + +def build(secret, deployment, identities, source_revision): + secret_meta = target(secret, 'Secret', 'keycape-config') + deploy_meta = target(deployment, 'Deployment', 'keycape') + require(not secret.get('immutable'), 'mutable_registry_required') + require(re.fullmatch(r'[0-9a-f]{40}', source_revision or '') is not None, 'exact_source_revision_required') + original = deployment['spec'] + require(original.get('replicas') == 1 and original.get('strategy', {}).get('type') == 'Recreate' + and not original.get('paused'), 'single_recreate_issuer_required') + current = container(original) + require(re.fullmatch(r'forgejo\.coulomb\.social/coulomb/key-cape@sha256:[0-9a-f]{64}', + current.get('image', '')) is not None, 'immutable_owner_image_required') + require(not current.get('envFrom'), 'explicit_env_bindings_required') + env = current.get('env', []) + require(isinstance(env, list) and all(isinstance(e, dict) and isinstance(e.get('name'), str) for e in env), + 'env_shape_invalid') + names = [e['name'] for e in env] + require(len(names) == len(set(names)), 'duplicate_env_name') + require([e for e in env if e['name'] == 'KEYCAPE_CONFIG'] == + [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], 'config_path_drift') + volumes = original['template']['spec'].get('volumes', []) + mounts = current.get('volumeMounts', []) + expected = [v['name'] for v in volumes if v.get('secret', {}).get('secretName') == 'keycape-config'] + require(len(expected) == 1 and any(m.get('name') == expected[0] and + m.get('mountPath') == '/etc/keycape' and m.get('readOnly') is True and + not m.get('subPath') and not m.get('subPathExpr') for m in mounts), 'registry_mount_drift') + encoded, created = registration.prepare_registry(secret, identities) + proposed_data = dict(secret['data'], **{'config.yaml': encoded}) + proposed_spec = copy.deepcopy(original) + proposed_env = container(proposed_spec).setdefault('env', []) + added_env = [] + records = registration.clients(identities) + for record in records[1:]: + binding = registration.registry.env_binding(record) + existing = [e for e in proposed_env if e['name'] == binding['name']] + require(not existing or existing == [binding], 'existing_binding_requires_reconciliation') + if not existing: + proposed_env.append(binding) + added_env.append(binding['name']) + return {'schema': 'netkingdom.playwright-activation.v1', 'source_revision': source_revision, + 'secret_identity': secret_meta, 'deployment_identity': deploy_meta, + 'before_data': copy.deepcopy(secret['data']), 'after_data': proposed_data, + 'before_spec': copy.deepcopy(original), 'after_spec': proposed_spec, + 'identity_inventory': copy.deepcopy(identities), 'created_client_ids': created, + 'created_env_names': added_env} + + +def summary(proposal): + changed = proposal['before_data'] != proposal['after_data'] or proposal['before_spec'] != proposal['after_spec'] + return {'schema': proposal['schema'], 'proposal_digest': digest(proposal), + 'source_revision': proposal['source_revision'], + 'created_client_ids': proposal['created_client_ids'], + 'created_env_names': proposal['created_env_names'], + 'change_needed': changed, 'maintenance_required': changed, + 'live_activated': False, 'custody_admitted': False} + + +def check_registry(proposal, binary, expected_binary_sha256): + """Run the exact admitted native verifier, no ambient env or raw diagnostics.""" + path = Path(binary) + require(path.is_file() and not path.is_symlink() and + hashlib.sha256(path.read_bytes()).hexdigest() == expected_binary_sha256, + 'native_verifier_digest_mismatch') + for stage in ('before', 'after'): + env_names = [e['name'] for e in container(proposal[stage + '_spec']).get('env', [])] + command = [str(path), 'check-config', '--config', '/dev/stdin', '--env-names', + ','.join(env_names) or '__NO_ENV_NAMES__'] + if 'KEYCAPE_POLICY_PATH' in env_names: + command.append('--policy-store') + try: + result = subprocess.run(command, input=base64.b64decode(proposal[stage + '_data']['config.yaml'], validate=True), + capture_output=True, timeout=60, env={'PATH': '/usr/bin:/bin'}) + report = json.loads(result.stdout) + valid = result.returncode == 0 and report.get('valid') is True and not report.get('errors') + except Exception: + valid = False + require(valid, 'native_registry_check_failed') + return {'original_registry_valid': True, 'proposed_registry_valid': True, + 'verifier_sha256': expected_binary_sha256} + + +def quiescent(deployment, pods): + """Caller supplies an unfiltered complete sso PodList from the same cluster.""" + status = deployment.get('status', {}) + require(status.get('observedGeneration') == deployment['metadata'].get('generation') + and deployment['spec'].get('replicas') == 0 and + all(status.get(k, 0) == 0 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']), + 'issuer_not_stopped') + require(pods.get('kind') == 'PodList' and not pods.get('metadata', {}).get('continue') + and isinstance(pods.get('items'), list), 'complete_namespace_pod_list_required') + # Terminating pods are still consumers. Do not infer quiescence from readiness. + for pod in pods['items']: + require(pod.get('metadata', {}).get('namespace') == 'sso', 'pod_inventory_namespace_mismatch') + pod_spec = pod.get('spec', {}) + uses_registry = any(v.get('secret', {}).get('secretName') == 'keycape-config' or + any(s.get('secret', {}).get('name') == 'keycape-config' + for s in v.get('projected', {}).get('sources', [])) + for v in pod_spec.get('volumes', [])) + for c in pod_spec.get('containers', []) + pod_spec.get('initContainers', []) + pod_spec.get('ephemeralContainers', []): + uses_registry = uses_registry or any(e.get('secretRef', {}).get('name') == 'keycape-config' + for e in c.get('envFrom', [])) or any( + e.get('valueFrom', {}).get('secretKeyRef', {}).get('name') == 'keycape-config' + for e in c.get('env', [])) + require(not uses_registry, 'registry_consumer_still_present') + + +def propose_step(proposal, approved_digest, phase, secret, deployment, pods): + """Return one private CAS patch or None for an exact no-op; never execute it. + + The caller must freshly read BOTH objects before EACH phase, verify every + response, and stop on unknown outcomes. The digest binds content, not authority. + Kubernetes has no cross-object transaction; exclusive maintenance is required. + """ + require(digest(proposal) == approved_digest, 'proposal_revision_changed') + secret_meta = target(secret, 'Secret', 'keycape-config') + deploy_meta = target(deployment, 'Deployment', 'keycape') + require(secret_meta['uid'] == proposal['secret_identity']['uid'] and + deploy_meta['uid'] == proposal['deployment_identity']['uid'], 'target_identity_changed') + old_data, new_data = proposal['before_data'], proposal['after_data'] + old_spec, new_spec = proposal['before_spec'], proposal['after_spec'] + data, current = secret['data'], deployment['spec'] + require(data == old_data or data == new_data, 'registry_drift_stop_without_stale_replay') + require(any(current == item for item in [old_spec, new_spec, stopped(old_spec), stopped(new_spec)]), + 'deployment_drift_stop_without_stale_replay') + require(phase in ('stop', 'registry', 'bindings', 'resume', 'rollback-stop', + 'rollback-registry', 'rollback-bindings', 'rollback-resume'), 'phase_invalid') + if phase.startswith('rollback-') and data == old_data and current == old_spec: + return None + if old_data == new_data and old_spec == new_spec: + require(data == old_data and current == old_spec, 'noop_state_changed') + return None + if phase == 'stop': + require(secret_meta == proposal['secret_identity'] and deploy_meta == proposal['deployment_identity'] + and data == old_data and current == old_spec, 'initial_revision_changed') + status = deployment.get('status', {}) + require(status.get('observedGeneration') == deployment['metadata'].get('generation') and + all(status.get(k) == 1 for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']), + 'initial_issuer_not_ready') + obj, field, wanted = deployment, 'spec', stopped(old_spec) + elif phase == 'rollback-stop': + obj, field, wanted = deployment, 'spec', stopped(current) + else: + quiescent(deployment, pods) + if phase == 'registry': + require(current == stopped(old_spec) and data == old_data, 'registry_phase_out_of_order') + obj, field, wanted = secret, 'data', new_data + elif phase == 'bindings': + require(current == stopped(old_spec) and data == new_data, 'bindings_phase_out_of_order') + obj, field, wanted = deployment, 'spec', stopped(new_spec) + elif phase == 'resume': + require(current == stopped(new_spec) and data == new_data, 'resume_phase_out_of_order') + obj, field, wanted = deployment, 'spec', new_spec + elif phase == 'rollback-registry': + obj, field, wanted = secret, 'data', old_data + elif phase == 'rollback-bindings': + require(data == old_data, 'restore_registry_before_bindings') + obj, field, wanted = deployment, 'spec', stopped(old_spec) + else: + require(data == old_data and current == stopped(old_spec), 'rollback_resume_out_of_order') + obj, field, wanted = deployment, 'spec', old_spec + if obj[field] == wanted: + return None + metadata = registration.registry.pin.safe_metadata(obj) + patch = [{'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, + {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, + {'op': 'test', 'path': '/' + field, 'value': copy.deepcopy(obj[field])}, + {'op': 'replace', 'path': '/' + field, 'value': copy.deepcopy(wanted)}] + return {'kind': obj['kind'], 'namespace': 'sso', 'name': obj['metadata']['name'], + 'uid': metadata['uid'], 'field': field, 'expected': copy.deepcopy(wanted), + 'proposal_digest': approved_digest, 'phase': phase, 'patch': patch} + + +def verify_response(operation, obj): + """Also use on a fresh GET after an acknowledged patch; failures stop the caller.""" + metadata = target(obj, operation['kind'], operation['name']) + require(metadata['uid'] == operation['uid'] and obj.get(operation['field']) == operation['expected'], + 'mutation_readback_mismatch') + return {'phase': operation['phase'], 'proposal_digest': operation['proposal_digest'], + 'uid': metadata['uid'], 'resource_version': metadata['resource_version'], 'readback_matches': True} diff --git a/sso-mfa/k8s/keycape/test_playwright_activation.py b/sso-mfa/k8s/keycape/test_playwright_activation.py new file mode 100644 index 0000000..33b7dfd --- /dev/null +++ b/sso-mfa/k8s/keycape/test_playwright_activation.py @@ -0,0 +1,221 @@ +import base64 +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import jsonpatch + +ROOT = Path(__file__).parent +def load(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) + return module +a = load('activation', 'playwright-activation.py') +f = load('registration_fixture', 'test_playwright_registration.py') +REVISION = '5dd74dfbdf3fd38adb1bfc1bdc1326181435f0e9' +IDENTITIES = [f.IDENTITY, dict(f.IDENTITY, agent_id='second', service_subject='service:second')] + + +def fixtures(): + secret, raw = f.fixture() + secret.update(kind='Secret', apiVersion='v1') + secret['metadata'].update(name='keycape-config', namespace='sso', uid='00000000-0000-4000-8000-000000000001') + deployment = {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': { + 'name': 'keycape', 'namespace': 'sso', 'uid': '00000000-0000-4000-8000-000000000002', + 'resourceVersion': '12', 'generation': 5}, 'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'}, + 'template': {'metadata': {'labels': {'app': 'keycape'}}, 'spec': { + 'containers': [{'name': 'keycape', 'image': 'forgejo.coulomb.social/coulomb/key-cape@sha256:' + 'a' * 64, + 'env': [{'name': 'KEYCAPE_CONFIG', 'value': '/etc/keycape/config.yaml'}], + 'volumeMounts': [{'name': 'registry', 'mountPath': '/etc/keycape', 'readOnly': True}]}], + 'volumes': [{'name': 'registry', 'secret': {'secretName': 'keycape-config'}}]}}}} + controller(deployment) + return secret, deployment + + +def controller(deployment): + # Simulates observed controller completion, separate from patch admission. + replicas = deployment['spec']['replicas'] + deployment['status'] = {'observedGeneration': deployment['metadata']['generation'], + **{k: replicas for k in ['replicas', 'readyReplicas', 'availableReplicas', 'updatedReplicas']}} + + +class World: + def __init__(self, secret, deployment): + self.secret, self.deployment = copy.deepcopy(secret), copy.deepcopy(deployment) + self.pods = {'kind': 'PodList', 'metadata': {}, 'items': []} + self.history = [] + + def step(self, proposal, phase, observe=True): + operation = a.propose_step(proposal, a.digest(proposal), phase, self.secret, self.deployment, self.pods) + if operation is None: return None + attr = 'secret' if operation['kind'] == 'Secret' else 'deployment' + # Independent RFC 6902 implementation verifies actual generated CAS tests. + updated = jsonpatch.apply_patch(getattr(self, attr), operation['patch'], in_place=False) + updated['metadata']['resourceVersion'] = str(int(updated['metadata']['resourceVersion']) + 1) + if attr == 'deployment': updated['metadata']['generation'] += 1 + self.history.append(phase) + setattr(self, attr, updated) + receipt = a.verify_response(operation, updated) + if attr == 'deployment' and observe: controller(updated) + return receipt + + +class ActivationTests(unittest.TestCase): + def setUp(self): + self.secret, self.deployment = fixtures() + self.proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION) + + def test_exact_sequence_keeps_original_source_and_returns_safe_receipts(self): + world = World(self.secret, self.deployment) + original = copy.deepcopy((self.secret, self.deployment)) + for phase in ['stop', 'registry', 'bindings', 'resume']: + receipt = world.step(self.proposal, phase) + self.assertTrue(receipt['readback_matches']) + self.assertNotIn('synthetic-private-marker', json.dumps(receipt)) + self.assertEqual(world.deployment['spec']['replicas'], 1) + self.assertEqual(world.secret['data'], self.proposal['after_data']) + self.assertEqual((self.secret, self.deployment), original) + self.assertNotIn('synthetic-private-marker', json.dumps(a.summary(self.proposal))) + + def test_rollback_preserves_preexisting_public_and_agent_registrations(self): + encoded, _ = a.registration.prepare_registry(self.secret, [IDENTITIES[0]]) + self.secret['data']['config.yaml'] = encoded + binding = a.registration.registry.env_binding(a.registration.agent_client(IDENTITIES[0])) + a.container(self.deployment['spec'])['env'].append(binding) + proposal = a.build(self.secret, self.deployment, IDENTITIES, REVISION) + self.assertEqual(proposal['created_client_ids'], ['playwright-agent-second']) + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings', 'resume', 'rollback-stop', + 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + world.step(proposal, phase) + self.assertEqual(world.secret['data'], self.secret['data']) + self.assertEqual(world.deployment['spec'], self.deployment['spec']) + + def test_each_partial_state_recovers_without_removing_original_entries(self): + phases = ['stop', 'registry', 'bindings', 'resume'] + for count in range(5): + world = World(self.secret, self.deployment) + for phase in phases[:count]: world.step(self.proposal, phase) + for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + world.step(self.proposal, phase) + self.assertEqual(world.secret['data'], self.secret['data']) + self.assertEqual(world.deployment['spec'], self.deployment['spec']) + + def test_identical_complete_registration_is_noop_without_outage(self): + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings', 'resume']: world.step(self.proposal, phase) + proposal = a.build(world.secret, world.deployment, IDENTITIES, REVISION) + self.assertFalse(a.summary(proposal)['change_needed']) + before = len(world.history) + for phase in ['stop', 'registry', 'bindings', 'resume']: self.assertIsNone(world.step(proposal, phase)) + self.assertEqual(len(world.history), before) + + def test_rollback_before_any_execution_never_stops_the_issuer(self): + world = World(self.secret, self.deployment) + for phase in ['rollback-stop', 'rollback-registry', 'rollback-bindings', 'rollback-resume']: + self.assertIsNone(world.step(self.proposal, phase)) + self.assertEqual(world.history, []) + + def test_reordered_phases_and_unobserved_controller_are_refused(self): + world = World(self.secret, self.deployment) + for phase in ['registry', 'bindings', 'resume']: + with self.subTest(phase=phase), self.assertRaises(a.Refused): world.step(self.proposal, phase) + world.step(self.proposal, 'stop', observe=False) + with self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + controller(world.deployment) + with self.assertRaises(a.Refused): world.step(self.proposal, 'resume') + + def test_terminating_direct_projected_and_env_consumers_prevent_registry_write(self): + specs = [{'volumes': [{'secret': {'secretName': 'keycape-config'}}]}, + {'volumes': [{'projected': {'sources': [{'secret': {'name': 'keycape-config'}}]}}]}, + {'containers': [{'envFrom': [{'secretRef': {'name': 'keycape-config'}}]}]}, + {'initContainers': [{'env': [{'valueFrom': {'secretKeyRef': {'name': 'keycape-config'}}}]}]}] + for spec in specs: + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + world.pods['items'] = [{'metadata': {'namespace': 'sso', 'deletionTimestamp': 'synthetic'}, 'spec': spec}] + with self.subTest(spec=spec), self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + + def test_partial_pod_inventory_refused(self): + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + world.pods['metadata']['continue'] = 'another-page' + with self.assertRaises(a.Refused): world.step(self.proposal, 'registry') + + def test_original_revision_and_proposal_digest_are_bound(self): + for attr in ['secret', 'deployment']: + world = World(self.secret, self.deployment) + getattr(world, attr)['metadata']['resourceVersion'] = '999' + with self.subTest(attr=attr), self.assertRaises(a.Refused): world.step(self.proposal, 'stop') + changed = copy.deepcopy(self.proposal); changed['after_data']['other'] = 'changed' + with self.assertRaises(a.Refused): + a.propose_step(changed, a.digest(self.proposal), 'stop', self.secret, self.deployment, {'kind': 'PodList', 'items': []}) + + def test_independent_jsonpatch_rejects_racing_version_or_spec(self): + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', + self.secret, self.deployment, {'kind': 'PodList', 'items': []}) + for field in ['version', 'spec']: + raced = copy.deepcopy(self.deployment) + if field == 'version': raced['metadata']['resourceVersion'] = '999' + else: raced['spec']['replicas'] = 2 + with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed): + jsonpatch.apply_patch(raced, operation['patch']) + + def test_private_registry_patch_checks_complete_data_and_uid(self): + world = World(self.secret, self.deployment); world.step(self.proposal, 'stop') + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'registry', world.secret, world.deployment, world.pods) + for field in ['data', 'uid']: + raced = copy.deepcopy(world.secret) + if field == 'data': raced['data']['other'] = 'racing-write' + else: raced['metadata']['uid'] = 'recreated' + with self.subTest(field=field), self.assertRaises(jsonpatch.JsonPatchTestFailed): + jsonpatch.apply_patch(raced, operation['patch']) + + def test_unrelated_registry_or_deployment_drift_refuses_rollback(self): + for field in ['data', 'spec']: + world = World(self.secret, self.deployment) + for phase in ['stop', 'registry', 'bindings']: world.step(self.proposal, phase) + if field == 'data': world.secret['data']['unrelated-new-field'] = 'keep' + else: world.deployment['spec']['template']['metadata']['labels']['new'] = 'keep' + with self.subTest(field=field), self.assertRaises(a.Refused): world.step(self.proposal, 'rollback-registry') + + def test_readback_mismatch_refused_without_raw_output(self): + operation = a.propose_step(self.proposal, a.digest(self.proposal), 'stop', self.secret, self.deployment, {}) + with self.assertRaisesRegex(a.Refused, '^mutation_readback_mismatch$'): + a.verify_response(operation, self.deployment) + + def test_unsupported_deployment_and_binding_drift_refused(self): + changes = [lambda d: d['spec'].update(replicas=2), + lambda d: d['spec']['strategy'].update(type='RollingUpdate'), + lambda d: a.container(d['spec']).update(image='unreviewed:latest'), + lambda d: a.container(d['spec']).update(envFrom=[{'secretRef': {'name': 'other'}}]), + lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_CONFIG', 'value': 'other'}), + lambda d: a.container(d['spec'])['env'].append({'name': 'KEYCAPE_PLAYWRIGHT_AGENT_TEST_AGENT_CLIENT_SECRET', 'value': 'synthetic-private-marker'})] + for change in changes: + deployment = copy.deepcopy(self.deployment); change(deployment) + with self.subTest(change=change), self.assertRaises(a.Refused): + a.build(self.secret, deployment, IDENTITIES, REVISION) + + def test_native_verifier_is_pinned_contained_and_ignores_ambient_environment(self): + with tempfile.TemporaryDirectory() as folder: + binary = Path(folder) / 'verifier'; binary.write_bytes(b'synthetic-binary') + sha = hashlib.sha256(binary.read_bytes()).hexdigest() + with patch.object(a.subprocess, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, b'{"valid": true, "errors": []}', b'') + self.assertTrue(a.check_registry(self.proposal, binary, sha)['proposed_registry_valid']) + self.assertEqual(run.call_count, 2) + self.assertEqual(run.call_args.kwargs['env'], {'PATH': '/usr/bin:/bin'}) + self.assertIn('synthetic-private-marker', run.call_args.kwargs['input'].decode()) + self.assertNotIn('synthetic-private-marker', str(run.call_args.args)) + run.return_value = subprocess.CompletedProcess([], 1, b'{"valid": false, "errors": ["synthetic-private-marker"]}', b'synthetic-private-marker') + with self.assertRaisesRegex(a.Refused, '^native_registry_check_failed$'): + a.check_registry(self.proposal, binary, sha) + with self.assertRaisesRegex(a.Refused, '^native_verifier_digest_mismatch$'): + a.check_registry(self.proposal, binary, 'wrong') + + +if __name__ == '__main__': unittest.main() diff --git a/workplans/NK-WP-0050-playwright-consumer-registration.md b/workplans/NK-WP-0050-playwright-consumer-registration.md index b91296e..2808f87 100644 --- a/workplans/NK-WP-0050-playwright-consumer-registration.md +++ b/workplans/NK-WP-0050-playwright-consumer-registration.md @@ -12,6 +12,7 @@ created: "2026-10-13" updated: "2026-10-13" depends_on: [] related: [CUST-WP-0080, RPF-WP-0056, RAPP-PLAYWRIGHT-WP-0004] +state_hub_workstream_id: "06ba98d5-609e-5aaa-9bf7-4c4f4460e377" --- Accept NetKingdom's concrete implementation request @@ -29,6 +30,7 @@ admin identity, kubeconfig, SSH identity or existing OpenBao credential. id: NK-WP-0050-T01 status: done priority: high +state_hub_task_id: "64893d94-9717-5812-b8e3-7cabe095fcdc" ``` `docs/playwright-registration.md` owns the bounded registration contract. @@ -52,9 +54,10 @@ and local fixtures are not issuer-owner or real consumer acceptance. ```task id: NK-WP-0050-T02 -status: todo +status: progress priority: high depends_on: [NK-WP-0050-T01] +state_hub_task_id: "a9dadd07-f136-5dc7-9229-7a9dbb941f0d" ``` Resolve the exact real agent records and Platform custody/delivery source task; @@ -65,6 +68,19 @@ action before any needed privileged approval. Source work can proceed while owner acceptance is collected; custody and live grants must not be invented. Neither compiler output nor another client's acceptance authorizes live apply. +2026-10-13: private activation patch preparation implemented in +`playwright-activation.py`. Original/candidate registry and Deployment state bind +an exact proposal digest. Ordered stop/configure/resume avoids a registry/env +startup race in the single-Recreate issuer. Native verifier digest checks, fresh +object UID/resource-version/data/spec conditions, complete pod-quiescence checks, +readback and exact original-state rollback retain pre-existing registrations and +bindings. No mutation occurs from this module; the protected executor owns fresh +reads, admitted custody, actual approval and live acceptance. Source tests cover +partial recovery and independent RFC 6902 patch application; native KeyCape +check-config accepts both synthetic configurations. Real identity/custody owner +returns, executor/recovery-state integration, exact current image verification +and the supervised maintenance action remain open. + ## Verify issuer and real consumer acceptance ```task @@ -73,6 +89,7 @@ status: wait priority: high depends_on: [NK-WP-0050-T02, RPF-WP-0056-T03] blocking_reason: "Await owner-scoped registry/custody activation, native cluster authentication and individual transport, then real human and agent acceptance under CUST-WP-0080-T05." +state_hub_task_id: "5a3aaf57-de5c-5a03-bc99-9bcf2fc5d95e" ``` Retain real human/agent issuance and identity/tenant/audience/scope/expiry proof,