diff --git a/sso-mfa/k8s/keycape/bind-env-adapter.md b/sso-mfa/k8s/keycape/bind-env-adapter.md index 39b899d..30ea7cd 100644 --- a/sso-mfa/k8s/keycape/bind-env-adapter.md +++ b/sso-mfa/k8s/keycape/bind-env-adapter.md @@ -20,7 +20,7 @@ bound to anything else (including a literal), is refused. `register-user-engine-portal.py` — closure checked by `test_adapter_source_closure.py`), the descriptor digest (`Pins.adapter`), case source and acceptance digests, and the - admitted invocation (step `bind-env`, actor `human`, lane `key-cape-oidc-login`, + admitted invocation (step `bind-env`, actor `human`, lane `keycape-attended-case-change`, created object `keycape-env-binding`, not retry-safe, `parent-session` scope). Every attempt is rebound to that invocation and re-checks source drift. - `publish`, `authenticate` and `revoke_session` are mandatory injected diff --git a/sso-mfa/k8s/keycape/bind-env-adapter.py b/sso-mfa/k8s/keycape/bind-env-adapter.py index 817fd0f..53d4792 100644 --- a/sso-mfa/k8s/keycape/bind-env-adapter.py +++ b/sso-mfa/k8s/keycape/bind-env-adapter.py @@ -85,7 +85,7 @@ def source_digest(): def descriptor(): return Descriptor('warden.case.execution.v1', 'bind-env', 'net-kingdom', REFERENCE, - source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change', + source_digest(), 'human', 'keycape-attended-case-change', 'client_id', 'change', (CREATED,), False, ('parent-session',)) diff --git a/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.md b/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.md index 975f9c1..2580cd6 100644 --- a/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.md +++ b/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.md @@ -12,7 +12,7 @@ credential provider, OpenBao access or evidence trust root. Canonical 1.1 stays the default: a 1.1 case, or a public client, is refused because the step does not exist there. No `--allow-unimplemented` admission is used or implied. -Binding: descriptor (`agent` actor, lane `consumer-owner-deployment`, effect +Binding: descriptor (`human` actor, lane `consumer-owner-deployment`, effect `change`, not retry-safe, `parent-session` revocation) pins this adapter source; case, descriptor, target (`client_id`) and owner (`consumer_owner_repo`) must match the admitted invocation; each attempt is rebound and the source rehashed. diff --git a/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py b/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py index f54da75..556ff6b 100644 --- a/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py +++ b/sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py @@ -39,7 +39,7 @@ def descriptor(owner): if not isinstance(owner, str) or not IDENTIFIER.fullmatch(owner): raise Refused('bounded consumer owner required') return Descriptor('warden.case.execution.v1', 'deliver-consumer-secret', owner, - REFERENCE, source_digest(), 'agent', 'consumer-owner-deployment', + REFERENCE, source_digest(), 'human', 'consumer-owner-deployment', 'client_id', 'change', OBJECTS, False, ('parent-session',)) diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.py b/sso-mfa/k8s/keycape/ensure-groups-adapter.py index 19450ba..3adea79 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.py +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.py @@ -50,7 +50,7 @@ def source_digest(): def descriptor(): return Descriptor('warden.case.execution.v1', 'ensure-groups', 'net-kingdom', REFERENCE, - source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change', + source_digest(), 'human', 'keycape-attended-case-change', 'client_id', 'change', CREATED, False, ('parent-session',)) diff --git a/sso-mfa/k8s/keycape/handover-adapter.md b/sso-mfa/k8s/keycape/handover-adapter.md index ba5cdc9..ce63167 100644 --- a/sso-mfa/k8s/keycape/handover-adapter.md +++ b/sso-mfa/k8s/keycape/handover-adapter.md @@ -21,7 +21,12 @@ Apps-owned consumer verification and the hand-over receipt. requires the consumer to enforce them (`consumer-enforces-groups`). - **Injected capabilities (required, never strings or case parameters):** `verify_consumer(client_id, groups)` must return exactly `client_id`, - `enforces_groups=True`, the case's `groups` and `login_verified=True`; + `enforces_groups=True`, the case's `groups`, `login_verified=True` and + `observations` (RAPPS-1.3-C2): a value-free list of `{probe, evidence}` records, + one each for `member-login`, `non-member-refused`, `membership-add-grants` and + `membership-remove-revokes`, evidence an owner evidence identifier (IDENTIFIER + pattern). Missing, extra, duplicate or unknown probes and any other key are + refused; the observation digest covers the records in canonical probe order; `record_handover(client_id, observation_digest) -> (identifier, created)`; `remove_handover(identifier) -> True`; `publish(bytes) -> identifier` and `authenticate(identifier, bytes) -> True` for owner evidence; diff --git a/sso-mfa/k8s/keycape/handover-adapter.py b/sso-mfa/k8s/keycape/handover-adapter.py index efa7db9..0659a67 100644 --- a/sso-mfa/k8s/keycape/handover-adapter.py +++ b/sso-mfa/k8s/keycape/handover-adapter.py @@ -26,6 +26,26 @@ SELECTABLE_TYPES = {'1.1': CANONICAL_TYPE, '1.2': 'oidc-browser-relying-party@1. '1.3': 'oidc-browser-relying-party@1.3'} OWNER_ROOTS = frozenset({'1.2', '1.3'}) RECEIPT = 'handover-receipt' +# Value-free probe records verify_consumer must report, each exactly once (RAPPS-1.3-C2). +PROBES = frozenset({'member-login', 'non-member-refused', 'membership-add-grants', + 'membership-remove-revokes'}) +SEEN_KEYS = frozenset({'client_id', 'enforces_groups', 'groups', 'login_verified', 'observations'}) + + +def _observations(value): + """Return the probe records in canonical order, or None if the contract differs.""" + if not isinstance(value, list) or len(value) != len(PROBES): + return None + records = [] + for rec in value: + if not (isinstance(rec, dict) and set(rec) == {'probe', 'evidence'} + and isinstance(rec['probe'], str) and rec['probe'] in PROBES + and isinstance(rec['evidence'], str) and IDENTIFIER.fullmatch(rec['evidence'])): + return None + records.append({'probe': rec['probe'], 'evidence': rec['evidence']}) + if {r['probe'] for r in records} != PROBES: + return None + return sorted(records, key=lambda r: r['probe']) def source_digest(): @@ -66,8 +86,10 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, """Bind one admitted case; return a closed kernel Adapter. verify_consumer(client_id, groups)->dict observes the consumer and must report - {'client_id', 'enforces_groups', 'groups', 'login_verified'} for exactly this - case. record_handover(client_id, observation_digest)->(identifier, created) + {'client_id', 'enforces_groups', 'groups', 'login_verified', 'observations'} for + exactly this case; 'observations' is a value-free list of {probe, evidence} + records, one per probe in PROBES, evidence an owner evidence IDENTIFIER. The + observation digest covers the whole report, observations included. record_handover(client_id, observation_digest)->(identifier, created) durably records the hand-over receipt; remove_handover(identifier) removes only that receipt. publish/authenticate are owner evidence capabilities. revoke_session(invocation)->identifier revokes this attempt's parent session @@ -136,10 +158,15 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, binding(inv, 'run') with contained_output(): seen = verify_consumer(client_id, groups) - if not (isinstance(seen, dict) and set(seen) == {'client_id', 'enforces_groups', 'groups', 'login_verified'} + if not (isinstance(seen, dict) and set(seen) == SEEN_KEYS and seen['client_id'] == client_id and seen['enforces_groups'] is True and seen['login_verified'] is True and tuple(sorted(seen['groups'] or ())) == groups): raise Refused('consumer verification contract differs') + probes = _observations(seen['observations']) + if probes is None: + raise Refused('consumer observation records differ') + seen = {'client_id': client_id, 'enforces_groups': True, 'groups': list(groups), + 'login_verified': True, 'observations': probes} observation = digest(json.dumps(seen, sort_keys=True, separators=(',', ':')).encode()) with contained_output(): receipt = record_handover(client_id, observation) diff --git a/sso-mfa/k8s/keycape/keycape-registration-adapter.py b/sso-mfa/k8s/keycape/keycape-registration-adapter.py index 3c51737..2bae68d 100644 --- a/sso-mfa/k8s/keycape/keycape-registration-adapter.py +++ b/sso-mfa/k8s/keycape/keycape-registration-adapter.py @@ -77,7 +77,7 @@ def source_digest(check_config_digest): def descriptor(check_config_digest): return Descriptor('warden.case.execution.v1', 'register-client', 'net-kingdom', - REFERENCE, source_digest(check_config_digest), 'human', 'key-cape-oidc-login', + REFERENCE, source_digest(check_config_digest), 'human', 'keycape-attended-case-change', 'client_id', 'change', ('keycape-client',), False, ('parent-session',)) diff --git a/sso-mfa/k8s/keycape/test_bind_env_adapter.py b/sso-mfa/k8s/keycape/test_bind_env_adapter.py index b80af14..db23090 100644 --- a/sso-mfa/k8s/keycape/test_bind_env_adapter.py +++ b/sso-mfa/k8s/keycape/test_bind_env_adapter.py @@ -51,7 +51,7 @@ class BindEnvTests(unittest.TestCase): pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes), digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet')) self.admitted = Invocation('bind-env', 'net-kingdom', 'forgejo', 'human', - 'founder', 'key-cape-oidc-login', pins) + 'founder', 'keycape-attended-case-change', pins) self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.undo = replace(self.admitted, attempt_id='attempt-two', operation='rollback') self.evidence, self.revocations = {}, [] diff --git a/sso-mfa/k8s/keycape/test_deliver_consumer_secret_adapter.py b/sso-mfa/k8s/keycape/test_deliver_consumer_secret_adapter.py index d8a9c78..70ca64c 100644 --- a/sso-mfa/k8s/keycape/test_deliver_consumer_secret_adapter.py +++ b/sso-mfa/k8s/keycape/test_deliver_consumer_secret_adapter.py @@ -36,7 +36,7 @@ class DeliveryTests(unittest.TestCase): desc = json.dumps(asdict(a.descriptor(owner)), sort_keys=True, separators=(',', ':')).encode() pins = Pins(digest(case), digest(b'type'), digest(desc), digest(b'catalog'), digest(b'acceptance'), digest(b'run sheet')) - return Invocation('deliver-consumer-secret', owner, 'forgejo', 'agent', 'founder', + return Invocation('deliver-consumer-secret', owner, 'forgejo', 'human', 'founder', 'consumer-owner-deployment', pins) def receipt(self, operation, names, want=None): diff --git a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py index b55778b..7f4bcf9 100644 --- a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py +++ b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py @@ -75,7 +75,7 @@ class EnsureGroupsTests(unittest.TestCase): pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes), digest(b'catalog'), digest(b'acceptance'), digest(b'run sheet')) self.admitted = Invocation('ensure-groups', 'net-kingdom', 'forgejo', 'human', - 'founder', 'key-cape-oidc-login', pins) + 'founder', 'keycape-attended-case-change', pins) self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.journal = self.make_journal() self.prov = FakeProvisioner(self.journal) diff --git a/sso-mfa/k8s/keycape/test_handover_adapter.py b/sso-mfa/k8s/keycape/test_handover_adapter.py index aaed936..63a0ffc 100644 --- a/sso-mfa/k8s/keycape/test_handover_adapter.py +++ b/sso-mfa/k8s/keycape/test_handover_adapter.py @@ -33,7 +33,11 @@ class HandoverTests(unittest.TestCase): self.rb = replace(self.admitted, attempt_id='attempt-two', operation='rollback') self.evidence, self.receipts, self.revoked = {}, set(), [] self.observed = {'client_id': 'forgejo', 'enforces_groups': True, - 'groups': ['forge-admins', 'forge-users'], 'login_verified': True} + 'groups': ['forge-admins', 'forge-users'], 'login_verified': True, + 'observations': [{'probe': p, 'evidence': 'apps-probe:' + p} + for p in ('member-login', 'non-member-refused', + 'membership-add-grants', 'membership-remove-revokes')]} + self.recorded = [] self.kw = dict(admitted=self.admitted, case_source=self.case, case_acceptance='case-acceptance:test', verify_consumer=lambda c, g: dict(self.observed), record_handover=self.record, remove_handover=self.remove, publish=self.publish, @@ -50,6 +54,7 @@ class HandoverTests(unittest.TestCase): return ref def record(self, client_id, observation): + self.recorded.append(observation) ref = 'handover:' + client_id created = ref not in self.receipts self.receipts.add(ref) @@ -129,6 +134,41 @@ class HandoverTests(unittest.TestCase): self.setUp() self.assertEqual(self.receipts, set()) + def test_observation_record_refusals(self): + obs = self.observed['observations'] + cases = { + 'missing-field': None, + 'not-a-list': {'member-login': 'apps-probe:x'}, + 'missing-probe': obs[:3], + 'extra-probe': obs + [{'probe': 'admin-login', 'evidence': 'apps-probe:y'}], + 'duplicate-probe': obs[:3] + [dict(obs[0])], + 'unknown-probe': obs[:3] + [{'probe': 'admin-login', 'evidence': 'apps-probe:y'}], + 'value-bearing-key': [{**obs[0], 'value': 'secret'}] + obs[1:], + 'missing-evidence': [{'probe': 'member-login'}] + obs[1:], + 'evidence-not-identifier': [{'probe': 'member-login', 'evidence': 'has space/x'}] + obs[1:], + 'evidence-not-string': [{'probe': 'member-login', 'evidence': 7}] + obs[1:], + 'record-not-dict': ['member-login'] + obs[1:], + } + for name, value in cases.items(): + self.setUp() + if value is None: + del self.observed['observations'] + else: + self.observed['observations'] = value + with self.subTest(name=name), self.assertRaises(Refused): + a.build(**self.kw).invoke(self.inv) + self.assertEqual(self.receipts, set()) + + def test_observations_bound_into_digest_order_independent(self): + def run(mutate): + self.setUp() + mutate(self.observed['observations']) + a.build(**self.kw).invoke(self.inv) + return self.recorded[-1] + base = run(lambda o: None) + self.assertEqual(base, run(lambda o: o.reverse())) + self.assertNotEqual(base, run(lambda o: o[0].update(evidence='apps-probe:changed'))) + def test_canonical_default_and_explicit_selection_only(self): with self.assertRaises(Refused): a.build(**{**self.kw, 'version_root': '1.0'}) diff --git a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py index 6402046..7f9786c 100644 --- a/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py +++ b/sso-mfa/k8s/keycape/test_keycape_registration_adapter.py @@ -34,7 +34,7 @@ class RegistrationTests(unittest.TestCase): pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes), digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet')) self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human', - 'founder', 'key-cape-oidc-login', pins) + 'founder', 'keycape-attended-case-change', pins) self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.secret, self.raw = f.fixture() self.effects = [] @@ -262,7 +262,7 @@ class RegistrationTests(unittest.TestCase): type_source = yaml.safe_dump(typ).encode() catalog = b'fixture-only-catalog' sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human', - 'founder', 'key-cape-oidc-login', 'fixture-acceptance', 'fixture-materialization', + 'founder', 'keycape-attended-case-change', 'fixture-acceptance', 'fixture-materialization', 'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode() pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes), digest(catalog), digest(b'fixture-acceptance'), digest(sheet))