Policies & patterns¶
Cedar separates policy logic from entity data, and sentrykit leans on that split hard:
Policy logic lives in files under version control. It changes by review, and git history is the change-control evidence an auditor asks for.
Grants are data in a mutable store. A role is a parent edge, a per-argument limit is an attribute, a time-boxed grant is a timestamp. “Add role” is CRUD — reviewed policy logic never changes at runtime.
A policy pack ships with the library — RBAC, groups, ownership, delegation, expiry, break-the-glass, separation-of-duties and JIT elevation — as data, reachable through the same union as your own directory.
Loading policy from a directory¶
policies/
00-base.cedar
10-support.cedar
app.cedarschema
entities.json
from sentrykit import install
install(policy_source="file://./policies")
Files matching *.cedar are read in sorted order and concatenated. The order matters:
Cedar numbers policies positionally in the text it parses, and the annotation index that
carries obligations and provenance is keyed by those numbers. Prefixing filenames
(00-, 10-) keeps that order explicit rather than incidental.
entities.json holds the stable graph — roles, teams, tools — in Cedar entity JSON:
[
{"uid": {"type": "Role", "id": "support"}, "attrs": {}, "parents": []},
{"uid": {"type": "Tool", "id": "accounts"}, "attrs": {}, "parents": []}
]
Principals never belong here. They arrive per request, materialized from a grant store, so one principal’s snapshot can never leak into another’s decision.
Validation at load, not at runtime¶
When a *.cedarschema is present, the source type-checks the policies against it and raises
ConfigurationError naming the offending policy. Cedar’s validator never raises on its own —
a policy parse error, a schema parse error and a genuine type error all come back as data —
so a source that did not branch on the result would wire up a policy set that cannot possibly
permit anything and deny every request for the life of the process.
Turn it off per source with file://./policies?validate=false.
Note that .cedarschema is Cedar’s schema surface syntax; the JSON schema form is not
accepted.
Always give a policy an @id¶
@id("support-may-look-up-accounts")
permit (
principal in Role::"support",
action == Action::"tool.lookup",
resource == Tool::"accounts"
);
Without it Cedar reports determining policies as policy0, policy1, … — positional ids
that shift the moment someone inserts a policy above, which would quietly make your audit
trail wrong.
Cedar does not enforce @id uniqueness. When several sources are unioned, sentrykit
namespaces declared ids as <source>/<id> so two sources declaring the same name stay
distinguishable.
Obligations¶
An obligation says what must also happen when a call is permitted. It is declared on the policy, because “you may do this, provided X” is reviewed logic rather than mutable grant data.
@id("support-may-look-up-accounts")
@obligations("hmac:account, notify:secops")
permit (
principal in Role::"support",
action == Action::"tool.lookup",
resource == Tool::"accounts"
);
One comma-separated @obligations annotation, not several — Cedar rejects a repeated
annotation key outright. Each entry is verb:target:
Masking verbs —
mask,redact,partial,hmac,tokenize— are consumed by sentrykit before the audit write. See Audit & compliance.Everything else —
notify:secops,justify— is carried on the_authzenvelope and the audit record for your application to discharge.
Obligations come from the determining permit policies of a call that proceeds. A
determining forbid’s obligations belong to the denial record instead: they describe what
must happen about a refusal, not about a call.
result["_authz"]["obligations"] # ['hmac:account', 'notify:secops']
Argument ceilings¶
Cedar’s gate can only deny. An argument ceiling is the one thing a policy can declare that
reaches sentrykit’s narrow-and-continue path instead: “you asked for 500, you may not have
that — here is the call at what you may have” is not expressible as a forbid, because a
forbid refuses the call.
A ceiling is declared in a @constraints annotation whose value is a compact JSON object
mapping an argument name to the rules that bound it:
@id("support-may-search")
@constraints("{\"limit\": {\"maximum\": 100}, \"region\": {\"allow\": [\"eu\", \"us\"]}}")
permit (
principal in Role::"support",
action == Action::"tool.search",
resource == Tool::"orders"
);
The JSON is a Cedar string literal, so its quotes are escaped — that is Cedar’s annotation grammar, not a sentrykit convention.
A ceiling may set allow, deny, pattern, minimum, maximum and required. It may
not set a default: a default is a value, often a per-principal one — someone’s tenant id
— and values belong in grant data, which is why the caller-facing error tells you a default
exists without telling you what it is.
Narrowing is what happens next, and it is reject-and-refill rather than a clamp. A proposal
above the ceiling is rejected like any other constraint violation, and under the default
arg_handling="lenient" the principal’s own default fills the gap. The value the call
runs with is that default, not the ceiling it breached — they coincide below only because this
principal’s grant happens to default to 100:
plan = governor.plan(spec, principal=support, action=SEARCH, resource=ORDERS,
proposed={"limit": 500, "region": "eu"})
plan.processed["limit"] # 100 -- the grant's default, within the policy's ceiling
plan.rejected[0].policy # 'support-may-search' -- the reviewed file that bounded it
One interaction to know about: narrowing refines a proposal, it does not replace one. If
every argument the caller supplied is rejected, the call is refused with NoValidArguments
rather than completed from defaults — see Errors for why. A ceiling does
not change that rule; it narrows the part of a proposal that stands.
A ceiling bounds the callable’s own default too¶
If the principal’s grant holds no default, there is nothing for sentrykit to put in the gap, so it passes nothing for that parameter — and Python fills the slot from the signature. A ceiling governs that value as well:
@protect(action='Action::"orders.search"', resource='Tool::"orders"')
def search(q: str = "", limit: int = 1000) -> dict[str, object]: ...
Under a ceiling of limit: {maximum: 10} this is refused, with SignatureDefaultRejected
naming limit and reporting that the rule came from reviewed policy text rather than from
grant data. It is refused whether the caller proposed limit=500 or omitted limit entirely,
because in both cases the only value available for a bounded parameter is one the bound
refuses.
That is what makes a ceiling the maximum it claims to be. Without the check the first case would have run at 1000 — more than the 500 the caller was refused — and the second would have reported a clean allow, with nothing in the envelope or the record to say a declared bound had been passed over. A reviewed file is the artifact a change-control process signs off on, and it has to mean what it says against the code it governs.
When the signature default does satisfy the ceiling, the call runs on it and the envelope names the parameter:
plan.signature_defaults # ('limit',) -- the value came from the code, and policy agreed
processed deliberately does not carry it: that field is the set sentrykit decided and passed,
and this is a slot it declined to fill. Keeping them apart is what lets you tell a
policy-supplied value from an application-supplied one.
A ceiling narrows; it never widens¶
Policy-declared ceilings and grant-held constraints intersect. Allow-sets intersect,
deny-sets union, bounds move inward, required is sticky, and two patterns must both match in
full. A grant that permits limit up to 10 stays capped at 10 under a ceiling of 100 — the
tighter of the two always applies, in either direction.
That is deliberately inverted relative to Cedar’s own semantics, where two permits widen what is allowed. If you want one policy’s bound to override another’s, the answer is to write the grant row rather than to mark a precedence inside the annotation: a combination order expressed in data is the first step toward a combination language, and the reviewed file should state the maximum anyone may have.
lives in |
mutable? |
reviewed? |
|
|---|---|---|---|
Ceiling — nobody’s |
|
no, needs a deploy |
yes, in git |
Grant — this principal’s |
grant store |
yes, via the admin API |
no |
What is checked, and when¶
Cedar does not type-check annotation values at all — a policy carrying a nonsense one validates clean. So every check is sentrykit’s, and every one of them happens at load, where a bad declaration fails at wiring rather than silently enforcing nothing:
The value parses as a JSON object of objects.
Every rule named is one a ceiling may set. A
defaultis refused by name.Every value type-checks, and the resulting constraint is self-consistent — the same check a grant store runs at the write.
A declared
patterncompiles and carries no backreference. A ceiling’s pattern is composed with the principal’s own, which renumbers its groups.The annotation is on a
permit. A determiningforbidhas already refused the call, so a ceiling there could never apply, and a silent no-op is exactly what leaves a reviewer believing a bound is enforced.
sentrykit policy validate runs the same load, so a broken ceiling fails the command.
One thing sentrykit cannot check: a misspelled argument name. {"limt": {"maximum": 100}}
enforces nothing and raises nothing, because policy validate reads a directory of policy
files and never imports your application, so there is no signature to compare against. Ceilings
name arguments; read them against the callable.
Ceilings and the fail-open path¶
A failed Cedar evaluation reports no determining policies, so it also reports no ceilings. A governor whose bundle declares any ceiling therefore refuses an engine failure under either fail mode, rather than proceeding unbounded at the moment the engine is least trustworthy. See Fail modes.
Composing several sources¶
install(policy_source=["file://./policies/base", "file://./policies/tenant-a"])
Sources compose by union. There is deliberately no source precedence, and adding one
would be a mistake: Cedar’s forbid always beats any permit, so an override is expressed
as a forbid and the result is the same whichever order the sources are read in. That
order-independence is what makes merging policy from several places safe at all.
# policies/tenant-a/20-restrictions.cedar
@id("no-lookups-outside-business-hours")
forbid (principal, action == Action::"tool.lookup", resource)
unless { context.now_hour >= 9 && context.now_hour < 18 };
Two sources declaring the same entity identically is fine — several policy directories may
each need Role::"admin" to exist. Declaring it differently is a ConfigurationError: an
ambiguous graph is a wiring fault, and which of the two definitions won would decide requests.
Source names namespace policy ids, so members of a union must have distinct names. A
file:// source is named after its directory, which usually makes that automatic; set
?name= when two directories share a basename.
The shipped policy pack¶
Eight patterns ship with sentrykit. They reach a governor as an ordinary policy source, so there is nothing special about them:
install(policy_source=["pack://", "file://./policies"])
The union namespaces their ids as pack/<id>, so an audit record says which artifact decided
a call. Note where that spelling appears: Cedar reports the raw @id it parsed, so a
decision’s determining_policies reads rbac-required-role, while the record’s provenance
carries {"policy_id": "pack/rbac-required-role", "source": "pack", ...}. The namespace is
sentrykit’s hygiene applied to the index, not a rewrite of the policy text.
Drop the DSN and the pack is not there.
Take a subset when you want one:
install(policy_source=["pack://rbac,ownership", "file://./policies"])
Every pattern is attribute-driven¶
A shipped pack cannot know your role names, your resource types or your actions, so none of
its policies name any. Each one reads the shape of the request instead: the resource
declares what it requires, the grant store supplies the principal’s edges and attributes,
and the context supplies now, break_glass, on_behalf_of and justification.
Adopting a pattern is therefore a matter of putting an attribute on an entity — which is data. The pack’s own logic stays reviewed in version control, exactly like your own.
Pattern |
What it decides |
Data it reads |
|---|---|---|
|
A principal holding the role the resource requires may act on it. |
|
|
A member of the group the resource belongs to may act on it, at any depth. |
|
|
The owner of a resource may act on it; ownership is not inherited. |
|
|
A principal may act on behalf of its delegator, but only within the delegated scope and only until the delegation lapses. |
|
|
An entitlement window that refuses the call outside its bounds. |
|
|
An emergency-only resource is refused outright unless the caller raises the flag, and is then reachable only by a principal entitled to the emergency, carrying a justification. |
|
|
A conflicting role is refused the resource outright, and nobody approves what they prepared themselves. |
|
|
A principal holds an elevated role for a window, and is refused once the window closes. |
|
Every timestamp is epoch seconds. coerce_value renders each datetime that way and the
engine injects now into the environment on every decision, so a TTL condition is a Cedar
integer comparison. Cedar has no ordering on strings, so an ISO timestamp could not be
compared at all.
from sentrykit.policy import PATTERNS
PATTERNS["break-glass"].policy_ids
# ('emergency-resource-forbidden-without-flag', 'break-the-glass')
PATTERNS["break-glass"].requires
# ('resource.emergency_only', 'resource.emergency_role', 'context.break_glass',
# 'context.justification')
Half the pack forbids¶
Five of the patterns contribute a forbid, and that is the half that does the containing:
delegation-outside-scope-forbidden— without it, a delegated principal could reach the plain RBAC permit and ignore the scope it was delegated. The forbid is what makes a delegation bound an agent rather than merely add to it.emergency-resource-forbidden-without-flag— this is what makes an emergency-only resource emergency-only. It fires whenever the flag is absent, and forbid beats every permit.entitlement-expired-forbidden,jit-elevation-expired-forbidden,sod-conflicting-role— the same shape.
Break-the-glass takes three things, not one¶
with establish(ana, channel="web", break_glass=True, justification="incident 4471"):
open_vault(item="master-key")
The principal must be in the resource’s emergency_role, the caller must raise
break_glass, and the justification must be non-empty. A permit conditioned on the flag
alone would authorize anyone: the flag is caller-supplied and a Cedar permit needs no
base entitlement to fire. The permit carries notify:secops and record:justification as
mandatory obligations, so a call that proceeds this way cannot proceed quietly.
The schema is opt-in¶
pack.cedarschema ships with the pack, but the loaded bundle does not carry it unless you
ask:
install(policy_source=["pack://?schema=true", "file://./policies"])
A composite concatenates its members’ schemas, and an engine built with a schema validates every request against it — so a pack that contributed one by default would make every request naming an action it had never heard of fail to parse, in an application that was working a moment earlier. The pack’s decisions are identical either way.
When you do opt in, your schema must declare only what the pack does not. Redeclaring User,
Role, Group or Resource is a Cedar schema parse error (`User` is declared twice).
Adding actions is safe — an action whose context is {} still type-checks against the pack’s
context has now conditions.
Time-boxed grants from a template¶
Cedar has policy templates, but cedarpy exposes no API for linking one, and an unlinked
template in a policy set is inert. link_template links on sentrykit’s side and hands back
both halves of the fact: reviewable logic, and revocable data.
from datetime import UTC, datetime
from sentrykit.policy import LinkedPolicySource, link_template, pack_template
link = link_template(
pack_template("time-boxed-grant"),
link_id="oncall-ana",
principal='User::"ana"',
resource='Resource::"paging"',
expires_at=datetime(2026, 9, 1, tzinfo=UTC),
granted_by="ops",
reason="on-call rotation",
)
install(policy_source=["pack://", LinkedPolicySource([link])])
The generated policy carries the expiry as a condition, guarded so it validates cleanly:
@id("oncall-ana")
permit (
principal == User::"ana",
action,
resource == Resource::"paging"
)
when { context has now && context.now <= 1788220800 };
link.grant is the matching GrantSpec, carrying the same expiry, so the grant is listed,
audited and revoked like any other.
Grants are data¶
from datetime import UTC, datetime, timedelta
from sentrykit import Entity, EntityRef, install
from sentrykit.stores import InMemoryGrantStore
from sentrykit.stores.base import GrantSpec
store = InMemoryGrantStore()
store.add_entity(Entity(EntityRef("Role", "support")))
record = store.grant(
GrantSpec(
principal=EntityRef("User", "bob"),
kind="role",
subject="support",
granted_by="ops",
reason="ticket-4412",
expires_at=datetime.now(UTC) + timedelta(hours=2),
)
)
store.get_principal("bob").roles # ('support',)
Four kinds of grant:
|
|
Confers |
|---|---|---|
|
role name |
Parent edge |
|
a uid |
Any other parent edge — team, project, delegation target |
|
attribute name |
A Cedar attribute ( |
|
argument name |
An |
role and parent are the interesting ones: Cedar computes the transitive closure, so
user → team → project → resource is resolved by policy rather than by a chain of if
statements. That is the offload this library exists for.
Expiry and revocation¶
store.revoke(record.id, revoked_by="ops", reason="rotation")
store.get_principal("bob").roles # ()
get_principal returns a principal with expired and revoked grants already excluded, so the
decision path never reasons about grant lifecycles. Revocation marks the record rather than
deleting it — “who had what, when” has to survive the withdrawal or the audit trail has a
hole in it. Pass include_inactive=True to grants_for to see the history.
Pass an explicit now= to make a test deterministic:
store.get_principal("bob", now=datetime(2026, 6, 1, tzinfo=UTC))
Per-argument grants¶
Constraints decide which arguments a principal may pass and which defaults to inject when they pass none:
from sentrykit.constraints import ArgumentConstraint
store.grant(
GrantSpec(
principal=EntityRef("User", "bob"),
kind="constraint",
subject="region",
action='Action::"tool.lookup"',
constraint=ArgumentConstraint(allow=("eu-west",), default="eu-west",
policy="grant-region"),
)
)
Use action="*" for a constraint that applies to every action; an action-specific entry
refines it. See Concepts for how the filter and default injection behave.
The store checks the constraint before it stores it. Inverted bounds, an uncompilable pattern,
a permitted value the constraint’s own other rules reject, and a default the constraint would
refuse are all ConfigurationError at the grant() call rather than a refusal of every call
later:
store.grant(
GrantSpec(
principal=EntityRef("User", "bob"),
kind="constraint",
subject="region",
constraint=ArgumentConstraint(allow=("eu-west",), default="us-east"),
)
)
# ConfigurationError: the constraint on argument 'region' cannot be satisfied:
# the configured default does not satisfy the constraint's own rules: ...
Administering grants¶
The admin surface is governed CRUD over the grant store — and because Action::"admin.grant"
is an ordinary Cedar action, it is governed by the same engine it administers and logged
into the same sink.
sentrykit grant 'User::"ana"' --role oncall --expires 2099-01-01 \
--reason "on-call rotation" \
--store jsonfile://./var/grants.json --policy file://./policies \
--as 'User::"root"'
sentrykit grants 'User::"ana"' --store jsonfile://./var/grants.json
sentrykit revoke <grant-id> --reason "shift ended" \
--store jsonfile://./var/grants.json --policy file://./policies \
--as 'User::"root"'
--as names the operator (or set SENTRYKIT_ACTOR). They are resolved through the grant
store like anyone else, so an operator the store has never heard of is refused — there is
no back door, and forgetting to configure the store surfaces immediately rather than as a
silent hole.
--policy is required on grant and revoke, and deliberately has no default. The
obvious default would be the shipped pack://, which contains no permit for the admin
actions — so every mutation would be refused, and you would read a message about your own
entitlement when the truth is that no policy source was ever named. A missing --policy is a
usage error instead, before a store or a sink is opened. sentrykit grants is read-only and
ungoverned, so it takes no --policy.
The three names your permit has to spell¶
The shipped pack contains no permit for the admin actions, deliberately: a permit for the action that hands out authority is not something a library gets to write on your behalf. So you write it, and it has to name exactly the action and resource the CLI decides against:
constant |
value |
|---|---|
|
|
|
|
|
|
All three are importable from sentrykit.cli, so a test of your own policy can assert against
the constants rather than against a string you retyped:
from sentrykit.cli import ADMIN_RESOURCE, GRANT_ACTION, REVOKE_ACTION
Write the policy that permits it, the same way you would for any other action:
@id("root-may-administer-grants")
permit (
principal in Role::"admin",
action in [Action::"admin.grant", Action::"admin.revoke"],
resource == Resource::"admin.grants"
);
principal in Role::"admin" is the only part that is a choice: the action and resource are
fixed by the CLI, and a permit naming anything else authorizes nothing. Narrow it however you
narrow any other permit — a different role, a group edge, an expiry, a when clause on
context.channel == "cli".
The proposed grant is argument-filtered, not just gated¶
The command builds its GrantSpec from the policy-processed arguments, not from what you
typed. So a constraint on the operator decides which grants they may make:
store.grant(GrantSpec(
principal=EntityRef("User", "root"),
kind="constraint",
subject="subject",
action='Action::"admin.grant"',
constraint=ArgumentConstraint(allow=("oncall",), default="oncall",
policy="grant-admin-scope"),
))
An operator who asks for --role admin now stores a grant of oncall, and the audit record
says so. That is the same proposes/decides split as everywhere else in the library, applied
to the surface that hands out authority.
Two records, or one¶
An allowed mutation writes two audit records — the decision that permitted it, then the
mutation itself — and a refused one writes one, the refusal, leaving the store untouched.
sentrykit grants mutates nothing, is ungoverned, and writes none.
Bootstrapping the first operator¶
There is a chicken and egg here, and it is deliberate: you cannot sentrykit grant yourself
into Role::"admin", because the grant that would let you is itself governed. Seed the first
operator in provisioning code, where the store is an ordinary object:
from sentrykit.stores import JsonFileGrantStore
from sentrykit.stores.base import GrantSpec
store = JsonFileGrantStore("./var/grants.json")
store.grant(GrantSpec(principal=EntityRef("User", "root"), kind="role", subject="admin",
granted_by="bootstrap", reason="initial operator"))
A CLI flag that skipped the gate for the first grant would be a back door that never closes.
Composing several grant stores¶
install(policy_source="file://./policies", grant_store=["memory://tenant-a", "memory://tenant-b"])
When every store declares a namespace the composite routes by it. Otherwise it merges by precedence: earlier stores win per field, and roles and parents are unioned.