Quickstart¶
Everything below runs against the core: a policy set held in memory, one decorator, and no framework. Guarding an HTTP route adds one line and changes none of it — see web frameworks.
The walkthrough builds to a call that asked for more than its principal may have: the values she may not have are rejected, her own defaults fill the gaps, and the call runs anyway. Which of the proposed arguments survive, and what is filled in for the rest, is decided per principal and per action; the allow or deny at the gate is the first step of that same decision.
Install¶
pip install sentrykit
Which cedarpy¶
sentrykit requires cedarpy >= 4.8, < 5, and the floor is load-bearing rather than
cosmetic. One module talks to the binding, and it does so by name: it parses the policy set,
the entity graph and the schema once at wiring time through PolicySet, Entities and
Schema, reuses those handles on every decision, and type-checks policy with
validate_policies. cedarpy 4.1 has none of those four names — its is_authorized takes
policy text and entity JSON on every call instead — so a resolver free to choose it installs
cleanly and then raises AttributeError the first time a governor is built.
4.8 is the lowest release confirmed to carry the whole surface. Which release between the two introduced it is not pinned down, so the floor sits at the confirmed end of that gap rather than at the earliest version that might work.
The upper bound is deliberate too: a Cedar major release is where that surface could change, and sentrykit should be re-verified against it rather than silently resolved into it. The test suite asserts every name and result shape the engine depends on against whatever cedarpy is actually installed, so a dependency drifting below the floor fails a test rather than a caller’s first protected call.
1. Write the policy¶
Cedar policy logic lives in files under version control. For a first run a string literal is fine.
POLICIES = """
@id("support-may-search-orders")
permit (
principal in Role::"support",
action == Action::"tool.search_orders",
resource == Tool::"orders"
);
@id("suspended-principals-are-forbidden")
forbid (principal, action, resource)
when { principal has suspended && principal.suspended };
"""
Always give a policy an @id("...") annotation. Cedar reports determining policies by
parser-generated ids (policy0, policy1, …) otherwise, and those shift the moment someone
inserts a policy above — which would quietly make your audit trail wrong.
2. Wire the engine¶
from sentrykit import Entity, EntityRef, install
install(
POLICIES,
entities=[
Entity(EntityRef("Role", "support")),
Entity(EntityRef("Tool", "orders")),
],
)
install parses eagerly, so a malformed policy raises
ConfigurationError here at startup rather than silently denying every
request for the rest of the process’s life.
3. Protect a callable¶
from sentrykit import protect
@protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
def search_orders(customer_id: str, region: str = "eu", limit: int = 100) -> dict[str, object]:
return {"customer_id": customer_id, "region": region, "limit": limit}
@protect works on any callable — an HTTP handler, a background job, an agent tool — and
preserves the wrapped signature, so type checkers and framework introspection still see the
original function. It supplies the action and the proposed arguments. Who is acting comes
from somewhere else.
4. Say who is acting¶
from sentrykit import ArgumentConstraint, ConstraintSet, Principal, establish
alice = Principal(
id="alice",
roles=("support",),
constraints={
'Action::"tool.search_orders"': ConstraintSet(
arguments={
"region": ArgumentConstraint(allow=("eu",), default="eu", policy="grant-eu-only"),
"limit": ArgumentConstraint(minimum=1, maximum=50, default=10, policy="grant-page-size"),
}
)
},
)
with establish(alice, channel="agent", request_id="req-1"):
result = search_orders(customer_id="c-1", region="us", limit=500)
Those constraints are resolved per (principal, action): which values alice may pass, and what
to fill in when she passes none. Outside a walkthrough they are rows in a grant store rather
than a literal, and the store refuses one that no value could satisfy at the write rather than
letting the next call discover it. A reviewed .cedar file can bound the same argument for
everybody with an argument ceiling; the two intersect, so the
reviewed file states the maximum and the store says what each principal actually has.
In a real application a context provider calls establish once at the framework boundary
and every protected callable below it — however deeply nested — is governed. Nothing has to
thread a principal through its signature.
5. Read what happened¶
Alice asked for region="us" and limit=500. Policy permits neither, so both were dropped
and replaced with her defaults — the values she may not have are refused, not trimmed to the
nearest one she may. The call still ran, and says so:
{
"customer_id": "c-1",
"region": "eu",
"limit": 10,
"_authz": {
"decision": "allow",
"action": "Action::\"tool.search_orders\"",
"resource": "Tool::\"orders\"",
"rejected": [
{
"name": "region",
"rule": "allow-set",
"reason": "Value is not one of the permitted values",
"policy": "grant-eu-only"
},
{
"name": "limit",
"rule": "maximum",
"reason": "Value is above the maximum of 50",
"policy": "grant-page-size"
}
],
"defaults_applied": { "region": null, "limit": null },
"narrowed": true,
"narrowed_arguments": ["region", "limit"],
"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."
}
}
That is summary verbosity, the default: it names what happened without disclosing values.
At verbose the envelope also carries the principal, the processed arguments, the offending
values ("us", 500) and the real defaults. See verbosity.
The outcome fields, and why they are separate from rejected¶
rejected and defaults_applied are the exceptions. narrowed, narrowed_arguments and
guidance are the outcome, and they exist because reporting only the exceptions turned out to
be actively misleading. A narrowed call succeeded — the callable ran, on values this principal
may use, and returned real data. An envelope that names only what was refused reads as a failure
report, and a reader acting on it withholds the answer it already has.
narrowed is true when the call ran with a value the caller did not propose. That is
narrower than “rejected is non-empty”: a rejection that left the argument with a surviving
value of the caller’s own is not narrowing, and a default injected for an argument the caller
never mentioned is not either — nothing was taken away. narrowed is omitted entirely when
false, so an ordinary allow stays terse.
guidance is one sentence, and its opening is conditional. When every narrowed argument was
refilled from this principal’s own entitlement it says the result is valid. When one was
not — the argument was dropped and your callable’s own signature default filled the slot — it
says the result was served and marks that part unverified, because no constraint ever saw
the value that landed there. The distinction is worth reading: it is the only place sentrykit
tells you that a parameter escaped the layer that was supposed to govern it.
None of the three carries an argument value or a policy id, so all three are present at every
verbosity above off.
The envelope is attached under _authz when the result is a mapping. For any other return
type the result is handed back untouched — rewrapping it would change your function’s type —
and the envelope is available from last_envelope(), which is the real channel: the framework
providers read it rather than the key, and a route returning a JSON array is reported through
response headers instead. See how the envelope reaches you.
6. Denials¶
from sentrykit import AuthzDenied
mallory = Principal(id="mallory", roles=("guest",))
with establish(mallory):
try:
search_orders(customer_id="c-1")
except AuthzDenied as exc:
print(exc.determining_policies) # [] -- nothing permitted it
A forbid names itself:
sam = Principal(id="sam", roles=("support",), attributes={"suspended": True})
with establish(sam):
try:
search_orders(customer_id="c-1")
except AuthzDenied as exc:
print(exc.determining_policies) # ['suspended-principals-are-forbidden']
And with no principal at all, the call is refused rather than run unguarded:
search_orders(customer_id="c-1") # NoContext
That last one is the rule worth internalizing: forgetting to establish context fails closed. A missing provider surfaces immediately instead of leaving a silent hole.
7. Async, and threads¶
Async callables work identically:
@protect(action='Action::"tool.search_orders"', resource='Tool::"orders"')
async def search_orders_async(customer_id: str) -> dict[str, object]: ...
The ambient principal follows await and asyncio.to_thread on its own. It does not
follow a bare ThreadPoolExecutor.submit — the worker thread starts from an empty context,
so every protected call inside it denies. Use bind_context there:
from sentrykit import bind_context
executor.submit(bind_context(reconcile_orders, batch_id))
Configuration¶
Settings come from SENTRYKIT_-prefixed environment variables or from configure(...):
export SENTRYKIT_VERBOSITY=verbose
export SENTRYKIT_ARG_HANDLING=strict
from sentrykit import configure, override_action
configure(verbosity="verbose")
override_action("tool.search_orders", arg_handling="strict")
Setting |
Values |
Meaning |
|---|---|---|
|
|
How much the |
|
|
Drop a rejected argument, or refuse the call |
|
|
What an engine failure does — see fail modes |
|
bool |
Mask sensitive values before they leave the engine |
|
bool |
Attach an envelope to successful calls |
Skip step 1 entirely¶
The policy above is the kind every project writes for itself. The common shapes — a role the resource requires, ownership, a group hierarchy, a delegation, a break-the-glass — ship with the library, expressed only in authorization vocabulary so they carry no domain of their own:
install(policy_source="pack://", entities=[
Entity(EntityRef("Role", "support")),
Entity(EntityRef("Resource", "orders"),
attributes={"required_role": EntityRef("Role", "support")}),
])
Role::"support" is now what the resource requires, and anyone the grant store puts in that
role may act on it — with no policy written at all. Union the pack with your own directory
when you need both:
install(policy_source=["pack://", "file://./policies"])
See the shipped policy pack for all eight patterns and the data each one reads.
Check it before you ship it¶
sentrykit policy validate ./policies
Type-checks every *.cedar file against your *.cedarschema, groups the errors by the
policy’s @id, and warns about formatting drift without rewriting anything. It exits
non-zero on a failure, so it belongs in CI — a policy set that cannot possibly permit
anything otherwise deploys cleanly and denies every request for the life of the process.
With no directory it validates the shipped pack, which is a quick way to confirm an install.
Next¶
Concepts — the pipeline, the two failure kinds, and why the caller proposes but the policy decides.
Policies & patterns — the shipped pack, grants as data, and administering them.
Audit & compliance — what a record contains, and the report that queries them.