Skip to main content
Authorization answers a different question than authentication. authn (the security guide) verifies the JWT and tells you who is calling; authz decides what that caller may do — which routes they can hit, which rows they can see, and which fields survive the response. nestrs ships a CASL-style authorization model with a deny-by-default posture:
  • An empty Ability denies everything — no rule, no access.
  • No Ability in request scope is an error, not a silent pass. The authorized repository paths and CrudService (under authz-row-level) return sqlx::Error::Protocol rather than falling back to unfiltered queries.
  • A row-level predicate with no principal denies — the runtime fails loud instead of leaking rows.
  • Reads are invisible, writes are denied. A row the caller can’t read comes back as Ok(None) / an empty list; a write the caller can’t perform comes back as an error.

Installation

Authorization is opt-in via Cargo features, scoped so you only pay for the layers you use:
Custom predicate closures only need authz — the blanket RowPredicate impl lives in the core module. The authz-row-level flag gates the enforcement seam: the repository methods that push rules into WHERE clauses and the CrudService methods that apply them to every read and write.

The ability model

An Ability is an ordered list of additive grants (Rules), built with AbilityBuilder:

The vocabulary

  • ActionRead, Create, Update, Delete, Manage (a wildcard that satisfies every other action on the same subject, CASL convention), or Custom(String) for domain verbs like "publish". The wildcard matches in both directions: a Manage rule grants any action, and can(&Action::Manage, ...) asks “may the caller do anything here”.
  • SubjectSubject::Type(String) matches a type of resource ("posts"); Subject::Instance(json) matches one concrete document, which is when conditions and row predicates get evaluated. Instances may use the CASL wire shape {"type": "Post", "attributes": {...}} — attributes under "attributes" — or be a bare row.
  • Conditions — a serde_json::Map<String, Value> of field: value equality constraints, checked against the instance’s fields with exact JSON equality ("12" the string never equals 12 the number; a missing field matches only a null condition value).
  • Rule — one grant: action + subject_type (&'static str in the builder) + optionally fields (an allow-list), conditions, and a predicate (a RowPredicate). None on each optional slot means “no restriction of that kind”.

Builder methods

Matching semantics: first match wins

Ability resolves two different questions, with different semantics:
  • can(action, subject)any matching rule grants the action (.any()).
  • allowed_fields / constraint / predicate — the first matching rule’s value is used (.find()).
The practical consequences:
  1. Put restrictive rules before broad grants. If a broad .can(Action::Read, "posts") comes first, it is also the first match for fields/conditions/predicate lookups — its None values win and the restrictive rule you added later is never consulted for them.
  2. There is no cannot. Rules are additive grants only — a CASL cannot() exception rule has no equivalent. If a broad grant exists, it grants; if you need “read only your own rows”, express that with one rule carrying a predicate, not a broad grant plus a restrictive one.

Registering and installing the ability

Two pieces of wiring make the ability visible to the framework:
  1. PoliciesModule::register(PoliciesOptions::new(ability)) — a dynamic module that registers the Ability and PoliciesGuard in the DI container (so #[use_guards(PoliciesGuard)] resolves).
  2. install_policies_middleware — an axum middleware that takes the Arc<Ability> via router state and stashes it in both the request extensions (where the guard and the masking interceptor read it) and a per-task slot (where the authorized repository methods read it).
To make the same ability available on every request, layer the middleware onto the router (see the worked example for the full picture):
The middleware pair is independent — install_authn_middleware verifies the bearer token and (when authz is enabled) also installs the policies::Principal into its per-task slot, and install_policies_middleware installs the ability. Both must wrap your routes because guards and handlers run inside them. Inside a handler you can pull the request-scoped ability explicitly:
nestrs::with_ability(ability, future) and nestrs::with_principal(principal, future) are the test-scoped equivalents — they run a future with the slot populated, no router required. Production code should rely on the middlewares.

Guarding routes

Route-level checks use the #[check_policies(...)] attribute plus the PoliciesGuard, normally chained behind AuthnGuard (which produces the verified identity):
  • Tokens are "action:Subject", comma-separated for multiple checks (#[check_policies("read:posts", "update:posts")]). Action names are case-insensitive; anything else parses as Action::Custom.
  • The guard fails closed with 403 in every misconfiguration: no ability on the request (middleware missing), no #[check_policies] metadata on the route, the action not granted, or conditions that the principal doesn’t satisfy.

Conditions at the guard

When the matched rule carries conditions, the guard re-evaluates them against the principal, built as {"type": "User", "attributes": {"sub": ..., "roles": [...]}}. Exact JSON equality applies, so {"sub": "alice"} is the way to express a per-user grant:
roles is compared as a JSON array, so a string condition like {"roles": "admin"} can never match — the principal’s roles always serializes as an array. For role gates, prefer #[roles("admin")] with AuthnGuard (verified against the token’s roles, any-of), and reserve can_with_conditions for field-equality checks.

Row-level authorization

Route guards decide “may this caller hit this endpoint at all”. Row-level authorization decides “which rows may this caller see, and which fields” — enforced inside the repository, not in handler code.

The Principal

nestrs::policies::Principal is the caller identity row-level rules are evaluated against — { subject, roles, claims }, built from the verified PrincipalIdentity when authn is enabled:
nestrs::Principal (root) is the axum extractor newtype from authn. The row-level value is deliberately module-qualified as nestrs::policies::Principal because only one can own the crate-root name.
The principal lives in a per-task slot, installed by install_authn_middleware (HTTP), the WebSocket scope (run_in_ws_scope), the GraphQL context (graphql_router_with_context), or with_principal in tests. Handlers read it with nestrs::current_principal() -> Option<Arc<policies::Principal>>.

RowPredicate: two layers

A RowPredicate answers “does this row belong to this principal?” and has two layers:
  • check is the enforcement. Every authorized read filters its rows through it (post-load), every authorized write requires the candidate row to pass it. It cannot be skipped by a missing pushdown.
  • sql_conditions is the optimization. When it returns a non-empty conditions map, the repository compiles it into WHERE json_extract(data, '$.field') = $N so the database filters before rows are loaded. The closure re-check stays as defense in depth.
Any closure Fn(&serde_json::Value, &Principal) -> bool + Send + Sync implements RowPredicate through a blanket impl. The signature is the leak-prevention guarantee: a predicate sees the row and the principal, nothing else — no headers, no environment, no ambient state.
Custom closures always take the post-load path — they can’t be compiled to SQL. For pushdown, use a pre-built predicate or implement sql_conditions yourself.
The pushdown renders json_extract(...), which is SQLite-flavored. On Postgres/MySQL the database-sqlx AnyPool JSON-blob path needs a dialect-aware renderer — a known limitation, so the post-load closure re-check is what guarantees correctness regardless of backend.

Pre-built predicates

nestrs::predicates (under authz-row-level) ships ten ready predicates, each a small struct with defaulted column names and builder methods: Realistic examples:
Attach one with can_with_predicate — the rule’s subject type is the entity’s table name (see below):
HasRole as a pure role gate wastes a fetch for principals without the role (the closure denies every row after load). Prefer #[roles(...)] with AuthnGuard for route-level gates; keep HasRole for composing role access with other row predicates.
Comparisons use JSON semantics: "12" the string is not 12 the number, and the field values live inside the entity’s JSON blob (the data column), so type your JSON consistently when you write rows.

The authorized repository

Under authz-row-level, the repository and CrudService enforce the ability for you — handler code stays policy-free.

Entities and the (id, data) shape

Authorized storage works over the Entity trait: each entity declares its table, decodes itself from a row, and is persisted as a JSON blob in a data TEXT column, so the same code works on every SQL backend:
The table needs the matching shape (create it with your usual migration tooling):
The rule’s subject type is the entity’s table namefind_*_authorized evaluates every check against Subject::Type(T::TABLE). A rule for "Post" never matches a Post entity whose TABLE is "posts".

Authorized reads

Repository<T> (root-exported under database-sqlx) offers plain methods (find_one, find_all, find_all_paged, find_where, delete, count) plus the authorized variants (requiring authz-row-level): All four read the ability and principal from the request scope — call them behind install_policies_middleware and install_authn_middleware. Pagination semantics. find_all_authorized_paged only pushes LIMIT/OFFSET into SQL when the result would be exact — either no predicate, or the predicate’s sql_conditions render non-empty for the current principal. When a predicate can’t be pushed down (custom closures, PublicOrOwner, an admin under OwnerOrAdmin), it transparently falls back to find_all_authorized + a Rust-side window slice: the page is always identical to filtering the full set and slicing, at the cost of loading the filtered set.
The FindManyParams::limit field applies to fetched rows: a predicate without SQL pushdown filters after the limit, so pages can come back short even when more matches exist past the boundary. Pre-built predicates that push down paginate exactly; with closure predicates, fetch and filter yourself or expect short pages.
find_many_authorized composes your own filter with the policy:

Seeding: repo_crud_create

Repository::repo_crud_create(&entity) inserts through the same JSON-blob path without consulting an ability. It exists for seeding, factories, and tests — under authz-row-level every CrudService::create is deny-closed, so this is the deliberate escape hatch for rows nobody has asked to create yet (use it in request paths never, in main/fixtures freely).

CrudService: mandatory enforcement

With authz-row-level enabled, CrudService<T> (also root-exported under database-sqlx) applies the request-scoped ability in every method — there is no opt-out, no skip_auth:

Deny semantics summary

Response masking

Field restrictions from can_on_fields are enforced on the way out by PolicyMaskingInterceptor, applied with the interceptor_layer! macro:
Layer order matters. The interceptor reads the Arc<Ability> from the request extensions, so it must run inside install_policies_middleware. With axum, the last .layer() is the outermost — so add the interceptor layer before the policies layer, as above. Reversed, the interceptor sees no ability and passes responses through untouched. What it does to a JSON response:
  • Looks for a top-level "type": "Post" string field — the subject marker. Any JSON object (at any nesting depth, including inside arrays) carrying it is treated as that subject type.
  • Asks allowed_fields(Read, Subject::Type(name)) — the first matching rule’s field allow-list. Fields not in the list are removed; the "type" field itself must be allow-listed to survive.
  • Recurses into nested objects and arrays of objects.
What passes through unchanged:
  • Responses with no ability on the request (authz middleware not installed) — never mask blindly.
  • Non-JSON responses (content-type check) and unparseable JSON.
  • Bodies without a "type" string marker — a no-op rather than a hard fail.
  • Bodies at or above the size cap: 1 MiB by default, configurable via MaskingConfig::max_body_bytes (over-cap bodies pass through unmasked with a tracing warning, so a hostile or accidental giant body can’t OOM the server).
For transports that build payloads directly, the walker is public: nestrs::mask_value(&mut value, &ability) masks a serde_json::Value in place, and nestrs::mask_response(response, &ability, cfg) masks a constructed response. nestrs::json_response(status, body) builds a JSON response in one call.
Masking keys off the response’s "type" field, which is your wire type — it is not automatically your entity’s table name. A rule for "posts" (row-level) and a rule for "Post" (masking) are different namespaces: keep them deliberately aligned or distinct, but be aware they are matched against different strings.

Worked examples

Entity, service, controller, module, and wiring — one runnable picture. Reads and creates are predicate-filtered end-to-end; the guard also requires read:posts before the handler runs.
Behavior, end to end:
  • No token → AuthnGuard 401s before the policy is even consulted.
  • A token whose ability lacks read:posts → 403 from PoliciesGuard.
  • A valid caller listing → only their own posts (AuthorIsCurrentUser post-filter + pushdown).
  • A create naming someone else as author → policy denied: create on posts (row predicate) — the candidate row is judged before it exists.
  • Migration table shape: posts (id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL); seed initial rows with Repository::<Post>::new(pool).repo_crud_create(&post).
Restrict a wire subject to type, id, and title, and let the interceptor strip everything else from JSON responses:
The response body leaves as {"type": "Post", "id": 1, "title": "hello"}body is stripped. The caller with a full .can(Action::Read, "Post") grant instead of can_on_fields sees the unchanged body, because an unrestricted rule yields no field allow-list.
FindManyParams composes your own filter with the policy pushdown; with_ability / with_principal install the slots without a router, which is exactly how tests drive the authorized paths:
Outside tests, the slots come from install_policies_middleware and install_authn_middleware — call find_many_authorized straight from a handler.

Migrating from 0.x

Subject::Type owns its name as of 1.0:
The builder’s subject_type arguments remain &'static str. The change removes two leaks: the masking walker used to Box::leak one box per masked object per response for runtime-built type names, and the guard leaked subject names through a dedup set behind a global mutex. Runtime-built names now mask identically and are freed with the subject.

Coming from NestJS CASL

The model is CASL-shaped — additive can rules, subjects, conditions, field restrictions — with deliberate differences:

Beyond HTTP

The same ability + principal slots power the other transports:
  • ws-authz — wrap each message handler in run_in_ws_scope(scope, ...) with a WsScope carrying the connection’s ability and principal (WsScope::new().with_ability(a).with_principal(p)); read the slots with current_ws_ability() / current_ws_principal(), and emit through nestrs::emit_masked(&client, event, data, &ability) — a drop-in for client.emit(event, data) that applies the same masking walker to outbound frames.
  • graphql-authz — mount resolvers through graphql_router_with_context with a GqlDataContext; per-resolver abilities resolve via current_gql_ability() / current_gql_principal(), and outbound responses pass through the same masking.
See the WebSocket and GraphQL guides for transport specifics. For the identity side — issuing JWTs, password hashing, AuthnGuard — see the security guide.