net-kingdom/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py
tegwick fad2c72750 NK-WP-0048-T12: register-client restarts the issuer and waits for readiness after a changed registry
Per coordinator r6. New register-client descriptor sha256:434c53e8...43cb;
1.2/1.3 compositions regenerated; 1.3 map and status-only ready_check updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 696317@bnt-lap001
Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
2026-10-23 07:06:48 +02:00

419 lines
22 KiB
Python

"""Actual helper/CAS execution against synthetic Kubernetes objects only."""
from dataclasses import asdict, replace
import copy
import importlib.util
import json
import os
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)
ISSUER = 'https://kc.coulomb.social'
class FakeIssuer:
"""Single-replica Recreate issuer; becomes Ready once a restart stamp is written."""
def __init__(self, outcome='ready'):
self.outcome, self.writes, self.sleeps = outcome, [], []
self.deployment = {'kind': 'Deployment',
'metadata': {'name': 'keycape', 'namespace': 'sso', 'resourceVersion': '40', 'generation': 3},
'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
'template': {'spec': {'containers': [{'name': 'keycape'}]}}},
'status': {'observedGeneration': 3, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1}}
def read(self):
return copy.deepcopy(self.deployment)
def write(self, deployment, expected):
assert expected == self.deployment['metadata']['resourceVersion']
self.writes.append(copy.deepcopy(deployment))
self.deployment = copy.deepcopy(deployment)
self.deployment['metadata']['resourceVersion'] = str(int(expected) + 1)
self.deployment['metadata']['generation'] += 1
self.deployment['status']['observedGeneration'] += 1
return self.deployment['metadata']['resourceVersion']
def observe(self):
stamp = self.deployment['spec']['template'].get('metadata', {}).get('annotations', {}).get(
a.restart_readiness.ANNOTATION)
state = {'waiting': {'reason': 'CrashLoopBackOff'}} if self.outcome == 'crash' else {'running': {}}
ready = 'False' if self.outcome in ('crash', 'stuck') else 'True'
pod = {'metadata': {'annotations': {a.restart_readiness.ANNOTATION: stamp}},
'status': {'phase': 'Running', 'conditions': [{'type': 'Ready', 'status': ready}],
'containerStatuses': [{'name': 'keycape', 'state': state}]}}
discovery = {'issuer': ISSUER, 'authorization_endpoint': ISSUER + '/authorize',
'token_endpoint': ISSUER + '/token', 'jwks_uri': ISSUER + '/jwks'}
return copy.deepcopy(self.deployment), [pod], discovery if ready == 'True' else None
def capabilities(self):
return {'read': self.read, 'write': self.write, 'observe': self.observe,
'sleep': self.sleeps.append, 'issuer': ISSUER}
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', 'keycape-attended-case-change', 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, restart=None)
self.issuer = FakeIssuer()
self.kw['restart'] = self.issuer.capabilities()
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_original_reconcile_capabilities_are_optional_but_paired(self):
self.assertIsNone(a.build(**self.kw).reconcile_original)
for extra in ({'holder': lambda attempt: []}, {'observe': lambda: {}},
{'holder': 'cmd', 'observe': lambda: {}}):
with self.subTest(extra=sorted(extra)), self.assertRaisesRegex(Refused, 'together'):
a.build(**dict(self.kw, **extra))
adapter = a.build(**dict(self.kw, holder=lambda attempt: [], observe=lambda: {}))
self.assertTrue(callable(adapter.reconcile_original))
def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
adapter = a.build(**self.kw)
original = copy.deepcopy(self.secret['data'])
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_changed_registry_restarts_issuer_and_records_ready_verdict(self):
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.assertEqual(result.outcome, Outcome.SUCCESS)
self.assertEqual(len(self.issuer.writes), 1)
payload = json.loads(self.evidence[result.evidence])
self.assertEqual(payload['issuer_restart']['verdict'], 'ready')
self.assertEqual(payload['issuer_restart']['before'], '40')
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
self.assertEqual(len(self.issuer.writes), 2)
def test_no_change_does_not_restart_issuer(self):
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
result = a.build(**self.kw).invoke(self.inv)
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
self.assertFalse(self.issuer.writes)
self.assertIsNone(json.loads(self.evidence[result.evidence])['issuer_restart'])
def test_failed_or_stuck_restart_is_uncertain_and_revokes_session(self):
for outcome in ('crash', 'stuck'):
with self.subTest(outcome=outcome):
self.setUp()
self.issuer = FakeIssuer(outcome)
adapter = a.build(**dict(self.kw, restart=self.issuer.capabilities()))
with self.assertRaises(Refused):
adapter.invoke(self.inv)
self.assertEqual(len(self.issuer.writes), 1)
self.assertFalse(self.evidence)
self.assertEqual(self.revocations, [self.inv])
if outcome == 'stuck':
self.assertEqual(len(self.issuer.sleeps), a.RESTART_ATTEMPTS - 1)
def test_restart_capabilities_are_required_and_bounded(self):
caps = self.issuer.capabilities()
for bad in (None, {}, dict(caps, issuer='http://kc.coulomb.social'),
dict(caps, write='not-callable'), dict(caps, extra=print)):
with self.subTest(bad=bad if not isinstance(bad, dict) else sorted(bad)):
with self.assertRaisesRegex(Refused, 'restart'):
a.build(**dict(self.kw, restart=bad))
def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self):
symlink = self.binary.with_name('symlink')
symlink.symlink_to(self.binary)
fifo = self.binary.with_name('fifo')
os.mkfifo(fifo)
oversized = self.binary.with_name('oversized')
with oversized.open('wb') as output:
output.truncate(64 * 1024 * 1024 + 1)
for source in (symlink, fifo, oversized):
with self.subTest(source=source.name), patch.object(a.helper, 'run') as run:
adapter = a.build(**dict(self.kw, check_config=source))
with self.assertRaises(Refused):
adapter.invoke(self.inv)
run.assert_not_called()
self.assertEqual(self.revocations, [self.inv] * 3)
self.assertFalse(self.effects)
self.assertFalse(self.evidence)
def test_snapshot_unavailable_refuses_before_tooling_and_revokes_session(self):
adapter = a.build(**self.kw)
with patch.object(a.os, 'memfd_create', side_effect=OSError('synthetic policy denial')):
with self.assertRaises(Refused):
adapter.invoke(self.inv)
self.assertFalse(self.effects)
self.assertFalse(self.evidence)
self.assertEqual(self.revocations, [self.inv])
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)}
# Fixture-only successor lane for the adapter's current descriptor.
# Canonical 1.1 still names the earlier login lane and is not edited.
typ['spec']['follow_through'] = [
dict(step, lane=self.admitted.lane) if step['id'] == 'register-client' else step
for step in typ['spec']['follow_through']]
type_source = yaml.safe_dump(typ).encode()
catalog = (ROOT / 'kernel-catalog.fixture.yaml').read_bytes()
sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human',
'founder', 'keycape-attended-case-change', '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 == 'catalog-routing-acceptance':
return source == digest(catalog) and evidence == 'fixture-descriptor'
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()