Stores¶
Three contracts — policy source, grant store, audit sink — and the reference backends that implement them. A third-party backend registers through the same entry-point groups and is indistinguishable from the ones below; see plugins.
sentrykit.stores¶
The package re-exports every contract and every shipped backend. The contracts are imported
eagerly; the backends resolve through a module-level __getattr__ on first access, so
from sentrykit.stores import JsonlAuditSink imports exactly the one module it needs and no
import cycle is created.
The three store contracts, and lazy access to the backends that ship with sentrykit.
The contracts are imported eagerly; the backends are not. Two reasons, and the second is the one that matters:
A backend module reaches back into
sentrykit.auditfor the record type, andsentrykit.auditreaches intosentrykit.stores.basefor the sink contract. Importing the backends from this package’s body would close that loop.Entry-point discovery, not a package import, is what makes a backend reachable. The registry resolves
jsonl://by exactly the path it resolves a third-partysentrykit-<x>sink — throughimportlib.metadata— and if the built-ins needed a privileged import to be found, the claim that built-in and third-party backends are indistinguishable to the core would simply be false.
from sentrykit.stores import JsonlAuditSink still works: PEP 562 resolves it on first
access, importing the one module it needs. There are three contracts rather than one because
they guarantee different things: a policy source is fetched and never written, a grant store
is mutable data, and an audit sink is append-only.
sentrykit.stores.base¶
The three store contracts, and the value objects that cross them.
Architecture §4 insists on three stores rather than one, because they have three different change profiles and three different guarantees:
PolicySource— Cedar policy logic. Read-mostly, authored in git, distributed as a versioned artifact. Multiple sources compose by union; ordering them cannot change a decision, because an override is expressed as a Cedarforbidand forbid always wins.GrantStore— roles, group edges, per-argument constraints, expiries. Mutable; the only thing an admin surface ever writes. Multiple stores merge by precedence or route by namespace.AuditSink— the decision log. Append-only and hash-chained. Multiple sinks fan out.
They are Protocol s, not base classes, so a third-party backend satisfies
them structurally without importing anything from sentrykit — the discovery half of the
plugin mechanism.
Every reference implementation in this package registers through the same decorator a
third-party package would use, so the core cannot tell them apart.
This module holds only contracts and value objects. It deliberately imports no backend, so
registry.py can talk about a sink it has not yet loaded.
- sentrykit.stores.base.CONSTRAINTS_ANNOTATION: Final = 'constraints'¶
The Cedar annotation an argument ceiling is declared in.
One JSON object per policy, for the reason
@obligationscarries one comma-separated value: Cedar rejects a repeated annotation key outright.
- sentrykit.stores.base.OBLIGATIONS_ANNOTATION: Final = 'obligations'¶
The Cedar annotation obligations are declared in.
One comma-separated value, because Cedar rejects a repeated annotation key outright (
ValueError: duplicate annotation).
- sentrykit.stores.base.WILDCARD_ACTION: Final = '*'¶
Constraint key meaning “every action”, matching
sentrykit.context’s spelling.
- class sentrykit.stores.base.AuditSink(*args, **kwargs)[source]¶
Bases:
ProtocolWhere audit records are written, and read back from.
Append-only and hash-chained: the chain makes an edited or deleted record visible, which is detection rather than prevention — durable immutability comes from the storage layer behind this protocol. A sink never updates or deletes a record, and never masks one: by the time a record reaches
write()its sensitive values are already masked, because masking runs upstream of the immutable write andAuditTrailis the only thing that calls this method.Implementations are addressed by DSN and registered under the
sentrykit.audit_sinksentry-point group.- property name: str¶
Short name for this sink, used in fan-out error reporting.
- write(record: AuthzAuditRecord) None[source]¶
Append one record.
- Parameters:
record – The record, already masked and already chained.
- Raises:
AuditUnavailable – If the record could not be durably appended. The caller decides what that means: under
fail_mode="closed"the governed call is refused, because an action that cannot be proven cannot be taken.
- query(window: AuditWindow) Iterator[AuthzAuditRecord][source]¶
Read back the records matching
window, oldest first.- Returns:
An iterator, so a sink over a large log need not materialize it.
- Raises:
AuditUnavailable – If the log cannot be read.
- class sentrykit.stores.base.AuditWindow(*, start: datetime | None = None, end: datetime | None = None, principal: str | None = None, action: str | None = None, resource: str | None = None, decision: Literal['allow', 'deny'] | None = None, kind: Literal['decision', 'mutation'] | None = None, limit: Annotated[int | None, Ge(ge=1)] = None)[source]¶
Bases:
BaseModelA filter over an audit log — the query half of “the yearly report is a query”.
Every field narrows; an empty window matches everything. Filtering lives on the window rather than in each sink so that a third-party backend can push down what it can and lean on
matches()for the rest, without the semantics drifting per backend.- Variables:
start (datetime.datetime | None) – Inclusive lower bound on
timestamp.end (datetime.datetime | None) – Exclusive upper bound on
timestamp.principal (str | None) – Match this principal uid exactly.
action (str | None) – Match this action, as either a full uid or a bare id.
resource (str | None) – Match this resource uid exactly.
decision (Literal['allow', 'deny'] | None) –
allowordeny.kind (Literal['decision', 'mutation'] | None) –
decisionormutation.limit (int | None) – Stop after this many records.
- matches(record: AuthzAuditRecord) bool[source]¶
Whether
recordfalls inside this window, ignoringlimit.The time bounds are half-open —
startinclusive,endexclusive — so adjacent windows tile a log without double-counting a record on the boundary.
- class sentrykit.stores.base.ContextProvider(*args, **kwargs)[source]¶
Bases:
ProtocolHow a framework establishes ambient context and renders sentrykit’s refusals.
Declared here, with the other three contracts, so the plugin machinery is uniform across all four entry-point groups. The implementations live under
sentrykit/integrations/, behind optional extras, and the core must not import them to know the contract. Registered under thesentrykit.context_providersentry-point group.- property name: str¶
Short name for this provider, e.g.
fastapi.
- establish(request: object) object[source]¶
Resolve the principal and environment from a framework request and establish them.
- Parameters:
request – The framework’s request object.
- Returns:
A
ContextScopeto reset at the end of the boundary.
- render_error(error: Exception) object[source]¶
Render a sentrykit refusal as the framework’s error response.
- Parameters:
error – A
SentrykitError.- Returns:
The framework-native response, carrying the
_authzenvelope at whatever the effective verbosity allows.
- sentrykit.stores.base.GrantKind¶
What a grant confers.
roleandparentare both parent edges —roleis the shorthand that becomesRole::"<subject>"— and they are what make a multi-hop relationship resolvable by Cedar.attributesets a principal attribute.constraintattaches anArgumentConstraintfor one argument of one action.alias of
Literal[‘role’, ‘parent’, ‘attribute’, ‘constraint’]
- class sentrykit.stores.base.GrantRecord(*, id: str, spec: GrantSpec, granted_at: datetime, revoked_at: datetime | None = None, revoked_by: str | None = None, revoked_reason: str | None = None)[source]¶
Bases:
BaseModelA grant as stored: a
GrantSpecplus its lifecycle.Returned by every mutation so the caller — and the audit trail — sees exactly what changed, including the id needed to revoke it later.
- Variables:
id (str) – Stable identifier for this grant.
spec (sentrykit.stores.base.GrantSpec) – What was granted.
granted_at (datetime.datetime) – When it was made.
revoked_at (datetime.datetime | None) – When it was withdrawn, or
Nonewhile it stands.revoked_by (str | None) – The actor who withdrew it.
revoked_reason (str | None) – Why it was withdrawn.
- is_active(now: datetime) bool[source]¶
Whether this grant applies at
now— neither revoked nor expired.A naive timestamp on either side is read as UTC rather than as local time, for the same reason
coerce_value()does it: whether a grant has expired must not depend on the timezone of the host that happened to ask.
- as_record() dict[str, object][source]¶
Render for an audit mutation record.
Rendered by hand rather than dumped, because two fields need shaping: the principal is written as its Cedar uid (a bare
model_dumpwould emit the underlying two-tuple), and a constraint is described rather than dumped, so a per-principal default value cannot leak into the log through the back door.
- class sentrykit.stores.base.GrantSpec(*, principal: EntityRef, kind: Literal['role', 'parent', 'attribute', 'constraint'], subject: str, value: object | None = None, constraint: ArgumentConstraint | None = None, action: str = '*', granted_by: str | None = None, expires_at: datetime | None = None, reason: str | None = None)[source]¶
Bases:
BaseModelA grant to make: what to confer on whom, and for how long.
Grants are data, never logic. Nothing here changes reviewed policy — a role is a parent edge, an expiry is a timestamp the store enforces at materialization, and an argument constraint is an attribute of the grant. That separation is what lets an admin API exist without an approval workflow around every call.
- Variables:
principal (sentrykit.cedar.EntityRef) – Who receives the grant.
kind (Literal['role', 'parent', 'attribute', 'constraint']) – What is being conferred.
subject (str) – The role name, parent uid, attribute name, or argument name, depending on
kind.value (object | None) – The attribute value, for
kind="attribute".constraint (sentrykit.constraints.ArgumentConstraint | None) – The argument constraint, for
kind="constraint".action (str) – Which action a
constraintapplies to — a full uid, a bare action id, or"*"for every action, matchingconstraints_for().granted_by (str | None) – The actor making the grant, recorded for the audit trail.
expires_at (datetime.datetime | None) – When the grant stops applying.
Nonemeans it does not expire.reason (str | None) – Why the grant was made — the justification an auditor asks for.
- class sentrykit.stores.base.GrantStore(*args, **kwargs)[source]¶
Bases:
ProtocolWhere principals, their edges, and their argument constraints live.
The mutable store, and the admin surface’s only write target. Because
Action::"grant_role"is an ordinary Cedar action, an admin surface built on this is governed by the same engine it administers and logged into the same sink.Implementations are addressed by DSN and registered under the
sentrykit.grant_storesentry-point group.- property name: str¶
Short name for this store, used for namespace routing and audit provenance.
- get_principal(principal: str | EntityRef, *, now: datetime | None = None) Principal | None[source]¶
Materialize a principal with its roles, parents, attributes and constraints.
“Materialized” is the whole contract: the returned
Principalis ready to hand to the engine, with expired and revoked grants already excluded, so the decision path never reasons about grant lifecycles.- Parameters:
principal – The principal id (assumed type
User) or a full reference.now – The moment to evaluate expiries against. Defaults to the current UTC time; pass one to make a test deterministic.
- Returns:
The principal, or
Nonewhen the store knows nothing about them.Noneis not an error — the caller fails closed on it.
- grants_for(principal: str | EntityRef, *, include_inactive: bool = False) tuple[GrantRecord, ...][source]¶
List the grants held by
principal.- Parameters:
principal – The principal id or reference.
include_inactive – Whether to include revoked and expired grants, which an audit report needs and a decision does not.
- Returns:
The grants, oldest first.
- grant(spec: GrantSpec, *, now: datetime | None = None) GrantRecord[source]¶
Confer a grant.
A store refuses a grant it could not honour, and an
ArgumentConstraintthat contradicts itself is one of those: inverted bounds, an uncompilable pattern, a permitted value the constraint’s own other rules reject, or a default it would refuse. Left to be discovered at call time, each of those is a refusal of everything, found by whoever calls next. The check isinconsistencies()and it belongs at the write, where the operator who wrote it is still present.- Parameters:
spec – What to confer.
now – The grant timestamp. Defaults to the current UTC time.
- Returns:
The stored record, carrying the id needed to revoke it.
- Raises:
ConfigurationError – If the spec is inconsistent — a
constraintgrant with no constraint, or one carrying a constraint no value could satisfy.
- revoke(grant_id: str, *, revoked_by: str | None = None, reason: str | None = None, now: datetime | None = None) GrantRecord[source]¶
Withdraw a grant.
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.
- Parameters:
grant_id – The id returned by
grant().revoked_by – The actor withdrawing it.
reason – Why.
now – The revocation timestamp. Defaults to the current UTC time.
- Returns:
The updated record.
- Raises:
ConfigurationError – If no such grant exists.
- entities() tuple[Entity, ...][source]¶
Return the stable entities this store contributes to the Cedar graph.
Roles, teams and projects that grants point at — the nodes a parent edge needs to resolve. Principals are not included: they are per-request deltas, so one principal’s snapshot can never leak into another’s decision.
- class sentrykit.stores.base.NullAuditSink[source]¶
Bases:
objectThe sink that discards everything — the default when none is configured.
A governor with no sink does not construct records at all, so this exists for the case where a sink is wanted structurally (a composite with nothing configured, a test asserting the write path runs) but nothing should be persisted.
- write(record: AuthzAuditRecord) None[source]¶
Discard
record.
- query(window: AuditWindow) Iterator[AuthzAuditRecord][source]¶
Yield nothing.
- class sentrykit.stores.base.PolicyBundle(policies: str, schema: str | None = None, entities: tuple[Entity, ...] = (), provenance: tuple[PolicyProvenance, ...] = (), bundle_hash: str = '', source: str = '')[source]¶
Bases:
objectEverything a
PolicySourceload produced, ready to become a policy engine.The bundle owns the join between a decision and the policies that made it. Cedar numbers policies positionally in the text it parsed, so
provenanceis only meaningful alongside the exactpoliciesstring it was derived from — which is why the two travel together in one immutable object rather than being reassembled by a caller.- Variables:
policies (str) – The concatenated Cedar policy text.
schema (str | None) – Cedar schema text, when the source supplies one.
entities (tuple[sentrykit.cedar.Entity, ...]) – The stable entity graph the source contributes — roles, teams, resources. Principals come from a
GrantStore, never from here.provenance (tuple[sentrykit.stores.base.PolicyProvenance, ...]) – One entry per static policy, in Cedar’s numbering order.
bundle_hash (str) – Digest pinning the exact logic a decision was made against, so the decision replays.
source (str) – Name of the source (or composite) that produced this bundle.
- engine() PolicyEngine[source]¶
Build the Cedar engine for this bundle.
- Returns:
An engine over
policies,entitiesandschema.- Raises:
ConfigurationError – If any of the three fail to parse.
- provenance_for(determining: Sequence[str]) tuple[PolicyProvenance, ...][source]¶
Resolve the provenance of the policies that decided a request.
- Parameters:
determining –
determining_policies, which names policies by their@idwhere they declare one and by Cedar’s generated id otherwise — so both spellings are looked up.- Returns:
The matching entries, in the order given. Names with no match are skipped rather than raising: an audit record must never fail the decision it describes.
- obligations_for(determining: Sequence[str], *, allowed: bool) tuple[str, ...][source]¶
Collect the obligations attached to the policies that decided a request.
Only the determining permit policies contribute obligations to a call that proceeds — an obligation on a
forbiddescribes what must happen about a refusal, and belongs to the denial record instead.- Parameters:
determining – The determining policy names from the decision.
allowed – Whether the decision permitted the call. Selects
permitpolicies when true andforbidpolicies when false.
- Returns:
The obligations, de-duplicated, first-seen order preserved.
- ceilings_for(determining: Sequence[str]) tuple[ConstraintSet, ...][source]¶
Collect the argument ceilings declared by the policies that permitted a call.
Cedar’s gate can only deny. A ceiling reaches sentrykit’s narrow-and-continue path instead, so “you asked for 500, you may not have that – here is the call at what you may have” becomes expressible in the reviewed policy file rather than only in mutable grant data. The value is not clamped to the bound: it is rejected, and the principal’s own default fills the hole.
Only determining permit policies contribute. A determining
forbidnever coexists with an allow, so a ceiling there could never apply – which is why one is refused at load rather than ignored here.- Parameters:
determining – The determining policy names from the decision.
- Returns:
One entry per contributing policy, in the order reported. Returned separately rather than pre-merged so the caller decides how they compose; they intersect, because a second layer may only narrow.
- property declares_ceilings: bool¶
Whether any policy in this bundle declares an argument ceiling.
Read on the fail-open path. A
no-decisionreports no determining policies, so every ceiling in the bundle silently stops applying at exactly the moment the engine is least trustworthy. A governor over a bundle that declares any refuses instead.
- classmethod from_text(policies: str, *, schema: str | None = None, entities: Sequence[Entity] = (), source: str = '', origin: str = '', namespace: str | None = None) PolicyBundle[source]¶
Build a bundle from policy text, reading the annotation index out of it.
- Parameters:
policies – Cedar policy text.
schema – Cedar schema text, if any.
entities – The stable entity graph.
source – Name of the contributing source, recorded on every provenance entry.
origin – Artifact-level provenance, recorded on every provenance entry.
namespace – When set, declared ids are rewritten as
<namespace>/<id>so that two sources declaring the same@idstay distinguishable. Cedar does not enforce@iduniqueness, so this is sentrykit’s hygiene, not Cedar’s.
- Returns:
The bundle, with
bundle_hashcomputed.- Raises:
ConfigurationError – If the policy text does not parse, or if a policy declares a malformed
@constraintsceiling. Cedar type-checks annotation values not at all, so a bad one would otherwise sit in a validated policy set enforcing nothing.
- sentrykit.stores.base.PolicyEffect¶
Whether a policy permits or forbids.
alias of
Literal[‘permit’, ‘forbid’]
- class sentrykit.stores.base.PolicyProvenance(cedar_id: str, policy_id: str, effect: ~typing.Literal['permit', 'forbid'], source: str = '', origin: str = '', annotations: ~collections.abc.Mapping[str, str | None] = <factory>, obligations: tuple[str, ...] = (), ceiling: ~sentrykit.constraints.ConstraintSet | None = None)[source]¶
Bases:
objectWhere one policy came from, and what it declares.
One of these per static policy in a loaded bundle. The audit record carries the entries matching a decision’s determining policies, which is what lets an auditor answer “which file, at which version, in which store, produced this denial” without re-deriving it.
- Variables:
cedar_id (str) – Cedar’s generated id (
policy0), positional in the bundle’s policy text.policy_id (str) – The
@idannotation, namespaced by the composite when more than one source contributes. Falls back tocedar_id. This is the spellingdetermining_policiesreports.effect (Literal['permit', 'forbid']) –
permitorforbid.source (str) – Name of the
PolicySourcethat contributed it.origin (str) – Artifact-level provenance — a file path, an S3 version id, a git commit.
annotations (collections.abc.Mapping[str, str | None]) – Every annotation on the policy.
obligations (tuple[str, ...]) – The parsed
@obligationsannotation, split and stripped.ceiling (sentrykit.constraints.ConstraintSet | None) – The parsed
@constraintsannotation – the argument bounds this policy declares – orNonewhen it declares none. Parsed once at load, never at decision time, so a malformed declaration fails at wiring.
- as_record() dict[str, object][source]¶
Render for the audit record’s
provenancelist.The declaring annotations are carried through when the policy has any, because
@risk("high")on the policy that refused a call is exactly the kind of thing an auditor reads the provenance for. Empty collections are omitted so a record stays readable.
- class sentrykit.stores.base.PolicySource(*args, **kwargs)[source]¶
Bases:
ProtocolWhere Cedar policy logic comes from.
Read-mostly by construction: policy logic is reviewed in version control, so a source’s job is to fetch an artifact and say which artifact it fetched — never to author or mutate policy at runtime.
Implementations are addressed by DSN and registered under the
sentrykit.policy_sourcesentry-point group.- property name: str¶
Short name for this source, used to namespace policy ids and tag provenance.
- load() PolicyBundle[source]¶
Fetch the current policy artifact.
- Returns:
The loaded bundle, ready to build a
PolicyEngine.- Raises:
ConfigurationError – If the artifact is missing, unparseable, or fails schema validation.
- class sentrykit.stores.base.SupportsClose(*args, **kwargs)[source]¶
Bases:
ProtocolA backend holding a resource that should be released.
Kept separate from
AuditSinkon purpose: a third-party sink that posts to an HTTP endpoint has nothing to close, and requiring a no-opclosefrom it would be contract for contract’s sake. Composites and the registry close what supports closing.
sentrykit.stores.filesystem¶
The zero-config policy source: Cedar policy logic read from a directory on disk.
Policy logic is an artifact: authored in git and reviewed before it ships. This is the local end of that pipeline — the same directory a developer edits, and the same one a build step syncs out of a versioned S3 prefix — so what runs in a test is loaded by the identical code path that will later load the deployed bundle.
Three details are load-bearing rather than incidental:
Files are read in sorted order. Cedar numbers policies positionally in the text it parsed, so the concatenation order decides the generated
policyNids. A load in directory order would produce a different provenance index per machine.The bundle is built from one string. The combined text is assembled first, and both the
PolicyEngineand the annotation index come out of it. Deriving the index from anything else silently mis-joins every decision. Per-fileoriginis then stitched back on by counting how many policies each file contributed, which is the one thing the combined parse cannot tell us.Validation is reported, not raised. Cedar reports a policy parse error, a schema parse error and a type error identically — as entries in
errorswithvalidation_passedfalse — so branching on the flag is the only way to notice.validate_policies()normalizes that, which also keepscedarpyconfined to one module.
- sentrykit.stores.filesystem.SCHEMA_PATTERN: Final = '*.cedarschema'¶
What a schema file is called when the source is left to find one itself.
Cedar schema syntax, not the JSON form:
cedarpy.Schema.from_strrejects the JSON schema withunexpected token '{'.
- class sentrykit.stores.filesystem.FilesystemPolicySource(path: str | Path, *, name: str = 'file', schema_path: str | Path | None = None, validate: bool = True, pattern: str = '*.cedar', entities_name: str = 'entities.json')[source]¶
Bases:
objectCedar policies, a schema and a stable entity graph, loaded from one directory.
- Parameters:
path – The directory to read. Every file matching
patternin it is a policy file.name – Short name for this source, recorded on every provenance entry.
schema_path – An explicit schema file. When omitted, a single
*.cedarschemain the directory is used, and having none is not an error — it only turns validation off.validate – Whether to type-check the policies against the schema at load. Catching an unsatisfiable policy at wiring time is much better than watching it deny every request at runtime, so this defaults on.
pattern – Glob for policy files, relative to
path.**/*.cedarworks when a deployment wants nested directories.entities_name – Cedar entity JSON in the directory, holding the stable graph — roles, teams, tools. Absent is fine. Principals never belong here: they come from a
GrantStore, per request.
Example
source = FilesystemPolicySource("./policies") bundle = source.load() governor = Governor(bundle.engine())
- classmethod from_dsn(dsn: DSN) FilesystemPolicySource[source]¶
Build from
file://<directory>?name=...&schema=...&validate=...&pattern=....The source’s name defaults to the directory’s own name, not to the scheme. That matters as soon as two directories are unioned: a composite namespaces policy ids by source name and refuses two members that share one, so a pair of sources both called
filewould make the ordinary["file://./policies/base", "file://./policies/tenant-a"]wiring unusable. Set?name=when the directory names collide or read badly in an audit record.- Parameters:
dsn – The parsed address.
nameoverrides the derived source name,schemanames a schema file,validateis a boolean,patternoverrides the policy-file glob.- Returns:
The source.
- Raises:
ConfigurationError – If the DSN names no directory, or an option is malformed.
- property name: str¶
Short name for this source, used to namespace policy ids and tag provenance.
- property path: Path¶
The directory this source reads.
- load() PolicyBundle[source]¶
Read the directory and build the bundle.
- Returns:
The bundle, with one provenance entry per policy naming the file it came from.
- Raises:
ConfigurationError – If the directory is missing, holds no matching files, fails to parse, or fails schema validation.
- current_hash() str[source]¶
Digest the policy text and schema as they are on disk right now.
Deliberately re-reads rather than caching: the point of a pollable hash is to notice that somebody edited a file, and a cached value would never notice anything.
- Returns:
The same digest
load()puts on the bundle.- Raises:
ConfigurationError – If the directory is missing or holds no matching files.
sentrykit.stores.jsonfile¶
A durable grant store: one JSON document, replaced atomically, re-read when it changes.
sentrykit grant in one shell and sentrykit revoke in another are two processes, and
the only shipped grant store before this one — InMemoryGrantStore
— dies with the process that made the grant. Every CLI invocation therefore started against
an empty store and refused itself, because the admin surface resolves its operator through
get_principal() and fails closed when the store has
never heard of them. Architecture §4 calls the grant store “the admin surface’s only write
target”; this module is the write target that survives the process.
Four decisions, and none of them is taste:
Persistence subclasses the in-memory semantics; it does not restate them.
JsonFileGrantStoreextendsInMemoryGrantStore, so materialization — expiry and revocation excluded, roles asRole::"…"parent edges, attributes, per-action constraints — is the same code. A second hand-written materializer is the one bug this shape cannot have.The whole document is replaced, never edited in place. A temp file in the same directory is written, flushed,
fsync-ed and then renamed over the target;replace()isos.replace, which is atomic on Windows and POSIX alike. A write that dies halfway leaves the previous grants untouched, which is the property a store of “who may do what” has to have. UnlikeJsonlAuditSink,fsyncis unconditional here: a grant is a rare administrative event, not a per-decision cost, so the 3.71 ms anfsynccosts on this platform buys durability where it is cheap.Records round-trip through pydantic with ``exclude_unset=True``. Not an optimization — a correctness requirement.
has_defaultis derived frommodel_fields_set, so a fullmodel_dumpwrites"default": nullfor a constraint that configured no default, and reading it back would make the store injectNoneinto every call that omitted the argument. Dumping only what was set preservesmodel_fields_setexactly.Reads are gated on a stat, not on a parse.
JsonFileGrantStore.get_principal()is on the hot path of every governed call, so the file is re-read only when itsst_mtime_nsorst_sizediffers from the pair recorded at the last read.
One writing process at a time. As with the JSONL audit sink, this is a store for a
single-node deployment, a development loop, and the CLI. Two processes
writing concurrently can lose a grant, because each replaces the whole document with its own
view of it and no lock is taken — fcntl does not exist on Windows and a cross-platform
lock file would be a distributed-locking protocol wearing a disguise. Readers are safe against
any number of writers: they only ever observe a completely written document. A deployment that
needs concurrent writers should put a real database behind the same Protocol.
- class sentrykit.stores.jsonfile.JsonFileGrantStore(path: str | Path, *, name: str = 'jsonfile', namespace: str | None = None)[source]¶
Bases:
InMemoryGrantStoreGrants held in one JSON file, shared between processes and durable across restarts.
Everything
InMemoryGrantStoredoes, plus a file. Writes (grant(),revoke(),add_entity()) replace the document atomically; reads (get_principal(),grants_for(),entities()) re-read it first, but only when astatsays it changed.A write is all-or-nothing in memory too: if the document cannot be replaced, the in-memory state is rolled back to what was last durable and the error is raised. A grant that did not reach the disk did not happen.
A missing file is not an error — it is an empty store that creates the file, and any missing parent directories, on its first write, because
sentrykit grantagainst a fresh deployment has to work. A file that exists and cannot be understood is an error: starting empty on unreadable JSON would silently discard an audit-relevant record set.One writing process at a time; see the module docstring for what that rules out.
- Parameters:
path – The JSON document. Parent directories are created on the first write.
name – Short name for this store, used for namespace routing and audit provenance.
namespace – The namespace this store answers for, when a
CompositeGrantStoreroutes by namespace rather than merging by precedence.Nonemeans it takes part in merging.
- Raises:
ConfigurationError – If the file exists but cannot be read, parsed, or understood.
Example
store = JsonFileGrantStore("./var/grants.json") store.grant(GrantSpec(principal=EntityRef("User", "alice"), kind="role", subject="support")) # another process, same file JsonFileGrantStore("./var/grants.json").get_principal("alice")
- classmethod from_dsn(dsn: DSN) JsonFileGrantStore[source]¶
Build from
jsonfile://./var/grants.json?name=…&namespace=….- Parameters:
dsn – The parsed address. The target is the file path — never the namespace, unlike
memory://tenant-a, because a path is what this backend needs and guessing between the two would makejsonfile://grants.jsonmean something surprising.- Returns:
The store.
- Raises:
ConfigurationError – If the DSN names no path, or the named file cannot be read.
- property path: Path¶
The file this store persists to.
- reload() None[source]¶
Re-read the file, discarding the in-memory state.
Unconditional, unlike the refresh every read performs: a caller reaches for this when they know something changed that a
statmight not show, or to drop local state they no longer trust. A missing file reloads to an empty store — the file is the store.- Raises:
ConfigurationError – If the file exists but cannot be read or understood.
- flush() None[source]¶
Write the current state out, whether or not it changed.
Every mutation already persists, so this exists for the case where the in-memory state was reached some other way — after a
reload()into a store a caller then wants re-stamped, or in a test.- Raises:
ConfigurationError – If the document cannot be written.
- close() None[source]¶
Refuse further writes. Idempotent.
There is no handle to release: every mutation has already been flushed and renamed into place by the time it returns. This satisfies
SupportsCloseso a host that closes every backend uniformly does not have to special-case this one, and it makes a write after teardown a loud error rather than a change nobody will find.
- grant(spec: GrantSpec, *, now: datetime | None = None) GrantRecord[source]¶
Confer a grant and persist the whole document.
Refreshes first, so a grant made by another process is carried forward rather than overwritten — the document is replaced wholesale, so writing a stale view of it is how a concurrent grant would disappear.
- Parameters:
spec – What to confer.
now – The grant timestamp. Defaults to the current UTC time.
- Returns:
The stored record, carrying the id needed to revoke it.
- Raises:
ConfigurationError – If the spec is inconsistent, the store is closed, or the document could not be written — in which case the in-memory state is rolled back and nothing was granted.
- revoke(grant_id: str, *, revoked_by: str | None = None, reason: str | None = None, now: datetime | None = None) GrantRecord[source]¶
Withdraw a grant and persist the whole document.
The record is marked, never deleted, so “who had what, when” survives in the file as well as in memory.
- Parameters:
grant_id – The id returned by
grant().revoked_by – The actor withdrawing it.
reason – Why.
now – The revocation timestamp. Defaults to the current UTC time.
- Returns:
The updated record.
- Raises:
ConfigurationError – If no such grant exists, the store is closed, or the document could not be written.
- add_entity(entity: Entity) None[source]¶
Seed one node of the stable entity graph and persist the whole document.
- Parameters:
entity – The node — a role, team, or project a parent edge points at. Principals do not belong here; they are materialized per request.
- Raises:
ConfigurationError – If the store is closed, or the document could not be written.
- get_principal(principal: str | EntityRef, *, now: datetime | None = None) Principal | None[source]¶
Materialize a principal from their active grants, refreshing from disk first.
The refresh is what makes
sentrykit grantin one process visible to a decision in another without either of them coordinating. It costs onestatwhen nothing changed, which is what keeps this method on the hot path of every governed call.- Parameters:
principal – The principal id (assumed type
User) or a full reference.now – The moment to evaluate expiries against. Defaults to the current UTC time.
- Returns:
The principal, or
Nonewhen the store holds neither a grant nor a seeded entity for them.- Raises:
ConfigurationError – If the file changed into something that cannot be understood.
- grants_for(principal: str | EntityRef, *, include_inactive: bool = False) tuple[GrantRecord, ...][source]¶
List the grants held by
principal, oldest first, refreshing from disk first.- Parameters:
principal – The principal id or reference.
include_inactive – Whether to include revoked and expired grants.
- Returns:
The grants, in the order they were made.
- Raises:
ConfigurationError – If the file changed into something that cannot be understood.
- entities() tuple[Entity, ...][source]¶
Return the stable entities this store contributes, refreshing from disk first.
- Returns:
The seeded nodes and the nodes the grant edges point at, exactly as the in-memory store derives them.
- Raises:
ConfigurationError – If the file changed into something that cannot be understood.
sentrykit.stores.jsonl¶
The append-only JSONL audit sink — one record per line, one writer per file.
Every design choice here was forced by measurement on the win-64 platform this repo
targets, rather than chosen for taste:
``newline=”n”``, always. Default text-mode append writes
\r\non Windows. A hash chain is over bytes, so left at the default the same record would hash differently per platform and an external verifier would disagree with the writer.One handle, one lock.
open(path, "a")per write is not atomic on Windows — the CRT emulates append as seek-to-end plus write, and the pair races. Measured: 8 threads x 200 records produced 1346 of 1600 lines, three of them malformed. One shared handle behind athreading.Lockproduced 1600 of 1600.Single writer per file. Not a shortcut:
record_hashdepends onprev_hash, so a writer must know the file’s tail before it can build the next record, and that state cannot be shared across processes without a distributed lock. Concurrent writers get separate files (or a fan-out composite); multi-process durability is what a database-backed sink is for.Flush always, fsync on request.
fsynccosts 3.71 ms per record against 0.007 ms forflushalone. Paying that on every authorization decision to defend against machine crash — process crash is already covered byflush— is the wrong default for a pre-action gate, so it lives behindjsonl://path?fsync=true.
This sink does not chain records. AuditTrail does that; the sink
receives an already-chained, already-masked record and persists it. JsonlAuditSink.tail_hash()
exists so a trail can pick a chain back up across a process restart.
- class sentrykit.stores.jsonl.JsonlAuditSink(path: str | Path, *, name: str = 'jsonl', fsync: bool = False)[source]¶
Bases:
objectOne JSON object per line, appended to a file that is never rewritten.
- Parameters:
path – The log file. Parent directories are created if they are missing.
name – Short name for this sink, used in fan-out error reporting.
fsync – Whether to force the bytes to disk after every record. Off by default; see the module docstring for the measured cost.
- Raises:
ConfigurationError – If the file cannot be opened, or if it already exists and its last line is not a readable audit record — a chain cannot be resumed from a tail that cannot be read, and guessing at it would silently start a second chain in one file.
Example
with JsonlAuditSink("./audit.jsonl") as sink: trail = AuditTrail(sink)
- classmethod from_dsn(dsn: DSN) JsonlAuditSink[source]¶
Build from
jsonl://<path>?fsync=true.- Parameters:
dsn – The parsed address.
- Returns:
The sink.
- Raises:
ConfigurationError – If the DSN names no path, or
fsyncis not a boolean.
- property name: str¶
Short name for this sink, used in fan-out error reporting.
- property path: Path¶
The file this sink appends to.
- tail_sequence() int[source]¶
The sequence number of the last record in the log, or
-1when it is empty.The companion to
tail_hash(): together they are everything anAuditTrailneeds to continue an existing chain instead of restarting one at sequence zero in the middle of a file — whichverify_chain()would read as a deleted record, not a restart.
- tail_hash() str[source]¶
The last record’s hash, or
GENESIS_HASHfor an empty log.Recovered from the file at construction and kept current on every write, so it is correct across a process restart without re-reading the log.
- write(record: AuthzAuditRecord) None[source]¶
Append one record, already masked and already chained.
- Parameters:
record – The record to persist.
- Raises:
AuditUnavailable – If the record could not be durably appended — the sink is closed, the disk is full, or the record does not render to JSON. Under
fail_mode="closed"the governed call is then refused: an action nobody can prove happened is an action that should not have happened.
- query(window: AuditWindow) Iterator[AuthzAuditRecord][source]¶
Read back the records matching
window, oldest first.Re-reads the file lazily rather than holding an index, so a long log costs one line at a time and a caller with a
limitstops paying at the limit. Reading a file this sink is still appending to is safe — confirmed on Windows, no sharing violation.- Parameters:
window – The filter.
- Returns:
An iterator over the matching records.
- Raises:
AuditUnavailable – If the log cannot be read, or holds a line that is not a readable audit record. A corrupt line is tamper evidence, so it is surfaced rather than skipped past.
- verify() ChainVerdict[source]¶
Re-read the whole file and verify the hash chain.
- Returns:
The verdict, naming the first record that fails. A line that will not parse is itself a broken chain rather than an exception:
verify()is what an operator runs because they suspect the file, so it has to survive a damaged one.- Raises:
AuditUnavailable – If the file cannot be read at all.
sentrykit.stores.memory¶
In-memory backends: a grant store, an audit sink, and a token vault.
The zero-dependency defaults. They exist for three audiences at once — a test that needs a grant store without a database, an example app that should run from a clone, and a host that wants to see the wiring work before choosing a real backend — and they implement the whole contract rather than a convenient subset, so behavior learned here transfers to a durable backend unchanged.
Two properties are worth stating plainly, because “in-memory” invites the wrong assumptions:
Grant lifecycles are enforced here, not downstream.
InMemoryGrantStore.get_principal()returns a principal with expired and revoked grants already excluded, so the decision path never reasons aboutexpires_at. That is the contract everyGrantStoreowes, and putting it in the reference implementation is what stops it from being reinvented per backend.Revocation marks; it never deletes. “Who had what, when” has to survive the withdrawal, so a revoked grant stays in the store with its revocation stamped on it and simply stops being active.
Nothing here is durable. InMemoryAuditSink is a fine target for a test asserting
what was written and a bad one for anything that has to survive a restart — that is what
JsonlAuditSink and the durable backends are for.
- class sentrykit.stores.memory.InMemoryAuditSink(*, name: str = 'memory')[source]¶
Bases:
objectAudit records held in a list. For tests and for seeing the write path run.
Satisfies the whole
AuditSinkcontract, includingquery(), so a report written against this sink runs unchanged against a durable one. It is not durable and does not pretend to be.- Parameters:
name – Short name for this sink, used in fan-out error reporting.
- classmethod from_dsn(dsn: DSN) InMemoryAuditSink[source]¶
Build from
memory://<name>. Each call is a separate, empty sink.
- property name: str¶
Short name for this sink, used in fan-out error reporting.
- property records: tuple[AuthzAuditRecord, ...]¶
Everything written so far, oldest first.
A tuple rather than the live list, so a test holding the result cannot append to the log it is asserting about.
- write(record: AuthzAuditRecord) None[source]¶
Append one already-masked, already-chained record.
- query(window: AuditWindow) Iterator[AuthzAuditRecord][source]¶
Read back the records matching
window, oldest first.Matching runs over a snapshot taken under the lock, so a concurrent write cannot make the iterator skip or repeat a record.
- class sentrykit.stores.memory.InMemoryGrantStore(*, name: str = 'memory', namespace: str | None = None)[source]¶
Bases:
objectRoles, parent edges, attributes and argument constraints, held in a dict.
- Parameters:
name – Short name for this store, used for audit provenance and error messages.
namespace – The namespace this store answers for, when a
CompositeGrantStoreroutes by namespace rather than merging by precedence.Nonemeans it takes part in merging.
Example
store = InMemoryGrantStore() store.add_entity(Entity(EntityRef("Team", "payments"))) store.grant(GrantSpec(principal=EntityRef("User", "alice"), kind="role", subject="support")) principal = store.get_principal("alice")
- classmethod from_dsn(dsn: DSN) InMemoryGrantStore[source]¶
Build from
memory://ormemory://<namespace>?namespace=....The target doubles as the namespace, so
memory://tenant-aandmemory://?namespace=tenant-amean the same thing. Each call builds a separate store: twomemory://DSNs are two independent sets of grants, which is what makes namespace routing testable without a database.- Parameters:
dsn – The parsed address.
- Returns:
The store.
- property name: str¶
Short name for this store, used for namespace routing and audit provenance.
- property namespace: str | None¶
The namespace this store answers for, or
Nonewhen it takes part in merging.
- add_entity(entity: Entity) None[source]¶
Seed one node of the stable entity graph.
The nodes a parent edge points at — roles, teams, projects, tools — have to exist for Cedar to resolve a multi-hop relationship through them. Seeding replaces any entity already held under the same uid.
- Parameters:
entity – The node. Principals do not belong here: they are materialized per request by
get_principal(), and a stale copy in the base graph is exactly the leakentities()exists to prevent.
- get_principal(principal: str | EntityRef, *, now: datetime | None = None) Principal | None[source]¶
Materialize a principal from their active grants.
- Parameters:
principal – The principal id (assumed type
User) or a full reference.now – The moment to evaluate expiries against. Defaults to the current UTC time.
- Returns:
The principal, or
Nonewhen this store holds neither a grant nor a seeded entity for them.Noneis not an error — the caller fails closed on it.
- grants_for(principal: str | EntityRef, *, include_inactive: bool = False) tuple[GrantRecord, ...][source]¶
List the grants held by
principal, oldest first.- Parameters:
principal – The principal id or reference.
include_inactive – Whether to include revoked and expired grants, which an audit report needs and a decision does not.
- Returns:
The grants, in the order they were made.
- grant(spec: GrantSpec, *, now: datetime | None = None) GrantRecord[source]¶
Confer a grant.
- Parameters:
spec – What to confer.
now – The grant timestamp. Defaults to the current UTC time.
- Returns:
The stored record, carrying the id needed to revoke it.
- Raises:
ConfigurationError – If the spec is inconsistent — a
constraintgrant with no constraint, one carrying a constraint no value could satisfy, or aparentgrant whose subject is not an entity reference. Rejected here, at the write, so a malformed grant cannot sit in the store and surface as a materialization failure on some unrelated request.
- revoke(grant_id: str, *, revoked_by: str | None = None, reason: str | None = None, now: datetime | None = None) GrantRecord[source]¶
Withdraw a grant, marking the record rather than deleting it.
Revoking an already-revoked grant returns it unchanged: overwriting the original revocation’s timestamp and actor would destroy the very fact the record exists to preserve.
- Parameters:
grant_id – The id returned by
grant().revoked_by – The actor withdrawing it.
reason – Why.
now – The revocation timestamp. Defaults to the current UTC time.
- Returns:
The updated record.
- Raises:
ConfigurationError – If no such grant exists.
- entities() tuple[Entity, ...][source]¶
Return the stable entities this store contributes to the Cedar graph.
Two sources, in this order: what
add_entity()seeded, then the nodes the grant edges point at —Role::"support"for a role grant, the parsed uid for a parent grant. Cedar cannot resolve an edge to an entity it has never heard of, so a store that held the edges but not their targets would make every relationship policy fail for reasons no one could see.Derived nodes are contributed whether or not the grant that named them is still active: an expired grant removes the edge, and churning the base graph as grants expire would mean rebuilding the engine on a timer.
A principal is never included. Principals are per-request deltas materialized by
get_principal(); a snapshot of one in the shared base graph is exactly how one principal’s roles end up in another principal’s decision.
- class sentrykit.stores.memory.InMemoryTokenVault[source]¶
Bases:
objectThe reversible mapping behind
tokenizemasking, held in a dict.Satisfies
TokenVault. Deliberately not an audit sink: the whole reason the two are separate is that this half must stay mutable, so an erasure request can drop a mapping and leave the hash chain intact but no longer resolvable.Tokens are minted per
(field, value)pair rather than per value, for the same reasonHmacStrategybinds its digest to the field name: the same string appearing ascustomer_idand asnoteshould not become correlatable just because it was tokenized.- tokenize(value: str, *, field: str) str[source]¶
Return a stable token for
value, minting one if this is the first sighting.- Parameters:
value – The raw value.
field – The argument name the value appeared under.
- Returns:
The token, without the
token:prefix — that is the strategy’s to add.
- resolve(token: str) str | None[source]¶
Return the raw value behind
token, orNoneif it is unknown or erased.Accepts the token either bare or with the
token:prefix it carries in an audit record, so an auditor can paste what they see in the log.
- erase(token: str) bool[source]¶
Forget one mapping, satisfying an erasure request.
The audit records keep their tokens and their hashes — the chain stays intact and still verifies — but the token no longer resolves to anything.
- Parameters:
token – The token, bare or prefixed.
- Returns:
Whether a mapping was actually removed.
sentrykit.stores.composite¶
Many backends behind one Protocol — the second half of the plugin mechanism.
Multiplicity never reaches the core. Configure three audit sinks and the governor still holds
exactly one AuditSink; configure two policy directories and it
still holds one PolicySource. Each composite is-a the
Protocol it composes, so “how many are configured” is a wiring question that
sentrykit.registry answers once and nothing downstream can ask again.
The three compose differently, because the three stores guarantee different things:
Policy sources union. Their bundles are concatenated in a fixed order and reparsed as one policy text. There is deliberately no source precedence, and no “later source wins” rule: in Cedar an override is expressed as a
forbid, and forbid always beats permit no matter where either policy sits in the text. That property is exactly what makes merging sources safe at all. Ordering is fixed only because Cedar numbers policies positionally, so the provenance index and the engine must be built from one identical string.Grant stores merge or route. Merge-by-precedence when the members are overlapping views of the same population (earlier members win per field); route-by-namespace when every member declares one and each owns a disjoint population.
Audit sinks fan out. Every sink is attempted on every record, under an explicit error policy that says which failures are allowed to refuse production traffic.
- sentrykit.stores.composite.AUDIT_ERROR_POLICIES: Final[Mapping[str, AuditErrorPolicy]] = {'all-or-nothing': 'all-or-nothing', 'primary-durable': 'primary-durable'}¶
The valid
?on_error=values, so the registry can validate a DSN option against them.
- sentrykit.stores.composite.AuditErrorPolicy¶
How a fan-out reacts when a member sink refuses a record. See
CompositeAuditSink.alias of
Literal[‘primary-durable’, ‘all-or-nothing’]
- class sentrykit.stores.composite.CompositeAuditSink(sinks: Sequence[AuditSink], *, on_error: Literal['primary-durable', 'all-or-nothing'] = 'primary-durable', name: str = 'composite')[source]¶
Bases:
objectSeveral audit sinks, written to on every record.
Every member is attempted before anything is raised, so one dead sink can never hide the state of the rest — an operator reading the failure needs to know whether one replica is down or all of them are. What happens next is the error policy:
primary-durable— the first sink is the record of truth and must succeed; its failure raisesAuditUnavailable, which underfail_mode="closed"refuses the governed call. A failure in any other member is logged atERRORand swallowed: a replica or a search index being unreachable is an operational problem, not a reason to refuse production traffic.all-or-nothing— any failure raises, carrying onefailuresentry per failed sink. This is not a transaction: sinks that already accepted the record keep it, and append-only storage has nothing to roll back to. The guarantee is “you will be told that the copies diverged”, not “nothing was written”.
- Parameters:
sinks – The members. The first is the primary under
primary-durable.on_error – The error policy.
name – This composite’s name, used when reporting a fan-out failure.
- Raises:
ConfigurationError – If no sinks were given or
on_erroris not a known policy.
- property name: str¶
This composite’s name.
- property on_error: Literal['primary-durable', 'all-or-nothing']¶
How this fan-out reacts to a member failing.
- write(record: AuthzAuditRecord) None[source]¶
Append
recordto every member.- Parameters:
record – The record, already masked and already chained.
- Raises:
AuditUnavailable – Under
primary-durablewhen the first sink failed, and underall-or-nothingwhen any sink did.failurescarries one entry per failed member either way.
- query(window: AuditWindow) Iterator[AuthzAuditRecord][source]¶
Read back matching records from the first sink only.
A fan-out writes the same record to every member, so reading from all of them would return each record once per sink. De-duplicating by
record_hashwould look tidier and would be worse: it would silently paper over a divergence between two copies of an allegedly tamper-evident log, which is precisely the thing an auditor came to find. Reading one sink at a time keeps a comparison possible.- Parameters:
window – The filter.
- Returns:
The primary sink’s matching records, oldest first.
- class sentrykit.stores.composite.CompositeGrantStore(stores: Sequence[GrantStore], *, name: str = 'composite')[source]¶
Bases:
objectSeveral grant stores, either merged by precedence or routed by namespace.
Which one it is is not configured — it is inferred, because the two arrangements are distinguishable by what the members declare. If every member exposes a non-empty
namespaceattribute, the members own disjoint populations and lookups route: a principal whose id or entity type is prefixed<namespace>:goes to exactly one store. Otherwise the members are overlapping views of one population and lookups merge, with earlier members winning.Merging is per field, not per store: a principal known to two stores comes back with their roles and parents unioned (order preserving, de-duplicated) and their attributes and per-action constraint sets layered with the earlier store on top. That is what lets a local override store sit in front of a corporate directory without either having to know about the other.
- Parameters:
stores – The members, in precedence order.
name – This composite’s name.
- Raises:
ConfigurationError – If no stores were given, or if two of them claim one namespace.
- property name: str¶
This composite’s name.
- property stores: tuple[GrantStore, ...]¶
The members, in precedence order.
- property routed: bool¶
Whether lookups route by namespace rather than merging by precedence.
- get_principal(principal: str | EntityRef, *, now: datetime | None = None) Principal | None[source]¶
Materialize a principal from the routed store, or from every store merged.
- Parameters:
principal – The principal id (assumed type
User) or a full reference.now – The moment expiries are evaluated against.
- Returns:
The principal, or
Nonewhen no member knows them — one member returningNoneis not an answer, it is an absence, and the caller fails closed only when every member is silent.
- grants_for(principal: str | EntityRef, *, include_inactive: bool = False) tuple[GrantRecord, ...][source]¶
List the grants held by
principal, oldest first, across every member.- Parameters:
principal – The principal id or reference.
include_inactive – Whether to include revoked and expired grants.
- Returns:
The grants. Members are expected to timestamp in UTC, which is what makes one ordering across several stores meaningful.
- grant(spec: GrantSpec, *, now: datetime | None = None) GrantRecord[source]¶
Confer a grant on the store that owns the principal.
- Parameters:
spec – What to confer.
now – The grant timestamp.
- Returns:
The stored record.
- Raises:
ConfigurationError – If routing is in effect and no member owns the principal’s namespace, or if the owning store rejects the spec.
- revoke(grant_id: str, *, revoked_by: str | None = None, reason: str | None = None, now: datetime | None = None) GrantRecord[source]¶
Withdraw a grant from whichever member holds it.
A grant id names no principal, so there is nothing to route on even when routing is in effect: the members are probed in order and the first one holding the id performs the revocation.
- Parameters:
grant_id – The id returned by
grant().revoked_by – The actor withdrawing it.
reason – Why.
now – The revocation timestamp.
- Returns:
The updated record.
- Raises:
ConfigurationError – If no member holds that grant.
- class sentrykit.stores.composite.CompositePolicySource(sources: Sequence[PolicySource], *, name: str = 'composite')[source]¶
Bases:
objectSeveral policy sources, unioned into one bundle.
The union is a concatenation, not a precedence chain. Ordering the members cannot change a decision: an override is a Cedar
forbid, and forbid wins wherever it sits. The order is fixed anyway because Cedar’s generated policy ids (policy0,policy1) are positional in the text it parsed — so the combined text is built once, and the provenance index is derived from that string rather than reassembled from the members’.Each member’s declared
@idis namespaced as<source-name>/<id>in the provenance index. Cedar does not enforce@iduniqueness — two sources may both declare@id("btg-read")and it parses without complaint — so the namespacing is sentrykit’s hygiene, and it is what keeps a decision’s determining policies attributable to one artifact. It is applied to the index rather than to the policy text, so Cedar keeps reporting the raw@id;provenance_for()resolves both spellings, which is what keeps the join working across a union.- Parameters:
sources – The members, in the order their policies are concatenated.
name – This composite’s name, recorded on the combined bundle.
- Raises:
ConfigurationError – If no sources were given, or if two of them share a name — the name is the namespace, so duplicates would defeat the disambiguation.
- property name: str¶
This composite’s name.
- property sources: tuple[PolicySource, ...]¶
The members, in concatenation order.
- load() PolicyBundle[source]¶
Load every member and union them into one bundle.
- Returns:
every member’s policy text concatenated in order, every member’s entities unioned, and one provenance entry per policy carrying the member that contributed it and that member’s namespaced policy id.
- Return type:
The combined bundle
- Raises:
ConfigurationError – If a member fails to load, if the combined text does not parse, or if two members contribute conflicting definitions of one entity.
- current_hash() str[source]¶
Return the digest of the bundle
load()would return right now.The union’s hash covers the concatenated text, so — unlike a single source, which can usually stat a file — there is no shortcut: the combined string does not exist until every member has been read. Polling a union therefore costs a load, which is the price of the contract that a bundle hash pins exactly the logic a decision was made against.