Concepts¶
The one idea¶
The caller proposes; the policy decides.
A web client or a language model says what it would like to do. sentrykit decides whether it may, which of the proposed arguments survive, and what to fill in for the rest — then runs the call, or refuses it, before the side effect happens.
That last clause is the positioning. sentrykit is a pre-action authorization layer, not an output validator. An output validator inspects what a model produced; by then the refund has been issued. sentrykit refuses the refund.
Five things follow from the one idea, and the rest of this page is mostly about them:
Argument constraints resolved per
(principal, action)— allow-sets, deny-sets, patterns and numeric bounds, decided per argument.Narrowing instead of refusal. A value above what the principal may have is rejected and their own default fills the gap, so the call proceeds. Narrowing refines a proposal rather than rescuing one: with no default configured the argument is simply dropped, and if every argument the caller supplied is rejected the call is refused.
Defaults supplied by the principal’s grant for the arguments the caller omitted — and for the ones that were just dropped.
Constraints validated when they are written. A constraint no value could satisfy is refused at the grant rather than discovered by whoever calls next.
A record of every decision and every admin mutation, masked before the append-only write, hash-chained, and pinned to a digest of the policy logic that produced it.
Sitting across the first four: an argument ceiling declared in reviewed policy text says what anybody may pass for an action, and intersects with what the store grants this principal — so the reviewed file states the maximum and can only narrow.
The pipeline¶
Every protected call runs through the same six steps, in this order:
gate -> filter args -> inject defaults -> validate -> invoke -> envelope
The order carries meaning:
Gate. Cedar answers one boolean question: may this principal perform this action on this resource, in this context? There is no point reasoning about arguments for a call that is not permitted at all.
Filter. Each proposed argument is checked against the constraints granted to this principal for this action — allow-set, deny-set, pattern, range. Violations become data, not exceptions.
Defaults. Arguments the caller omitted — and arguments that were just dropped — are filled in from the principal’s defaults. Filtering has to come first for this to work: a dropped argument gets replaced rather than leaving a hole.
Validate. The final argument set is checked against a pydantic model derived from the callable’s own signature. Running last means the callable never sees a value policy did not sanction, and a validation failure is genuinely about types rather than about authorization.
Invoke.
Envelope. The caller is told what was processed, rejected and defaulted.
Governor.plan() performs steps 1–4 and Governor.invoke() performs 5–6. They are separate
so a decision can be reached without performing the side effect — which is what tool-manifest
filtering needs in order to decide whether a model should even be shown a tool.
Who is acting: ambient context¶
A protected callable rarely receives the principal as an argument. A Flask view takes a request; an agent tool takes whatever the model proposed; a background job takes nothing. Threading a principal through every signature between the boundary and the call is the tax that stops teams from protecting inner functions at all.
So a context provider calls establish(principal, **environment) once at the boundary,
and @protect reads it wherever the callable lives. Three rules follow:
An explicit
_as=on the call beats the ambient principal.No principal means deny. Not “run unauthenticated”, not “skip the check”.
ContextVarvalues followawaitandasyncio.to_thread, but not a bareThreadPoolExecutor.submit. Usebind_contextfor that — and note that forgetting to is safe: the worker starts with no principal, and by the previous rule that denies.
This split — the decorator supplies the action and arguments, the provider supplies the principal and environment — is the whole reason one decorator can guard a Flask view, a background function and an MCP tool unchanged.
Policies-as-code, grants-as-data¶
Cedar separates policy logic from entity data, and sentrykit leans on that split:
Policy logic lives in files and changes through review. Git history is the change-control evidence.
Grants are data — a role is a parent edge, an argument constraint is an attribute, a time-boxed grant is an attribute plus a
context.nowcondition.
“Add a role” is therefore CRUD on entity data; reviewed policy logic never changes at
runtime. Because Action::"admin.grant" is an ordinary Cedar action, the admin surface ends
up governed by the same engine it administers.
Argument constraints¶
Constraints are resolved per (principal, action). Lookup runs least- to most-specific: the
"*" wildcard set, then the bare action id, then the full uid — each layered over the last,
so a baseline can be refined per action.
Rule |
Effect |
|---|---|
|
The value must be one of these |
|
Neither the value nor anything inside it may be one of these |
|
A regex the value must match in full |
|
Inclusive numeric bounds |
|
Injected when the caller supplies none, or theirs was dropped |
|
The call cannot proceed without it |
An argument with no entry is unconstrained — the coarse gate already permitted the call. An argument with an empty constraint is also unconstrained. Neither is a deny.
A few deliberate sharp edges:
A default never overrides a value that survived filtering. The caller’s own valid input wins.
A default is checked against its own constraint before it is injected, and one that fails is withheld rather than applied. Otherwise the way to defeat a constraint would be to stop proposing a value: a supplied value is filtered, an omitted one is filled in, and only the first path would have run a check.
The callable’s own default is a default too, and answers to the same rule. When a governed argument ends up with no value — the caller proposed none that survived, and the grant holds none — sentrykit passes nothing for it, and Python fills the slot from the signature. That value is checked against the constraint like any other default: if it satisfies the rule the call proceeds on it and the envelope names the parameter in
signature_defaults; if it does not, the call is refused withSignatureDefaultRejected. Without that check a bound could be exceeded upward — a caller refused a value above a ceiling would run on the signature’s value instead, which may be larger than the one they asked for.Truedoes not match an allow-set of(1,), and aboolis not a number for range purposes — Python would say otherwise, and authorization is the wrong place for that surprise.An unparseable
patternrejects the value and logs once, rather than raising. Filtering promises not to raise, so a malformed grant fails closed.denyreads inside containers; every other rule judges the whole value. A deny-set is matched against the value and then against what the value contains — list and tuple elements, set members, and both the keys and the values of a mapping, recursively. Sodeny=("prod",)refuses"prod"and["dev", "prod"]alike, and wrapping a forbidden value in a list is not a way past the rule.allow,patternand the bounds do the opposite: they compare the value as a whole, so a container is refused outright rather than checked element-wise. The asymmetry is deliberate — reading a denial element-wise can only refuse more, while reading a permission element-wise would have to decide whatallow=("eu","uk")means for["eu"]or for[], and a wrong answer there grants something nobody granted. There is no way to say “every element must be in this set” today.A structure nested beyond a fixed depth, or one that refers to itself, is refused by a deny-set rather than searched. A value that cannot be read cannot be shown to satisfy a rule.
A numeric bound compares anything real.
int,float,DecimalandFractionall work, each at its own precision — which is what makesmaximum=1000usable ondef issue_refund(amount: Decimal). The bound itself is written as anintor afloat;Decimalcompares against both exactly, so nothing is lost. A NaN satisfies no bound and is reported as not comparable, in either direction.
Two layers: the grant and the ceiling¶
Constraints reach a decision from two places, and they intersect:
a grant in the store says what this principal may pass — mutable, per-principal, and the only layer that can carry a
default;a ceiling declared in a reviewed
.cedarfile says what anybody may pass for that action — version-controlled, covered by the policy-set hash, and unable to widen anything.
Because intersection can only narrow, the reviewed file states the maximum and the store states what each principal actually has. A ceiling is also the one thing a policy can declare that reaches narrow-and-continue rather than the gate: Cedar can only deny, so “you asked for 500, you may not have that — here is the call with your own default instead” has nowhere else to live. See Argument ceilings.
A constraint has to be satisfiable¶
Because constraints are data, one can be written that nothing satisfies. minimum=100, maximum=10 is the obvious case; a permitted value the pattern rejects and a default outside
the allow-set are the quieter ones. Discovered at call time, each is a refusal of everything,
found by whoever calls next.
ArgumentConstraint.inconsistencies() names them, and a grant store runs it at the write:
store.grant(GrantSpec(principal=..., kind="constraint", subject="limit",
constraint=ArgumentConstraint(minimum=100, maximum=10)))
# ConfigurationError: the constraint on argument 'limit' cannot be satisfied: ...
Four checks: the pattern compiles, minimum is not above maximum, every allow member
satisfies the constraint’s other rules, and a configured default satisfies the constraint.
The last two delegate to the same check() the filter runs, so what a grant promises and what
the filter enforces cannot drift apart.
The check is deliberately not a validator on the model itself. A store also reads constraints, and refusing at construction would make one bad row already on disk fail the load of every principal in the file — a wider refusal than the fault deserves. The write is where the fault is introduced and where someone is present to fix it; the call path stays fail-closed per argument either way.
Relationships compose underneath¶
The argument layer stands on Cedar’s gate, and composes with what the engine underneath is
already good at. Nested, multi-hop relationships — user → team → project → resource,
delegation, on-behalf-of, expiry, break-the-glass — are resolved deterministically by
Cedar’s entity hierarchy, never inferred by a model and never reimplemented as a ladder of
if statements.
Roles, teams, projects and delegation edges are all the same thing: a parent edge. Cedar computes the transitive closure, so a policy can name only the far end of a chain:
@id("project-members-may-read-project-orders")
permit (
principal in Project::"orders-eu",
action == Action::"order.read",
resource in Project::"orders-eu"
);
Given alice -> Team::"eu-support" -> Division::"emea" -> Project::"orders-eu" and
Order::"ord-0003" -> Project::"orders-eu", alice may read the order. The policy mentions neither
the team nor the division. Sever any one edge and the access disappears — because the
relationship is the authorization. An agent acting for a human is the same pattern:
delegation, expressed as an edge, resolved by the same machinery.
The constraints then apply to whatever that chain permitted: the hierarchy decides whether the call may happen, and the argument layer decides what it may carry.
Errors¶
Error |
Raised when |
|---|---|
|
No ambient principal and no |
|
The gate refused. Carries |
|
An argument violated its constraint under |
|
The caller supplied arguments and every one was rejected. |
|
A required argument has neither a supplied value nor a default. |
|
The final argument set failed the callable’s model. Carries pydantic’s structured errors. |
|
Supplied data spelled one of Cedar’s reserved |
|
Bad settings, an unparseable policy, or no installed governor. |
NoValidArguments fires even when defaults could complete the call. That is deliberate:
filling in defaults after discarding the entire request would answer a question the caller
did not ask — they wanted one customer’s orders and would silently get their own. Partial
rejection is different, and narrows.
Fail modes¶
fail_mode is the most commonly misread setting, so it is worth stating plainly. It
arbitrates engine failure only:
Situation |
|
|
|---|---|---|
A policy denies |
deny |
deny |
No policy matches |
deny |
deny |
No principal |
deny |
deny |
Cedar cannot evaluate (unparseable policy, evaluation error) |
deny |
allow, and record the failure |
Conflating a policy denial with an engine failure would let a typo in a policy quietly open a
door. AuthzDenied.engine_errors is populated only in the last row, so an operator can tell
a correctly enforced boundary from a broken policy set.
The last row has one exception. A failed evaluation reports no determining policies, so it also reports no argument ceilings — the call would proceed unbounded. A governor whose policy set declares any ceiling therefore refuses an engine failure under either fail mode.
fail_mode="open" exists to tolerate operator error — a policy set that will not evaluate
should not take an application down with it. It is not a tolerance for caller input, which is
why the boundary below is enforced ahead of the gate rather than through it.
Arguments as policy input¶
The proposed arguments are exposed to policy as context.args, so a Cedar forbid can
inspect them directly. That backstop is defense in depth: the Python-side filter is the
enforcement, and the forbid catches a filter that was never configured.
Everything in context.args is caller data. Two consequences follow, and the second one
is the sharp edge.
Supplied data cannot spell Cedar’s escape forms¶
Cedar’s request JSON reserves record keys beginning with __ for its own escape forms:
__entity promotes a record into a typed entity reference, __extn into an extension value.
sentrykit refuses any supplied mapping that spells one, anywhere in the request, with
ReservedKey — so a dict-valued argument cannot promote itself into a typed entity.
The refusal is deliberately not routed through fail_mode: it happens before Cedar is
consulted, so there is no engine failure for open to tolerate.
An entity-valued argument lets the caller choose the entity — type included¶
If your callable annotates a parameter as EntityRef, you have said that the caller names an
entity, and every web and agent integration validates the caller’s JSON against that
annotation before the decorator runs. {"type": "Region", "id": "eu"} therefore reaches
policy as a genuine Region::"eu" — and {"type": "Group", "id": "approved"} reaches it as a
genuine Group::"approved", because the caller picked the type too.
That matters because Cedar’s in is reflexive, so this looks like a guard and is not one:
@id("region-must-be-approved")
forbid (principal, action, resource)
when { context.args has region && !(context.args.region in Group::"approved") };
A caller who passes the approved group itself as the region is trivially “in” it, and the
forbid does not fire. Pin the type. Cedar’s is operator is the whole fix:
@id("region-must-be-approved")
forbid (principal, action, resource)
when {
context.args has region &&
!(context.args.region is Region && context.args.region in Group::"approved")
};
Two further defenses, either of which also closes it, and which compose:
Bound the argument. An allow-set of
EntityRefvalues in a grant, or an argument ceiling in the policy, decides which entities the caller may name before Cedar ever sees the value.Declare a Cedar schema. A schema saying
args.regionis aRegionmakes the mismatched request fail to parse at all, which isno-decision— refused under the defaultfail_mode="closed".
If a parameter is not meant to be caller-chosen, do not annotate it as an EntityRef: build
the reference in your own code from something you trust.
Verbosity¶
The envelope trades transparency against disclosing policy structure and argument values. Trimming happens when the envelope is built, so a field excluded by verbosity is never carried on the object at all.
|
|
|
|
|
|---|---|---|---|---|
Envelope at all |
— |
yes |
yes |
yes |
Decision, action, resource, reason |
— |
yes |
yes |
yes |
|
— |
yes |
yes |
yes |
Rejected argument names and reasons |
— |
yes |
yes |
yes |
Rejected argument values |
— |
— |
yes |
yes |
Principal, processed arguments |
— |
— |
yes |
yes |
|
— |
names only |
yes |
yes |
Determining policies, trace |
— |
— |
— |
yes |
At summary, defaults_applied keeps the argument names as keys with null values, so the
field has one shape at every verbosity and defaults_applied.keys() always works.
The three outcome fields have nothing for verbosity to trim: narrowed is a boolean,
narrowed_arguments repeats names that rejected already carries at summary, and guidance
is a sentence built from those names alone — no values, no policy ids. That is deliberate. A
caller who may not be told which value was refused can still be told that the result is good,
and withholding the outcome from them is how a successful narrowed call gets reported as a
failure.
Narrowing is defined against the proposal¶
narrowed is true when the call ran with a value the caller did not propose. That is not the
same as rejected being non-empty, and the difference runs both ways:
situation |
|
|
|---|---|---|
a proposed value was refused and refilled from the principal’s default |
non-empty |
true |
a proposed value was refused and nothing refilled it |
non-empty |
true, and |
a value was refused but the argument kept a surviving value of the caller’s own |
non-empty |
false |
a default was injected for an argument the caller never mentioned |
empty |
false — nothing was taken away |
a configured default was withheld for failing its own constraint |
non-empty |
false, unless the caller proposed that argument |
narrowed is omitted from the rendered envelope when false, so an ordinary allow stays terse.
How the envelope reaches you, and why _authz is the second-best way¶
There are two channels, and the one most people meet first is the narrower of the two.
last_envelope() is the mechanism. Every decision — allow and refusal alike — leaves its
envelope on the context, whatever the protected callable returned. That is the channel the
framework providers read, and the only one that always works.
The _authz key is a convenience layered on top. When a protected callable returns a
mapping, sentrykit writes the envelope into it under _authz, so the report travels with the
answer and an agent’s model reads it without anyone wiring anything. That is genuinely useful
and it is why the README leads with it. But it is conditional on your return type, and the
condition is not a small one:
@protect(action="orders.search")
def as_dict(region: str = "eu") -> dict: # -> {"region": "eu", "_authz": {...}}
...
@protect(action="orders.search")
def as_list(region: str = "eu") -> list[dict]: # -> [{"region": "eu"}] no _authz, ever
...
A list, a str, a pydantic model and None cannot carry a key, and rewrapping them would
change your function’s declared return type. A web route returning a JSON array is the most
common shape there is, so do not read _authz as the mechanism — if you do, narrowing is
invisible for most of your surface and you will not find out from a test that only exercises a
dict-returning handler.
What you get instead, without doing anything:
boundary |
channel |
carries |
|---|---|---|
any protected call |
|
the whole envelope |
FastAPI / Flask route |
|
that it happened, and to which arguments |
MCP tool |
|
the facts, and the sentence the model reads |
Pydantic-AI tool |
|
the facts, and the sentence the model reads |
The headers appear only when something was narrowed, which is the same rule the narrowed
field follows. guidance is deliberately not a header: it is prose, it runs past 300
characters, and an HTTP field value cannot spell it. The agent adapters append rather than
substitute, so the tool’s own result reaches the model untouched and the note rides beside it.
Behind the headers is an EnvelopeRecorder, installed per request by the provider, which
accumulates every decision in the response rather than keeping only the last — a route may make
several protected calls, and “was anything narrowed while serving this” is the question a
response-level report has to answer. It also solves a problem last_envelope() cannot: a
sync framework route runs in a worker thread holding a context copy, so nothing it sets
survives its return. The recorder is a mutable object reached through the context variable, so
the copy points at the same instance and the mutation reaches the boundary. A hand-written
ContextProvider that does not inherit BaseContextProvider needs to call record_decisions()
and close it to get any of this. See ADR-029.
Two output channels, never conflated¶
Operational logs — ordinary
logging, for developers. Ephemeral. Argument names, rules and policy ids; never values. A deny is normal operation and logs atINFO.The audit log — not
logging. A hash-chained record stream, masked before the write, written append-only. It is a data product, not a side effect. The chain is detection, not prevention — Audit & compliance states what it does and does not cover.
What sentrykit is not¶
Not a content filter or an output validator. It refuses the side effect, not the text.
Not an authentication system. It decides what an already-identified principal may do.
Not a place for business rules that are not authorization decisions.
Nor is it a policy engine in its own right. General-purpose engines — Cedar (including Amazon Verified Permissions), OPA, Casbin — and relationship-based systems such as OpenFGA answer a boolean question at the gate: may this principal perform this action on this resource. sentrykit is built on Cedar, and adds the layer above that decision: which arguments the call may carry, which defaults to supply when the caller omits them, and a record of both. If you already run a policy engine, that argument layer is the part you would otherwise assemble yourself.