net-kingdom/sso-mfa/k8s/keycape/compose-1.2-descriptors.py
tegwick 835680ceb9 Source support for oidc-browser-relying-party@1.3 successor selection
ensure-groups and hand-over accept 1.3 via explicit version_root only (1.1
stays default; hand-over applies 1.2 owner handling to 1.3).
deliver-consumer-secret accepts exactly {1.2, 1.3}. compose-1.2-descriptors
gains --version {1.2,1.3} (default 1.2) and refuses cleanly when
security-case-type-revisions/<version> is absent. The committed 1.2
composition artifact is regenerated with its recorded args: only the adapter
source and descriptor digests of the three edited adapters change.

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:21:15 +02:00

268 lines
12 KiB
Python

"""Compose the seven-step source descriptor set for oidc-browser-relying-party@1.2.
Source candidate for NK-WP-0048-T30. Read-only: it loads the 1.2 type only
through the validator's explicit version-root selection, asks each step adapter
for its descriptor, closes each one through Warden's own Descriptor.decode, and
checks step, owner and lane against the 1.2 follow_through. It refuses a partial
map, a duplicate or unknown step, any mismatch, and any change to canonical 1.1.
Nothing here writes a type file, pins a descriptor, admits anything or touches
a live system. A composed map is a successor candidate that needs a reviewed
revision under canon section 6 and owner review (T16) before any pinning.
Generalised (NK-WP-0048) to a selectable successor version via --version
('1.2' default, or '1.3' = 1.2 content plus execution_descriptors). The type is
loaded from security-case-type-revisions/<version>/ through the explicit version
root, and each version-aware adapter must expose SELECTABLE_TYPES[<version>].
The file name is kept so review targets do not move; with the default the
output is byte-identical to the 1.2-only composer.
"""
from __future__ import annotations
from dataclasses import asdict
import argparse
import importlib.util
import json
from pathlib import Path
import sys
from warden.case_run import Descriptor, digest
from warden.case_receipt_state import Refused
ROOT = Path(__file__).resolve().parent
REPO = ROOT.parents[2]
DEFAULT_ROOT = REPO / 'security-case-types'
REVISIONS = REPO / 'security-case-type-revisions'
SUCCESSOR_VERSIONS = ('1.2', '1.3')
DEFAULT_VERSION = '1.2'
VERSION_ROOTS = {'1.2': REVISIONS / '1.2'}
TYPE_ID = 'oidc-browser-relying-party'
TYPE_REF = f'{TYPE_ID}@1.2'
CANONICAL_REF = f'{TYPE_ID}@1.1'
SECTION6 = ('successor candidate only: new descriptor bytes need a reviewed successor '
'revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning')
# step -> (adapter file, needs explicit 1.2 version-root support)
ADAPTERS = {
'ensure-groups': ('ensure-groups-adapter.py', True),
'mint-secret': ('mint-secret-adapter.py', False),
'deliver-secret': ('deliver-secret-adapter.py', False),
'bind-env': ('bind-env-adapter.py', False),
'register-client': ('keycape-registration-adapter.py', False),
'deliver-consumer-secret': ('deliver-consumer-secret-adapter.py', False),
'hand-over': ('handover-adapter.py', True),
}
# Names (T25-T29) that the type does not fix. Worker-proposed, then confirmed by
# the founder on 2026-10-17; the artifact records them as names_confirmed.
NAMES_CONFIRMED = {'by': 'founder', 'on': '2026-10-17'}
PROPOSED = {
# Actor 'human' (founder, 2026-10-17): the kernel refuses agent mint; only the schema is open.
'mint-secret': {'task': 'NK-WP-0048-T25',
'schema': 'railiance-platform.custody-mint.v1'},
'deliver-secret': {'task': 'NK-WP-0048-T26', 'lane': 'platform-secret-delivery',
'schema': 'netkingdom.deliver-secret.v1'},
'bind-env': {'task': 'NK-WP-0048-T27', 'schema': 'netkingdom.keycape-bind-env.attempt.v1'},
'deliver-consumer-secret': {'task': 'NK-WP-0048-T28', 'lane': 'consumer-owner-deployment',
'schema': 'netkingdom.consumer-secret-delivery.v1'},
'hand-over': {'task': 'NK-WP-0048-T29', 'lane': 'apps-consumer-handover',
'schema': 'netkingdom.apps-handover.attempt.v1'},
}
def _load(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _validator():
return _load('security_case_validator',
REPO / 'tools' / 'security-case-types' / 'security_case_validator.py')
def encode(desc: Descriptor) -> bytes:
return json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
def _version(version):
if version not in SUCCESSOR_VERSIONS:
raise Refused(f'unsupported successor version {version!r}; allowed {list(SUCCESSOR_VERSIONS)}')
return version
def _type_ref(version):
return f'{TYPE_ID}@{_version(version)}'
def load_types(default_root=DEFAULT_ROOT, version_roots=None, version=DEFAULT_VERSION):
"""Return (<version> spec, canonical 1.1 file digest) via explicit selection only."""
version, type_ref = _version(version), _type_ref(version)
if version_roots is None:
root = REVISIONS / version
if not root.is_dir():
raise Refused(f'revision directory security-case-type-revisions/{version} absent')
version_roots = {version: root}
v = _validator()
roots = dict(version_roots)
if set(roots) != {version}:
raise Refused(f'only an explicit {version} version root may be selected')
issues, data, _ = v.load_type(Path(default_root), type_ref, version_roots=roots)
if issues or str(data['spec'].get('version')) != version:
raise Refused(f'{version} type not loadable through explicit version-root selection')
issues, default_12, _ = v.load_type(Path(default_root), type_ref)
if not issues:
raise Refused(f'{version} is reachable by default; canonical 1.1 default changed')
issues, canonical, type_dir = v.load_type(Path(default_root), CANONICAL_REF)
if issues or str(canonical['spec'].get('version')) != '1.1':
raise Refused('canonical 1.1 default not loadable')
return data['spec'], digest((type_dir / 'type.yaml').read_bytes())
def _resolve(value, params):
try:
return value.format(**params) if isinstance(value, str) else value
except (KeyError, IndexError, ValueError):
raise Refused('type owner template has no bound parameter') from None
def adapter_descriptors(check_config_digest, consumer_owner, version=DEFAULT_VERSION):
"""Ask every step adapter for its descriptor; T13 needs the checker digest."""
type_ref = _type_ref(version)
out = []
for step, (filename, needs_12) in ADAPTERS.items():
m = _load(f'compose_{step.replace("-", "_")}', ROOT / filename)
if needs_12 and getattr(m, 'SELECTABLE_TYPES', {}).get(version) != type_ref:
raise Refused(f'{step}: adapter has no explicit {version} version-root path')
if step == 'register-client':
desc, source = m.descriptor(check_config_digest), m.source_digest(check_config_digest)
elif step in ('deliver-consumer-secret', 'hand-over'):
desc, source = m.descriptor(consumer_owner), m.source_digest()
else:
desc, source = m.descriptor(), m.source_digest()
if desc.adapter_source != source:
raise Refused(f'{step}: descriptor does not pin its own source digest')
out.append((step, desc))
return out
def compose(type_spec, candidates, params, canonical_digest_before, *, default_root=DEFAULT_ROOT,
version=DEFAULT_VERSION, version_roots=None):
"""Return {step: descriptor digest} for the complete <version> set, or raise Refused."""
steps = [s['id'] for s in type_spec.get('follow_through', ())]
if len(steps) != 7 or len(set(steps)) != 7:
raise Refused(f'{version} type does not declare exactly seven distinct steps')
by_step, sources = {}, {}
for step, desc in candidates:
if step in by_step:
raise Refused(f'duplicate step {step!r}')
if step not in steps:
raise Refused(f'unknown step {step!r}')
try:
source = encode(desc)
Descriptor.decode(source)
except (TypeError, ValueError, Refused):
raise Refused(f'{step}: descriptor refused by the Warden v1 kernel') from None
if desc.step != step:
raise Refused(f'{step}: descriptor names step {desc.step!r}')
by_step[step], sources[step] = desc, source
missing = [s for s in steps if s not in by_step]
if missing:
raise Refused(f'partial descriptor map; missing {missing}')
for entry in type_spec['follow_through']:
desc = by_step[entry['id']]
if desc.owner != _resolve(entry['owner'], params):
raise Refused(f"{entry['id']}: owner {desc.owner!r} does not match the {version} type")
if 'lane' in entry and desc.lane != entry['lane']:
raise Refused(f"{entry['id']}: lane {desc.lane!r} does not match the {version} type")
_, after = load_types(default_root, version_roots, version)
if after != canonical_digest_before:
raise Refused('canonical 1.1 type changed during composition')
return {s: digest(sources[s]) for s in steps}
def review(type_spec, candidates, params, version=DEFAULT_VERSION):
"""Per-step findings without stopping at the first refusal (for the artifact)."""
entries = {e['id']: e for e in type_spec.get('follow_through', ())}
rows = []
for step, desc in candidates:
entry, findings = entries.get(step), []
source = encode(desc)
try:
Descriptor.decode(source)
except Refused:
findings.append('refused by Warden v1 Descriptor.decode')
if entry is None:
findings.append(f'step not in {version} type')
else:
try:
owner = _resolve(entry['owner'], params)
except Refused as exc:
owner = None
findings.append(str(exc))
if owner is not None and desc.owner != owner:
findings.append(f'owner {desc.owner!r} != type owner {owner!r}')
if 'lane' in entry and desc.lane != entry['lane']:
findings.append(f"lane {desc.lane!r} != type lane {entry['lane']!r}")
proposed = dict(PROPOSED.get(step, {}))
if entry is not None and 'lane' not in entry:
proposed.setdefault('lane', desc.lane)
rows.append({'step': step, 'descriptor': asdict(desc),
'candidate_descriptor_digest': digest(source),
'type_owner': entry and entry['owner'], 'type_lane': entry and entry.get('lane'),
'findings': findings,
'owner_confirmation_required': None,
'names_confirmed': dict(proposed, **NAMES_CONFIRMED) if proposed else None})
return rows
def artifact(check_config_digest, params, version=DEFAULT_VERSION):
type_spec, canonical = load_types(version=version)
candidates = adapter_descriptors(check_config_digest, params['consumer_owner_repo'], version)
try:
composed, refusal = compose(type_spec, candidates, params, canonical, version=version), None
except Refused as exc:
composed, refusal = None, str(exc)
return {
'schema': 'netkingdom.descriptor-composition-candidate.v1',
'task': 'NK-WP-0048-T30',
'type': _type_ref(version),
'selection': (f'explicit version root {version}=security-case-type-revisions/{version}; '
'default root unchanged'),
'canonical_default': {'type': CANONICAL_REF, 'type_yaml_digest': canonical,
'unchanged': True},
'status': 'not_an_admission',
'review_requirement': SECTION6,
'check_config_digest': check_config_digest,
'params': params,
'complete_executable_descriptor_set': composed is not None,
'execution_descriptors': composed,
'refusal': refusal,
'steps': review(type_spec, candidates, params, version),
'owner_review': False, 'admitted': False, 'dispatch_permitted': False,
}
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument('--check-config-digest', required=True)
p.add_argument('--consumer-owner-repo', required=True)
p.add_argument('--out', type=Path)
p.add_argument('--version', choices=SUCCESSOR_VERSIONS, default=DEFAULT_VERSION)
a = p.parse_args(argv)
try:
doc = artifact(a.check_config_digest, {'consumer_owner_repo': a.consumer_owner_repo}, a.version)
except Refused as exc:
sys.stderr.write(f'refused: {exc}\n')
return 3
text = json.dumps(doc, indent=2, sort_keys=True) + '\n'
if a.out:
a.out.write_text(text)
else:
sys.stdout.write(text)
return 0 if doc['complete_executable_descriptor_set'] else 2
if __name__ == '__main__':
raise SystemExit(main())