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:

  1. A backend module reaches back into sentrykit.audit for the record type, and sentrykit.audit reaches into sentrykit.stores.base for the sink contract. Importing the backends from this package’s body would close that loop.

  2. 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-party sentrykit-<x> sink — through importlib.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 Cedar forbid and 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 @obligations carries 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: Protocol

Where 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 and AuditTrail is the only thing that calls this method.

Implementations are addressed by DSN and registered under the sentrykit.audit_sinks entry-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: BaseModel

A 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) – allow or deny.

  • kind (Literal['decision', 'mutation'] | None) – decision or mutation.

  • limit (int | None) – Stop after this many records.

matches(record: AuthzAuditRecord) → bool[source]

Whether record falls inside this window, ignoring limit.

The time bounds are half-open — start inclusive, end exclusive — so adjacent windows tile a log without double-counting a record on the boundary.

class sentrykit.stores.base.ContextProvider(*args, **kwargs)[source]

Bases: Protocol

How 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 the sentrykit.context_providers entry-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 ContextScope to 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 _authz envelope at whatever the effective verbosity allows.

sentrykit.stores.base.GrantKind

What a grant confers.

role and parent are both parent edges — role is the shorthand that becomes Role::"<subject>" — and they are what make a multi-hop relationship resolvable by Cedar. attribute sets a principal attribute. constraint attaches an ArgumentConstraint for 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: BaseModel

A grant as stored: a GrantSpec plus 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 None while 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_dump would 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: BaseModel

A 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 constraint applies to — a full uid, a bare action id, or "*" for every action, matching constraints_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. None means 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: Protocol

Where 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_stores entry-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 Principal is 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 None when the store knows nothing about them. None is 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 ArgumentConstraint that 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 is inconsistencies() 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 constraint grant 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: object

The 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: object

Everything a PolicySource load 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 provenance is only meaningful alongside the exact policies string 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, entities and schema.

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 @id where 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 forbid describes 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 permit policies when true and forbid policies 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 forbid never 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-decision reports 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 @id stay distinguishable. Cedar does not enforce @id uniqueness, so this is sentrykit’s hygiene, not Cedar’s.

Returns:

The bundle, with bundle_hash computed.

Raises:

ConfigurationError – If the policy text does not parse, or if a policy declares a malformed @constraints ceiling. 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: object

Where 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 @id annotation, namespaced by the composite when more than one source contributes. Falls back to cedar_id. This is the spelling determining_policies reports.

  • effect (Literal['permit', 'forbid']) – permit or forbid.

  • source (str) – Name of the PolicySource that 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 @obligations annotation, split and stripped.

  • ceiling (sentrykit.constraints.ConstraintSet | None) – The parsed @constraints annotation – the argument bounds this policy declares – or None when 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 provenance list.

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: Protocol

Where 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_sources entry-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.

current_hash() → str[source]

Return the digest of the artifact that load() would return right now.

Cheap enough to poll: a caller uses it to decide whether a reload is needed without paying for a parse. Equal to the loaded bundle’s PolicyBundle.bundle_hash.

class sentrykit.stores.base.SupportsClose(*args, **kwargs)[source]

Bases: Protocol

A backend holding a resource that should be released.

Kept separate from AuditSink on purpose: a third-party sink that posts to an HTTP endpoint has nothing to close, and requiring a no-op close from it would be contract for contract’s sake. Composites and the registry close what supports closing.

close() → None[source]

Release the backend’s resources. Idempotent.

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 policyN ids. 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 PolicyEngine and the annotation index come out of it. Deriving the index from anything else silently mis-joins every decision. Per-file origin is 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 errors with validation_passed false — so branching on the flag is the only way to notice. validate_policies() normalizes that, which also keeps cedarpy confined 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_str rejects the JSON schema with unexpected 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: object

Cedar policies, a schema and a stable entity graph, loaded from one directory.

Parameters:
  • path – The directory to read. Every file matching pattern in 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 *.cedarschema in 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. **/*.cedar works 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 file would 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. name overrides the derived source name, schema names a schema file, validate is a boolean, pattern overrides 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. JsonFileGrantStore extends InMemoryGrantStore, so materialization — expiry and revocation excluded, roles as Role::"…" 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() is os.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. Unlike JsonlAuditSink, fsync is unconditional here: a grant is a rare administrative event, not a per-decision cost, so the 3.71 ms an fsync costs 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_default is derived from model_fields_set, so a full model_dump writes "default": null for a constraint that configured no default, and reading it back would make the store inject None into every call that omitted the argument. Dumping only what was set preserves model_fields_set exactly.

  • 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 its st_mtime_ns or st_size differs 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: InMemoryGrantStore

Grants held in one JSON file, shared between processes and durable across restarts.

Everything InMemoryGrantStore does, 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 a stat says 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 grant against 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 CompositeGrantStore routes by namespace rather than merging by precedence. None means 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 make jsonfile://grants.json mean 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 stat might 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 SupportsClose so 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 grant in one process visible to a decision in another without either of them coordinating. It costs one stat when 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 None when 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\n on 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 a threading.Lock produced 1600 of 1600.

  • Single writer per file. Not a shortcut: record_hash depends on prev_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. fsync costs 3.71 ms per record against 0.007 ms for flush alone. Paying that on every authorization decision to defend against machine crash — process crash is already covered by flush — is the wrong default for a pre-action gate, so it lives behind jsonl://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: object

One 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 fsync is 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 -1 when it is empty.

The companion to tail_hash(): together they are everything an AuditTrail needs to continue an existing chain instead of restarting one at sequence zero in the middle of a file — which verify_chain() would read as a deleted record, not a restart.

tail_hash() → str[source]

The last record’s hash, or GENESIS_HASH for 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 limit stops 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.

close() → None[source]

Flush and release the file handle. Idempotent.

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 about expires_at. That is the contract every GrantStore owes, 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: object

Audit records held in a list. For tests and for seeing the write path run.

Satisfies the whole AuditSink contract, including query(), 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: object

Roles, 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 CompositeGrantStore routes by namespace rather than merging by precedence. None means 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:// or memory://<namespace>?namespace=....

The target doubles as the namespace, so memory://tenant-a and memory://?namespace=tenant-a mean the same thing. Each call builds a separate store: two memory:// 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 None when 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 leak entities() 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 None when this store holds neither a grant nor a seeded entity for them. None is 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 constraint grant with no constraint, one carrying a constraint no value could satisfy, or a parent grant 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: object

The reversible mapping behind tokenize masking, 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 reason HmacStrategy binds its digest to the field name: the same string appearing as customer_id and as note should 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, or None if 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: object

Several 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 raises AuditUnavailable, which under fail_mode="closed" refuses the governed call. A failure in any other member is logged at ERROR and 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 one failures entry 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_error is not a known policy.

property name: str

This composite’s name.

property sinks: tuple[AuditSink, ...]

The members. The first is the primary.

property on_error: Literal['primary-durable', 'all-or-nothing']

How this fan-out reacts to a member failing.

write(record: AuthzAuditRecord) → None[source]

Append record to every member.

Parameters:

record – The record, already masked and already chained.

Raises:

AuditUnavailable – Under primary-durable when the first sink failed, and under all-or-nothing when any sink did. failures carries 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_hash would 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.

close() → None[source]

Close every member that holds a resource.

Failures are logged rather than raised: close is cleanup, and one member’s failure to release a handle must not stop the others from releasing theirs.

class sentrykit.stores.composite.CompositeGrantStore(stores: Sequence[GrantStore], *, name: str = 'composite')[source]

Bases: object

Several 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 namespace attribute, 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 None when no member knows them — one member returning None is 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.

entities() → tuple[Entity, ...][source]

Union the stable entities every member contributes, first definition winning.

class sentrykit.stores.composite.CompositePolicySource(sources: Sequence[PolicySource], *, name: str = 'composite')[source]

Bases: object

Several 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 @id is namespaced as <source-name>/<id> in the provenance index. Cedar does not enforce @id uniqueness — 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.