net-kingdom/sso-mfa/k8s/keycape/ensure-groups-adapter.py
tegwick cbadd359ba NK-WP-0048 r5 adapters: OW-1.3-C1 attended change lane, RAPPS-1.3-C1 human actor, RAPPS-1.3-C2 handover observations
Lane key-cape-oidc-login -> keycape-attended-case-change in the register-client,
ensure-groups and bind-env descriptors; deliver-consumer-secret actor agent ->
human; verify_consumer reports a value-free, exactly-once probe record list bound
into the observation digest. Composition artifacts, type.yaml and admit/compose
tests are left for the orchestrator to regenerate.

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
2026-10-17 11:59:14 +02:00

217 lines
12 KiB
Python

"""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 hashlib
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
# 1.2 and 1.3 are 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',
'1.3': 'oidc-browser-relying-party@1.3'}
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', 'keycape-attended-case-change', 'client_id', 'change',
CREATED, False, ('parent-session',))
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
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')
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,
revoke_session, 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
the trusted owner journal as group_provenance. publish(bytes)->identifier and
authenticate(identifier, bytes)->True are owner evidence capabilities.
revoke_session(invocation)->identifier revokes this attempt's parent session;
it runs in a finally block, so even a failed attempt ends the session.
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' / '1.3' 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.
"""
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, revoke_session)) 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), 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 = 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=()):
try:
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'}
finally:
# Even failure must end the parent session; failures propagate to the
# kernel as uncertain, never safe to replay.
with contained_output():
revocation = revoke_session(inv)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
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),
'session_revocation': revocation},
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, True, revocation,
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))