gargoyle-receipts 0.15.1.dev0__tar.gz → 0.16.2.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {gargoyle_receipts-0.15.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.2.dev0}/PKG-INFO +20 -15
  2. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/README.md +18 -13
  3. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approvers.py +95 -9
  4. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/cli.py +52 -24
  5. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/identity.py +40 -4
  6. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer_service.py +149 -13
  7. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.html +13 -3
  8. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info}/PKG-INFO +20 -15
  9. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -1
  10. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/requires.txt +1 -1
  11. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/pyproject.toml +5 -3
  12. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approvers.py +95 -0
  13. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_cli.py +16 -18
  14. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_console_js.py +2 -2
  15. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_identity.py +58 -0
  16. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_issuer_service.py +98 -1
  17. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_self_issue.py +3 -3
  18. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_remote.py +2 -2
  19. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_revert.py +2 -2
  20. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_state.py +4 -4
  21. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_witness.py +8 -5
  22. gargoyle_receipts-0.15.1.dev0/gargoyle/review.py +0 -213
  23. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/LICENSE +0 -0
  24. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/NOTICE +0 -0
  25. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/__init__.py +0 -0
  26. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/adapters.py +0 -0
  27. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approval.py +0 -0
  28. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_cli.py +0 -0
  29. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_trust.py +0 -0
  30. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/consumer.py +0 -0
  31. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/entra_login.py +0 -0
  32. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/envelope.py +0 -0
  33. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/evidence.py +0 -0
  34. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/execution.py +0 -0
  35. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/fs_server.py +0 -0
  36. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/holdstore.py +0 -0
  37. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer.py +0 -0
  38. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/jws.py +0 -0
  39. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/key_record_source.py +0 -0
  40. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/mcp_server.py +0 -0
  41. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/notify.py +0 -0
  42. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/opa.py +0 -0
  43. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/pending_store.py +0 -0
  44. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/plans.py +0 -0
  45. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/policy.py +0 -0
  46. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/published_record_source.py +0 -0
  47. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/reconcile.py +0 -0
  48. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/remote.py +0 -0
  49. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/replay_cache.py +0 -0
  50. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/revert.py +0 -0
  51. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/rules.py +0 -0
  52. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/specs/graph-reference.json +0 -0
  53. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/state.py +0 -0
  54. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.js +0 -0
  55. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/verifier.py +0 -0
  56. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/witness.py +0 -0
  57. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  58. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  59. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  60. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/setup.cfg +0 -0
  61. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_annotations.py +0 -0
  62. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approver_cli.py +0 -0
  63. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_canonical.py +0 -0
  64. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_consumer.py +0 -0
  65. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_login.py +0 -0
  66. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_evidence.py +0 -0
  67. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_execution.py +0 -0
  68. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_fs_server.py +0 -0
  69. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_graph.py +0 -0
  70. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_historical.py +0 -0
  71. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_identity.py +0 -0
  72. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_jws.py +0 -0
  73. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_proxy.py +0 -0
  74. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_server.py +0 -0
  75. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_notify.py +0 -0
  76. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_opa.py +0 -0
  77. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_plans.py +0 -0
  78. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_policy.py +0 -0
  79. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_published_record.py +0 -0
  80. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_rules.py +0 -0
  81. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_stdio_encoding.py +0 -0
  82. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_threshold.py +0 -0
  83. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_typescript_conformance.py +0 -0
  84. {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.15.1.dev0
3
+ Version: 0.16.2.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -25,7 +25,7 @@ License-File: NOTICE
25
25
  Requires-Dist: cryptography>=44.0.0
26
26
  Requires-Dist: fastapi>=0.115
27
27
  Requires-Dist: uvicorn>=0.30
28
- Requires-Dist: requests>=2.32
28
+ Requires-Dist: requests>=2.32.3
29
29
  Provides-Extra: redis
30
30
  Requires-Dist: redis; extra == "redis"
31
31
  Provides-Extra: entra
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.15.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -221,7 +221,9 @@ chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval"
221
221
  That is the sentence Gargoyle can say that nobody else can: a specific
222
222
  policy version authorized a specific call for a specific principal, a
223
223
  human's key released it, and exactly that call ran once, verifiable offline
224
- by anyone, in Python or TypeScript.
224
+ by anyone. The receipt, grant and execution receipt verify in Python or
225
+ TypeScript against the same vectors; the evidence log, the witness, plans
226
+ and the older formats verify in Python.
225
227
 
226
228
  A held call is approved against the state it was shown in. Before asking
227
229
  for the receipt the middleware hashes the target (a file's bytes, or its
@@ -272,15 +274,6 @@ field. A hold can also be announced to Slack or any webhook
272
274
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
273
275
  chat channel can approve anything.
274
276
 
275
- ## Reviewing it
276
-
277
- `gargoyle review-packet --out packet.zip` builds one archive for an outside
278
- reviewer: the spec, the vectors, the verification source, a reviewer
279
- prompt, and `verify_stdlib.py`, which recomputes every conformance vector
280
- (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
281
- so a reviewer with no pip and no network can still check every hash and
282
- signature rather than take the README's word.
283
-
284
277
  ## Deploy it
285
278
 
286
279
  pip install gargoyle-receipts
@@ -297,7 +290,17 @@ the page shows each held call with its full arguments, checked against
297
290
  the receipt, and signs the grant locally with WebCrypto. The key never
298
291
  leaves the browser, and with Entra it is generated there, non-exportable,
299
292
  and enrolled under the approver's UPN, so grants and audits name the
300
- person. Then paste the server entry from
293
+ person. An approver retires a lost or replaced key with
294
+ `POST /approvers/revoke` from their own sign-in; the operator revokes
295
+ anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
296
+ the running service picks the change up without a restart. Revoked keys
297
+ stay on file so earlier grants still audit; new ones are refused at the
298
+ issuer and at every middleware on its next held call. Revocation retires
299
+ a key, not a person: while the Approver role stands, the person can sign
300
+ in and enroll a fresh key, so remove the role too; the revoked key itself
301
+ is never accepted again. Approvers, and
302
+ only approvers, see the whole queue and the witness; everyone else sees
303
+ their own holds. Then paste the server entry from
301
304
  `gargoyle-deploy/mcp-config.json` into your MCP client.
302
305
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
303
306
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -387,8 +390,10 @@ cannot attach receipts work unchanged. Configuration is by environment:
387
390
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
388
391
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
389
392
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
390
- | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
393
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
394
+ | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
391
395
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
396
+ | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
392
397
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
393
398
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
394
399
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.15.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -183,7 +183,9 @@ chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval"
183
183
  That is the sentence Gargoyle can say that nobody else can: a specific
184
184
  policy version authorized a specific call for a specific principal, a
185
185
  human's key released it, and exactly that call ran once, verifiable offline
186
- by anyone, in Python or TypeScript.
186
+ by anyone. The receipt, grant and execution receipt verify in Python or
187
+ TypeScript against the same vectors; the evidence log, the witness, plans
188
+ and the older formats verify in Python.
187
189
 
188
190
  A held call is approved against the state it was shown in. Before asking
189
191
  for the receipt the middleware hashes the target (a file's bytes, or its
@@ -234,15 +236,6 @@ field. A hold can also be announced to Slack or any webhook
234
236
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
235
237
  chat channel can approve anything.
236
238
 
237
- ## Reviewing it
238
-
239
- `gargoyle review-packet --out packet.zip` builds one archive for an outside
240
- reviewer: the spec, the vectors, the verification source, a reviewer
241
- prompt, and `verify_stdlib.py`, which recomputes every conformance vector
242
- (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
243
- so a reviewer with no pip and no network can still check every hash and
244
- signature rather than take the README's word.
245
-
246
239
  ## Deploy it
247
240
 
248
241
  pip install gargoyle-receipts
@@ -259,7 +252,17 @@ the page shows each held call with its full arguments, checked against
259
252
  the receipt, and signs the grant locally with WebCrypto. The key never
260
253
  leaves the browser, and with Entra it is generated there, non-exportable,
261
254
  and enrolled under the approver's UPN, so grants and audits name the
262
- person. Then paste the server entry from
255
+ person. An approver retires a lost or replaced key with
256
+ `POST /approvers/revoke` from their own sign-in; the operator revokes
257
+ anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
258
+ the running service picks the change up without a restart. Revoked keys
259
+ stay on file so earlier grants still audit; new ones are refused at the
260
+ issuer and at every middleware on its next held call. Revocation retires
261
+ a key, not a person: while the Approver role stands, the person can sign
262
+ in and enroll a fresh key, so remove the role too; the revoked key itself
263
+ is never accepted again. Approvers, and
264
+ only approvers, see the whole queue and the witness; everyone else sees
265
+ their own holds. Then paste the server entry from
263
266
  `gargoyle-deploy/mcp-config.json` into your MCP client.
264
267
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
265
268
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -349,8 +352,10 @@ cannot attach receipts work unchanged. Configuration is by environment:
349
352
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
350
353
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
351
354
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
352
- | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
355
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
356
+ | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
353
357
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
358
+ | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
354
359
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
355
360
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
356
361
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -35,6 +35,11 @@ ENV_APPROVER_GROUPS = "GARGOYLE_APPROVER_GROUPS" # or: comma-separated group
35
35
  DEFAULT_APPROVER_ROLE = "Approver"
36
36
 
37
37
 
38
+ class RevokedKeyError(ValueError):
39
+ """The public key offered for enrollment was revoked before: a revoked
40
+ key stays revoked, the browser must generate a new one."""
41
+
42
+
38
43
  def valid_public_key_hex(value: Any) -> bool:
39
44
  return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
40
45
 
@@ -47,26 +52,61 @@ class ApproverRegistry:
47
52
  self.path = Path(path) if path else None
48
53
  self._lock = threading.Lock()
49
54
  self._approvers: Dict[str, Dict[str, Any]] = {}
50
- if self.path is not None and self.path.exists():
51
- doc = json.loads(self.path.read_text(encoding="utf-8"))
52
- for upn, entry in (doc.get("approvers") or {}).items():
53
- if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
54
- self._approvers[upn] = dict(entry)
55
+ self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
56
+ self._mtime: Optional[float] = None
57
+ self._load()
58
+
59
+ def _load(self) -> None:
60
+ if self.path is None or not self.path.exists():
61
+ return
62
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
63
+ approvers, revoked = {}, {}
64
+ for upn, entry in (doc.get("approvers") or {}).items():
65
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
66
+ approvers[upn] = dict(entry)
67
+ for upn, entry in (doc.get("revoked") or {}).items():
68
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
69
+ revoked[upn] = dict(entry)
70
+ self._approvers, self._revoked = approvers, revoked
71
+ self._mtime = self.path.stat().st_mtime
72
+
73
+ def _refresh(self) -> None:
74
+ """Re-read the file when something else wrote it: `gargoyle approvers
75
+ revoke` on the issuer's host takes effect without a restart."""
76
+ if self.path is None:
77
+ return
78
+ try:
79
+ mtime = self.path.stat().st_mtime
80
+ except OSError:
81
+ return
82
+ if mtime != self._mtime:
83
+ self._load()
55
84
 
56
85
  def _save(self) -> None:
57
86
  if self.path is None:
58
87
  return
59
88
  self.path.parent.mkdir(parents=True, exist_ok=True)
60
- self.path.write_text(json.dumps({"approvers": self._approvers}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
89
+ doc = {"approvers": self._approvers}
90
+ if self._revoked:
91
+ doc["revoked"] = self._revoked
92
+ self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
61
93
  try:
62
94
  os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
63
95
  except OSError:
64
96
  pass
97
+ self._mtime = self.path.stat().st_mtime
65
98
 
66
99
  def enroll(self, upn: str, public_key_hex: str, *, enrolled_by: Optional[str] = None,
67
- now: Optional[datetime] = None) -> Dict[str, Any]:
100
+ now: Optional[datetime] = None, oid: Optional[str] = None, tid: Optional[str] = None) -> Dict[str, Any]:
68
101
  """Bind `upn` to `public_key_hex`, replacing any earlier key for that
69
- UPN (one key per approver; a new browser re-enrolls)."""
102
+ UPN (one key per approver; a new browser re-enrolls). The directory's
103
+ immutable object id and tenant id are kept beside the UPN when the
104
+ token carried them: the UPN is the name people read, the oid is what
105
+ survives a rename. A key on the revoked list is never enrolled again,
106
+ for anyone (RevokedKeyError): the live exercise of 2026-10-20 showed a
107
+ browser re-enrolling the very key the operator had just revoked. The
108
+ revoked record stays when a fresh key enrolls, so grants signed under
109
+ the old key still audit."""
70
110
  if not isinstance(upn, str) or not upn:
71
111
  raise ValueError("upn must be a non-empty string")
72
112
  if not valid_public_key_hex(public_key_hex):
@@ -74,14 +114,37 @@ class ApproverRegistry:
74
114
  entry = {"public_key_hex": public_key_hex,
75
115
  "enrolled_at": (now or datetime.now(timezone.utc)).isoformat(),
76
116
  "enrolled_by": enrolled_by or upn}
117
+ if oid:
118
+ entry["oid"] = oid
119
+ if tid:
120
+ entry["tid"] = tid
77
121
  with self._lock:
122
+ self._refresh()
123
+ if any(e.get("public_key_hex") == public_key_hex for e in self._revoked.values()):
124
+ raise RevokedKeyError("that key was revoked; enroll a new one")
78
125
  replaced = upn in self._approvers
79
126
  self._approvers[upn] = entry
80
127
  self._save()
81
128
  return {"upn": upn, "replaced": replaced, **entry}
82
129
 
130
+ def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
131
+ """Stop trusting `upn`'s key from now on. The entry moves to the
132
+ revoked list with when and by whom, so an audit of earlier evidence
133
+ can still verify the grants it signed; the issuer and the
134
+ middleware stop accepting new ones at once."""
135
+ with self._lock:
136
+ self._refresh()
137
+ entry = self._approvers.pop(upn, None)
138
+ if entry is None:
139
+ return False
140
+ self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
141
+ "revoked_by": revoked_by or upn}
142
+ self._save()
143
+ return True
144
+
83
145
  def remove(self, upn: str) -> bool:
84
146
  with self._lock:
147
+ self._refresh()
85
148
  gone = self._approvers.pop(upn, None) is not None
86
149
  if gone:
87
150
  self._save()
@@ -90,15 +153,38 @@ class ApproverRegistry:
90
153
  def public_hex(self) -> Dict[str, str]:
91
154
  """{upn: public_key_hex}, what GET /approvers publishes."""
92
155
  with self._lock:
156
+ self._refresh()
93
157
  return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
94
158
 
159
+ def revoked_entries(self) -> Dict[str, Dict[str, Any]]:
160
+ """The revoked list with when and by whom, re-read from the file: the
161
+ service notices an operator's `gargoyle approvers revoke` here."""
162
+ with self._lock:
163
+ self._refresh()
164
+ return {upn: dict(e) for upn, e in self._revoked.items()}
165
+
166
+ def revoked_hex(self) -> Dict[str, str]:
167
+ """{upn: public_key_hex} of revoked approvers: for verifying the
168
+ grants they signed while trusted, never for accepting new ones."""
169
+ with self._lock:
170
+ self._refresh()
171
+ return {upn: e["public_key_hex"] for upn, e in self._revoked.items()}
172
+
173
+ def entries(self) -> Dict[str, Dict[str, Any]]:
174
+ """Every current approver's record, for `gargoyle approvers list`."""
175
+ with self._lock:
176
+ self._refresh()
177
+ return {upn: dict(e) for upn, e in self._approvers.items()}
178
+
95
179
  def keys(self) -> Dict[str, Any]:
96
180
  """{upn: Ed25519PublicKey}, the map a verifier uses."""
97
181
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
98
182
  return {upn: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for upn, h in self.public_hex().items()}
99
183
 
100
184
  def __contains__(self, upn: str) -> bool:
101
- return upn in self._approvers
185
+ with self._lock:
186
+ self._refresh()
187
+ return upn in self._approvers
102
188
 
103
189
 
104
190
  def may_enroll(who: Dict[str, Any], *, role: str = DEFAULT_APPROVER_ROLE, groups: Optional[list] = None) -> bool:
@@ -14,8 +14,6 @@ policy engine, a container runtime, or any client changes.
14
14
  gargoyle remote --out DIR [--upstream-url URL | -- <command>] the gate as an HTTPS MCP
15
15
  server, for hosted connectors
16
16
  gargoyle remote-token --out DIR --principal UPN a capability URL for one person
17
- gargoyle review-packet --out packet.zip [--audit-excerpt FILE] everything an outside
18
- reviewer needs, verifier included
19
17
  gargoyle audit <receipt.json> <execution.json> [grant.json]
20
18
  gargoyle audit --log audit.jsonl verify the middleware's evidence log
21
19
 
@@ -45,6 +43,8 @@ import os
45
43
  import re
46
44
  import stat
47
45
  import sys
46
+
47
+ from gargoyle.approvers import DEFAULT_APPROVER_ROLE
48
48
  from datetime import datetime, timedelta, timezone
49
49
  from pathlib import Path
50
50
 
@@ -109,7 +109,11 @@ def cmd_init(args) -> int:
109
109
  executor, executor_hex, executor_pub = new_key()
110
110
  exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
111
111
  client_token = encode({"sub": PRINCIPAL, "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
112
- approver_token = encode({"sub": "approver", "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
112
+ # One identity token per approver, named by the key id it travels with and
113
+ # carrying the approver role: the issuer shows the whole queue to approvers
114
+ # and only a person's own holds to anyone else.
115
+ def approver_token_for(kid: str) -> str:
116
+ return encode({"sub": kid, "roles": [DEFAULT_APPROVER_ROLE], "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
113
117
  issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
114
118
 
115
119
  rules = out / "rules.json"
@@ -137,6 +141,7 @@ def cmd_init(args) -> int:
137
141
  _write(out / "middleware.env", _env_lines(middleware_env), private=True)
138
142
  b64url = lambda b: __import__("base64").urlsafe_b64encode(b).rstrip(b"=").decode()
139
143
  for n, (kid, _k, khex, kpub) in enumerate(approvers, 1):
144
+ approver_token = approver_token_for(kid)
140
145
  # approver.env / approver-web.json for the first; -2, -3 ... for the others.
141
146
  suffix = "" if n == 1 else "-%d" % n
142
147
  _write(out / ("approver%s.env" % suffix), _env_lines({
@@ -175,7 +180,9 @@ WantedBy=multi-user.target
175
180
  ProxyPass / http://127.0.0.1:%(port)d/
176
181
  ProxyPassReverse / http://127.0.0.1:%(port)d/
177
182
  ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
178
- CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined
183
+ # Remote-mode capability URLs carry a bearer token in the path; keep them out of the access log.
184
+ SetEnvIf Request_URI "^/mcp/" gargoyle_capability_url
185
+ CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined env=!gargoyle_capability_url
179
186
  </VirtualHost>
180
187
  """ % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
181
188
  upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
@@ -284,21 +291,9 @@ def cmd_remote(args) -> int:
284
291
  return 0
285
292
 
286
293
 
287
- def cmd_review_packet(args) -> int:
288
- """One archive for an outside reviewer: spec, README, vectors, the
289
- verification source, a reviewer prompt, and verify_stdlib.py, which
290
- recomputes every vector with the standard library alone."""
291
- from gargoyle.review import build_packet
292
- root = Path(__file__).resolve().parent.parent
293
- excerpt = Path(args.audit_excerpt).read_text(encoding="utf-8") if args.audit_excerpt else None
294
- result = build_packet(str(root), args.out, audit_excerpt=excerpt, extra_files=args.extra or [])
295
- print("wrote %s (%d files); give the reviewer the archive and REVIEWER_PROMPT.txt" % (result["path"], len(result["files"])))
296
- return 0
297
-
298
-
299
294
  def cmd_issuer(args) -> int:
300
295
  import uvicorn
301
- from gargoyle.issuer_service import app_from_env
296
+ from gargoyle.issuer_service import app_from_env, redact_access_log
302
297
 
303
298
  out = Path(args.out).resolve()
304
299
  os.environ.update(_read_env(out / "issuer.env"))
@@ -307,6 +302,7 @@ def cmd_issuer(args) -> int:
307
302
  os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
308
303
  os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
309
304
  app = app_from_env()
305
+ redact_access_log() # no sign-in codes in the access log
310
306
  # The middleware reads the policy pin from a file; write it next to the
311
307
  # config so the client-side entry finds it at the configured path.
312
308
  from gargoyle.rules import RulesPolicy
@@ -335,6 +331,34 @@ def cmd_approve(args) -> int:
335
331
  return approver_main(argv)
336
332
 
337
333
 
334
+ def cmd_approvers(args) -> int:
335
+ """List the enrolled approvers, or revoke one. Works on the issuer's
336
+ approvers.json; the running service re-reads the file, so a revocation
337
+ takes effect without a restart and reaches the middleware at its next
338
+ held call."""
339
+ from gargoyle.approvers import ApproverRegistry
340
+
341
+ reg = ApproverRegistry(str(Path(args.out).resolve() / "approvers.json"))
342
+ if args.action == "list":
343
+ entries = reg.entries()
344
+ if not entries:
345
+ print("no enrolled approvers (configured approver keys are in issuer.env)")
346
+ for upn, e in sorted(entries.items()):
347
+ print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
348
+ (" oid " + e["oid"]) if e.get("oid") else ""))
349
+ revoked = reg.revoked_entries()
350
+ if revoked:
351
+ print("revoked: " + ", ".join("%s (key %s, %s by %s)" % (u, e["public_key_hex"][:16], e.get("revoked_at", "?")[:19], e.get("revoked_by", "?"))
352
+ for u, e in sorted(revoked.items())))
353
+ return 0
354
+ import getpass
355
+ if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
356
+ print("revoked %s; grants under that key are refused from now on, earlier ones still audit" % args.upn)
357
+ return 0
358
+ print("%s is not an enrolled approver" % args.upn, file=sys.stderr)
359
+ return 1
360
+
361
+
338
362
  def cmd_audit(args) -> int:
339
363
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
340
364
  from gargoyle.execution import verify_chain
@@ -344,7 +368,11 @@ def cmd_audit(args) -> int:
344
368
  approvers = dict(keys["approver"])
345
369
  enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
346
370
  if enrolled.exists():
347
- approvers.update({u: e["public_key_hex"] for u, e in json.loads(enrolled.read_text()).get("approvers", {}).items()})
371
+ doc = json.loads(enrolled.read_text())
372
+ # Revoked approvers too: the audit verifies what was signed while they
373
+ # were trusted. Only the live service and middleware refuse them.
374
+ approvers.update({u: e["public_key_hex"] for u, e in doc.get("revoked", {}).items()})
375
+ approvers.update({u: e["public_key_hex"] for u, e in doc.get("approvers", {}).items()})
348
376
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
349
377
  approver_public_keys=load(approvers))
350
378
  if args.log:
@@ -468,14 +496,14 @@ def main(argv=None) -> int:
468
496
  p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
469
497
  p.add_argument("--out", default="gargoyle-deploy")
470
498
  p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
471
- p.add_argument("--days", type=int, default=365)
499
+ p.add_argument("--days", type=int, default=30, help="lifetime of the identity token inside the capability URL (default 30)")
472
500
  p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
473
501
  p.set_defaults(fn=cmd_remote_token)
474
- p = sub.add_parser("review-packet", help="build the archive an outside reviewer needs")
475
- p.add_argument("--out", default="gargoyle-review-packet.zip")
476
- p.add_argument("--audit-excerpt", help="a text file with real audit output to include")
477
- p.add_argument("--extra", action="append", help="additional files to include (repeatable)")
478
- p.set_defaults(fn=cmd_review_packet)
502
+ p = sub.add_parser("approvers", help="list enrolled approvers, or revoke one on the issuer's host")
503
+ p.add_argument("action", choices=["list", "revoke"])
504
+ p.add_argument("upn", nargs="?", help="the approver to revoke")
505
+ p.add_argument("--out", default="gargoyle-deploy")
506
+ p.set_defaults(fn=cmd_approvers)
479
507
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
480
508
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
481
509
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
@@ -32,15 +32,38 @@ entra_keys (fetched from the tenant's discovery endpoint; cache and
32
32
  refresh on an unknown kid, as every OIDC client does).
33
33
  """
34
34
 
35
+ import re
35
36
  from datetime import datetime, timezone
36
- from typing import Any, Dict, Optional
37
+ from typing import Any, Dict, Optional, Set
37
38
 
38
39
  from gargoyle.jws import (
39
40
  IDENTITY_ALGS, REF_JWS_AUDIENCE_MISMATCH, REF_JWS_EXPIRED, REF_JWS_MALFORMED, decode, keys_from_jwks,
40
41
  )
41
42
 
42
43
  REF_IDENTITY_NO_SUBJECT = "REF_IDENTITY_NO_SUBJECT"
44
+ REF_IDENTITY_WRONG_TENANT = "REF_IDENTITY_WRONG_TENANT" # a directory token for some other tenant, or one that names none
43
45
  ENTRA_JWKS_URL = "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
46
+ ENTRA_OPENID_URL = "https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration"
47
+ _GUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
48
+
49
+
50
+ def entra_tenant_id(tenant: str, *, http: Any = None, timeout: float = 10.0) -> str:
51
+ """The tenant id (a GUID) for a tenant given by id or by domain. Tokens
52
+ name their tenant by id in `tid`, so the check in principal_from_token
53
+ needs the id; a domain is resolved once, from the tenant's OpenID
54
+ configuration, at startup. Raises on transport failure."""
55
+ if _GUID.match(tenant):
56
+ return tenant.lower()
57
+ import requests
58
+ http = http if http is not None else requests
59
+ r = http.get(ENTRA_OPENID_URL.format(tenant=tenant), timeout=timeout)
60
+ if r.status_code != 200:
61
+ raise RuntimeError("OpenID configuration fetch returned %s" % r.status_code)
62
+ issuer = r.json().get("issuer") or ""
63
+ m = re.search(r"/([0-9a-fA-F-]{36})/", issuer)
64
+ if not m or not _GUID.match(m.group(1)):
65
+ raise RuntimeError("OpenID configuration for %s names no tenant id: %r" % (tenant, issuer))
66
+ return m.group(1).lower()
44
67
 
45
68
 
46
69
  def entra_keys(tenant: str, *, http: Any = None, timeout: float = 10.0) -> Dict[str, Any]:
@@ -77,9 +100,18 @@ def _innermost_actor(act: Any) -> Optional[str]:
77
100
 
78
101
 
79
102
  def principal_from_token(token: str, public_keys: Dict[str, Any], *,
80
- audience: Any = None, now: Optional[datetime] = None) -> Dict[str, Any]:
103
+ audience: Any = None, now: Optional[datetime] = None,
104
+ tenant: Optional[str] = None, tenant_key_ids: Optional[Set[str]] = None) -> Dict[str, Any]:
81
105
  """{"principal", "on_behalf_of" (or None), "claims", "reason": None} for
82
- a trusted identity token, or {"principal": None, "reason": code}."""
106
+ a trusted identity token, or {"principal": None, "reason": code}.
107
+
108
+ `tenant` (a tenant id) makes the token's `tid` part of trust: a token
109
+ signed by one of `tenant_key_ids` (the keys fetched from the directory;
110
+ every key when None) must carry that tid, and its `iss`, when present,
111
+ must name it. Directory signing keys are shared across tenants, so a
112
+ valid signature alone says only that some tenant issued the token.
113
+ Tokens under other trusted keys (a pinned key `gargoyle init` wrote)
114
+ name no tenant and are not held to one."""
83
115
  res = decode(token, public_keys, allowed_algs=IDENTITY_ALGS)
84
116
  if res["reason"] is not None:
85
117
  return {"principal": None, "reason": res["reason"]}
@@ -97,6 +129,10 @@ def principal_from_token(token: str, public_keys: Dict[str, Any], *,
97
129
  accepted = audience if isinstance(audience, (list, tuple)) else [a.strip() for a in str(audience).split(",")]
98
130
  if not any(a in presented for a in accepted if a):
99
131
  return {"principal": None, "reason": REF_JWS_AUDIENCE_MISMATCH}
132
+ if tenant is not None and (tenant_key_ids is None or res["header"].get("kid") in tenant_key_ids):
133
+ tid = _nonempty(claims.get("tid")); iss = _nonempty(claims.get("iss"))
134
+ if tid is None or tid.lower() != tenant.lower() or (iss is not None and tenant.lower() not in iss.lower()):
135
+ return {"principal": None, "reason": REF_IDENTITY_WRONG_TENANT}
100
136
 
101
137
  # OIDC providers' `sub` is opaque; the UPN is the identity people and
102
138
  # rules recognise.
@@ -115,4 +151,4 @@ def principal_from_token(token: str, public_keys: Dict[str, Any], *,
115
151
  return {"principal": None, "reason": REF_IDENTITY_NO_SUBJECT}
116
152
  return {"principal": principal, "on_behalf_of": on_behalf_of, "claims": claims, "reason": None,
117
153
  "groups": _string_list(claims.get("groups")), "roles": _string_list(claims.get("roles")),
118
- "oid": _nonempty(claims.get("oid"))}
154
+ "oid": _nonempty(claims.get("oid")), "tid": _nonempty(claims.get("tid"))}