sentrykit 0.1.0rc1__py3-none-any.whl

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 (46) hide show
  1. sentrykit/__init__.py +200 -0
  2. sentrykit/audit.py +967 -0
  3. sentrykit/cedar.py +750 -0
  4. sentrykit/cli.py +1405 -0
  5. sentrykit/config.py +300 -0
  6. sentrykit/constraints.py +928 -0
  7. sentrykit/context.py +393 -0
  8. sentrykit/decorator.py +252 -0
  9. sentrykit/engine.py +1509 -0
  10. sentrykit/envelope.py +513 -0
  11. sentrykit/errors.py +252 -0
  12. sentrykit/integrations/__init__.py +1 -0
  13. sentrykit/integrations/base.py +767 -0
  14. sentrykit/integrations/fastapi.py +361 -0
  15. sentrykit/integrations/flask.py +342 -0
  16. sentrykit/integrations/mcp.py +929 -0
  17. sentrykit/integrations/pydantic_ai.py +728 -0
  18. sentrykit/integrations/tools.py +479 -0
  19. sentrykit/masking.py +459 -0
  20. sentrykit/policy/__init__.py +40 -0
  21. sentrykit/policy/loader.py +764 -0
  22. sentrykit/policy/pack/10-rbac.cedar +9 -0
  23. sentrykit/policy/pack/20-groups.cedar +9 -0
  24. sentrykit/policy/pack/30-ownership.cedar +8 -0
  25. sentrykit/policy/pack/40-delegation.cedar +40 -0
  26. sentrykit/policy/pack/50-expiry.cedar +24 -0
  27. sentrykit/policy/pack/60-break-glass.cedar +34 -0
  28. sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
  29. sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
  30. sentrykit/policy/pack/entities.json +3 -0
  31. sentrykit/policy/pack/pack.cedarschema +45 -0
  32. sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
  33. sentrykit/py.typed +0 -0
  34. sentrykit/registry.py +666 -0
  35. sentrykit/stores/__init__.py +116 -0
  36. sentrykit/stores/base.py +870 -0
  37. sentrykit/stores/composite.py +635 -0
  38. sentrykit/stores/filesystem.py +400 -0
  39. sentrykit/stores/jsonfile.py +701 -0
  40. sentrykit/stores/jsonl.py +357 -0
  41. sentrykit/stores/memory.py +500 -0
  42. sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
  43. sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
  44. sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
  45. sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
  46. sentrykit-0.1.0rc1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,541 @@
1
+ Metadata-Version: 2.4
2
+ Name: sentrykit
3
+ Version: 0.1.0rc1
4
+ Summary: Lightweight, framework-agnostic authorization for Python web frameworks and agent tooling, built on AWS Cedar.
5
+ Project-URL: Homepage, https://github.com/namitkewat/sentrykit
6
+ Project-URL: Documentation, https://github.com/namitkewat/sentrykit/releases
7
+ Project-URL: Source, https://github.com/namitkewat/sentrykit
8
+ Project-URL: Changelog, https://github.com/namitkewat/sentrykit/blob/main/CHANGELOG.md
9
+ Author-email: Namit Kewat <namitkewat@gmail.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: abac,access-control,agents,authorization,authz,cedar,fastapi,flask,llm,mcp,model-context-protocol,policy,pydantic-ai,rbac,rebac
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: cedarpy<5,>=4.8
22
+ Requires-Dist: pydantic-settings>=2.2
23
+ Requires-Dist: pydantic>=2.7
24
+ Requires-Dist: typing-extensions>=4.10
25
+ Provides-Extra: agents
26
+ Requires-Dist: mcp<3,>=2; extra == 'agents'
27
+ Requires-Dist: pydantic-ai; extra == 'agents'
28
+ Provides-Extra: all
29
+ Requires-Dist: fastapi>=0.110; extra == 'all'
30
+ Requires-Dist: flask>=3.0; extra == 'all'
31
+ Requires-Dist: mcp<3,>=2; extra == 'all'
32
+ Requires-Dist: pydantic-ai; extra == 'all'
33
+ Requires-Dist: starlette>=0.37; extra == 'all'
34
+ Provides-Extra: fastapi
35
+ Requires-Dist: fastapi>=0.110; extra == 'fastapi'
36
+ Requires-Dist: starlette>=0.37; extra == 'fastapi'
37
+ Provides-Extra: flask
38
+ Requires-Dist: flask>=3.0; extra == 'flask'
39
+ Provides-Extra: mcp
40
+ Requires-Dist: mcp<3,>=2; extra == 'mcp'
41
+ Provides-Extra: pydantic-ai
42
+ Requires-Dist: pydantic-ai; extra == 'pydantic-ai'
43
+ Provides-Extra: web
44
+ Requires-Dist: fastapi>=0.110; extra == 'web'
45
+ Requires-Dist: flask>=3.0; extra == 'web'
46
+ Requires-Dist: starlette>=0.37; extra == 'web'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # sentrykit
50
+
51
+ Framework-agnostic **authorization** for Python, built on
52
+ [AWS Cedar](https://www.cedarpolicy.com/). Clip a `@protect` decorator onto any callable — a
53
+ web route, a plain function, an agent tool — and sentrykit decides, per call, whether the
54
+ principal may invoke it, **which arguments they may pass**, and **which defaults to inject
55
+ when they pass none**; then narrows an over-broad request rather than refusing it — the value
56
+ the principal may not have is rejected and their own default fills the gap — and writes a
57
+ masked, hash-chained record of what it decided.
58
+
59
+ > The caller *proposes*; the **policy decides** — and what it decides includes the arguments,
60
+ > not only the call.
61
+
62
+ It targets two worlds with one decorator:
63
+
64
+ - **Web frameworks** — FastAPI, Flask (more to come).
65
+ - **Agent tooling** — MCP tool servers, Pydantic-AI (more to come).
66
+
67
+ What that gives you, in the order it matters:
68
+
69
+ - **Per-(principal, action) argument constraints.** Allow-sets, deny-sets, patterns and numeric
70
+ bounds, resolved for the principal and the action, one argument at a time.
71
+ - **Narrow-and-continue instead of refuse.** A value above what the principal may have is
72
+ rejected and their own default fills the gap, so the call proceeds instead of being turned
73
+ away.
74
+ - **Policy-supplied default injection.** Arguments the caller omitted — and arguments that were
75
+ just dropped — are filled in from what policy says this principal may have.
76
+ - **Grant-time constraint validation.** A constraint no value could satisfy, or whose own
77
+ default it would reject, is refused when it is written rather than found by whoever calls
78
+ next.
79
+ - **A masked, hash-chained audit trail.** One record per decision and per admin mutation,
80
+ masked *before* the append-only write and pinned to a hash of the exact policy text.
81
+
82
+ Across the first four sits one more: **argument ceilings declared in reviewed policy text**. A
83
+ `.cedar` file says what *anybody* may pass for an action, the grant store says what *this*
84
+ principal has, and the two intersect — so the reviewed file states the maximum and can only
85
+ narrow.
86
+
87
+ sentrykit sits at the **action layer**: it refuses the side effect *before* it happens, rather
88
+ than inspecting what a model has already produced. It is self-hosted, Cedar-based, and
89
+ audit-first.
90
+
91
+ General-purpose policy engines — Cedar, including Amazon Verified Permissions, as well as OPA
92
+ and Casbin — and relationship-based systems such as OpenFGA answer a boolean question at the
93
+ gate: may this principal perform this action on this resource. sentrykit is built on one of
94
+ them, and adds the layer above that decision: which arguments the call may carry, which
95
+ defaults to supply when the caller omits them, and a record of both. If you already run a
96
+ policy engine, that argument layer is the part you would otherwise assemble yourself.
97
+
98
+ > **Status: 0.1.0, the first release — everything below works.** `@protect`, ambient context,
99
+ > the Cedar gate, argument filtering, policy-declared **argument ceilings**, default
100
+ > injection, **constraint validation at the write**, signature validation and the `_authz`
101
+ > envelope; the three pluggable stores with entry-point discovery; masking and the
102
+ > hash-chained audit log; the
103
+ > **FastAPI, Flask, MCP and Pydantic-AI** providers with one runnable example app each, plus
104
+ > an attack/defence suite that runs nine named attacks twice — once past the policy and once
105
+ > around it; the **shipped policy pack**; and the `sentrykit` command line. Not yet shipped:
106
+ > the durable WORM audit backends (S3 Object Lock, Iceberg) — those are a `Protocol` away
107
+ > rather than built.
108
+
109
+ ## Install
110
+
111
+ ```bash
112
+ pip install sentrykit # core
113
+ pip install "sentrykit[fastapi]" # + FastAPI provider
114
+ pip install "sentrykit[flask]" # + Flask provider
115
+ pip install "sentrykit[mcp]" # + MCP tool-server provider
116
+ pip install "sentrykit[pydantic-ai]" # + Pydantic-AI provider
117
+ pip install "sentrykit[all]" # everything
118
+ ```
119
+
120
+ The only pinned core dependency worth knowing about is **`cedarpy >= 4.8, < 5`**: the engine
121
+ parses a policy set, an entity graph and a schema once at wiring time and reuses the handles,
122
+ and releases below 4.8 do not expose those. See
123
+ [which cedarpy](docs/quickstart.md#which-cedarpy).
124
+
125
+ ## The idea in 20 seconds
126
+
127
+ ```python
128
+ from sentrykit import Entity, EntityRef, Principal, establish, install, protect
129
+
130
+ install(
131
+ """
132
+ @id("support-may-search-orders")
133
+ permit (
134
+ principal in Role::"support",
135
+ action == Action::"tool.search_orders",
136
+ resource == Tool::"orders"
137
+ );
138
+ """,
139
+ entities=[Entity(EntityRef("Role", "support")), Entity(EntityRef("Tool", "orders"))],
140
+ )
141
+
142
+
143
+ @protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
144
+ def search_orders(customer_id: str, region: str = "eu", limit: int = 100) -> dict[str, object]:
145
+ ...
146
+
147
+
148
+ with establish(Principal(id="alice", roles=("support",)), channel="agent"):
149
+ search_orders(customer_id="c-1", region="us", limit=500)
150
+ ```
151
+
152
+ A web dependency or an agent tool-runner establishes *who* is acting; the decorator supplies
153
+ *what* and *with which arguments*; Cedar decides; sentrykit filters the arguments, injects
154
+ per-principal defaults, validates, invokes, and returns the result with an `_authz` report of
155
+ what was processed, rejected, and defaulted.
156
+
157
+ If alice is only granted `region="eu"` and a page size up to 50, the call above still runs.
158
+ That is **narrow-and-continue**: the parts of the proposal she is not entitled to are dropped
159
+ and refilled from her own defaults, the call proceeds, and the envelope says so.
160
+
161
+ ```json
162
+ {
163
+ "customer_id": "c-1", "region": "eu", "limit": 10,
164
+ "_authz": {
165
+ "decision": "allow",
166
+ "rejected": [
167
+ {"name": "region", "rule": "allow-set", "reason": "Value is not one of the permitted values"},
168
+ {"name": "limit", "rule": "maximum", "reason": "Value is above the maximum of 50"}
169
+ ],
170
+ "defaults_applied": {"region": null, "limit": null},
171
+ "narrowed": true,
172
+ "narrowed_arguments": ["region", "limit"],
173
+ "guidance": "This result is valid and was served. The authorization policy adjusted region, limit before the call ran, because the proposed value is not one this principal may use. Report the result as delivered and say which parameters were adjusted; do not retry with the original values."
174
+ }
175
+ }
176
+ ```
177
+
178
+ `rejected` says what the policy would not accept. `narrowed`, `narrowed_arguments` and
179
+ `guidance` say what the *outcome* was — and that pairing is deliberate. A narrowed call
180
+ **succeeded**: real data was returned on values the principal is entitled to. An envelope that
181
+ reports only the exceptions describes that success in the vocabulary of a failure, and a reader
182
+ acting on it — most sharply a language model — concludes the call failed and withholds the answer
183
+ it is already holding. Measured across language models, that happened on roughly half of narrowed
184
+ calls. The three outcome fields carry no argument values and no policy ids, so they are present
185
+ at every verbosity above `off`: a caller who may not be told *what* was narrowed is still told
186
+ *that* the result is good.
187
+
188
+ Narrowing *refines* a proposal; it does not invent one. If **every** argument the caller
189
+ supplied is rejected, the call is refused with `NoValidArguments` rather than completed from
190
+ defaults — completing it would answer a question nobody asked, handing back alice's own orders
191
+ when she asked about a customer's.
192
+
193
+ With no established principal, the call is refused rather than run unguarded. Forgetting to
194
+ wire a provider fails closed.
195
+
196
+ ### The reviewed file states the ceiling; the store states the grant
197
+
198
+ Constraints reach a decision from two places, and they **intersect**. A **grant** in the store
199
+ says what *this* principal may pass — mutable, per-principal, and the only layer that carries a
200
+ `default`. A **ceiling** declared on a `permit` in a reviewed `.cedar` file says what *anybody*
201
+ may pass for that action:
202
+
203
+ ```cedar
204
+ @id("support-may-search")
205
+ @constraints("{\"limit\": {\"maximum\": 100}, \"region\": {\"allow\": [\"eu\", \"us\"]}}")
206
+ permit (
207
+ principal in Role::"support",
208
+ action == Action::"tool.search",
209
+ resource == Tool::"orders"
210
+ );
211
+ ```
212
+
213
+ Intersection can only narrow — allow-sets intersect, deny-sets union, bounds move inward — so a
214
+ grant capped at 10 stays capped at 10 under a ceiling of 100. The file under review states the
215
+ maximum anyone may have; the store stays the one place a per-principal value lives.
216
+
217
+ A ceiling is also the only declaration a policy can make that reaches narrow-and-continue
218
+ rather than the gate. Cedar answers allow or deny, so "you asked for 500, you may not have
219
+ that — here is what you may have" has nowhere else to live: the value above the bound is
220
+ rejected, the principal's own default fills the hole, and the call is served rather than
221
+ refused. Every part of a declaration is checked at load — the JSON, the rule names, the value
222
+ types, the pattern, and that the annotation sits on a `permit` — because Cedar does not
223
+ type-check annotation values, and a bound nobody enforces is worse than no bound. What load
224
+ cannot check is a misspelled argument name: nothing here imports your application, so read a
225
+ ceiling against the callable's signature. A failed evaluation reports no determining policies
226
+ and therefore no ceilings, so a policy set that declares any ceiling refuses an engine failure
227
+ under `fail_mode="open"` too, rather than proceeding unbounded. See
228
+ [docs/policies.md](docs/policies.md).
229
+
230
+ ### A constraint nothing can satisfy is refused when it is written
231
+
232
+ Constraints are data, so one can be written that nothing satisfies. `minimum=100, maximum=10`
233
+ is the obvious case; an allow-set member the pattern rejects, or a default outside the
234
+ allow-set, are the quieter ones. Left to be discovered at call time, each is a refusal of
235
+ everything, found by whoever calls next:
236
+
237
+ ```python
238
+ store.grant(GrantSpec(principal=..., kind="constraint", subject="limit",
239
+ constraint=ArgumentConstraint(minimum=100, maximum=10)))
240
+ # ConfigurationError: the constraint on argument 'limit' cannot be satisfied: ...
241
+ ```
242
+
243
+ The check runs at the write rather than on the model itself, because a store also *reads*
244
+ constraints: refusing at construction would make one bad row already on disk fail the load of
245
+ every principal in the file. The write is where the fault is introduced and where someone is
246
+ present to fix it.
247
+
248
+ The same reasoning covers defaults at the call. A default is checked against its own constraint
249
+ and withheld if it fails, so omitting an argument is not a way around the rule that governs it.
250
+ The rules, how they resolve per `(principal, action)`, and their sharp edges are written up in
251
+ [docs/concepts.md](docs/concepts.md).
252
+
253
+ ### Guard an HTTP route — same decorator, one extra line
254
+
255
+ A **context provider** is all a framework contributes. It establishes the principal at the
256
+ request boundary and renders a refusal as an HTTP response; nothing else changes.
257
+
258
+ ```python
259
+ from fastapi import FastAPI
260
+
261
+ from sentrykit import install
262
+ from sentrykit.integrations.fastapi import FastAPIProvider, protect_app
263
+
264
+ provider = FastAPIProvider(identify=lambda request: request.headers.get("x-user"), grants=store)
265
+ install(POLICY_TEXT, grant_store=store, context_provider=provider)
266
+
267
+ app = FastAPI()
268
+ protect_app(app, provider=provider) # <- the one extra line
269
+
270
+
271
+ @app.get("/orders/summary") # this route is NOT protected
272
+ def summary() -> dict[str, object]:
273
+ return summarize_orders() # this call is
274
+ ```
275
+
276
+ Flask is the same shape (`from sentrykit.integrations.flask import protect_app`), and
277
+ `protect_blueprint` scopes it to one blueprint. `identify` answers *who is this*; the grant
278
+ store answers *what may they do*. A denial comes back as a 403 carrying the same `_authz`
279
+ envelope a success carries, trimmed by `verbosity`:
280
+
281
+ ```json
282
+ {
283
+ "detail": "The policy denied action Action::\"orders.search\".",
284
+ "error": "AuthzDenied",
285
+ "_authz": {"decision": "deny", "action": "Action::\"orders.search\"", "resource": "Tool::\"orders\""}
286
+ }
287
+ ```
288
+
289
+ Two runnable apps — one orders-and-refunds desk built twice, with the same seven protected
290
+ callables, the same reviewed `.cedar` directory and the same grant data on both frameworks —
291
+ live in `examples/fastapi_app/` and `examples/flask_app/`. Each drives 22 requests through its
292
+ own test client and prints what policy did to every one before it starts serving. See
293
+ [docs/web-frameworks.md](docs/web-frameworks.md).
294
+
295
+ ### Guard an agent's tools — same decorator, and the model is told what it may attempt
296
+
297
+ An agent boundary needs one thing a web boundary does not: the **tool manifest is
298
+ per-caller**. sentrykit filters it with the same decision that enforces the call.
299
+
300
+ ```python
301
+ from mcp.server.mcpserver import MCPServer
302
+
303
+ from sentrykit import install
304
+ from sentrykit.integrations.mcp import MCPProvider, governed_tool, protect_server
305
+
306
+ provider = MCPProvider(grants=store)
307
+ install(POLICY_TEXT, grant_store=store, context_provider=provider)
308
+
309
+ server = MCPServer("orders")
310
+ governed_tool(server)(search_orders) # register the protected callable
311
+ protect_server(server) # <- the one extra line
312
+ ```
313
+
314
+ Pydantic-AI is the same shape, and its principal is **the human the agent acts for** — never a
315
+ service identity for the agent process, and never anything the model can write:
316
+
317
+ ```python
318
+ from sentrykit.integrations.pydantic_ai import PydanticAIProvider, governed_tool
319
+
320
+ agent = Agent(model, deps_type=Caller)
321
+ governed_tool(agent)(search_orders)
322
+
323
+ await agent.run("what is on order ord-0003?", deps=Caller(principal="alice"))
324
+ ```
325
+
326
+ The filtering guarantee is stated as an invariant and tested as one:
327
+
328
+ > **A tool is hidden from a principal if and only if invoking it would be refused.**
329
+
330
+ Hiding is a courtesy to the model, never the control. A client that skips the manifest and
331
+ calls a hidden tool by name is refused just the same — both halves are asserted in the same
332
+ test, because a filter that *were* the control would be a hole the moment someone skipped
333
+ `tools/list`.
334
+
335
+ An MCP tool server and an on-behalf-of agent govern that same desk in `examples/mcp_server/`
336
+ and `examples/pydantic_ai_agent/`, and print a 22-flow table of their own. Neither needs an API
337
+ key: each uses a local [Ollama](https://ollama.com) model when one is reachable and a
338
+ deterministic stand-in otherwise, and every authorization outcome is identical either way — a
339
+ decision is made from the principal and the proposed arguments, never from which model proposed
340
+ them.
341
+ Setting `SENTRYKIT_OLLAMA_URL` to an empty value turns the search off without opening a socket.
342
+ See [docs/agents.md](docs/agents.md).
343
+
344
+ ### Relationships are Cedar's job, and this composes with them
345
+
346
+ Underneath the argument layer, nested relationships — user → team → project → resource,
347
+ delegation, on-behalf-of, expiry, break-the-glass — are resolved deterministically by Cedar's
348
+ entity hierarchy, rather than by a ladder of application `if` statements or by a model. A
349
+ policy therefore names only the far end of a chain:
350
+
351
+ ```cedar
352
+ @id("project-members-may-read-project-orders")
353
+ permit (
354
+ principal in Project::"orders-eu",
355
+ action == Action::"order.read",
356
+ resource in Project::"orders-eu"
357
+ );
358
+ ```
359
+
360
+ Given `alice → Team::"eu-support" → Division::"emea" → Project::"orders-eu"`, alice may read
361
+ an order owned by that project. The policy mentions neither the team nor the division; sever
362
+ one edge and the access is gone, because the relationship *is* the authorization. That is
363
+ Cedar's strength and sentrykit inherits it — it is the foundation the argument layer stands on,
364
+ and a ceiling can be declared on exactly that permit.
365
+
366
+ ### What that is worth, measured rather than asserted
367
+
368
+ Nine named attacks, each run **twice** against the same seeded database and the same function
369
+ body — once past the policy, once around it:
370
+
371
+ ```bash
372
+ pixi run run-scenarios
373
+ ```
374
+
375
+ | | proposed | ungoverned | governed |
376
+ | --- | --- | --- | --- |
377
+ | exfiltration by `limit` | `limit=100000` | 81 orders | 10 orders |
378
+ | an instruction planted in a ticket thread | another tenant, `limit=5000` | 25 orders, another tenant's | 5 orders, the caller's own tenant |
379
+ | a destructive call with no arguments | `cancel_open_orders()` | 81 orders cancelled | refused: the signature's own default is denied |
380
+ | a state handle presented by somebody else | an export id from a narrowed call | 81 orders | **81 orders** |
381
+
382
+ The comparison is kept fair mechanically: one module holds the plain functions, another applies
383
+ `@protect` to those same function objects, so neither arm has a body of its own and there is
384
+ nothing to drift. Three of the nine the gate does not win, and they are in the suite on
385
+ purpose — an authorization layer that ships only the cases it wins is describing a feature
386
+ rather than a boundary. See [docs/scenarios.md](docs/scenarios.md).
387
+
388
+ ## Every decision is auditable
389
+
390
+ Point sentrykit at a sink and each decision — allow *and* refusal — becomes one hash-chained
391
+ record: the principal's effective attributes snapshotted at decision time, the arguments
392
+ proposed, processed, rejected and defaulted, the policies that decided and where they came
393
+ from, and the `policy_set_hash` that lets the decision be replayed.
394
+
395
+ ```python
396
+ install(
397
+ policy_source="file://./policies",
398
+ grant_store="memory://",
399
+ audit_sink="jsonl://./var/audit.jsonl",
400
+ )
401
+ ```
402
+
403
+ Sensitive values are masked **before** the write, never after — anything in an append-only
404
+ store is permanent, so masking at report time is too late. A policy says what to mask:
405
+
406
+ ```cedar
407
+ @id("support-may-look-up")
408
+ @obligations("hmac:account, notify:secops")
409
+ permit (principal in Role::"support", action == Action::"tool.lookup", resource == Tool::"accounts");
410
+ ```
411
+
412
+ The callable receives `ACCT-4417-9930`; the audit log receives
413
+ `hmac:9aceb229b6349cc0…`. Keyed HMAC is the default because it is the one
414
+ strategy that lets an auditor correlate records without the store ever holding the value.
415
+ Obligations that are not masking instructions — `notify:secops` — ride along on the record and
416
+ the envelope for your application to discharge.
417
+
418
+ Tampering is detectable, not preventable: each record's hash covers its predecessor's, so
419
+ `verify_chain` catches both an edited record and a deleted one. Preventing the rewrite is the
420
+ storage layer's job.
421
+
422
+ ## A policy pack ships with it
423
+
424
+ The shapes every project ends up writing — a role the resource requires, ownership, a group
425
+ hierarchy, a delegation, an expiry, a break-the-glass — ship as **data**, reachable through
426
+ the same union as your own directory:
427
+
428
+ ```python
429
+ install(policy_source=["pack://", "file://./policies"])
430
+ ```
431
+
432
+ Eight patterns: `rbac`, `groups`, `ownership`, `delegation`, `expiry`, `break-glass`,
433
+ `separation-of-duties`, `jit-elevation`. None of them names a role, a resource type or an
434
+ action — a shipped pack cannot know your vocabulary, so each one reads the *shape* of the
435
+ request instead. The resource declares what it requires, the grant store supplies the
436
+ principal's edges, the context supplies `now`, `break_glass`, `on_behalf_of` and
437
+ `justification`. Adopting a pattern is putting an attribute on an entity.
438
+
439
+ Half the pack **forbids**, and that half is the part that contains rather than permits:
440
+
441
+ ```cedar
442
+ @id("delegation-outside-scope-forbidden")
443
+ forbid (principal, action, resource)
444
+ when { principal has delegated_actions && !(action in principal.delegated_actions) };
445
+ ```
446
+
447
+ Without it a delegated agent could reach the plain RBAC permit and ignore the scope it was
448
+ delegated. Same story for break-the-glass: the `forbid` is what makes an emergency-only
449
+ resource emergency-only, and the permit that reopens it demands an emergency role, the flag
450
+ **and** a justification — because a permit conditioned on a caller-supplied boolean alone
451
+ authorizes anyone at all. It carries `notify:secops` and `record:justification` as mandatory
452
+ obligations, so a call that proceeds that way cannot proceed quietly.
453
+
454
+ ```python
455
+ with establish(ana, channel="web", break_glass=True, justification="incident 4471"):
456
+ open_vault(item="master-key")
457
+ ```
458
+
459
+ See [docs/policies.md](docs/policies.md).
460
+
461
+ ## A command line for the parts an API is the wrong shape for
462
+
463
+ ```bash
464
+ sentrykit policy validate ./policies
465
+ sentrykit grant 'User::"ana"' --role oncall --expires 2099-01-01 \
466
+ --store jsonfile://./var/grants.json --policy file://./policies \
467
+ --as 'User::"root"'
468
+ sentrykit audit-report --from 2026-01-01 --to 2026-12-31 --out audit.md
469
+ ```
470
+
471
+ `policy validate` type-checks a directory against its schema before it deploys, groups errors
472
+ by the policy's `@id`, and exits non-zero — so it belongs in CI, where it catches the policy
473
+ set that cannot possibly permit anything and would otherwise deploy cleanly and deny every
474
+ request for the life of the process.
475
+
476
+ `grant` and `revoke` are **not** back doors into the store. Each is a `@protect`-ed callable
477
+ decided by the same governor the application uses, so a policy governs who may administer
478
+ grants *and which grants they may make* — the spec is built from the policy-processed
479
+ arguments, not from `argv`. An operator entitled only to `oncall` who asks for `--role admin`
480
+ stores `oncall`, and the audit log says so. `--policy` is required on both, with no default:
481
+ the shipped pack ships no permit for the admin actions, so defaulting to it would turn "you
482
+ named no policy source" into "you are not entitled". An allowed mutation leaves two records,
483
+ the decision and the mutation; a refused one leaves the refusal and an untouched store.
484
+
485
+ `audit-report` makes audit-first literal: the yearly report is a query. It aggregates by
486
+ **outcome** rather than allow/deny, because allow/deny folds six materially different refusals
487
+ into one bucket and both admin mutations into the other.
488
+
489
+ See [docs/audit.md](docs/audit.md).
490
+
491
+ ## Everything on the outside is a plugin
492
+
493
+ Policy sources, grant stores, audit sinks and framework providers all
494
+ use one mechanism: a `Protocol`, a DSN, a `register_*` decorator, and an entry point. The
495
+ built-ins take exactly the path a third-party package takes.
496
+
497
+ ```toml
498
+ # pyproject.toml of sentrykit-splunk
499
+ [project.entry-points."sentrykit.audit_sinks"]
500
+ splunk = "sentrykit_splunk.sink:SplunkAuditSink"
501
+ ```
502
+
503
+ After `pip install sentrykit-splunk`, `audit_sink="splunk://..."` works with no change to
504
+ sentrykit. Pass a list instead of one DSN and you get a composite that satisfies the same
505
+ `Protocol` — policy sources union, grant stores merge or route by namespace, audit sinks fan
506
+ out — so the decision path never learns how many were configured.
507
+
508
+ ## Develop
509
+
510
+ This repo uses [pixi](https://pixi.sh/) for environments and helper tasks.
511
+
512
+ ```bash
513
+ git init # required: `pixi run setup` installs git hooks
514
+ pixi install
515
+ pixi run setup # install git hooks (incl. the commit-message gate)
516
+ pixi run -e all check # format-check, lint, typecheck, tests+coverage, docs
517
+ ```
518
+
519
+ `pixi run setup` installs pre-commit hooks into `.git/hooks`, so the repository has to be a
520
+ git repository first — on a fresh clone it already is.
521
+
522
+ The user docs cover [policies and grants](docs/policies.md),
523
+ [audit and masking](docs/audit.md), and [writing a plugin](docs/plugins.md).
524
+
525
+ `pixi run docs` builds them into HTML, a PDF, and `llms.txt` / `llms-full.txt` — the four
526
+ documentation artifacts every release attaches. There is no hosted site: the documentation
527
+ travels with the release it describes, so a reader always has the version they installed.
528
+
529
+ ## Security
530
+
531
+ [SECURITY.md](SECURITY.md) has the disclosure contact and, more usefully, an explicit account
532
+ of **what sentrykit defends and what it does not**. The short version: it authorizes the
533
+ action before the side effect and records a hash-chained trail of every decision. It does
534
+ not sandbox a tool body — once a call is authorized, the function runs with your process's
535
+ privileges — and without a durable write-once sink behind the `AuditSink` protocol it
536
+ *detects* tampering rather than preventing it. It also does not authenticate: `Principal` is
537
+ whatever your context provider establishes, and sentrykit trusts it.
538
+
539
+ ## License
540
+
541
+ Apache-2.0. See `LICENSE`.
@@ -0,0 +1,46 @@
1
+ sentrykit/__init__.py,sha256=L_FVqLLdKhjdD-pn6t8yir726Xd8XGcCFy5IdoUa53w,4609
2
+ sentrykit/audit.py,sha256=sefBR4d-tJ7v0cVfnkTgKLHiv6Hqsfu08G7va5NZkis,43946
3
+ sentrykit/cedar.py,sha256=fmOllEKRVwNjCeTmivd3T3ihQxO-WLhzYKxIrkY0yX8,32004
4
+ sentrykit/cli.py,sha256=GM_gd2pNekB_pVZRl8SW8U6FqjDmuj7imZECtlOadb8,55667
5
+ sentrykit/config.py,sha256=EuZ32VimTIaQyTlXUUPXaBuzkAXkiO1eWuFA9NzdPU8,10709
6
+ sentrykit/constraints.py,sha256=_GL6fOQ4B2JQ_rsLOx99_gwSVjNmbM_3TKzCFMwwsU4,43514
7
+ sentrykit/context.py,sha256=P3JKn3-bMPzvr2viDTfQiGxCDWy6xOrMteMX29RBDPU,15646
8
+ sentrykit/decorator.py,sha256=CyAOpIyhCcIS_c-IsYqMdI_qpwt7xRbR_hpO2ssT470,9038
9
+ sentrykit/engine.py,sha256=_2QcO0unY0dSzR2a8pUQyeIp_WFRce5sYbUTWINW0hM,65561
10
+ sentrykit/envelope.py,sha256=yR56HpDM-f779kz4AwEQf-yhbWC1gAMb33p7zwE67rc,24714
11
+ sentrykit/errors.py,sha256=h9_Vkwdvq_UmWcsSz58KmEQm7lfDhcyqSRbVM8IJZks,11837
12
+ sentrykit/masking.py,sha256=9dOYZv4H8P1CUrTNx0WesgtRz1ClK2vBP8tsSuH9uH0,18555
13
+ sentrykit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ sentrykit/registry.py,sha256=GcMd1McOv-Oc6mpkKjVnki2cEupiAYcpEkUlSGKMonQ,25756
15
+ sentrykit/integrations/__init__.py,sha256=beucoCa6AarYVmlRLo2fUO4oHvxur8lPL8n51Se5XaE,71
16
+ sentrykit/integrations/base.py,sha256=W92p6bozgbilI5CxdWc5quDAziKpBv9mnKWfQIJvfg4,33477
17
+ sentrykit/integrations/fastapi.py,sha256=9auer-iuUdC75dRSr_DkEzhjdnvQ3dEAW5EA6nyNZ_o,15304
18
+ sentrykit/integrations/flask.py,sha256=K_0Aoe6eELsaLdOhkFXoINHDNfVaTEQYyOW0ldRnDQI,15616
19
+ sentrykit/integrations/mcp.py,sha256=4mrREjBivrgj2W9F9A-YREyKLOdlOH9bsOaOeMqZRFk,43293
20
+ sentrykit/integrations/pydantic_ai.py,sha256=g2xCf1XTdtrCRDpqr8cxzKWWmD-DZexnlh71PoXUvik,33239
21
+ sentrykit/integrations/tools.py,sha256=6mwCZJsvw2ghGqkwjmyiM2NVn9pLIqCV1nImKdp-Xr4,21141
22
+ sentrykit/policy/__init__.py,sha256=_95gljhh2jxfUVxJNJFOfrm0tLLO8bEY2ReldFrPm7w,884
23
+ sentrykit/policy/loader.py,sha256=59FsPibZous_WqhSGLEQKQGXP3uJYmPp9AqZCvGMU9Y,31687
24
+ sentrykit/policy/pack/10-rbac.cedar,sha256=Z4LxgBRcgxlz_pscerXFH5gFyrOXpj4gIO_YvoV5XCU,564
25
+ sentrykit/policy/pack/20-groups.cedar,sha256=ENPoN9FfRq9_OllxOGXURV9N38EC1ihIMj3jUkjAHJk,524
26
+ sentrykit/policy/pack/30-ownership.cedar,sha256=mpss4VDTtc4wBh5dz8EhO3lkTWwI8iKH8v9x68mvSW0,360
27
+ sentrykit/policy/pack/40-delegation.cedar,sha256=Vov8oMMkcl66nmhZ5FWF2QbOnFSP7l4f8CTvzI43afs,1512
28
+ sentrykit/policy/pack/50-expiry.cedar,sha256=aWY4qa5GzZUyzGCY9tUdsHcR_UuK7dJxgjD_A_tbln8,853
29
+ sentrykit/policy/pack/60-break-glass.cedar,sha256=pkc3pdpoUaU44hWOi7gUF2bkfdB9-nQA5qZ74e7oVbk,1279
30
+ sentrykit/policy/pack/70-separation-of-duties.cedar,sha256=3f8r9D41-0InjCSBNhYaRhEe5p0o9YEk46SXsldlWSQ,922
31
+ sentrykit/policy/pack/80-jit-elevation.cedar,sha256=OUkjdUGEN_lfeUIECBXHN5rSg3QQWLuPJhllgipolks,1231
32
+ sentrykit/policy/pack/entities.json,sha256=JG3WRh4p-OccGBEf_oA13z5gx999ebOUlpfGZOhMp-E,79
33
+ sentrykit/policy/pack/pack.cedarschema,sha256=TcZ7a7kEN2jzScISk_0xeLGymsaJwANNJAMC0VhXzcU,1154
34
+ sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate,sha256=W10UNb4oQZqKSnFaiGgNkZeALuKNgQsLBjhkCZDYPzQ,73
35
+ sentrykit/stores/__init__.py,sha256=2e56e9kMic7FzkB4unoiEAk7Q0IJ-6WlEq5rBMasZic,3909
36
+ sentrykit/stores/base.py,sha256=AG5HmVWceNc5K6PJEidEpoAlxn60DqBx-fbZqBcHolQ,35653
37
+ sentrykit/stores/composite.py,sha256=lpIH7DiQpVL_vB456b0sEs5k6a5_zWq1A9sJozxVLEk,27343
38
+ sentrykit/stores/filesystem.py,sha256=wvwzLBp6_NMtuHYxjvSaWyUK1lSNhjqZZRctn_B7ICo,16568
39
+ sentrykit/stores/jsonfile.py,sha256=GBL6BSJtmuYur-rfrfoxGL7giKSlejD1nZ-O9Ht0970,29659
40
+ sentrykit/stores/jsonl.py,sha256=eTOgQnqX6FQqBsxH05X6gXysjw3KmLeIZNgv0pHVXrI,14179
41
+ sentrykit/stores/memory.py,sha256=dM6x5MxeBf9i0XA6F_npq7Du0BfO66bWZxi5N3wQFc8,20547
42
+ sentrykit-0.1.0rc1.dist-info/METADATA,sha256=n9HoiMN3aMkKeZdiRP4O28Bj4MjSfWqgVPRyeH4kDPI,26271
43
+ sentrykit-0.1.0rc1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
44
+ sentrykit-0.1.0rc1.dist-info/entry_points.txt,sha256=5QZKakilcWPw882JhQTRmnSY4vEUoL9kRJuLNjNIFUc,691
45
+ sentrykit-0.1.0rc1.dist-info/licenses/LICENSE,sha256=de-gfE0q-xTYImzwC3dj3S7BxVhanf6RmIGjo_7y3aw,11357
46
+ sentrykit-0.1.0rc1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,20 @@
1
+ [console_scripts]
2
+ sentrykit = sentrykit.cli:main
3
+
4
+ [sentrykit.audit_sinks]
5
+ jsonl = sentrykit.stores.jsonl:JsonlAuditSink
6
+ memory = sentrykit.stores.memory:InMemoryAuditSink
7
+
8
+ [sentrykit.context_providers]
9
+ fastapi = sentrykit.integrations.fastapi:FastAPIProvider
10
+ flask = sentrykit.integrations.flask:FlaskProvider
11
+ mcp = sentrykit.integrations.mcp:MCPProvider
12
+ pydantic-ai = sentrykit.integrations.pydantic_ai:PydanticAIProvider
13
+
14
+ [sentrykit.grant_stores]
15
+ jsonfile = sentrykit.stores.jsonfile:JsonFileGrantStore
16
+ memory = sentrykit.stores.memory:InMemoryGrantStore
17
+
18
+ [sentrykit.policy_sources]
19
+ file = sentrykit.stores.filesystem:FilesystemPolicySource
20
+ pack = sentrykit.policy.loader:PackPolicySource