NK-WP-0048 T30 prereq: explicit 1.2 version root for ensure-groups adapter

Canonical 1.1 stays the default; oidc-browser-relying-party@1.2 is accepted
only via build(version_root='1.2'). Adds a public group_binding helper pinned
by test to the provisioner's _group_binding, replacing the private call.

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
This commit is contained in:
tegwick 2026-10-17 08:01:32 +02:00
parent ece7b0a2a8
commit 531151b854
3 changed files with 70 additions and 9 deletions

View file

@ -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.

View file

@ -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):

View file

@ -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')