gargoyle-receipts 0.15.0.dev0__tar.gz → 0.16.0.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 (83) hide show
  1. {gargoyle_receipts-0.15.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.0.dev0}/PKG-INFO +16 -6
  2. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/README.md +14 -4
  3. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approvers.py +78 -9
  4. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/cli.py +49 -4
  5. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/identity.py +40 -4
  6. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer_service.py +70 -8
  7. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -6
  8. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/requires.txt +1 -1
  9. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/pyproject.toml +5 -3
  10. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approvers.py +60 -0
  11. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_cli.py +18 -1
  12. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_console_js.py +2 -2
  13. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_identity.py +58 -0
  14. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_issuer_service.py +31 -0
  15. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_self_issue.py +3 -3
  16. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_remote.py +2 -2
  17. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_revert.py +2 -2
  18. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_state.py +4 -4
  19. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_witness.py +8 -5
  20. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/LICENSE +0 -0
  21. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/NOTICE +0 -0
  22. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/__init__.py +0 -0
  23. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/adapters.py +0 -0
  24. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approval.py +0 -0
  25. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_cli.py +0 -0
  26. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_trust.py +0 -0
  27. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/consumer.py +0 -0
  28. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/entra_login.py +0 -0
  29. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/envelope.py +0 -0
  30. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/evidence.py +0 -0
  31. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/execution.py +0 -0
  32. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/fs_server.py +0 -0
  33. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/holdstore.py +0 -0
  34. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer.py +0 -0
  35. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/jws.py +0 -0
  36. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/key_record_source.py +0 -0
  37. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/mcp_server.py +0 -0
  38. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/notify.py +0 -0
  39. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/opa.py +0 -0
  40. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/pending_store.py +0 -0
  41. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/plans.py +0 -0
  42. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/policy.py +0 -0
  43. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/published_record_source.py +0 -0
  44. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/reconcile.py +0 -0
  45. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/remote.py +0 -0
  46. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/replay_cache.py +0 -0
  47. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/revert.py +0 -0
  48. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/rules.py +0 -0
  49. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  50. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/state.py +0 -0
  51. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.html +0 -0
  52. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.js +0 -0
  53. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/verifier.py +0 -0
  54. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/witness.py +0 -0
  55. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  56. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  57. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  58. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  59. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/setup.cfg +0 -0
  60. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_annotations.py +0 -0
  61. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approver_cli.py +0 -0
  62. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_canonical.py +0 -0
  63. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_consumer.py +0 -0
  64. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_login.py +0 -0
  65. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_evidence.py +0 -0
  66. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_execution.py +0 -0
  67. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_fs_server.py +0 -0
  68. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_graph.py +0 -0
  69. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_historical.py +0 -0
  70. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_identity.py +0 -0
  71. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_jws.py +0 -0
  72. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_proxy.py +0 -0
  73. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_server.py +0 -0
  74. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_notify.py +0 -0
  75. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_opa.py +0 -0
  76. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_plans.py +0 -0
  77. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_policy.py +0 -0
  78. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_published_record.py +0 -0
  79. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_rules.py +0 -0
  80. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.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.0.dev0
3
+ Version: 0.16.0.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.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.16.0.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
@@ -288,7 +290,14 @@ the page shows each held call with its full arguments, checked against
288
290
  the receipt, and signs the grant locally with WebCrypto. The key never
289
291
  leaves the browser, and with Entra it is generated there, non-exportable,
290
292
  and enrolled under the approver's UPN, so grants and audits name the
291
- 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. Approvers, and
299
+ only approvers, see the whole queue and the witness; everyone else sees
300
+ their own holds. Then paste the server entry from
292
301
  `gargoyle-deploy/mcp-config.json` into your MCP client.
293
302
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
294
303
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -378,7 +387,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
378
387
  | 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 |
379
388
  | `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` |
380
389
  | 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 |
381
- | 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 |
390
+ | 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 |
391
+ | 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` |
382
392
  | 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 |
383
393
  | 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 |
384
394
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
@@ -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.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.16.0.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
@@ -250,7 +252,14 @@ the page shows each held call with its full arguments, checked against
250
252
  the receipt, and signs the grant locally with WebCrypto. The key never
251
253
  leaves the browser, and with Entra it is generated there, non-exportable,
252
254
  and enrolled under the approver's UPN, so grants and audits name the
253
- 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. Approvers, and
261
+ only approvers, see the whole queue and the witness; everyone else sees
262
+ their own holds. Then paste the server entry from
254
263
  `gargoyle-deploy/mcp-config.json` into your MCP client.
255
264
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
256
265
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -340,7 +349,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
340
349
  | 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 |
341
350
  | `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` |
342
351
  | 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 |
343
- | 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 |
352
+ | 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 |
353
+ | 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` |
344
354
  | 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 |
345
355
  | 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 |
346
356
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
@@ -47,26 +47,57 @@ class ApproverRegistry:
47
47
  self.path = Path(path) if path else None
48
48
  self._lock = threading.Lock()
49
49
  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)
50
+ self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
51
+ self._mtime: Optional[float] = None
52
+ self._load()
53
+
54
+ def _load(self) -> None:
55
+ if self.path is None or not self.path.exists():
56
+ return
57
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
58
+ approvers, revoked = {}, {}
59
+ for upn, entry in (doc.get("approvers") or {}).items():
60
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
61
+ approvers[upn] = dict(entry)
62
+ for upn, entry in (doc.get("revoked") or {}).items():
63
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
64
+ revoked[upn] = dict(entry)
65
+ self._approvers, self._revoked = approvers, revoked
66
+ self._mtime = self.path.stat().st_mtime
67
+
68
+ def _refresh(self) -> None:
69
+ """Re-read the file when something else wrote it: `gargoyle approvers
70
+ revoke` on the issuer's host takes effect without a restart."""
71
+ if self.path is None:
72
+ return
73
+ try:
74
+ mtime = self.path.stat().st_mtime
75
+ except OSError:
76
+ return
77
+ if mtime != self._mtime:
78
+ self._load()
55
79
 
56
80
  def _save(self) -> None:
57
81
  if self.path is None:
58
82
  return
59
83
  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")
84
+ doc = {"approvers": self._approvers}
85
+ if self._revoked:
86
+ doc["revoked"] = self._revoked
87
+ self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
61
88
  try:
62
89
  os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
63
90
  except OSError:
64
91
  pass
92
+ self._mtime = self.path.stat().st_mtime
65
93
 
66
94
  def enroll(self, upn: str, public_key_hex: str, *, enrolled_by: Optional[str] = None,
67
- now: Optional[datetime] = None) -> Dict[str, Any]:
95
+ now: Optional[datetime] = None, oid: Optional[str] = None, tid: Optional[str] = None) -> Dict[str, Any]:
68
96
  """Bind `upn` to `public_key_hex`, replacing any earlier key for that
69
- UPN (one key per approver; a new browser re-enrolls)."""
97
+ UPN (one key per approver; a new browser re-enrolls). The directory's
98
+ immutable object id and tenant id are kept beside the UPN when the
99
+ token carried them: the UPN is the name people read, the oid is what
100
+ survives a rename."""
70
101
  if not isinstance(upn, str) or not upn:
71
102
  raise ValueError("upn must be a non-empty string")
72
103
  if not valid_public_key_hex(public_key_hex):
@@ -74,14 +105,36 @@ class ApproverRegistry:
74
105
  entry = {"public_key_hex": public_key_hex,
75
106
  "enrolled_at": (now or datetime.now(timezone.utc)).isoformat(),
76
107
  "enrolled_by": enrolled_by or upn}
108
+ if oid:
109
+ entry["oid"] = oid
110
+ if tid:
111
+ entry["tid"] = tid
77
112
  with self._lock:
113
+ self._refresh()
78
114
  replaced = upn in self._approvers
79
115
  self._approvers[upn] = entry
116
+ self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
80
117
  self._save()
81
118
  return {"upn": upn, "replaced": replaced, **entry}
82
119
 
120
+ def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
121
+ """Stop trusting `upn`'s key from now on. The entry moves to the
122
+ revoked list with when and by whom, so an audit of earlier evidence
123
+ can still verify the grants it signed; the issuer and the
124
+ middleware stop accepting new ones at once."""
125
+ with self._lock:
126
+ self._refresh()
127
+ entry = self._approvers.pop(upn, None)
128
+ if entry is None:
129
+ return False
130
+ self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
131
+ "revoked_by": revoked_by or upn}
132
+ self._save()
133
+ return True
134
+
83
135
  def remove(self, upn: str) -> bool:
84
136
  with self._lock:
137
+ self._refresh()
85
138
  gone = self._approvers.pop(upn, None) is not None
86
139
  if gone:
87
140
  self._save()
@@ -90,15 +143,31 @@ class ApproverRegistry:
90
143
  def public_hex(self) -> Dict[str, str]:
91
144
  """{upn: public_key_hex}, what GET /approvers publishes."""
92
145
  with self._lock:
146
+ self._refresh()
93
147
  return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
94
148
 
149
+ def revoked_hex(self) -> Dict[str, str]:
150
+ """{upn: public_key_hex} of revoked approvers: for verifying the
151
+ grants they signed while trusted, never for accepting new ones."""
152
+ with self._lock:
153
+ self._refresh()
154
+ return {upn: e["public_key_hex"] for upn, e in self._revoked.items()}
155
+
156
+ def entries(self) -> Dict[str, Dict[str, Any]]:
157
+ """Every current approver's record, for `gargoyle approvers list`."""
158
+ with self._lock:
159
+ self._refresh()
160
+ return {upn: dict(e) for upn, e in self._approvers.items()}
161
+
95
162
  def keys(self) -> Dict[str, Any]:
96
163
  """{upn: Ed25519PublicKey}, the map a verifier uses."""
97
164
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
98
165
  return {upn: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for upn, h in self.public_hex().items()}
99
166
 
100
167
  def __contains__(self, upn: str) -> bool:
101
- return upn in self._approvers
168
+ with self._lock:
169
+ self._refresh()
170
+ return upn in self._approvers
102
171
 
103
172
 
104
173
  def may_enroll(who: Dict[str, Any], *, role: str = DEFAULT_APPROVER_ROLE, groups: Optional[list] = None) -> bool:
@@ -43,6 +43,8 @@ import os
43
43
  import re
44
44
  import stat
45
45
  import sys
46
+
47
+ from gargoyle.approvers import DEFAULT_APPROVER_ROLE
46
48
  from datetime import datetime, timedelta, timezone
47
49
  from pathlib import Path
48
50
 
@@ -107,7 +109,11 @@ def cmd_init(args) -> int:
107
109
  executor, executor_hex, executor_pub = new_key()
108
110
  exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
109
111
  client_token = encode({"sub": PRINCIPAL, "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
110
- 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")
111
117
  issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
112
118
 
113
119
  rules = out / "rules.json"
@@ -135,6 +141,7 @@ def cmd_init(args) -> int:
135
141
  _write(out / "middleware.env", _env_lines(middleware_env), private=True)
136
142
  b64url = lambda b: __import__("base64").urlsafe_b64encode(b).rstrip(b"=").decode()
137
143
  for n, (kid, _k, khex, kpub) in enumerate(approvers, 1):
144
+ approver_token = approver_token_for(kid)
138
145
  # approver.env / approver-web.json for the first; -2, -3 ... for the others.
139
146
  suffix = "" if n == 1 else "-%d" % n
140
147
  _write(out / ("approver%s.env" % suffix), _env_lines({
@@ -173,7 +180,9 @@ WantedBy=multi-user.target
173
180
  ProxyPass / http://127.0.0.1:%(port)d/
174
181
  ProxyPassReverse / http://127.0.0.1:%(port)d/
175
182
  ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
176
- 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
177
186
  </VirtualHost>
178
187
  """ % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
179
188
  upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
@@ -321,6 +330,33 @@ def cmd_approve(args) -> int:
321
330
  return approver_main(argv)
322
331
 
323
332
 
333
+ def cmd_approvers(args) -> int:
334
+ """List the enrolled approvers, or revoke one. Works on the issuer's
335
+ approvers.json; the running service re-reads the file, so a revocation
336
+ takes effect without a restart and reaches the middleware at its next
337
+ held call."""
338
+ from gargoyle.approvers import ApproverRegistry
339
+
340
+ reg = ApproverRegistry(str(Path(args.out).resolve() / "approvers.json"))
341
+ if args.action == "list":
342
+ entries = reg.entries()
343
+ if not entries:
344
+ print("no enrolled approvers (configured approver keys are in issuer.env)")
345
+ for upn, e in sorted(entries.items()):
346
+ print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
347
+ (" oid " + e["oid"]) if e.get("oid") else ""))
348
+ revoked = reg.revoked_hex()
349
+ if revoked:
350
+ print("revoked: " + ", ".join(sorted(revoked)))
351
+ return 0
352
+ import getpass
353
+ if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
354
+ print("revoked %s; grants under that key are refused from now on, earlier ones still audit" % args.upn)
355
+ return 0
356
+ print("%s is not an enrolled approver" % args.upn, file=sys.stderr)
357
+ return 1
358
+
359
+
324
360
  def cmd_audit(args) -> int:
325
361
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
326
362
  from gargoyle.execution import verify_chain
@@ -330,7 +366,11 @@ def cmd_audit(args) -> int:
330
366
  approvers = dict(keys["approver"])
331
367
  enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
332
368
  if enrolled.exists():
333
- approvers.update({u: e["public_key_hex"] for u, e in json.loads(enrolled.read_text()).get("approvers", {}).items()})
369
+ doc = json.loads(enrolled.read_text())
370
+ # Revoked approvers too: the audit verifies what was signed while they
371
+ # were trusted. Only the live service and middleware refuse them.
372
+ approvers.update({u: e["public_key_hex"] for u, e in doc.get("revoked", {}).items()})
373
+ approvers.update({u: e["public_key_hex"] for u, e in doc.get("approvers", {}).items()})
334
374
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
335
375
  approver_public_keys=load(approvers))
336
376
  if args.log:
@@ -454,9 +494,14 @@ def main(argv=None) -> int:
454
494
  p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
455
495
  p.add_argument("--out", default="gargoyle-deploy")
456
496
  p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
457
- p.add_argument("--days", type=int, default=365)
497
+ p.add_argument("--days", type=int, default=30, help="lifetime of the identity token inside the capability URL (default 30)")
458
498
  p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
459
499
  p.set_defaults(fn=cmd_remote_token)
500
+ p = sub.add_parser("approvers", help="list enrolled approvers, or revoke one on the issuer's host")
501
+ p.add_argument("action", choices=["list", "revoke"])
502
+ p.add_argument("upn", nargs="?", help="the approver to revoke")
503
+ p.add_argument("--out", default="gargoyle-deploy")
504
+ p.set_defaults(fn=cmd_approvers)
460
505
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
461
506
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
462
507
  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"))}
@@ -61,7 +61,7 @@ from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF
61
61
  from gargoyle.notify import notifier_from_env
62
62
  from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
63
63
  ApproverRegistry, may_enroll, valid_public_key_hex)
64
- from gargoyle.identity import entra_keys, principal_from_token
64
+ from gargoyle.identity import entra_keys, entra_tenant_id, principal_from_token
65
65
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
66
66
  from gargoyle.policy import Policy
67
67
 
@@ -86,6 +86,7 @@ REF_ISSUER_ARGS_NOT_TEXT = "REF_ISSUER_ARGS_NOT_TEXT" # arguments carry charac
86
86
  REF_ISSUER_UNKNOWN_HOLD = "REF_ISSUER_UNKNOWN_HOLD"
87
87
  REF_ISSUER_NO_APPROVER_TRUST = "REF_ISSUER_NO_APPROVER_TRUST"
88
88
  REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without the approver role or group
89
+ REF_ISSUER_FORBIDDEN = "REF_ISSUER_FORBIDDEN" # authenticated, but not an approver and not the hold's own principal
89
90
 
90
91
 
91
92
  def build_issuer_app(
@@ -106,6 +107,8 @@ def build_issuer_app(
106
107
  hold_store: Optional[HoldStore] = None,
107
108
  log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
108
109
  witness: Optional[WitnessLog] = None,
110
+ tenant: Optional[str] = None,
111
+ tenant_key_ids: Optional[set] = None,
109
112
  ) -> FastAPI:
110
113
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
111
114
  posts it to a webhook); it is notice, not admission, and its failure
@@ -147,13 +150,28 @@ def build_issuer_app(
147
150
  """Static keys from configuration plus every enrolled approver, by UPN."""
148
151
  return {**static_approvers, **registry.keys()}
149
152
 
153
+ def is_approver(who: Dict[str, Any]) -> bool:
154
+ """May this identity see every hold and the witness: the approver role
155
+ or group, a configured approver key id, or an enrolled approver."""
156
+ upn = who["principal"]
157
+ return may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers or upn in registry
158
+
159
+ def hold_owner(arid: str) -> Optional[str]:
160
+ """The principal whose call is held under `arid` (a hold or a plan), or None."""
161
+ env = app.state.pending.get(arid)
162
+ if env is not None:
163
+ return env.get("request_context", {}).get("principal")
164
+ plan = app.state.pending_plans.get(arid)
165
+ return plan.get("principal") if plan is not None else None
166
+
150
167
  def _refuse(status: int, reason: str) -> HTTPException:
151
168
  return HTTPException(status_code=status, detail={"reason": reason})
152
169
 
153
170
  def authenticate(authorization: Optional[str]) -> Dict[str, Any]:
154
171
  if not authorization or not authorization.startswith("Bearer "):
155
172
  raise _refuse(401, REF_ISSUER_UNAUTHENTICATED)
156
- who = principal_from_token(authorization[7:].strip(), idp_public_keys, audience=audience, now=now())
173
+ who = principal_from_token(authorization[7:].strip(), idp_public_keys, audience=audience, now=now(),
174
+ tenant=tenant, tenant_key_ids=tenant_key_ids)
157
175
  if who["reason"] is not None:
158
176
  raise _refuse(401, who["reason"])
159
177
  return who
@@ -211,6 +229,7 @@ def build_issuer_app(
211
229
  covered = True
212
230
  break
213
231
  event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
232
+ **({"oid": who["oid"]} if who.get("oid") else {}),
214
233
  covered=covered, decision_id=result.envelope.get("decision_id"), decision_sha256=result.envelope["decision_sha256"],
215
234
  approval_request_id=result.envelope.get("approval_request_id"), args_sha256=result.envelope["request_context"]["args_sha256"])
216
235
  if result.envelope["requires_approval"] and not covered:
@@ -286,7 +305,11 @@ def build_issuer_app(
286
305
 
287
306
  @app.get("/pending")
288
307
  async def pending(authorization: Optional[str] = Header(default=None)):
289
- authenticate(authorization)
308
+ """Approvers see every hold with its arguments; anyone else sees only
309
+ the holds on their own calls. Held arguments are often file contents,
310
+ and one user's are not another's to read."""
311
+ who = authenticate(authorization)
312
+ sees_all = is_approver(who)
290
313
  current = now()
291
314
  live = {}
292
315
  for arid, plan in list(app.state.pending_plans.items()):
@@ -294,6 +317,8 @@ def build_issuer_app(
294
317
  del app.state.pending_plans[arid]
295
318
  app.state.grants.pop(arid, None)
296
319
  elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
320
+ if not sees_all and plan.get("principal") != who["principal"]:
321
+ continue
297
322
  have = app.state.grants.get(arid, [])
298
323
  live[arid] = {"plan": plan, "collected": len(have), "required": _plan_terms(plan)["required"],
299
324
  "approvers": [g["approver_key_id"] for g in have]}
@@ -304,6 +329,8 @@ def build_issuer_app(
304
329
  app.state.pending_state.pop(arid, None)
305
330
  app.state.grants.pop(arid, None)
306
331
  elif not _complete(arid):
332
+ if not sees_all and env.get("request_context", {}).get("principal") != who["principal"]:
333
+ continue
307
334
  have = app.state.grants.get(arid, [])
308
335
  live[arid] = {"envelope": env, "args": app.state.pending_args.get(arid),
309
336
  "state": app.state.pending_state.get(arid),
@@ -364,6 +391,8 @@ def build_issuer_app(
364
391
  arid = body.get("approval_request_id") if isinstance(body, dict) else None
365
392
  if not isinstance(arid, str) or (arid not in app.state.pending and arid not in app.state.pending_plans):
366
393
  return {"declined": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
394
+ if not is_approver(who) and hold_owner(arid) != who["principal"]:
395
+ raise _refuse(403, REF_ISSUER_FORBIDDEN) # only an approver, or the person whose call it is, may cancel it
367
396
  app.state.pending_plans.pop(arid, None)
368
397
  app.state.pending.pop(arid, None)
369
398
  app.state.pending_args.pop(arid, None)
@@ -406,10 +435,32 @@ def build_issuer_app(
406
435
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
407
436
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
408
437
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
409
- result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
410
- event("enrolled", approver=who["principal"], replaced=result["replaced"])
438
+ result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
439
+ event("enrolled", approver=who["principal"], replaced=result["replaced"], oid=who.get("oid"))
411
440
  return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
412
441
 
442
+ @app.post("/approvers/revoke")
443
+ async def revoke(request: Request, authorization: Optional[str] = Header(default=None)):
444
+ """An approver retires their own key (a lost or replaced device): from
445
+ now on grants under it are refused here and at every middleware that
446
+ asks this service for approver keys. Revoking someone else is the
447
+ operator's act, done on the host with `gargoyle approvers revoke`,
448
+ so that one compromised approver cannot remove the others."""
449
+ who = authenticate(authorization)
450
+ try:
451
+ body = await request.json()
452
+ except Exception:
453
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
454
+ upn = body.get("upn") if isinstance(body, dict) else None
455
+ if not isinstance(upn, str) or not upn:
456
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
457
+ if upn != who["principal"]:
458
+ raise _refuse(403, REF_ISSUER_FORBIDDEN)
459
+ gone = registry.revoke(upn, revoked_by=who["principal"], now=now())
460
+ if gone:
461
+ event("revoked", approver=upn, by=who["principal"])
462
+ return {"revoked": gone}
463
+
413
464
  @app.get("/me")
414
465
  async def me(authorization: Optional[str] = Header(default=None)):
415
466
  """Who the console is signed in as, whether they may approve, and
@@ -422,8 +473,10 @@ def build_issuer_app(
422
473
 
423
474
  @app.get("/witness")
424
475
  async def witness_entries(authorization: Optional[str] = Header(default=None)):
425
- """The issuer's signed witness log, for reconciliation with a client's evidence."""
426
- authenticate(authorization)
476
+ """The issuer's signed witness log, for reconciliation with a client's
477
+ evidence. It names every user's decisions, so it is for approvers."""
478
+ if not is_approver(authenticate(authorization)):
479
+ raise _refuse(403, REF_ISSUER_FORBIDDEN)
427
480
  return {"entries": list(witness.entries) if witness is not None else []}
428
481
 
429
482
  @app.get("/health")
@@ -475,6 +528,13 @@ def app_from_env() -> FastAPI:
475
528
  missing = [k for k, v in required.items() if not v]
476
529
  if missing:
477
530
  raise RuntimeError("issuer service needs " + ", ".join(missing))
531
+ if tenant and not os.environ.get(ENV_AUDIENCE):
532
+ # Fail closed: without an audience, any token the tenant ever issued
533
+ # for any application would be accepted here.
534
+ raise RuntimeError("issuer service with %s set needs %s (the issuer API's client id or app id URI)"
535
+ % (ENV_ENTRA_TENANT, ENV_AUDIENCE))
536
+ tenant_id = entra_tenant_id(tenant) if tenant else None
537
+ tenant_keys = entra_keys(tenant_id) if tenant else {}
478
538
  rules_path = os.environ.get(ENV_RULES_PATH)
479
539
  if rules_path:
480
540
  from gargoyle.rules import RulesPolicy
@@ -502,7 +562,7 @@ def app_from_env() -> FastAPI:
502
562
  # Both may be trusted at once: the tenant's keys for users who ran
503
563
  # `gargoyle login`, the pinned key for tokens `gargoyle init` minted
504
564
  # (the approver's, until approvers log in with Entra too).
505
- idp_public_keys={**(entra_keys(tenant) if tenant else {}),
565
+ idp_public_keys={**tenant_keys,
506
566
  **({os.environ[ENV_IDP_KEY_ID]: Ed25519PublicKey.from_public_bytes(bytes.fromhex(os.environ[ENV_IDP_PUBLIC_KEY_HEX]))}
507
567
  if pinned_idp else {})},
508
568
  approver_public_keys=approver,
@@ -516,6 +576,8 @@ def app_from_env() -> FastAPI:
516
576
  approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
517
577
  approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
518
578
  approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
579
+ tenant=tenant_id,
580
+ tenant_key_ids=set(tenant_keys) if tenant else None,
519
581
  console_entra=({"tenant": tenant, "client_id": os.environ[ENV_CONSOLE_CLIENT_ID],
520
582
  "scope": os.environ.get(ENV_CONSOLE_SCOPE) or "api://%s/access" % (os.environ.get(ENV_AUDIENCE) or "").split(",")[-1].strip()}
521
583
  if tenant and os.environ.get(ENV_CONSOLE_CLIENT_ID) else None),