Authorization
Authorization answers what the caller may do. You write one
AbilityFactory per app that compiles the caller’s principal into an
Ability — the per-request capability set. The framework reads that
ability from three places: the query pre-filter on every read, the
by-id binding when a route loads a row, and the response mask after
the handler returns. None of them are opt-in per route — they apply
to every handler whose controller imports the transport’s authz
module.
The engine is CASL-style — grants, conditional rules, fields — but
backed by one shared Predicate AST so the SQL filter and the
in-memory check can never disagree row by row.
Install
Section titled “Install”cargo add nest-rs-authzOne factory, one ability per request
Section titled “One factory, one ability per request”AbilityFactory is a tiny trait you implement once. The framework
calls define per request, after the authn guard attached the
principal. The resulting Ability lives in request extensions and as
a task_local! for the data layer.
A minimal policy is one statement — “members read their org’s users”:
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 one line lands the class-level gate, the SQL pre-filter
(WHERE org_id = $caller_org_id), and the response-side check on
every loaded row. The next step is a role matrix — same builder,
branched on the principal:
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) { if actor.is_admin() { 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)); }}A RuleSpec commits on drop — ab.can(...).when(...).fields(...) is
one statement, no terminal .add() call. See
Policies for the full builder API.
Mount it on the controller
Section titled “Mount it on the controller”The AuthzHttpModule provides AuthzGuard — a project type alias for
AbilityGuard<AppAbility> defined in features/authz/http/guard.rs, not
exported from nest_rs_authz. Import AuthzGuard from your feature crate, or
use AbilityGuard<YourAbility> directly from nest_rs_authz::http. Bind it
after AuthGuard:
#[controller(path = "/users")]#[use_guards(AuthGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}Order matters: AuthGuard attaches the principal; AuthzGuard reads
it and builds the Ability. Every handler downstream sees an ambient
Ability it does not have to thread.
Three places the framework reads it
Section titled “Three places the framework reads it”Repo::scoped(Action)— the query pre-filter.condition_for::<E>(action)lowers the grants matching this action+entity into asea_orm::Condition, joined into every query. No matching grant ⇒1 = 0⇒ the query returns nothing. See Row-level filtering.Bind<S, A>— the by-id loader.Service::access(id)loads the row unscoped, then callsAbility::can(action, &model). Missing row ⇒ 404, present but denied ⇒ 403. See By-id binding.Authorize<A, S>— the response shaper. After a 2xx, the shaper deserializes the body intoS::Model, runsAbility::mask, thenretain_wire_keysto strip back anything outside the wire DTO. See Response masking.
Reading the ambient ability
Section titled “Reading the ambient ability”A handler that builds its own query (rare — prefer letting Repo
scope itself) reads the Condition via Scope<E, A>:
use nest_rs_authz::{Read, http::Scope};
#[get("/users/search")]async fn search( &self, scope: Scope<user::Entity, Read>, q: Query<String>,) -> Result<Json<Vec<User>>> { let rows = user::Entity::find() .filter(scope.into_inner()) .filter(user::Column::Name.contains(&q.0)) .all(&conn) .await?; Ok(Json(rows.into_iter().map(User::from).collect()))}A service method or any code running under the request’s task-local reads the ability directly:
use nest_rs_authz::current_ability;
if let Some(ab) = current_ability() { if !ab.can::<user::Entity>(Action::Update, &model) { return Err(ServiceError::Forbidden); }}current_ability() returns None outside a request (worker job,
shutdown hook). Worker jobs run unscoped by design — system work
should not be filtered by a caller’s ability. See
Threat model for what that buys and what
it does not.
What an Ability exposes
Section titled “What an Ability exposes”ability.can_class(Action::Read, TypeId::of::<user::Entity>())ability.condition_for::<user::Entity>(Action::Read)ability.can::<user::Entity>(Action::Update, &model)ability.mask::<user::Entity>(Action::Read, &model)ability.mask_many::<user::Entity>(Action::Read, models.iter())ability.permitted_fields::<user::Entity>(Action::Read, &model)Each method targets one layer of enforcement — the framework picks
which one to call where, but the surface is documented because your
own services may reach for can directly when they cannot fit through
Repo or Bind.
Action::Manage is the wildcard: a grant on Manage matches every
other action. A grant on Action::Read does not imply Update.
Where each piece lives
Section titled “Where each piece lives”- Policies — the
Subject/Action/RuleSpec/Predicatesurface and a real match-on-role example. - Row-level filtering
— what
Repo::scopeddoes, with before/after SQL. - By-id binding —
Bind<S, A>and the 404/403 distinction. - Response masking —
declare
Authorize<A, E>and masking is automatic; themask/retain_wire_keys/WireModelDefaultspipeline is documented as internals (skip on a first pass). - Per-transport bridges — how GraphQL, WS, and MCP re-establish the ambient ability (explanation — useful once you add a second transport, not before).