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:
parent
ece7b0a2a8
commit
531151b854
3 changed files with 70 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue