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 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 principal. A missing journal, a local SQLite journal or a differing binding is
refused before any call. refused before any call.
- The case must be canonical `oidc-browser-relying-party@1.1`. `access_groups` - By default the case must be canonical `oidc-browser-relying-party@1.1`.
and `initial_members` are parsed exactly, and members outside `access_groups` `oidc-browser-relying-party@1.2` is accepted only when `build` is called with
are refused. 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 - 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` every attempt resumes the same journal receipt. A replay returns `no_change`
and never recreates removed state. 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 __future__ import annotations
from dataclasses import asdict, replace from dataclasses import asdict, replace
import hashlib
import json import json
import sys import sys
from pathlib import Path from pathlib import Path
@ -28,6 +29,10 @@ SOURCE_FILES = (('sso-mfa/k8s/keycape', 'ensure-groups-adapter.py'),
('identity-provisioner', 'group_provenance_postgres.py')) ('identity-provisioner', 'group_provenance_postgres.py'))
REFERENCE = 'net-kingdom/ensure-groups@v1' REFERENCE = 'net-kingdom/ensure-groups@v1'
CASE_TYPE = 'oidc-browser-relying-party@1.1' # canonical 1.1 stays the default 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') CREATED = ('service-group', 'service-group-membership')
if str(PROVISIONER) not in sys.path: if str(PROVISIONER) not in sys.path:
@ -48,10 +53,22 @@ def descriptor():
CREATED, False, ()) 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 spec = case.get('spec') if isinstance(case, dict) else None
if not isinstance(spec, dict) or spec.get('type') != CASE_TYPE: expected = SELECTABLE_TYPES.get(version_root) if isinstance(version_root, str) else None
raise Refused('canonical oidc-browser-relying-party@1.1 case required') 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') params = spec.get('parameters')
if not isinstance(params, dict) or not isinstance(params.get('access_groups'), str): if not isinstance(params, dict) or not isinstance(params.get('access_groups'), str):
raise Refused('access_groups required') raise Refused('access_groups required')
@ -71,7 +88,7 @@ def _request(case):
def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate, 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. """Bind one admitted case to the owner journal; return a closed kernel Adapter.
provisioner exposes ensure_service_groups/revoke_service_groups and carries 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 The idempotency key and correlation derive from the case pin, so every
attempt resumes the one journal receipt and never recreates removed state. 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 Rollback reverses only journal-confirmed creations; preexisting/shared
groups and memberships are preserved. Ambiguity propagates as uncertain. 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') 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: 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') raise Refused('owner evidence capabilities required')
if version_root not in SELECTABLE_TYPES:
raise Refused('unknown version root')
with contained_output(): 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: if client_id != admitted.target:
raise Refused('case target differs') raise Refused('case target differs')
key = 'warden-ensure:' + admitted.pins.case key = 'warden-ensure:' + admitted.pins.case
request = {'groups': groups, 'members': members, 'correlation_id': 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 = {} records = {}
def check_attempt(inv, operation): def check_attempt(inv, operation):

View file

@ -152,6 +152,35 @@ class EnsureGroupsTests(unittest.TestCase):
with self.assertRaises(Refused): with self.assertRaises(Refused):
a.build(**{**self.kw, 'case_source': case, 'admitted': replace(self.admitted, pins=pins)}) 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): def test_rollback_refuses_unconfirmed_or_foreign_objects(self):
adapter = a.build(**self.kw) adapter = a.build(**self.kw)
rb_inv = replace(self.inv, operation='rollback') rb_inv = replace(self.inv, operation='rollback')