diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.md b/sso-mfa/k8s/keycape/ensure-groups-adapter.md index 0453440..cdd5ba8 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.md +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.md @@ -13,9 +13,19 @@ unexercised against any live system and is not pinned, admitted or reviewed. 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. +- By default the case must be canonical `oidc-browser-relying-party@1.1`. + `oidc-browser-relying-party@1.2` is accepted only when `build` is called with + an explicit `version_root='1.2'` (as in `handover-adapter.py`); an unknown root + or a case type that differs from the selected root is refused. The 1.2 + revision leaves the ensure-groups step and its `access_groups` / + `initial_members` parameters unchanged, so both roots share one parser. + `access_groups` and `initial_members` are parsed exactly, and members outside + `access_groups` are refused. This is a source path only; it admits nothing. +- The journal binding is computed by the adapter's public `group_binding`, + which a test pins byte-identical to the provisioner's private + `LLDAPProvisioner._group_binding`; the adapter no longer calls the private + method. Promoting it to a public provisioner API is an identity-provisioner + owner change, not made here. - 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. diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.py b/sso-mfa/k8s/keycape/ensure-groups-adapter.py index e19124f..ebbee9a 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.py +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.py @@ -11,6 +11,7 @@ Owner source review and every native action remain separate gates. from __future__ import annotations from dataclasses import asdict, replace +import hashlib import json import sys from pathlib import Path @@ -28,6 +29,10 @@ SOURCE_FILES = (('sso-mfa/k8s/keycape', 'ensure-groups-adapter.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 +# 1.2 is reachable only by explicit version-root selection, never by default. +# Its ensure-groups step and access_groups/initial_members parameters are +# unchanged from 1.1, so the same request parsing applies to both. +SELECTABLE_TYPES = {'1.1': CASE_TYPE, '1.2': 'oidc-browser-relying-party@1.2'} CREATED = ('service-group', 'service-group-membership') if str(PROVISIONER) not in sys.path: @@ -48,10 +53,22 @@ def descriptor(): CREATED, False, ()) -def _request(case): +def group_binding(groups, members, correlation_id) -> str: + """Public form of the provisioner's exact ensure binding (journal key binding). + + Must stay byte-identical to identity-provisioner's Provisioner._group_binding; + the test suite pins that equality so drift fails closed in review. + """ + canonical = {'ensure_correlation_id': correlation_id, 'groups': sorted(groups), + 'members': {g: sorted(members.get(g, [])) for g in sorted(groups)}} + return hashlib.sha256(json.dumps(canonical, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def _request(case, version_root='1.1'): 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') + expected = SELECTABLE_TYPES.get(version_root) if isinstance(version_root, str) else None + if expected is None or not isinstance(spec, dict) or spec.get('type') != expected: + raise Refused('case type differs from selected version root') params = spec.get('parameters') if not isinstance(params, dict) or not isinstance(params.get('access_groups'), str): raise Refused('access_groups required') @@ -71,7 +88,7 @@ def _request(case): def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate, - delete_created_if_empty: bool = False): + delete_created_if_empty: bool = False, version_root: str = '1.1'): """Bind one admitted case to the owner journal; return a closed kernel Adapter. provisioner exposes ensure_service_groups/revoke_service_groups and carries @@ -80,6 +97,9 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut The idempotency key and correlation derive from the case pin, so every attempt resumes the one journal receipt and never recreates removed state. + version_root selects the accepted case type: '1.1' (default, canonical) or + '1.2' only when passed explicitly. Unknown roots are refused. + Rollback reverses only journal-confirmed creations; preexisting/shared groups and memberships are preserved. Ambiguity propagates as uncertain. """ @@ -111,13 +131,15 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut 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') + if version_root not in SELECTABLE_TYPES: + raise Refused('unknown version root') with contained_output(): - client_id, groups, members = _request(yaml.safe_load(case_source)) + client_id, groups, members = _request(yaml.safe_load(case_source), version_root) 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) + journal_binding = group_binding(groups, members, admitted.pins.case) records = {} def check_attempt(inv, operation): diff --git a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py index d6105c3..2e8e969 100644 --- a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py +++ b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py @@ -152,6 +152,35 @@ class EnsureGroupsTests(unittest.TestCase): 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_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')