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.
- {gargoyle_receipts-0.15.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.0.dev0}/PKG-INFO +16 -6
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/README.md +14 -4
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approvers.py +78 -9
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/cli.py +49 -4
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/identity.py +40 -4
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer_service.py +70 -8
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -6
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/requires.txt +1 -1
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/pyproject.toml +5 -3
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approvers.py +60 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_cli.py +18 -1
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_console_js.py +2 -2
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_identity.py +58 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_issuer_service.py +31 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_self_issue.py +3 -3
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_remote.py +2 -2
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_revert.py +2 -2
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_state.py +4 -4
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_witness.py +8 -5
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.15.0.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.15.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
|
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.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
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
|
|
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.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
|
426
|
-
|
|
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={**
|
|
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),
|