Policy pack

The shipped Cedar policy pack and the loader that makes it an ordinary policy source. The pack files themselves live under sentrykit/policy/pack/ and are read as data — they are never imported. See policies for what each pattern does.

sentrykit.policy

The shipped Cedar policy pack, and the loader that makes it an ordinary policy source.

Re-exported from sentrykit.policy.loader so the pack is addressable as sentrykit.policy — the pack files themselves live under sentrykit/policy/pack/ and are read as data, never imported.

sentrykit.policy.loader

The shipped Cedar policy pack, loaded as an ordinary policy source.

The pack is data, not a second policy mechanism. It reaches a governor through exactly the machinery a user’s own directory does — a PolicySource registered under the sentrykit.policy_sources entry-point group, addressed by DSN, unioned by CompositePolicySource:

install(policy_source=["pack://", "file://./policies"])

That union namespaces the pack’s declared ids as pack/<id>, so an audit record for a decision the pack made names the pack, and a decision one of your own policies made names your directory. Nothing about the pack is privileged: drop the DSN and it is not there.

Every pattern is attribute-driven

A shipped pack cannot know your role names, your resource types or your actions, so none of its policies name any. Each pattern reads the shape of the request instead:

  • the resource declares what it requires (required_role, group, owner, emergency_only, emergency_role, conflicting_role, prepared_by);

  • the grant store supplies the principal’s edges and attributes;

  • the context supplies now, break_glass, on_behalf_of and justification.

So adopting a pattern is a matter of putting an attribute on an entity, which is data. The policy logic itself stays reviewed in version control and is never rewritten at runtime.

The schema is opt-in, and the reason matters

pack.cedarschema ships with the pack, but PackPolicySource.load() does not put it on the bundle unless asked (pack://?schema=true). A composite concatenates its members’ schemas, and a Cedar engine built with a schema validates every request against it — so a pack that contributed a schema by default would make every request naming an action the pack never heard of fail to parse, in an application that was working a moment earlier. The pack’s decisions are identical with and without the schema, so the default costs nothing and the opt-in is a real choice rather than a workaround.

When you do opt in, your own schema must declare only what the pack does not: redeclaring User, Role, Group or Resource is a Cedar schema parse error (` `User` is declared twice `). Adding actions is safe, and an action whose context is {} still type-checks against the pack’s context has now conditions.

Templates are linked here, not by Cedar

cedarpy 4.8.7 exposes no template-linking API at all, and an unlinked template in a policy set is inert. link_template() therefore performs the link on our side — textual substitution of ?principal and ?resource — and returns the concrete policy and the matching GrantSpec together, since a time-boxed grant is a rule and a piece of data at the same time.

sentrykit.policy.loader.PACK_NAME: Final = 'pack'

Default source name, and therefore the namespace declared ids appear under in a union.

sentrykit.policy.loader.PATTERNS: Final[Mapping[str, PackPattern]] = {'break-glass': PackPattern(name='break-glass', file='60-break-glass.cedar', summary='An emergency-only resource is refused outright unless the caller raises the flag, and is then reachable only by a principal entitled to the emergency, carrying a justification.', policy_ids=('emergency-resource-forbidden-without-flag', 'break-the-glass'), requires=('resource.emergency_only', 'resource.emergency_role', 'context.break_glass', 'context.justification'), obligations=('notify:secops', 'record:justification')), 'delegation': PackPattern(name='delegation', file='40-delegation.cedar', summary='A principal may act on behalf of its delegator, but only within the delegated scope and only until the delegation lapses.', policy_ids=('delegation-on-behalf-of', 'delegation-outside-scope-forbidden', 'delegation-expired-forbidden'), requires=('principal.delegated_by', 'principal.delegated_actions', 'principal.delegation_expires', 'resource.required_role', 'context.on_behalf_of', 'context.now'), obligations=('record:on_behalf_of',)), 'expiry': PackPattern(name='expiry', file='50-expiry.cedar', summary='An entitlement window that refuses the call outside its bounds.', policy_ids=('entitlement-expired-forbidden', 'entitlement-not-yet-valid-forbidden'), requires=('principal.entitlement_starts', 'principal.entitlement_expires', 'context.now'), obligations=()), 'groups': PackPattern(name='groups', file='20-groups.cedar', summary='A member of the group the resource belongs to may act on it, at any depth.', policy_ids=('group-membership',), requires=('resource.group',), obligations=()), 'jit-elevation': PackPattern(name='jit-elevation', file='80-jit-elevation.cedar', summary='A principal holds an elevated role for a window, and is refused once the window closes.', policy_ids=('jit-elevation', 'jit-elevation-expired-forbidden'), requires=('principal.elevation_role', 'principal.elevation_until', 'resource.required_role', 'context.now'), obligations=('notify:secops',)), 'ownership': PackPattern(name='ownership', file='30-ownership.cedar', summary='The owner of a resource may act on it; ownership is not inherited.', policy_ids=('resource-owner',), requires=('resource.owner',), obligations=()), 'rbac': PackPattern(name='rbac', file='10-rbac.cedar', summary='A principal holding the role the resource requires may act on it.', policy_ids=('rbac-required-role',), requires=('resource.required_role',), obligations=()), 'separation-of-duties': PackPattern(name='separation-of-duties', file='70-separation-of-duties.cedar', summary='A conflicting role is refused the resource outright, and nobody approves what they prepared themselves.', policy_ids=('sod-conflicting-role', 'sod-same-actor-approval'), requires=('resource.conflicting_role', 'resource.prepared_by'), obligations=())}

Every shipped pattern, keyed by name, in the order the files are concatenated.

Declared in Python rather than in a manifest file beside the policies because it is typed, and because a second file listing the first is a second thing to keep in step. The tests assert the table and the directory agree.

sentrykit.policy.loader.SCHEMA_FILE: Final = 'pack.cedarschema'

The pack’s Cedar schema, shipped alongside the policies and loaded only on request.

sentrykit.policy.loader.TEMPLATE_SUFFIX: Final = '.cedartemplate'

Templates live under pack/templates/ with this suffix, deliberately outside the *.cedar glob: an unlinked template can never permit anything, so loading one as if it were a policy would put something inert into a reviewed policy set.

class sentrykit.policy.loader.LinkedPolicySource(links: Sequence[TemplateLink], *, name: str = 'linked')[source]

Bases: object

Linked templates, as a policy source that composes like any other.

Held in memory rather than written to a directory, because a link is generated logic: it is derived from a reviewed template plus a piece of grant data, and round-tripping it through a file would invite someone to edit the derived half.

Parameters:
  • links – The links to contribute, in order.

  • name – The source name, and therefore the namespace for the links’ ids in a union.

Example

link = link_template(pack_template("time-boxed-grant"), link_id="oncall-ana",
                     principal='User::"ana"', resource='Resource::"paging"',
                     expires_at=end_of_shift)
install(policy_source=["pack://", LinkedPolicySource([link])])
property name: str

Short name for this source.

The links this source contributes, in order.

load() → PolicyBundle[source]

Build a bundle from the linked policies.

No links is not an error: “nobody currently holds a time-boxed grant” is the ordinary resting state of this source, and a union that refused to build in that state would take the whole policy set down every time the last link expired.

Returns:

The bundle, with each link’s link_id as its policy id.

Raises:

ConfigurationError – If the concatenated links do not parse.

current_hash() → str[source]

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

The links are held in memory, so this is the digest of what the process currently holds — it changes as links are added or dropped, which is exactly the signal a caller polling for a reload is after.

class sentrykit.policy.loader.PackPattern(name: str, file: str, summary: str, policy_ids: tuple[str, ...], requires: tuple[str, ...] = (), obligations: tuple[str, ...] = ())[source]

Bases: object

One shipped pattern: what it is, which file holds it, and what it declares.

Variables:
  • name (str) – The pattern’s short name, as it is spelled in a pack://<names> DSN.

  • file (str) – The policy file, relative to the pack root.

  • summary (str) – One sentence, in authorization vocabulary, on what the pattern decides.

  • policy_ids (tuple[str, ...]) – The @id annotations the file declares, in file order. These are the names a decision reports as determining, and <pack-name>/<id> once unioned.

  • requires (tuple[str, ...]) – The entity attributes and context keys the pattern reads, so a host can tell what data adopting it needs. Spelled resource.owner, context.now, principal.elevation_until.

  • obligations (tuple[str, ...]) – The obligations the pattern’s permits carry, if any.

class sentrykit.policy.loader.PackPolicySource(*, patterns: Sequence[str] = (), name: str = PACK_NAME, schema: bool = False, validate: bool = True)[source]

Bases: object

The shipped policy pack, as a PolicySource.

Parameters:
  • patterns – Which patterns to include. Empty — the default — means every one of them. An unknown name is a configuration error rather than a silent omission, because a pattern that is quietly absent is a permit that quietly never fires.

  • name – The source name, which becomes the namespace for its policy ids in a union. Change it when you ship a second, differently-configured pack alongside.

  • schema – Whether to put SCHEMA_FILE on the loaded bundle. Off by default; see the module docstring for why that default is the safe one.

  • validate – Whether to type-check the pack against its own schema at load. Independent of schema: validation happens against the shipped schema either way, and only the bundle’s schema is affected by schema.

Example

install(policy_source=["pack://ownership,rbac", "file://./policies"])
classmethod from_dsn(dsn: DSN) → PackPolicySource[source]

Build from pack://[pattern,pattern]?name=...&schema=...&validate=....

The DSN target is a comma-separated list of pattern names; an empty target means the whole pack. pack on its own parses as pack:// and means the same thing.

Parameters:

dsn – The parsed address.

Returns:

The source.

Raises:

ConfigurationError – If a named pattern does not ship, or an option is malformed.

property name: str

Short name for this source, used to namespace policy ids and tag provenance.

property patterns: tuple[PackPattern, ...]

The patterns this source contributes, in concatenation order.

load() → PolicyBundle[source]

Read the shipped pack and build the bundle.

Returns:

The bundle, with one provenance entry per policy naming the pattern file it came from as its origin, and schema set only when the source was built with schema=True.

Raises:

ConfigurationError – If the shipped pack is missing, unparseable, or fails validation against its own schema — all of which mean a broken install.

current_hash() → str[source]

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

Loads to compute it. The pack ships inside the distribution and cannot change under a running process, so a cheaper stat-based shortcut would be optimizing a poll nobody needs to make — and it would have to reproduce the schema opt-in, which is the one thing about this source’s digest that is not simply “what is on disk”.

Bases: object

A linked template: one concrete policy, and the grant that records it as data.

Both halves describe the same authorization fact, for two different audiences. The policy is reviewed logic a Cedar engine can decide against; the grant is the store record an admin surface lists, an auditor queries, and a revocation targets.

Variables:
  • link_id (str) – The @id written onto the generated policy, and the id the grant carries.

  • policy (str) – Concrete Cedar policy text, with the template’s slots filled and an expiry condition appended when one was asked for.

  • grant (sentrykit.stores.base.GrantSpec) – The matching grant, carrying the same principal, subject and expiry.

  • template (str) – The template text the link was made from, kept for provenance.

Link a Cedar template into a concrete policy, and the grant that records it.

cedarpy has no linking API, so the link is textual: every ?principal becomes the principal’s uid and every ?resource becomes the resource’s, an @id(link_id) annotation is prepended, and — when expires_at is given — a when { context has now && context.now <= <epoch> } condition is appended so the policy stops permitting on its own. Epoch seconds, because coerce_value() renders every datetime that way and Cedar has no ordering on strings; guarded by has, because Cedar’s validator refuses an unconditional read of an optional context attribute and a link that evaluated correctly but failed sentrykit policy validate would be a trap.

Parameters:
  • template – The template text, from pack_template() or your own file.

  • link_id – The id for this link. Must be unique across the policy set; it is what a decision reports and what an auditor joins on.

  • principal – Who the link is for.

  • resource – What it is over.

  • expires_at – When it stops applying. None means it does not expire — which is a standing grant, not a time-boxed one, and is worth being deliberate about.

  • granted_by – The actor making the grant, recorded on the grant.

  • reason – The justification, recorded on the grant.

Returns:

The link.

Raises:

ConfigurationError – If the template declares no slots, if a slot is left unfilled, or if link_id is empty.

sentrykit.policy.loader.load_pack(*, patterns: Sequence[str] = (), schema: bool = False, name: str = PACK_NAME) → PolicyBundle[source]

Load the shipped pack directly, without going through a DSN.

Parameters:
  • patterns – Which patterns to include; empty means all of them.

  • schema – Whether the bundle carries the pack’s schema.

  • name – The source name recorded on every provenance entry.

Returns:

The bundle.

Raises:

ConfigurationError – If a named pattern does not ship, or the install is broken.

sentrykit.policy.loader.pack_root() → Path[source]

Return the directory the shipped pack lives in.

Resolved from __file__ rather than through importlib.resources, because the pack is read as a directory by FilesystemPolicySource and files() yields a Traversable that is only a real path for the ordinary (non-zipped) install. sentrykit does not support running from a zipimport, and saying so here is better than a context manager that pretends otherwise.

Returns:

The absolute path of sentrykit/policy/pack.

sentrykit.policy.loader.pack_schema() → str[source]

Return the pack’s Cedar schema text.

Returns:

The contents of pack.cedarschema.

Raises:

ConfigurationError – If the shipped schema is missing, which means a broken install.

sentrykit.policy.loader.pack_template(name: str) → str[source]

Return one shipped Cedar template’s text.

Parameters:

name – The template’s name, with or without the TEMPLATE_SUFFIX suffix.

Returns:

The template text, with its ?principal / ?resource slots unfilled.

Raises:

ConfigurationError – If no such template ships with the pack.