diff --git a/sso-mfa/k8s/keycape/compose-1.2-descriptors.py b/sso-mfa/k8s/keycape/compose-1.2-descriptors.py index 0fb76f9..2b2b22f 100644 --- a/sso-mfa/k8s/keycape/compose-1.2-descriptors.py +++ b/sso-mfa/k8s/keycape/compose-1.2-descriptors.py @@ -53,7 +53,7 @@ PROPOSED = { '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', 'owner': 'apps', 'lane': 'apps-consumer-handover', + 'hand-over': {'task': 'NK-WP-0048-T29', 'lane': 'apps-consumer-handover', 'schema': 'netkingdom.apps-handover.attempt.v1'}, } @@ -108,7 +108,7 @@ def adapter_descriptors(check_config_digest, consumer_owner): raise Refused(f'{step}: adapter has no explicit 1.2 version-root path') if step == 'register-client': desc, source = m.descriptor(check_config_digest), m.source_digest(check_config_digest) - elif step == 'deliver-consumer-secret': + 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() diff --git a/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json b/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json index 3d53fe2..7074762 100644 --- a/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json +++ b/sso-mfa/k8s/keycape/descriptor-composition-1.2.candidate.json @@ -13,18 +13,18 @@ "params": { "consumer_owner_repo": "railiance-apps" }, - "refusal": "ensure-groups: descriptor refused by the Warden v1 kernel", + "refusal": "mint-secret: descriptor refused by the Warden v1 kernel", "review_requirement": "successor candidate only: new descriptor bytes need a reviewed successor revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning", "schema": "netkingdom.descriptor-composition-candidate.v1", "selection": "explicit version root 1.2=security-case-type-revisions/1.2; default root unchanged", "status": "not_an_admission", "steps": [ { - "candidate_descriptor_digest": "sha256:0a35e4bf7ce1b98a7538bda8b36bdce88d152880df05218020274a38c0f89c16", + "candidate_descriptor_digest": "sha256:775dd71d7da43d16437dcdd9cba81ea6eef5803ab74e93c24d4994a764180135", "descriptor": { "actor": "human", "adapter": "net-kingdom/ensure-groups@v1", - "adapter_source": "sha256:e38067a40486379620d8ea37de48d436e9e7eac193541ddd73808e98af54be32", + "adapter_source": "sha256:691ace82aec8a3d2ba09fa8c884fcfae247397bc77644e16936b3da1e7942739", "allowed_created": [ "service-group", "service-group-membership" @@ -33,14 +33,14 @@ "lane": "key-cape-oidc-login", "owner": "net-kingdom", "retry_safe": false, - "revocation_scopes": [], + "revocation_scopes": [ + "parent-session" + ], "step": "ensure-groups", "target_parameter": "client_id", "version": "warden.case.execution.v1" }, - "findings": [ - "refused by Warden v1 Descriptor.decode" - ], + "findings": [], "owner_confirmation_required": null, "step": "ensure-groups", "type_lane": "key-cape-oidc-login", @@ -197,30 +197,28 @@ "type_owner": "{consumer_owner_repo}" }, { - "candidate_descriptor_digest": "sha256:366e8829dd6363e69abd817ea0d4ea966ac1ebcf994493b9bf9eee1a648a6ce0", + "candidate_descriptor_digest": "sha256:24d1ab891bfccaac1d691245d495ced2571d940d055c5e387076bb57dc68444e", "descriptor": { "actor": "human", "adapter": "net-kingdom/apps-handover@v1", - "adapter_source": "sha256:4ad0396928f71bfb57760c414237b6cfa06aea57ccca2ef1293e63ab22f2953a", + "adapter_source": "sha256:997bde2b03e546d6a86e73f9634f272736ab76b4ff6d2c4ad592f4efd890d352", "allowed_created": [ "handover-receipt" ], "effect": "change", "lane": "apps-consumer-handover", - "owner": "apps", + "owner": "railiance-apps", "retry_safe": false, - "revocation_scopes": [], + "revocation_scopes": [ + "parent-session" + ], "step": "hand-over", "target_parameter": "client_id", "version": "warden.case.execution.v1" }, - "findings": [ - "refused by Warden v1 Descriptor.decode", - "owner 'apps' != type owner 'railiance-apps'" - ], + "findings": [], "owner_confirmation_required": { "lane": "apps-consumer-handover", - "owner": "apps", "schema": "netkingdom.apps-handover.attempt.v1", "task": "NK-WP-0048-T29" }, diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.md b/sso-mfa/k8s/keycape/ensure-groups-adapter.md index cdd5ba8..e9c47f4 100644 --- a/sso-mfa/k8s/keycape/ensure-groups-adapter.md +++ b/sso-mfa/k8s/keycape/ensure-groups-adapter.md @@ -6,8 +6,14 @@ unexercised against any live system and is not pinned, admitted or reviewed. ## Binding - `build()` takes an admitted `ensure-groups` invocation, the exact case bytes, - an injected provisioner, and owner `publish`/`authenticate` evidence - capabilities. + an injected provisioner, owner `publish`/`authenticate` evidence + capabilities and `revoke_session(invocation) -> identifier`. +- The descriptor declares revocation scope `parent-session` (the Warden v1 + kernel refuses an empty scope set). `revoke_session` runs in a `finally` block + on every run and rollback, including failed ones, so the parent session always + ends; a missing or malformed revocation identifier refuses before publication. + The identifier is recorded in the evidence payload and returned as + `session_revocation_evidence` with `revoked=True`. - Group creation binds to the T14/T21 journal seam. The provisioner's `group_provenance` must be a `PostgresGroupProvenance`. Its `GroupJournalBinding` must name this case pin, the reviewed adapter source digest and the admitted diff --git a/sso-mfa/k8s/keycape/ensure-groups-adapter.py b/sso-mfa/k8s/keycape/ensure-groups-adapter.py index ebbee9a..533aebc 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', - CREATED, False, ()) + CREATED, False, ('parent-session',)) def group_binding(groups, members, correlation_id) -> str: @@ -88,12 +88,14 @@ def _request(case, version_root='1.1'): def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate, - delete_created_if_empty: bool = False, version_root: str = '1.1'): + 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. @@ -129,7 +131,7 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut 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)) or type(delete_created_if_empty) is not bool: + 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') @@ -150,18 +152,26 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut raise Refused('owner source changed') def execute(inv, rollback, created=()): - 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'} + 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))): @@ -178,13 +188,14 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut '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)}, + '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, rollback, + owner = OwnerResult(outcome, evidence, objects, True, revocation, attempt_id=inv.attempt_id, operation=inv.operation) records[evidence] = (inv, payload, owner) return owner diff --git a/sso-mfa/k8s/keycape/handover-adapter.md b/sso-mfa/k8s/keycape/handover-adapter.md index c0d2bd1..17497b1 100644 --- a/sso-mfa/k8s/keycape/handover-adapter.md +++ b/sso-mfa/k8s/keycape/handover-adapter.md @@ -4,10 +4,14 @@ kernel, modelled on the T13 registration adapter. It covers step (f), Apps-owned consumer verification and the hand-over receipt. -- **Descriptor:** step `hand-over`, owner `apps`, actor `human`, lane - `apps-consumer-handover`, target `client_id`, effect `change`, allowed created - object `handover-receipt`, not retry-safe, no revocation scopes. Owner and lane - names are candidate values for Apps owner review and T30 composition. +- **Descriptor:** `descriptor(owner)` — step `hand-over`, owner is a parameter + (type 1.2 declares `{consumer_owner_repo}`; the T30 composer passes it, e.g. + `railiance-apps`), actor `human`, lane `apps-consumer-handover`, target + `client_id`, effect `change`, allowed created object `handover-receipt`, not + retry-safe, revocation scope `parent-session` (the Warden v1 kernel refuses an + empty scope set). `build()` derives the descriptor from `admitted.owner`; a 1.2 + case must carry a bounded `consumer_owner_repo` equal to it. The lane name is + still a candidate value for Apps owner review. - **Case binding:** canonical `oidc-browser-relying-party@1.1` is the default; `@1.2` is reached only through an explicit `version_root='1.2'` selection. No `--allow-unimplemented` admission exists. Case, descriptor and acceptance @@ -18,7 +22,12 @@ Apps-owned consumer verification and the hand-over receipt. `enforces_groups=True`, the case's `groups` and `login_verified=True`; `record_handover(client_id, observation_digest) -> (identifier, created)`; `remove_handover(identifier) -> True`; `publish(bytes) -> identifier` and - `authenticate(identifier, bytes) -> True` for owner evidence. + `authenticate(identifier, bytes) -> True` for owner evidence; + `revoke_session(invocation) -> identifier`, called in a `finally` block on + every run and rollback (even failed ones) so the parent session always ends. + A missing or malformed revocation identifier refuses before publication; the + identifier is recorded in the evidence payload and returned as + `session_revocation_evidence` with `revoked=True`. - **Rollback is created-only:** only a receipt this process created and whose evidence the owner authenticates may be removed. An existing receipt is `no_change` and never rolled back; unconfirmed removal refuses and keeps the diff --git a/sso-mfa/k8s/keycape/handover-adapter.py b/sso-mfa/k8s/keycape/handover-adapter.py index 960bcde..476565c 100644 --- a/sso-mfa/k8s/keycape/handover-adapter.py +++ b/sso-mfa/k8s/keycape/handover-adapter.py @@ -30,10 +30,13 @@ def source_digest(): return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode()) -def descriptor(): - return Descriptor('warden.case.execution.v1', 'hand-over', 'apps', REFERENCE, +def descriptor(owner): + """The owner is the case's consumer_owner_repo (type 1.2 '{consumer_owner_repo}').""" + if not isinstance(owner, str) or not IDENTIFIER.fullmatch(owner): + raise Refused('bounded consumer owner required') + return Descriptor('warden.case.execution.v1', 'hand-over', owner, REFERENCE, source_digest(), 'human', 'apps-consumer-handover', 'client_id', - 'change', (RECEIPT,), False, ()) + 'change', (RECEIPT,), False, ('parent-session',)) def _case(case_source, version_root): @@ -48,12 +51,15 @@ def _case(case_source, version_root): groups = tuple(sorted(g.strip() for g in groups.split(','))) if isinstance(groups, str) else () if not groups or not all(IDENTIFIER.fullmatch(g) for g in groups): raise Refused('hand-over requires access_groups the consumer enforces') - return client_id, groups + owner = params.get('consumer_owner_repo') + if version_root == '1.2' and (not isinstance(owner, str) or not IDENTIFIER.fullmatch(owner)): + raise Refused('1.2 hand-over requires a bounded consumer_owner_repo') + return client_id, groups, owner if version_root == '1.2' else None def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, verify_consumer, record_handover, remove_handover, publish, authenticate, - version_root: str = '1.1'): + revoke_session, version_root: str = '1.1'): """Bind one admitted case; return a closed kernel Adapter. verify_consumer(client_id, groups)->dict observes the consumer and must report @@ -61,19 +67,24 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, case. 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 + in a finally block, so even a failed attempt ends the session. + + The descriptor owner is admitted.owner; a 1.2 case must name the same + consumer_owner_repo. A 1.1 case carries no such parameter and is bound to + the admitted owner alone. Only a receipt verifiably created by this process may be rolled back. Process loss, ambiguity or publication failure require owner reconciliation. """ 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): + desc = descriptor(admitted.owner) + if (admitted.step, admitted.actor, admitted.lane) != (desc.step, desc.actor, desc.lane): raise Refused('hand-over invocation boundary differs') if admitted.operation != 'admission' or admitted.attempt_id: raise Refused('expected admission invocation') @@ -84,12 +95,15 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, raise Refused('descriptor pin differs') if not isinstance(case_acceptance, str) or digest(case_acceptance.encode()) != admitted.pins.acceptance: raise Refused('acceptance evidence binding differs') - if not all(callable(x) for x in (verify_consumer, record_handover, remove_handover, publish, authenticate)): + if not all(callable(x) for x in (verify_consumer, record_handover, remove_handover, publish, authenticate, + revoke_session)): raise Refused('owner verification, receipt and evidence capabilities required') with contained_output(): - client_id, groups = _case(case_source, version_root) + client_id, groups, owner = _case(case_source, version_root) if admitted.target != client_id: raise Refused('case target differs') + if owner is not None and owner != admitted.owner: + raise Refused('case consumer_owner_repo differs from admitted owner') records = {} creation = None @@ -100,21 +114,22 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, if source_digest() != reviewed: raise Refused('owner source changed') - def finish(inv, outcome, objects, body): + def finish(inv, outcome, objects, body, revocation): payload = json.dumps({'schema': 'netkingdom.apps-handover.attempt.v1', 'invocation': asdict(inv), 'source': reviewed, - 'outcome': outcome.value, 'created': objects, **body}, + 'outcome': outcome.value, 'created': objects, + 'session_revocation': revocation, **body}, 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') - result = OwnerResult(outcome, evidence, objects, attempt_id=inv.attempt_id, operation=inv.operation) + result = OwnerResult(outcome, evidence, objects, True, revocation, + attempt_id=inv.attempt_id, operation=inv.operation) records[evidence] = (inv, payload, result) return result def invoke(inv): - nonlocal creation binding(inv, 'run') with contained_output(): seen = verify_consumer(client_id, groups) @@ -130,14 +145,10 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, raise Refused('hand-over receipt contract differs') ref, created = receipt objects = (RECEIPT,) if created else () - result = finish(inv, Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects, - {'observation': observation, 'receipt': ref}) - if created: - creation = (inv, result, ref) - return result + return (Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects, + {'observation': observation, 'receipt': ref}, ref if created else None) def rollback(inv, created): - nonlocal creation binding(inv, 'rollback') if created != (RECEIPT,) or creation is None or not verify(*creation[:2]): raise Refused('no authenticated case-created hand-over receipt') @@ -146,8 +157,26 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, removed = remove_handover(ref) if removed is not True: raise Refused('hand-over receipt removal unconfirmed') - creation = None - return finish(inv, Outcome.ROLLED_BACK, (), {'removed_receipt': ref}) + return Outcome.ROLLED_BACK, (), {'removed_receipt': ref}, None + + def execute(inv, operation, created=()): + nonlocal creation + try: + outcome, objects, body, ref = (invoke(inv) if operation == 'run' + else rollback(inv, created)) + 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') + result = finish(inv, outcome, objects, body, revocation) + if ref is not None: + creation = (inv, result, ref) + elif operation == 'rollback': + creation = None + return result def verify(inv, result): record = records.get(result.evidence) if type(result) is OwnerResult else None @@ -156,4 +185,5 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, with contained_output(): return authenticate(result.evidence, record[1]) is True - return Adapter(REFERENCE, reviewed, invoke, verify, rollback) + return Adapter(REFERENCE, reviewed, lambda inv: execute(inv, 'run'), verify, + lambda inv, created: execute(inv, 'rollback', created)) diff --git a/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py b/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py index 4dff5d3..8af980b 100644 --- a/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py +++ b/sso-mfa/k8s/keycape/test_compose_1_2_descriptors.py @@ -102,12 +102,22 @@ class ComposeTests(unittest.TestCase): self.assertIsNone(doc['execution_descriptors']) self.assertFalse(doc['admitted'] or doc['owner_review'] or doc['dispatch_permitted']) findings = {r['step']: r['findings'] for r in doc['steps']} - self.assertTrue(findings['ensure-groups']) - self.assertTrue(findings['mint-secret']) - self.assertTrue(any('owner' in f for f in findings['hand-over'])) - self.assertEqual(findings['register-client'], []) + # Only mint-secret stays refused: its 'warden' actor is an owner decision. + self.assertEqual({s for s, f in findings.items() if f}, {'mint-secret'}) + self.assertEqual(findings['mint-secret'], ['refused by Warden v1 Descriptor.decode']) + self.assertIn('mint-secret', doc['refusal']) + hand_over = next(r for r in doc['steps'] if r['step'] == 'hand-over') + self.assertEqual(hand_over['descriptor']['owner'], PARAMS['consumer_owner_repo']) + self.assertNotIn('owner', hand_over['owner_confirmation_required']) flagged = {r['step'] for r in doc['steps'] if r['owner_confirmation_required']} - self.assertTrue({'mint-secret', 'deliver-secret', 'bind-env', 'hand-over'} <= flagged) + self.assertEqual(flagged, {'mint-secret', 'deliver-secret', 'bind-env', + 'deliver-consumer-secret', 'hand-over'}) + + def test_committed_artifact_matches_regeneration(self): + import json + committed = json.loads((ROOT / 'descriptor-composition-1.2.candidate.json').read_text()) + regenerated = c.artifact(committed['check_config_digest'], committed['params']) + self.assertEqual(json.loads(json.dumps(regenerated)), committed) def test_adapters_need_bounded_inputs(self): with self.assertRaises(Refused): diff --git a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py index 2e8e969..b3af41b 100644 --- a/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py +++ b/sso-mfa/k8s/keycape/test_ensure_groups_adapter.py @@ -11,7 +11,7 @@ import unittest from unittest.mock import patch import uuid -from warden.case_run import Invocation, digest +from warden.case_run import Descriptor, Invocation, digest from warden.case_receipt_state import Outcome, Pins, Refused import yaml @@ -79,9 +79,10 @@ class EnsureGroupsTests(unittest.TestCase): self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.journal = self.make_journal() self.prov = FakeProvisioner(self.journal) - self.evidence = {} + self.evidence, self.revoked = {}, [] self.kw = dict(admitted=self.admitted, case_source=self.case, provisioner=self.prov, - publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p) + publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p, + revoke_session=self.revoke) get = patch.object(PostgresGroupProvenance, 'get', lambda s, k, b: {'state': self.prov.state}) get.start() self.addCleanup(get.stop) @@ -94,6 +95,10 @@ class EnsureGroupsTests(unittest.TestCase): return PostgresGroupProvenance(connect=no_connect, binding=GroupJournalBinding(**values), continuity=Continuity()) + def revoke(self, inv): + self.revoked.append(inv) + return 'session-revocation:' + inv.attempt_id + def publish(self, payload): ref = 'owner-evidence:' + str(len(self.evidence)) self.evidence[ref] = payload @@ -116,6 +121,38 @@ class EnsureGroupsTests(unittest.TestCase): self.assertIs(revoke['delete_if_empty'], False) self.assertTrue(adapter.verify(rb_inv, rolled)) self.assertFalse(adapter.verify(self.inv, result)) # journal now says revoked + self.assertEqual(self.revoked, [self.inv, rb_inv]) + for res, inv in ((result, self.inv), (rolled, rb_inv)): + self.assertIs(res.revoked, True) + self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id) + self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence]) + + def test_descriptor_decodes_in_warden_kernel(self): + desc = a.descriptor() + self.assertEqual(desc.revocation_scopes, ('parent-session',)) + raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + self.assertEqual(Descriptor.decode(raw), desc) + + def test_parent_session_revoked_even_when_attempt_fails(self): + def boom(payload): + raise RuntimeError('owner journal unavailable') + self.prov.ensure_service_groups = boom + adapter = a.build(**self.kw) + with self.assertRaises(Refused): # contained as 'owner callback failed' + adapter.invoke(self.inv) + rb_inv = replace(self.inv, attempt_id='attempt-two', operation='rollback') + with self.assertRaises(Refused): + adapter.rollback(rb_inv, ('foreign-object',)) + self.assertEqual(self.revoked, [self.inv, rb_inv]) + self.assertEqual(self.evidence, {}) + + def test_missing_revocation_evidence_refused_before_publication(self): + for bad in (None, '', 'bad id', 7): + with self.subTest(bad=bad), self.assertRaises(Refused): + a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv) + self.assertEqual(self.evidence, {}) + with self.assertRaises(Refused): + a.build(**{**self.kw, 'revoke_session': None}) def test_replay_resumes_without_creation(self): adapter = a.build(**self.kw) diff --git a/sso-mfa/k8s/keycape/test_handover_adapter.py b/sso-mfa/k8s/keycape/test_handover_adapter.py index 97f44ca..2dcbe93 100644 --- a/sso-mfa/k8s/keycape/test_handover_adapter.py +++ b/sso-mfa/k8s/keycape/test_handover_adapter.py @@ -5,7 +5,7 @@ import json from pathlib import Path import unittest -from warden.case_run import Invocation, OwnerResult, digest +from warden.case_run import Descriptor, Invocation, OwnerResult, digest from warden.case_receipt_state import Outcome, Pins, Refused import yaml @@ -24,20 +24,25 @@ def case_bytes(type_=a.CANONICAL_TYPE, params=PARAMS): class HandoverTests(unittest.TestCase): def setUp(self): self.case = case_bytes() - desc_bytes = json.dumps(asdict(a.descriptor()), sort_keys=True, separators=(',', ':')).encode() + desc_bytes = json.dumps(asdict(a.descriptor('apps')), sort_keys=True, separators=(',', ':')).encode() 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('hand-over', 'apps', 'forgejo', 'human', 'founder', 'apps-consumer-handover', pins) self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.rb = replace(self.admitted, attempt_id='attempt-two', operation='rollback') - self.evidence, self.receipts = {}, set() + self.evidence, self.receipts, self.revoked = {}, set(), [] self.observed = {'client_id': 'forgejo', 'enforces_groups': True, 'groups': ['forge-admins', 'forge-users'], 'login_verified': True} 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, - authenticate=lambda ref, payload: self.evidence.get(ref) == payload) + authenticate=lambda ref, payload: self.evidence.get(ref) == payload, + revoke_session=self.revoke) + + def revoke(self, inv): + self.revoked.append(inv) + return 'session-revocation:' + inv.attempt_id def publish(self, payload): ref = 'owner-evidence:' + str(len(self.evidence)) @@ -63,6 +68,40 @@ class HandoverTests(unittest.TestCase): back = adapter.rollback(self.rb, (a.RECEIPT,)) self.assertEqual(back.outcome, Outcome.ROLLED_BACK) self.assertEqual(self.receipts, set()) + self.assertEqual(self.revoked, [self.inv, self.rb]) + for res, inv in ((result, self.inv), (back, self.rb)): + self.assertIs(res.revoked, True) + self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id) + self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence]) + + def test_descriptor_decodes_in_warden_kernel_with_parameter_owner(self): + for owner in ('apps', 'railiance-apps'): + desc = a.descriptor(owner) + self.assertEqual((desc.owner, desc.revocation_scopes), (owner, ('parent-session',))) + raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode() + self.assertEqual(Descriptor.decode(raw), desc) + for owner in (None, '', 'bad owner', 3): + with self.subTest(owner=owner), self.assertRaises(Refused): + a.descriptor(owner) + + def test_parent_session_revoked_even_when_attempt_fails(self): + self.observed['login_verified'] = False + adapter = a.build(**self.kw) + with self.assertRaises(Refused): + adapter.invoke(self.inv) + self.assertEqual(self.revoked, [self.inv]) + with self.assertRaises(Refused): # nothing created: rollback refused, session still revoked + adapter.rollback(self.rb, (a.RECEIPT,)) + self.assertEqual(self.revoked, [self.inv, self.rb]) + self.assertEqual(self.evidence, {}) + + def test_missing_revocation_evidence_refused_before_publication(self): + for bad in (None, '', 'bad id', 7): + with self.subTest(bad=bad), self.assertRaises(Refused): + a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv) + self.assertEqual(self.evidence, {}) + with self.assertRaises(Refused): + a.build(**{**self.kw, 'revoke_session': None}) def test_existing_receipt_is_no_change_and_not_rollbackable(self): self.receipts.add('handover:forgejo') @@ -98,7 +137,17 @@ class HandoverTests(unittest.TestCase): admitted12 = replace(self.admitted, pins=pins) with self.assertRaises(Refused): # 1.2 never reached by default a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12}) - a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'}) + with self.assertRaises(Refused): # 1.2 case must name consumer_owner_repo + a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'}) + case12 = case_bytes(a.SELECTABLE_TYPES['1.2'], {**PARAMS, 'consumer_owner_repo': 'railiance-apps'}) + desc = json.dumps(asdict(a.descriptor('railiance-apps')), sort_keys=True, separators=(',', ':')).encode() + admitted12 = replace(self.admitted, owner='railiance-apps', + pins=replace(self.admitted.pins, case=digest(case12), adapter=digest(desc))) + kw12 = {**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'} + inv = replace(admitted12, attempt_id='attempt-one', operation='run') + self.assertEqual(a.build(**kw12).invoke(inv).outcome, Outcome.SUCCESS) + with self.assertRaises(Refused): # admitted owner differs from the case's consumer_owner_repo + a.build(**{**kw12, 'admitted': replace(admitted12, owner='apps')}) def test_binding_refusals(self): bad = [{'case_acceptance': 'other'}, {'case_source': self.case + b'\n#'},