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
Abilitydenies everything — no rule, no access. - No
Abilityin request scope is an error, not a silent pass. The authorized repository paths andCrudService(underauthz-row-level) returnsqlx::Error::Protocolrather 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
AnAbility is an ordered list of additive grants (Rules), built with AbilityBuilder:
The vocabulary
Action—Read,Create,Update,Delete,Manage(a wildcard that satisfies every other action on the same subject, CASL convention), orCustom(String)for domain verbs like"publish". The wildcard matches in both directions: aManagerule grants any action, andcan(&Action::Manage, ...)asks “may the caller do anything here”.Subject—Subject::Type(String)matches a type of resource ("posts");Subject::Instance(json)matches one concrete document, which is whenconditionsand row predicates get evaluated. Instances may use the CASL wire shape{"type": "Post", "attributes": {...}}— attributes under"attributes"— or be a bare row.Conditions— aserde_json::Map<String, Value>offield: valueequality constraints, checked against the instance’s fields with exact JSON equality ("12"the string never equals12the number; a missing field matches only anullcondition value).Rule— one grant:action+subject_type(&'static strin the builder) + optionallyfields(an allow-list),conditions, and apredicate(aRowPredicate).Noneon 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()).
- 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 — itsNonevalues win and the restrictive rule you added later is never consulted for them. - There is no
cannot. Rules are additive grants only — a CASLcannot()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:PoliciesModule::register(PoliciesOptions::new(ability))— a dynamic module that registers theAbilityandPoliciesGuardin the DI container (so#[use_guards(PoliciesGuard)]resolves).install_policies_middleware— an axum middleware that takes theArc<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).
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 asAction::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 carriesconditions, 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:
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.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
ARowPredicate answers “does this row belong to this principal?” and has two layers:
checkis 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_conditionsis the optimization. When it returns a non-empty conditions map, the repository compiles it intoWHERE json_extract(data, '$.field') = $Nso the database filters before rows are loaded. The closure re-check stays as defense in depth.
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.
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:
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."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
Underauthz-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:
find_*_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.
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
Withauthz-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 fromcan_on_fields are enforced on the way out by PolicyMaskingInterceptor, applied with the interceptor_layer! macro:
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.
- 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 atracingwarning, so a hostile or accidental giant body can’t OOM the server).
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.
Worked examples
Field masking on the way out
Field masking on the way out
Restrict a wire subject to The response body leaves as
type, id, and title, and let the interceptor strip everything else from JSON responses:{"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.Migrating from 0.x
Subject::Type owns its name as of 1.0:
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 — additivecan 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 inrun_in_ws_scope(scope, ...)with aWsScopecarrying the connection’s ability and principal (WsScope::new().with_ability(a).with_principal(p)); read the slots withcurrent_ws_ability()/current_ws_principal(), and emit throughnestrs::emit_masked(&client, event, data, &ability)— a drop-in forclient.emit(event, data)that applies the same masking walker to outbound frames.graphql-authz— mount resolvers throughgraphql_router_with_contextwith aGqlDataContext; per-resolver abilities resolve viacurrent_gql_ability()/current_gql_principal(), and outbound responses pass through the same masking.
AuthnGuard — see the security guide.