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.
- sentrykit/__init__.py +200 -0
- sentrykit/audit.py +967 -0
- sentrykit/cedar.py +750 -0
- sentrykit/cli.py +1405 -0
- sentrykit/config.py +300 -0
- sentrykit/constraints.py +928 -0
- sentrykit/context.py +393 -0
- sentrykit/decorator.py +252 -0
- sentrykit/engine.py +1509 -0
- sentrykit/envelope.py +513 -0
- sentrykit/errors.py +252 -0
- sentrykit/integrations/__init__.py +1 -0
- sentrykit/integrations/base.py +767 -0
- sentrykit/integrations/fastapi.py +361 -0
- sentrykit/integrations/flask.py +342 -0
- sentrykit/integrations/mcp.py +929 -0
- sentrykit/integrations/pydantic_ai.py +728 -0
- sentrykit/integrations/tools.py +479 -0
- sentrykit/masking.py +459 -0
- sentrykit/policy/__init__.py +40 -0
- sentrykit/policy/loader.py +764 -0
- sentrykit/policy/pack/10-rbac.cedar +9 -0
- sentrykit/policy/pack/20-groups.cedar +9 -0
- sentrykit/policy/pack/30-ownership.cedar +8 -0
- sentrykit/policy/pack/40-delegation.cedar +40 -0
- sentrykit/policy/pack/50-expiry.cedar +24 -0
- sentrykit/policy/pack/60-break-glass.cedar +34 -0
- sentrykit/policy/pack/70-separation-of-duties.cedar +22 -0
- sentrykit/policy/pack/80-jit-elevation.cedar +32 -0
- sentrykit/policy/pack/entities.json +3 -0
- sentrykit/policy/pack/pack.cedarschema +45 -0
- sentrykit/policy/pack/templates/time-boxed-grant.cedartemplate +5 -0
- sentrykit/py.typed +0 -0
- sentrykit/registry.py +666 -0
- sentrykit/stores/__init__.py +116 -0
- sentrykit/stores/base.py +870 -0
- sentrykit/stores/composite.py +635 -0
- sentrykit/stores/filesystem.py +400 -0
- sentrykit/stores/jsonfile.py +701 -0
- sentrykit/stores/jsonl.py +357 -0
- sentrykit/stores/memory.py +500 -0
- sentrykit-0.1.0rc1.dist-info/METADATA +541 -0
- sentrykit-0.1.0rc1.dist-info/RECORD +46 -0
- sentrykit-0.1.0rc1.dist-info/WHEEL +4 -0
- sentrykit-0.1.0rc1.dist-info/entry_points.txt +20 -0
- 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,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
|