Skip to content

Policies

A policy is a function from a principal to an Ability. The function runs per request — AbilityFactory::define is called once after authn, the returned Ability lives in the request’s task-local and extensions. This page covers the builder API: subjects, actions, conditional rules, restricted fields, denials.

Predicates compile through a single Predicate AST that lowers to SeaORM’s Condition for the SQL filter and matches in-memory for the by-id check and the response mask. One AST, two interpreters — the rows the query returns and the rows the response check accepts cannot diverge.

crates/features/src/authz/ability.rs
use nest_rs_authz::{AbilityBuilder, AbilityFactory, Action};
#[injectable]
#[derive(Default)]
pub struct AppAbility;
impl AbilityFactory for AppAbility {
type Actor = Claims;
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) {
ab.can(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id));
}
}

That is the whole surface for “members read their org’s users”. Three things land at once: the class-level gate (can a member read users?), the SQL pre-filter (WHERE org_id = $caller_org_id), and the response-side check on every loaded row.

A subject is any type that implements nest_rs_authz::Subject. The crate ships a blanket impl for every sea_orm::EntityTrait:

pub trait Subject: 'static {}
impl<E: sea_orm::EntityTrait> Subject for E {}

So a SeaORM entity (user::Entity, org::Entity) is the canonical subject — the bridge isolates the ORM coupling to one impl block. Introducing a second data layer moves one impl, not the engine.

Subject is used as a type-system guardrail in Authorize<A, S> and in the condition_for::<E> calls. Subjects are keyed by TypeId under the hood; Action::Manage on a subject acts as a wildcard for every other action on that same subject.

Five variants, four typed markers:

pub enum Action { Read, Create, Update, Delete, Manage }

Manage is the action wildcard — a grant on Manage matches every other action on the same subject. Each variant has a type marker (Read, Create, Update, Delete, Manage) implementing ActionMarker, so routes can name an action as a type parameter (Authorize<Read, user::Entity>).

A grant on Read does not imply Update. A grant on Manage does.

ab.can(Action::Manage, org::Entity); // covers Read, Create, Update, Delete

AbilityBuilder has two starters: can (grant) and cannot (denial). Each returns a RuleSpec that commits on drop — bind it to a variable only if you want to defer the commit:

ab.can(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id))
.fields([user::Column::Id, user::Column::Name]);
MethodWhat it does
.can(action, subject)Begin a grant
.cannot(action, subject)Begin a denial — a matching denial overrides a matching grant
.when(|p| ...)Constrain the rule to rows matching the predicate
.fields([col, ...])Restrict the response mask to these columns

Skip .when and the rule is unconditional. Skip .fields and every field is permitted.

The closure handed to when receives a PredicateBuilder<E> typed to the rule’s entity, so column references are type-checked at compile time. A rule scoped to user::Entity cannot accidentally reference org::Column::Id.

pub enum Predicate<E: EntityTrait> {
Always,
Eq(E::Column, Value),
In(E::Column, Vec<Value>),
And(Vec<Predicate<E>>),
Or(Vec<Predicate<E>>),
Not(Box<Predicate<E>>),
}

PredicateBuilder exposes the constructors:

ab.can(Action::Read, user::Entity).when(|p| {
p.all([
p.eq(user::Column::OrgId, actor.org_id),
p.any([
p.eq(user::Column::Role, "admin"),
p.eq(user::Column::Id, actor.sub),
]),
])
});
Builder methodVariantUse
p.eq(col, value)EqColumn equals value
p.is_in(col, [v, ...])InColumn in list
p.all([...])AndEvery part matches
p.any([...])OrAt least one part matches
p.not(inner)NotNegation

Predicate::Always is the default — a rule with no .when admits every row. An empty p.any([]) matches nothing (SQL FALSE); an empty p.all([]) matches every row (SQL TRUE). The same identities hold for the SQL lowering and the in-memory check.

.fields([cols]) restricts the response mask to a set of columns. A rule without .fields permits every field. The masker unions the permitted fields across every matching grant — so an admin grant with no .fields clears the field restriction even if a narrower grant existed.

ab.can(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id))
.fields([user::Column::Id, user::Column::Name]);

A member reads id and name; the masker drops every other column from the response — including columns the wire DTO exposes that the member should not see.

The exemplar in crates/features/src/authz/ability.rs:

impl AbilityFactory for AppAbility {
type Actor = Claims;
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) {
if actor.is_admin() {
ab.can(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id));
ab.can(Action::Manage, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id));
ab.can(Action::Manage, org::Entity);
} else {
ab.can(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id))
.fields([user::Column::Id, user::Column::Name]);
ab.can(Action::Create, user::Entity)
.when(|p| p.eq(user::Column::OrgId, actor.org_id));
ab.can(Action::Read, org::Entity)
.when(|p| p.eq(org::Column::Id, actor.org_id));
}
}
}

What this buys:

  • Admin reads and manages every user in their org; reads and manages every org unconditionally.
  • Member reads users in their org but only id and name; can create users in their org; reads their own org only.
  • No role at all (or unknown role) falls into the member branch — the test empty_roles_behave_as_a_non_admin_member pins this so a degraded token cannot silently grant admin powers.

A cannot rule that matches a row overrides every matching grant on the same action+subject. Use it sparingly — the common case is a narrow grant, not a broad grant clawed back.

ab.can(Action::Read, user::Entity);
ab.cannot(Action::Read, user::Entity)
.when(|p| p.eq(user::Column::Status, "banned"));

The SQL pre-filter for Read becomes (OR of grant conditions) AND NOT (OR of deny conditions). The in-memory check refuses if any denial matches even when grants do.

Always in crates/features/src/authz/ability.rs. The factory is the single source of truth for the app’s authorization rules — HTTP, GraphQL, WS, and MCP transports all bridge to the same AppAbility. Spreading policy across handlers is the drift the framework prevents.