NK48-R2-F1: revoke session and broker grant independently in mint-secret

A raising parent-session revocation no longer skips broker-grant revocation,
and the reverse; the first failure is re-raised after both are attempted, so
the attempt is refused rather than reported as success. Regression tests
cover each direction and both failing.

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
This commit is contained in:
tegwick 2026-10-17 11:07:16 +02:00
parent 68f4bbb8ca
commit eeed8e118e
3 changed files with 47 additions and 1 deletions

View file

@ -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.

View file

@ -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')

View file

@ -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'),