Skip to content

Authorization

AuthzAbility compiles your policy into an Ability per request — the framework reads it from the query filter, the by-id binding, and the response mask.

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 ability-based — 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.

Terminal window
cargo add nest-rs --features authz

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”:

crates/features/src/authz/ability.rs (from the demo)
use nest_rs::authz::{AbilityBuilder, AbilityFactory, Action};
use nest_rs::core::injectable;
#[injectable]
#[derive(Default)]
pub struct AuthzAbility;
impl AbilityFactory for AuthzAbility {
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:

crates/features/src/authz/ability.rs
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.

The trait has a second, defaulted branch — define_visitor — that the guard calls instead of define when a #[public] route is reached with no credential. It grants nothing until you write into it; see Public reads.

The AuthzModule provides AuthzGuard — a project type alias for AbilityGuard<AuthzAbility> defined in features/authz/guard.rs, not exported from nest_rs::authz. Import AuthzGuard from your feature crate, or use AbilityGuard<YourAbility> directly from nest_rs::authz. Bind it after AuthnGuard:

crates/features/src/users/http/controller.rs (from the demo)
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController {
#[inject]
svc: Arc<UsersService>,
}

Order matters: AuthnGuard attaches the principal; AuthzGuard reads it and builds the Ability. Every handler downstream sees an ambient Ability it does not have to thread.

  1. Repo::scoped(Action) — the query pre-filter. condition_for::<E>(action) lowers the grants matching this action+entity into a sea_orm::Condition, joined into every query. No matching grant ⇒ 1 = 0 ⇒ the query returns nothing. See Row-level filtering.
  2. Bind<A, S> — the by-id loader. Service::access(id) loads the row unscoped, then re-checks the same primary key filtered by condition_for(action) — the decision is made in SQL, by the same predicate the list path joins. Missing row ⇒ 404, present but denied ⇒ 403. See By-id binding.
  3. Authorize<A, S> — the response shaper. After a 2xx, the shaper deserializes the body into S::Model, runs Ability::mask, then retain_wire_keys to strip back anything outside the wire DTO. See Response masking.

A handler that builds its own query (rare — prefer letting Repo scope itself) reads the Condition via Scope<E, A>:

crates/features/src/users/http/controller.rs
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:

crates/features/src/users/service.rs
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.

nest_rs::authz::Ability
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.

  • Policies — the Subject/Action/RuleSpec/Predicate surface and a real match-on-role example.
  • Row-level filtering — what Repo::scoped does, with before/after SQL.
  • By-id binding — Bind<A, S> and the 404/403 distinction.
  • Response masking — declare Authorize<A, S> and masking is automatic; the mask/retain_wire_keys/WireModelDefaults pipeline is documented as internals (skip on a first pass).
  • Public reads — define_visitor, the anonymous branch a #[public] route reads.
  • OAuth scopes — .requires_scope(...) narrows a rule to the scope a token carries, and refuses with an insufficient_scope challenge naming what to request. Read it when you hand tokens to third-party clients (MCP, in particular).
  • Per-transport bridges — how GraphQL, WS, and MCP re-establish the ambient ability (explanation — useful once you add a second transport, not before).
  • Policies — subjects, actions, predicates and fields compiled into one Ability.
  • Row-level filtering — what Repo::scoped joins into the SQL, with before/after.
  • Route model binding — Bind<A, S> turning a path id into a loaded, authorized model.
  • Response masking — the fail-closed field mask every successful response goes through.
  • Authorization on GraphQL, WS & MCP — the bridges that re-establish the ambient ability per transport.
  • Public reads — the anonymous branch of the same policy, granting nothing until you write it.
  • OAuth scopes — gate a rule on the scope a token carries, and refuse with an RFC 6750 challenge.
  • Authentication — how the principal an ability reads gets attached.
  • Database — the Repo layer the query pre-filter joins into.
  • Multi-tenant SaaS — the two layers under a production tenancy model.