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
261 lines
13 KiB
Python
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()
|