diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.md b/sso-mfa/k8s/keycape/ensure-groups-adapter.md new file mode 100644 index 0000000..0453440 --- /dev/null +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.md @@ -0,0 +1,47 @@ +# Ensure-groups adapter source candidate (NK-WP-0048-T24) + +Source candidate only, modelled on the T13 registration adapter. It is +unexercised against any live system and is not pinned, admitted or reviewed. + +## Binding + +- `build()` takes an admitted `ensure-groups` invocation, the exact case bytes, + an injected provisioner, and owner `publish`/`authenticate` evidence + capabilities. +- Group creation binds to the T14/T21 journal seam. The provisioner's + `group_provenance` must be a `PostgresGroupProvenance`. Its `GroupJournalBinding` + must name this case pin, the reviewed adapter source digest and the admitted + principal. A missing journal, a local SQLite journal or a differing binding is + refused before any call. +- The case must be canonical `oidc-browser-relying-party@1.1`. `access_groups` + and `initial_members` are parsed exactly, and members outside `access_groups` + are refused. +- The idempotency key and correlation id are derived from the case pin, so + every attempt resumes the same journal receipt. A replay returns `no_change` + and never recreates removed state. +- The source digest pins the adapter plus `provisioner.py`, + `group_provenance.py` and `group_provenance_postgres.py`. + +## Rollback + +Rollback calls `revoke_service_groups` against the original journal receipt. +It accepts only `service-group`/`service-group-membership` created objects. +The journal, not the caller, decides what was created, and preexisting or +shared state is preserved. `delete_created_if_empty` defaults to `false`. +Enabling it requires an owner fact that no other case uses the groups. This +adapter does not supply that fact. + +`verify` requires the authenticated owner evidence and a journal readback +state (`ensured` / `revoked`) matching the outcome. It fails closed on any +journal error. + +## Not supplied here + +- NetKingdom (directory) owner source review before pinning. +- Native CNPG journal admission, trusted continuity/checkpoint callbacks and + protected `GroupJournalBinding` construction (T14). +- Writer isolation or CAS against external LDAP writers. +- Any native action. + +Tests use synthetic fakes only (no SQL, no LLDAP): +`PYTHONPATH=~/ops-warden/src ~/ops-warden/.venv/bin/python -m pytest sso-mfa/k8s/keycape/test_ensure_groups_adapter.py` diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.py b/sso-mfa/k8s/keycape/ensure-groups-adapter.py new file mode 100644 index 0000000..e19124f --- /dev/null +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.py @@ -0,0 +1,183 @@ +"""Unexercised, injection-only ensure-groups seam for Warden's v1 kernel (T24). + +Source candidate only. No CLI, credentials, LLDAP session, SQL connection or +evidence trust root is installed here. Group creation is bound to T14's +journal/provenance seam: the injected provisioner must carry a +PostgresGroupProvenance whose GroupJournalBinding names this exact case, +principal and reviewed adapter source, with independent continuity callbacks. +SQLite/local journals, a missing journal or a differing binding are refused. +Owner source review and every native action remain separate gates. +""" +from __future__ import annotations + +from dataclasses import asdict, replace +import json +import sys +from pathlib import Path + +import yaml + +from warden.case_run import Adapter, Descriptor, Invocation, OwnerResult, contained_output, digest, IDENTIFIER +from warden.case_receipt_state import Outcome, Refused + +ROOT = Path(__file__).resolve().parent +PROVISIONER = ROOT.parents[2] / 'identity-provisioner' +SOURCE_FILES = (('sso-mfa/k8s/keycape', 'ensure-groups-adapter.py'), + ('identity-provisioner', 'provisioner.py'), + ('identity-provisioner', 'group_provenance.py'), + ('identity-provisioner', 'group_provenance_postgres.py')) +REFERENCE = 'net-kingdom/ensure-groups@v1' +CASE_TYPE = 'oidc-browser-relying-party@1.1' # canonical 1.1 stays the default +CREATED = ('service-group', 'service-group-membership') + +if str(PROVISIONER) not in sys.path: + sys.path.insert(0, str(PROVISIONER)) +from group_provenance_postgres import PostgresGroupProvenance # noqa: E402 (no I/O on import) + + +def source_digest(): + """Pin adapter plus the provisioner/journal import closure, in path order.""" + base = ROOT.parents[2] + files = {f'{d}/{n}': digest((base / d / n).read_bytes()) for d, n in SOURCE_FILES} + return digest(json.dumps({'files': files}, sort_keys=True, separators=(',', ':')).encode()) + + +def descriptor(): + return Descriptor('warden.case.execution.v1', 'ensure-groups', 'net-kingdom', REFERENCE, + source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change', + CREATED, False, ()) + + +def _request(case): + spec = case.get('spec') if isinstance(case, dict) else None + if not isinstance(spec, dict) or spec.get('type') != CASE_TYPE: + raise Refused('canonical oidc-browser-relying-party@1.1 case required') + params = spec.get('parameters') + if not isinstance(params, dict) or not isinstance(params.get('access_groups'), str): + raise Refused('access_groups required') + groups = params['access_groups'].split(',') + if len(set(groups)) != len(groups): + raise Refused('duplicate access group') + members = {} + raw = params.get('initial_members', '') + if not isinstance(raw, str): + raise Refused('initial_members must be a string') + for entry in filter(None, raw.split(';')): + group, _, users = entry.partition('=') + if group not in groups or group in members or not users: + raise Refused('initial_members names a group outside access_groups') + members[group] = users.split('+') + return params.get('client_id'), groups, members + + +def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate, + delete_created_if_empty: bool = False): + """Bind one admitted case to the owner journal; return a closed kernel Adapter. + + provisioner exposes ensure_service_groups/revoke_service_groups and carries + the trusted owner journal as group_provenance. publish(bytes)->identifier and + authenticate(identifier, bytes)->True are owner evidence capabilities. + + The idempotency key and correlation derive from the case pin, so every + attempt resumes the one journal receipt and never recreates removed state. + Rollback reverses only journal-confirmed creations; preexisting/shared + groups and memberships are preserved. Ambiguity propagates as uncertain. + """ + reviewed = source_digest() + desc = descriptor() + if type(admitted) is not Invocation or not all( + isinstance(v, str) and IDENTIFIER.fullmatch(v) + for v in (admitted.step, admitted.owner, admitted.target, + admitted.actor, admitted.principal, admitted.lane)): + raise Refused('bounded invocation required') + if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != ( + desc.step, desc.owner, desc.actor, desc.lane): + raise Refused('ensure-groups invocation boundary differs') + if admitted.operation != 'admission' or admitted.attempt_id: + raise Refused('expected admission invocation') + if type(case_source) is not bytes or len(case_source) > 262144 or digest(case_source) != admitted.pins.case: + raise Refused('case source pin differs') + descriptor_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + if admitted.pins.adapter != digest(descriptor_bytes): + raise Refused('descriptor pin differs') + journal = getattr(provisioner, 'group_provenance', None) + if type(journal) is not PostgresGroupProvenance: + raise Refused('trusted owner journal callbacks required') + binding = journal.binding + if (binding.case_digest, binding.source_digest, binding.principal) != ( + admitted.pins.case, reviewed, admitted.principal): + raise Refused('owner journal binding differs from this case') + if not all(callable(getattr(provisioner, m, None)) for m in ('ensure_service_groups', 'revoke_service_groups')): + raise Refused('owner group operations required') + if not all(callable(x) for x in (publish, authenticate)) or type(delete_created_if_empty) is not bool: + raise Refused('owner evidence capabilities required') + with contained_output(): + client_id, groups, members = _request(yaml.safe_load(case_source)) + if client_id != admitted.target: + raise Refused('case target differs') + key = 'warden-ensure:' + admitted.pins.case + request = {'groups': groups, 'members': members, 'correlation_id': admitted.pins.case} + journal_binding = provisioner._group_binding(groups, members, admitted.pins.case) + records = {} + + def check_attempt(inv, operation): + if (replace(inv, attempt_id='', operation='admission') != admitted + or inv.operation != operation or not IDENTIFIER.fullmatch(inv.attempt_id)): + raise Refused('attempt binding differs') + if source_digest() != reviewed: + raise Refused('owner source changed') + + def execute(inv, rollback, created=()): + with contained_output(): + check_attempt(inv, 'rollback' if rollback else 'run') + if rollback: + if not created or set(created) - set(CREATED): + raise Refused('only journal-confirmed creations may be rolled back') + result = provisioner.revoke_service_groups(dict( + request, idempotency_key=key + ':revoke', ensure_idempotency_key=key, + ensure_correlation_id=admitted.pins.case, delete_if_empty=delete_created_if_empty)) + expected = {'groups_revoked', 'groups_revoked_and_deleted'} + else: + result = provisioner.ensure_service_groups(dict(request, idempotency_key=key)) + expected = {'groups_ensured'} + if (result.status not in expected or result.ensure_idempotency_key != key + or result.ensure_correlation_id != admitted.pins.case + or result.external_subject != ','.join(sorted(groups))): + raise Refused('owner journal result contract differs') + objects = tuple(name for name, items in zip(CREATED, (result.created_groups, result.created_memberships)) if items) + if rollback: + outcome = Outcome.ROLLED_BACK + elif result.resumed or not objects: + outcome, objects = Outcome.NO_CHANGE, () + else: + outcome = Outcome.SUCCESS + # Names and ids only: the journal holds no secret values. + payload = json.dumps({'schema': 'netkingdom.ensure-groups.attempt.v1', + 'invocation': asdict(inv), 'source': reviewed, 'outcome': outcome.value, + 'created': objects, 'journal_key': key, 'status': result.status, + 'created_groups': list(result.created_groups), + 'created_memberships': list(result.created_memberships)}, + sort_keys=True, separators=(',', ':')).encode() + with contained_output(): + evidence = publish(payload) + if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records: + raise Refused('owner evidence identifier invalid or reused') + owner = OwnerResult(outcome, evidence, objects, rollback, + attempt_id=inv.attempt_id, operation=inv.operation) + records[evidence] = (inv, payload, owner) + return owner + + def verify(inv, result): + record = records.get(result.evidence) if type(result) is OwnerResult else None + if record is None or record[0] != inv or record[2] != result: + return False + with contained_output(): + try: + state = journal.get(key, journal_binding)['state'] + except Exception: + return False + want = 'revoked' if result.outcome is Outcome.ROLLED_BACK else 'ensured' + return state == want and authenticate(result.evidence, record[1]) is True + + return Adapter(REFERENCE, reviewed, lambda inv: execute(inv, False), verify, + lambda inv, created: execute(inv, True, created)) diff --git a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py new file mode 100644 index 0000000..d6105c3 --- /dev/null +++ b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py @@ -0,0 +1,186 @@ +"""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 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', 'key-cape-oidc-login', 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.kw = dict(admitted=self.admitted, case_source=self.case, provisioner=self.prov, + publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p) + 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 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 + + 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 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()