diff --git a/sso-mfa/k8s/keycape/mint-secret-adapter.md b/sso-mfa/k8s/keycape/mint-secret-adapter.md index febb615..bae111f 100644 --- a/sso-mfa/k8s/keycape/mint-secret-adapter.md +++ b/sso-mfa/k8s/keycape/mint-secret-adapter.md @@ -15,6 +15,8 @@ modelled on the T13 registration adapter. authenticate is destroyed, by exact version; otherwise rollback is refused. - **Revocation.** Descriptor effect `mint` with scopes `parent-session` and `broker-grant`; both are revoked after every attempt, including failures. + Each revocation is attempted independently: if one raises, the other is still + called, and the attempt is refused rather than reported as success. - Confidential clients only; case, descriptor, acceptance and attempt pins bound as in T13. Not retry-safe. diff --git a/sso-mfa/k8s/keycape/mint-secret-adapter.py b/sso-mfa/k8s/keycape/mint-secret-adapter.py index a6c7c19..3714ba1 100644 --- a/sso-mfa/k8s/keycape/mint-secret-adapter.py +++ b/sso-mfa/k8s/keycape/mint-secret-adapter.py @@ -113,8 +113,21 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, receipt = destroy(path, version) check(receipt, 'destroy', version) finally: + # Each revocation is attempted independently: a failing session + # revocation must not leave the broker grant alive, or the reverse. + # The first failure is re-raised only after both were attempted. with contained_output(): - session, grant = revoke_session(inv), revoke_grant(inv) + session = grant = failure = None + try: + session = revoke_session(inv) + except BaseException as exc: + failure = exc + try: + grant = revoke_grant(inv) + except BaseException as exc: + failure = failure or exc + if failure is not None: + raise failure for ref in (session, grant): if not isinstance(ref, str) or not IDENTIFIER.fullmatch(ref): raise Refused('revocation evidence missing') diff --git a/sso-mfa/k8s/keycape/test_mint_secret_adapter.py b/sso-mfa/k8s/keycape/test_mint_secret_adapter.py index 6faa80f..31749f2 100644 --- a/sso-mfa/k8s/keycape/test_mint_secret_adapter.py +++ b/sso-mfa/k8s/keycape/test_mint_secret_adapter.py @@ -90,6 +90,37 @@ class MintSecretTests(unittest.TestCase): self.assertEqual([c[0] for c in self.calls], []) self.assertEqual(self.revoked, [('session', 'rollback'), ('grant', 'rollback')]) + def _failing(self, kind): + def revoke(inv): + self.revoked.append((kind, inv.operation)) + raise RuntimeError(kind + ' revocation unavailable') + return revoke + + def test_failing_session_revocation_still_revokes_grant(self): + self.kw['revoke_session'] = self._failing('session') + adapter = a.build(**self.kw) + with self.assertRaises(Exception): + adapter.invoke(self.run) + self.assertEqual(self.revoked, [('session', 'run'), ('grant', 'run')]) + self.assertEqual(self.evidence, {}) # never published as success + + def test_failing_grant_revocation_still_revokes_session(self): + self.kw['revoke_grant'] = self._failing('grant') + adapter = a.build(**self.kw) + with self.assertRaises(Exception): + adapter.invoke(self.run) + self.assertEqual(self.revoked, [('session', 'run'), ('grant', 'run')]) + self.assertEqual(self.evidence, {}) + + def test_both_revocations_failing_attempts_both_and_refuses(self): + self.kw['revoke_session'] = self._failing('session') + self.kw['revoke_grant'] = self._failing('grant') + adapter = a.build(**self.kw) + with self.assertRaises(Exception): + adapter.rollback(self.back, a.CREATED) + self.assertEqual(self.revoked, [('session', 'rollback'), ('grant', 'rollback')]) + self.assertEqual(self.evidence, {}) + def test_receipt_with_value_or_extra_field_refused(self): for bad in (receipt('mint', value='x'), receipt('mint', values_emitted=True), receipt('mint', path='platform/workloads/keycape/other'),