security: bind Playwright activation and recovery to exact issuer state
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
5dd74dfbdf
commit
eba6e1fdbb
4 changed files with 536 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
230
sso-mfa/k8s/keycape/playwright-activation.py
Normal file
230
sso-mfa/k8s/keycape/playwright-activation.py
Normal file
|
|
@ -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}
|
||||
221
sso-mfa/k8s/keycape/test_playwright_activation.py
Normal file
221
sso-mfa/k8s/keycape/test_playwright_activation.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue