Skip to main content

Attribute-based access control

A role answers who someone is. An attribute answers what is true right now — the amount on this transfer, the region this record lives in, whether the session was re-authenticated a minute ago. Both travel the same way, in context.

permit(principal, action == Action::"transfer", resource)
when { context.amount <= 1000 && context.mfa == true };

The engine compares what you pass. It has no view of your database, so an attribute it was not given is an attribute it cannot read.

@govern.tool(intent="transfer", context=lambda **kw: {
"amount": kw["amount"],
"mfa": kw["session"].mfa_verified,
})
def transfer(session, to, amount):
...

What you can compare

KindExample
Stringcontext.department == "finance"
Numbercontext.clearance >= 3, context.amount <= 1000
Booleancontext.mfa == true
String patterncontext.email like "*@acme.com"
Set membershipcontext.regions.contains("eu")
Nested objectcontext.user.department == "finance"

Combine them with &&, || and !. A nested object is fine, so you can pass a shape your code already has rather than flattening it.

Attributes belong in context, not on the principal

// Denies silently. There is no entity store to read `clearance` from.
when { principal.clearance >= 3 }

principal names the subject and nothing more. Pass the attribute: context.clearance >= 3. This holds in both editions and in both language lanes, so a policy written this way behaves identically everywhere it runs.

Relating one attribute to another

Two facts you pass can be compared with each other, which is how ownership rules are usually written:

permit(principal, action == Action::"read", resource)
when { context.owner == context.requester };

Excluding on an attribute

forbid is the right tool for a condition that must hold no matter which permit matched — a data-residency rule, a frozen account, a maintenance window.

forbid(principal, action, resource)
when { context.region == "eu" && context.destination != "eu" };

Optional attributes

An attribute that is not always present needs a guard, because a rule that reads a missing key denies:

when { context has clearance && context.clearance >= 3 }

Without the guard the rule denies whenever the key is absent. That is the safe direction, and it is also indistinguishable from a policy that meant to deny — so decide deliberately which you want, and assert it in a fixture.

Combining with roles

Most policies end up using both: a role to say who may attempt the action, and attributes to bound it.

permit(principal, action == Action::"transfer", resource)
when {
context.roles.contains("treasurer")
&& context.amount <= 50000
&& context.mfa == true
};

Read that as a sentence — a treasurer, under fifty thousand, with MFA — which is the property that makes a policy reviewable by someone who does not write Cedar.

Next