gargoyle-receipts 0.15.1.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.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.0.dev0}/PKG-INFO +16 -15
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/README.md +14 -13
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approvers.py +78 -9
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/cli.py +49 -23
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/identity.py +40 -4
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer_service.py +70 -8
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -15
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -1
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/requires.txt +1 -1
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/pyproject.toml +5 -3
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approvers.py +60 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_cli.py +16 -18
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_console_js.py +2 -2
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_identity.py +58 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_issuer_service.py +31 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_self_issue.py +3 -3
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_remote.py +2 -2
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_revert.py +2 -2
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_state.py +4 -4
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_witness.py +8 -5
- gargoyle_receipts-0.15.1.dev0/gargoyle/review.py +0 -213
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.15.1.dev0 → gargoyle_receipts-0.16.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.15.1.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
|
|
@@ -272,15 +274,6 @@ field. A hold can also be announced to Slack or any webhook
|
|
|
272
274
|
(`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
|
|
273
275
|
chat channel can approve anything.
|
|
274
276
|
|
|
275
|
-
## Reviewing it
|
|
276
|
-
|
|
277
|
-
`gargoyle review-packet --out packet.zip` builds one archive for an outside
|
|
278
|
-
reviewer: the spec, the vectors, the verification source, a reviewer
|
|
279
|
-
prompt, and `verify_stdlib.py`, which recomputes every conformance vector
|
|
280
|
-
(RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
|
|
281
|
-
so a reviewer with no pip and no network can still check every hash and
|
|
282
|
-
signature rather than take the README's word.
|
|
283
|
-
|
|
284
277
|
## Deploy it
|
|
285
278
|
|
|
286
279
|
pip install gargoyle-receipts
|
|
@@ -297,7 +290,14 @@ the page shows each held call with its full arguments, checked against
|
|
|
297
290
|
the receipt, and signs the grant locally with WebCrypto. The key never
|
|
298
291
|
leaves the browser, and with Entra it is generated there, non-exportable,
|
|
299
292
|
and enrolled under the approver's UPN, so grants and audits name the
|
|
300
|
-
person.
|
|
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
|
|
301
301
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
302
302
|
[docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
|
|
303
303
|
is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
|
|
@@ -387,7 +387,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
387
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 |
|
|
388
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` |
|
|
389
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 |
|
|
390
|
-
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
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` |
|
|
391
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 |
|
|
392
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 |
|
|
393
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
|
|
@@ -234,15 +236,6 @@ field. A hold can also be announced to Slack or any webhook
|
|
|
234
236
|
(`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
|
|
235
237
|
chat channel can approve anything.
|
|
236
238
|
|
|
237
|
-
## Reviewing it
|
|
238
|
-
|
|
239
|
-
`gargoyle review-packet --out packet.zip` builds one archive for an outside
|
|
240
|
-
reviewer: the spec, the vectors, the verification source, a reviewer
|
|
241
|
-
prompt, and `verify_stdlib.py`, which recomputes every conformance vector
|
|
242
|
-
(RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
|
|
243
|
-
so a reviewer with no pip and no network can still check every hash and
|
|
244
|
-
signature rather than take the README's word.
|
|
245
|
-
|
|
246
239
|
## Deploy it
|
|
247
240
|
|
|
248
241
|
pip install gargoyle-receipts
|
|
@@ -259,7 +252,14 @@ the page shows each held call with its full arguments, checked against
|
|
|
259
252
|
the receipt, and signs the grant locally with WebCrypto. The key never
|
|
260
253
|
leaves the browser, and with Entra it is generated there, non-exportable,
|
|
261
254
|
and enrolled under the approver's UPN, so grants and audits name the
|
|
262
|
-
person.
|
|
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
|
|
263
263
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
264
264
|
[docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
|
|
265
265
|
is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
|
|
@@ -349,7 +349,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
349
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 |
|
|
350
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` |
|
|
351
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 |
|
|
352
|
-
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
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` |
|
|
353
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 |
|
|
354
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 |
|
|
355
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:
|
|
@@ -14,8 +14,6 @@ policy engine, a container runtime, or any client changes.
|
|
|
14
14
|
gargoyle remote --out DIR [--upstream-url URL | -- <command>] the gate as an HTTPS MCP
|
|
15
15
|
server, for hosted connectors
|
|
16
16
|
gargoyle remote-token --out DIR --principal UPN a capability URL for one person
|
|
17
|
-
gargoyle review-packet --out packet.zip [--audit-excerpt FILE] everything an outside
|
|
18
|
-
reviewer needs, verifier included
|
|
19
17
|
gargoyle audit <receipt.json> <execution.json> [grant.json]
|
|
20
18
|
gargoyle audit --log audit.jsonl verify the middleware's evidence log
|
|
21
19
|
|
|
@@ -45,6 +43,8 @@ import os
|
|
|
45
43
|
import re
|
|
46
44
|
import stat
|
|
47
45
|
import sys
|
|
46
|
+
|
|
47
|
+
from gargoyle.approvers import DEFAULT_APPROVER_ROLE
|
|
48
48
|
from datetime import datetime, timedelta, timezone
|
|
49
49
|
from pathlib import Path
|
|
50
50
|
|
|
@@ -109,7 +109,11 @@ def cmd_init(args) -> int:
|
|
|
109
109
|
executor, executor_hex, executor_pub = new_key()
|
|
110
110
|
exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
|
|
111
111
|
client_token = encode({"sub": PRINCIPAL, "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
|
|
112
|
-
|
|
112
|
+
# One identity token per approver, named by the key id it travels with and
|
|
113
|
+
# carrying the approver role: the issuer shows the whole queue to approvers
|
|
114
|
+
# and only a person's own holds to anyone else.
|
|
115
|
+
def approver_token_for(kid: str) -> str:
|
|
116
|
+
return encode({"sub": kid, "roles": [DEFAULT_APPROVER_ROLE], "aud": "gargoyle-issuer", "exp": exp}, idp, "idp-1")
|
|
113
117
|
issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
|
|
114
118
|
|
|
115
119
|
rules = out / "rules.json"
|
|
@@ -137,6 +141,7 @@ def cmd_init(args) -> int:
|
|
|
137
141
|
_write(out / "middleware.env", _env_lines(middleware_env), private=True)
|
|
138
142
|
b64url = lambda b: __import__("base64").urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
139
143
|
for n, (kid, _k, khex, kpub) in enumerate(approvers, 1):
|
|
144
|
+
approver_token = approver_token_for(kid)
|
|
140
145
|
# approver.env / approver-web.json for the first; -2, -3 ... for the others.
|
|
141
146
|
suffix = "" if n == 1 else "-%d" % n
|
|
142
147
|
_write(out / ("approver%s.env" % suffix), _env_lines({
|
|
@@ -175,7 +180,9 @@ WantedBy=multi-user.target
|
|
|
175
180
|
ProxyPass / http://127.0.0.1:%(port)d/
|
|
176
181
|
ProxyPassReverse / http://127.0.0.1:%(port)d/
|
|
177
182
|
ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
|
|
178
|
-
|
|
183
|
+
# Remote-mode capability URLs carry a bearer token in the path; keep them out of the access log.
|
|
184
|
+
SetEnvIf Request_URI "^/mcp/" gargoyle_capability_url
|
|
185
|
+
CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined env=!gargoyle_capability_url
|
|
179
186
|
</VirtualHost>
|
|
180
187
|
""" % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
|
|
181
188
|
upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
|
|
@@ -284,18 +291,6 @@ def cmd_remote(args) -> int:
|
|
|
284
291
|
return 0
|
|
285
292
|
|
|
286
293
|
|
|
287
|
-
def cmd_review_packet(args) -> int:
|
|
288
|
-
"""One archive for an outside reviewer: spec, README, vectors, the
|
|
289
|
-
verification source, a reviewer prompt, and verify_stdlib.py, which
|
|
290
|
-
recomputes every vector with the standard library alone."""
|
|
291
|
-
from gargoyle.review import build_packet
|
|
292
|
-
root = Path(__file__).resolve().parent.parent
|
|
293
|
-
excerpt = Path(args.audit_excerpt).read_text(encoding="utf-8") if args.audit_excerpt else None
|
|
294
|
-
result = build_packet(str(root), args.out, audit_excerpt=excerpt, extra_files=args.extra or [])
|
|
295
|
-
print("wrote %s (%d files); give the reviewer the archive and REVIEWER_PROMPT.txt" % (result["path"], len(result["files"])))
|
|
296
|
-
return 0
|
|
297
|
-
|
|
298
|
-
|
|
299
294
|
def cmd_issuer(args) -> int:
|
|
300
295
|
import uvicorn
|
|
301
296
|
from gargoyle.issuer_service import app_from_env
|
|
@@ -335,6 +330,33 @@ def cmd_approve(args) -> int:
|
|
|
335
330
|
return approver_main(argv)
|
|
336
331
|
|
|
337
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
|
+
|
|
338
360
|
def cmd_audit(args) -> int:
|
|
339
361
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
340
362
|
from gargoyle.execution import verify_chain
|
|
@@ -344,7 +366,11 @@ def cmd_audit(args) -> int:
|
|
|
344
366
|
approvers = dict(keys["approver"])
|
|
345
367
|
enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
346
368
|
if enrolled.exists():
|
|
347
|
-
|
|
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()})
|
|
348
374
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
349
375
|
approver_public_keys=load(approvers))
|
|
350
376
|
if args.log:
|
|
@@ -468,14 +494,14 @@ def main(argv=None) -> int:
|
|
|
468
494
|
p = sub.add_parser("remote-token", help="mint one person's capability URL for the remote gate")
|
|
469
495
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
470
496
|
p.add_argument("--principal", required=True, help="the UPN or principal the receipts will name")
|
|
471
|
-
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)")
|
|
472
498
|
p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
|
|
473
499
|
p.set_defaults(fn=cmd_remote_token)
|
|
474
|
-
p = sub.add_parser("
|
|
475
|
-
p.add_argument("
|
|
476
|
-
p.add_argument("
|
|
477
|
-
p.add_argument("--
|
|
478
|
-
p.set_defaults(fn=
|
|
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)
|
|
479
505
|
p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
|
|
480
506
|
p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
|
|
481
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"))}
|