Skip to content

Policy & Authorization

Trails uses a Cedar-subset language for authorization. Policies are declarative rules evaluated at capability dispatch time -- before any handler code runs.

Quick start

// approve.cedar
permit (
    principal,
    action == Action::"approve_test_plan",
    resource is Trails::Resource::TestPlan
) when { principal.role == "qa_lead" };
from trails import capability
from trails.policy import policy

@capability
@policy("approve.cedar::permit_qa_lead")
def approve_test_plan(ctx, plan_id: str) -> dict: ...

Only principals with role == "qa_lead" can invoke this capability on TestPlan resources. Everyone else gets a PermissionError.

The @policy decorator

@policy(policy_ref) attaches a Cedar policy file to a capability. The policy_ref is either "file.cedar" or "file.cedar::rule_name" (the ::rule_name suffix is advisory and currently ignored by the evaluator).

File resolution: absolute paths are used verbatim; relative paths resolve against the decorated function's source directory; REPL/dynamic functions fall back to CWD. Files are loaded lazily on first invoke().

Decorator ordering: @capability must be outermost, @policy directly below. Reversed order raises TrailsError at decoration time with a fix hint.

@capability                          # outermost
@policy("admin.cedar")               # directly below
def delete_entity(ctx, iri: str) -> dict: ...

Cedar syntax subset

The parser recognises a small slice of Cedar. Anything outside this subset raises ValueError at load time.

Statement structure

policy  = effect "(" principal_clause "," action_clause "," resource_clause ")" body_block* ";"
effect  = "permit" | "forbid"

Statements end with ;. Multiple statements per file are evaluated in order; first applicable policy wins.

Head clauses

Each head has three slots: principal, action, resource. Head clauses AND with the body -- both must pass for a policy to apply.

Principal:

Syntax Meaning
principal Unconstrained
principal == User::"alice" Exact match (entity ref or plain id)
principal in Group::"admins" Membership via principal_attrs["groups"]

Action:

Syntax Meaning
action Unconstrained
action == Action::"approve" Exact match
action in [Action::"read", Action::"list"] One of the listed values

Resource:

Syntax Meaning
resource Unconstrained
resource is Trails::Resource::Note Type match (strongest type per ADR-0022)
resource == Trails::Resource::Note::"urn:note:42" Exact entity (type + id)
resource in [Entity::"a", Entity::"b"] One of the listed entities

Entity refs follow Namespace::Type::"id". Type names can be multi-segment: Trails::Resource::TestPlan.

when / unless conditions

permit (principal, action, resource)
when { principal.role == "admin" && resource.regulated != "true" };

Operators: ==, !=. Joined with &&. Dot-path attribute access:

Path Resolves to
principal.role principal_attrs["role"]
resource.readOnly resource["readOnly"]
environment.time environment["time"]

unless { ... } skips the policy if any condition holds (exception carve-outs from broad rules).

// comments

Line comments are stripped before parsing. Block comments (/* */) are not supported.

Not supported

like patterns, set comprehensions, has, nested entity hierarchies (beyond flat groups), arithmetic, string concatenation, context.*, if/then/else, is T in G composite forms. All raise ValueError.

.cedar file support

from pathlib import Path
from trails.policy import load_cedar_file

policies = load_cedar_file(Path("policies/default.cedar"))
# Returns list of parsed policy dicts with keys: effect, head, when, unless

evaluate_policies()

Programmatic evaluation without going through invoke():

from trails.policy import evaluate_policies, PolicyContext, PolicyDecision

ctx = PolicyContext(
    principal="alice",
    action="approve_test_plan",
    resource={"type": "Trails::Resource::TestPlan"},
    principal_attrs={"role": "qa_lead"},
)
decision = evaluate_policies(policies, ctx)
# PolicyDecision.ALLOW or PolicyDecision.DENY

PolicyContext fields: principal (str), action (str), resource (dict), environment (dict), principal_attrs (dict).

Semantics: policies evaluated in order; first applicable wins; default is deny when no policy matches.

register_principal_attrs()

Register principal attributes at app startup for role-based policies:

from trails.policy import register_principal_attrs

register_principal_attrs("alice", {"role": "qa_lead"})
register_principal_attrs("bob",   {"role": "developer", "groups": ['Group::"admins"']})

Per-invoke principal_attrs kwargs take precedence over the registry. Re-registering the same principal replaces its attributes.

Helpers: get_principal_attrs(id) returns a copy, clear_principal_attrs() wipes the registry (for tests).

Error handling

On deny, invoke() raises trails.PermissionError (subclass of TrailsError). The denial is also recorded in provenance.

from trails._core import PermissionError

try:
    invoke("delete_entity", {"iri": "urn:x:1"}, principal="mallory")
except PermissionError as e:
    # "policy denied: principal 'mallory' lacks permission for action
    #  'delete_entity' (policy ref='admin.cedar')"
    ...
Situation Exception When
Policy denies access PermissionError invoke() pre-dispatch
Cedar file not found TrailsError First invoke() (lazy load)
Malformed Cedar syntax ValueError load_cedar_file()
Wrong decorator order TrailsError Decoration time

Examples

Role-based access

forbid (principal, action == Action::"delete", resource)
unless { principal.role == "admin" };

permit (principal, action == Action::"read", resource);

Action-specific permits

permit (
    principal,
    action in [Action::"read", Action::"list", Action::"search"],
    resource
);

Resource-type guards

permit (
    principal,
    action == Action::"approve",
    resource is Trails::Resource::TestPlan
) when { principal.role == "qa_lead" };

Principal-scoped rules

permit (principal == User::"alice", action, resource);

permit (principal in Group::"admins", action == Action::"delete", resource);

Deny-first pattern (forbid + permit fallback)

forbid (principal, action, resource)
when  { resource.regulated == "true" }
unless { principal.role == "admin" };

permit (principal, action, resource);