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¶
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);