net-kingdom/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py
tegwick cbadd359ba NK-WP-0048 r5 adapters: OW-1.3-C1 attended change lane, RAPPS-1.3-C1 human actor, RAPPS-1.3-C2 handover observations
Lane key-cape-oidc-login -> keycape-attended-case-change in the register-client,
ensure-groups and bind-env descriptors; deliver-consumer-secret actor agent ->
human; verify_consumer reports a value-free, exactly-once probe record list bound
into the observation digest. Composition artifacts, type.yaml and admit/compose
tests are left for the orchestrator to regenerate.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
2026-10-17 11:59:14 +02:00

261 lines
13 KiB
Python

"""Ensure-groups adapter against synthetic journal and provisioner fakes only.
No SQL connection, LLDAP session or native action happens here; the fake
connector raises if anything tries to open it.
"""
from dataclasses import asdict, replace
import importlib.util
import json
from pathlib import Path
import unittest
from unittest.mock import patch
import uuid
from warden.case_run import Descriptor, Invocation, digest
from warden.case_receipt_state import Outcome, Pins, Refused
import yaml
ROOT = Path(__file__).parent
spec = importlib.util.spec_from_file_location('ensure_groups_adapter', ROOT / 'ensure-groups-adapter.py')
a = importlib.util.module_from_spec(spec)
spec.loader.exec_module(a)
from group_provenance_postgres import GroupJournalBinding, PostgresGroupProvenance # noqa: E402
from group_provenance import GroupProvenance # noqa: E402
from provisioner import GroupResult, LLDAPProvisioner # noqa: E402
class Continuity:
def check(self, *args):
return True
def advance(self, *args):
return True
def no_connect():
raise AssertionError('no SQL connection in source tests')
class FakeProvisioner:
_group_binding = staticmethod(LLDAPProvisioner._group_binding)
def __init__(self, journal):
self.group_provenance = journal
self.calls = []
self.state = None
self.created_groups = ({'name': 'forgejo-users', 'id': 7},)
self.created_memberships = ({'group': 'forgejo-users', 'user': 'alice'},)
def _result(self, payload, status, resumed):
return GroupResult('netkingdom-lldap', ','.join(sorted(payload['groups'])), status, resumed,
payload.get('ensure_idempotency_key', payload['idempotency_key']),
payload.get('ensure_correlation_id', payload['correlation_id']),
self.created_groups, self.created_memberships)
def ensure_service_groups(self, payload):
self.calls.append(('ensure', payload))
resumed = self.state == 'ensured'
self.state = 'ensured'
return self._result(payload, 'groups_ensured', resumed)
def revoke_service_groups(self, payload):
self.calls.append(('revoke', payload))
if self.state != 'ensured':
raise RuntimeError('group provenance unresolved')
self.state = 'revoked'
return self._result(payload, 'groups_revoked', False)
class EnsureGroupsTests(unittest.TestCase):
def setUp(self):
self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1', 'parameters': {
'client_id': 'forgejo', 'access_groups': 'forgejo-users,forgejo-admins',
'initial_members': 'forgejo-users=alice'}}}).encode()
desc_bytes = json.dumps(asdict(a.descriptor()), sort_keys=True, separators=(',', ':')).encode()
pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes),
digest(b'catalog'), digest(b'acceptance'), digest(b'run sheet'))
self.admitted = Invocation('ensure-groups', 'net-kingdom', 'forgejo', 'human',
'founder', 'keycape-attended-case-change', pins)
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
self.journal = self.make_journal()
self.prov = FakeProvisioner(self.journal)
self.evidence, self.revoked = {}, []
self.kw = dict(admitted=self.admitted, case_source=self.case, provisioner=self.prov,
publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p,
revoke_session=self.revoke)
get = patch.object(PostgresGroupProvenance, 'get', lambda s, k, b: {'state': self.prov.state})
get.start()
self.addCleanup(get.stop)
def make_journal(self, **override):
values = dict(tenant_scope='platform', directory='netkingdom-lldap',
case_digest=digest(self.case), source_digest=a.source_digest(),
principal='founder', epoch=str(uuid.uuid4()))
values.update(override)
return PostgresGroupProvenance(connect=no_connect, binding=GroupJournalBinding(**values),
continuity=Continuity())
def revoke(self, inv):
self.revoked.append(inv)
return 'session-revocation:' + inv.attempt_id
def publish(self, payload):
ref = 'owner-evidence:' + str(len(self.evidence))
self.evidence[ref] = payload
return ref
def test_run_creates_verifies_and_rolls_back_only_creations(self):
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.assertEqual(result.outcome, Outcome.SUCCESS)
self.assertEqual(result.created, ('service-group', 'service-group-membership'))
self.assertTrue(adapter.verify(self.inv, result))
ensure = self.prov.calls[0][1]
self.assertEqual(ensure['idempotency_key'], 'warden-ensure:' + digest(self.case))
self.assertEqual(ensure['members'], {'forgejo-users': ['alice']})
rb_inv = replace(self.inv, attempt_id='attempt-two', operation='rollback')
rolled = adapter.rollback(rb_inv, result.created)
self.assertEqual(rolled.outcome, Outcome.ROLLED_BACK)
revoke = self.prov.calls[1][1]
self.assertEqual(revoke['ensure_idempotency_key'], ensure['idempotency_key'])
self.assertIs(revoke['delete_if_empty'], False)
self.assertTrue(adapter.verify(rb_inv, rolled))
self.assertFalse(adapter.verify(self.inv, result)) # journal now says revoked
self.assertEqual(self.revoked, [self.inv, rb_inv])
for res, inv in ((result, self.inv), (rolled, rb_inv)):
self.assertIs(res.revoked, True)
self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id)
self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence])
def test_descriptor_decodes_in_warden_kernel(self):
desc = a.descriptor()
self.assertEqual(desc.revocation_scopes, ('parent-session',))
raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
self.assertEqual(Descriptor.decode(raw), desc)
def test_parent_session_revoked_even_when_attempt_fails(self):
def boom(payload):
raise RuntimeError('owner journal unavailable')
self.prov.ensure_service_groups = boom
adapter = a.build(**self.kw)
with self.assertRaises(Refused): # contained as 'owner callback failed'
adapter.invoke(self.inv)
rb_inv = replace(self.inv, attempt_id='attempt-two', operation='rollback')
with self.assertRaises(Refused):
adapter.rollback(rb_inv, ('foreign-object',))
self.assertEqual(self.revoked, [self.inv, rb_inv])
self.assertEqual(self.evidence, {})
def test_missing_revocation_evidence_refused_before_publication(self):
for bad in (None, '', 'bad id', 7):
with self.subTest(bad=bad), self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv)
self.assertEqual(self.evidence, {})
with self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': None})
def test_replay_resumes_without_creation(self):
adapter = a.build(**self.kw)
adapter.invoke(self.inv)
again = adapter.invoke(replace(self.inv, attempt_id='attempt-two'))
self.assertEqual((again.outcome, again.created), (Outcome.NO_CHANGE, ()))
def test_refuses_untrusted_or_differing_journal(self):
for journal in (None, GroupProvenance.__new__(GroupProvenance),
self.make_journal(case_digest=digest(b'other')),
self.make_journal(principal='someone-else'),
self.make_journal(source_digest=digest(b'other source'))):
self.prov.group_provenance = journal
with self.assertRaises(Refused):
a.build(**self.kw)
def test_refuses_case_and_invocation_drift(self):
bad = [dict(case_source=self.case + b'\n'),
dict(admitted=replace(self.admitted, target='gitea')),
dict(admitted=replace(self.admitted, step='mint-secret')),
dict(publish=None),
dict(delete_created_if_empty='yes')]
for override in bad:
with self.assertRaises(Refused):
a.build(**{**self.kw, **override})
def test_refuses_non_canonical_or_malformed_case(self):
for params, kind in (({'client_id': 'forgejo', 'access_groups': 'g'}, 'oidc-browser-relying-party@1.2'),
({'client_id': 'forgejo', 'access_groups': 'g', 'initial_members': 'h=alice'}, None),
({'client_id': 'forgejo'}, None)):
case = yaml.safe_dump({'spec': {'type': kind or a.CASE_TYPE, 'parameters': params}}).encode()
pins = replace(self.admitted.pins, case=digest(case))
self.prov.group_provenance = self.make_journal(case_digest=digest(case))
with self.assertRaises(Refused):
a.build(**{**self.kw, 'case_source': case, 'admitted': replace(self.admitted, pins=pins)})
def _rebind(self, kind):
case = yaml.safe_dump({'spec': {'type': kind, 'parameters': {
'client_id': 'forgejo', 'access_groups': 'forgejo-users,forgejo-admins',
'initial_members': 'forgejo-users=alice'}}}).encode()
self.prov.group_provenance = self.make_journal(case_digest=digest(case))
return {**self.kw, 'case_source': case,
'admitted': replace(self.admitted, pins=replace(self.admitted.pins, case=digest(case)))}
def test_explicit_version_root_selects_1_2(self):
kw = self._rebind('oidc-browser-relying-party@1.2')
with self.assertRaises(Refused):
a.build(**kw) # default stays canonical 1.1
adapter = a.build(**kw, version_root='1.2')
result = adapter.invoke(replace(kw['admitted'], attempt_id='attempt-one', operation='run'))
self.assertEqual(result.outcome, Outcome.SUCCESS)
self.assertEqual(self.prov.calls[0][1]['members'], {'forgejo-users': ['alice']})
def test_explicit_version_root_selects_1_3(self):
kw = self._rebind('oidc-browser-relying-party@1.3')
for root in (None, '1.2'): # default 1.1 and a different successor root refuse
with self.assertRaises(Refused):
a.build(**kw) if root is None else a.build(**kw, version_root=root)
adapter = a.build(**kw, version_root='1.3')
result = adapter.invoke(replace(kw['admitted'], attempt_id='attempt-one', operation='run'))
self.assertEqual(result.outcome, Outcome.SUCCESS)
def test_version_root_must_match_case_type_and_be_known(self):
with self.assertRaises(Refused):
a.build(**self.kw, version_root='1.2') # 1.1 case under 1.2 root
for root in ('2.0', '', None, 1.2):
with self.assertRaises(Refused):
a.build(**self.kw, version_root=root)
def test_public_group_binding_matches_provisioner(self):
groups, members = ['b-group', 'a-group'], {'a-group': ['bob', 'alice']}
self.assertEqual(a.group_binding(groups, members, 'corr'),
LLDAPProvisioner._group_binding(groups, members, 'corr'))
def test_rollback_refuses_unconfirmed_or_foreign_objects(self):
adapter = a.build(**self.kw)
rb_inv = replace(self.inv, operation='rollback')
for created in ((), ('keycape-client',)):
with self.assertRaises(Refused):
adapter.rollback(rb_inv, created)
with self.assertRaises(Refused): # journal has no ensured receipt (contained owner failure)
adapter.rollback(rb_inv, ('service-group',))
self.assertEqual([c[0] for c in self.prov.calls], ['revoke'])
def test_attempt_binding_and_contract_drift(self):
adapter = a.build(**self.kw)
with self.assertRaises(Refused):
adapter.invoke(replace(self.inv, principal='other'))
with self.assertRaises(Refused):
adapter.invoke(replace(self.inv, operation='rollback'))
self.prov.ensure_service_groups = lambda p: GroupResult(
'netkingdom-lldap', 'x', 'groups_ensured', False, 'k', 'c', (), ())
with self.assertRaises(Refused):
adapter.invoke(self.inv)
def test_verify_fails_closed_on_journal_or_evidence_failure(self):
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.evidence.clear()
self.assertFalse(adapter.verify(self.inv, result))
with patch.object(PostgresGroupProvenance, 'get', side_effect=RuntimeError):
self.assertFalse(adapter.verify(self.inv, result))
if __name__ == '__main__':
unittest.main()