Route model binding
Bind<A, S> turns a path id into the loaded, authorized model — 404 when the row is absent, 403 when it exists but the caller cannot see it.
Bind<A, S> is the handler argument that turns /users/:id into a
loaded, authorized model. It runs through the service’s access
method, which loads the row under the caller’s own grant — one SQL
lookup, filtered by the same condition a list query uses. Three outcomes,
three HTTP statuses:
- Row not in the database →
404 NOT FOUND. - Row exists but the caller cannot see it →
403 FORBIDDEN. - Row exists and the caller may act on it → the loaded
Modelis handed to the handler.
The 404-vs-403 distinction is a policy decision, not an implementation detail. nestrs’s posture is 403 leaks existence intentionally: a member trying to read a user in another tenant’s org learns that the id is real but off-limits, not that “nothing is there”.
What it looks like
Section titled “What it looks like”use nest_rs::authz::Read;use nest_rs::seaorm::Bind;
#[get("/:id")]async fn get(&self, user: Bind<Read, UsersService>) -> Json<User> { Json(User::from(&*user))}Bind derefs to the entity’s Model, so &*user is &user::Model.
Take ownership with user.into_inner() when the handler moves it.
Bind<Read, UsersService> reads as: “load a user by id, authorize
the caller for Read on it, refuse with 404 or 403 otherwise”. The
type parameters are the action marker first and the service
(not the entity) second — the same order #[authorize(Read, users::Entity)] and the GraphQL bind::<Read, UsersService> use, so
the action always leads. The service is what the framework calls
access on; the entity comes from S::Entity.
The four outcomes
Section titled “The four outcomes”Bind’s extractor decides among four early returns before the
handler ever runs:
| Path id | Ability | Row | Status |
|---|---|---|---|
| Not a UUID v7 | — | — | 400 BAD REQUEST |
| Valid | Missing (wiring bug) | — | 500 INTERNAL SERVER ERROR |
| Valid | Present | Absent in DB | 404 NOT FOUND |
| Valid | Present | Present, denied | 403 FORBIDDEN |
| Valid | Present | Present, allowed | Handler runs with Bind |
The UUID v7 check is deliberate — the framework’s id format is ordered, v7 only. A v4 id (or a malformed string) fails fast at the edge.
A missing ability is a 500 because it means the controller was not
mounted with AbilityGuard — a wiring bug, never a client error.
The access graph catches the bad import tree at boot, but a route
that uses Bind and forgets #[use_guards(AuthnGuard, AuthzGuard)]
fails at request time with this 500.
How access decides
Section titled “How access decides”Bind<A, S> calls S::access(action, id) from the service. The
default CrudService::access is (simplified):
async fn access(&self, action: Action, id: Uuid) -> Result<Access<Model>, DbErr>{ let conn = Repo::<E>::conn()?; // Scoped load: the caller's own grant for `action`, in one lookup. if let Some(model) = Repo::<E>::unscoped_by_id(id) .filter(scope_for::<E>(action)) .filter(live_read_filter()) .one(&conn).await? { return Ok(Access::Found(model)); } // Only now, and only to tell "forbidden" from "absent". let exists = Repo::<E>::unscoped_by_id(id) .filter(live_read_filter()) .one(&conn).await? .is_some(); if exists { Ok(Access::Denied) } else { Ok(Access::Missing) }}Two things to notice:
- The authorization is decided in SQL. The scoped lookup filters the
primary key by
scope_for(action)— the exactcondition_forthe list path joins, evaluated by the same database. One source of truth: the row a list query returns and the rowaccessgrants cannot diverge. It is also what makes relational rules enforceable here — ap.relatedscope reaches through a join no in-memory check could evaluate without loading the parent. - The authorized path costs one lookup. The second, unscoped one runs
only after the first came back empty, purely to separate
DeniedfromMissing— so the caller who is refused pays for the distinction, and the caller who is allowed does not. Collapsing the two into a singleRepo::scoped(Read).find_by_id(id)would cost the same on the happy path and returnNonefor both refusals, hiding the policy decision behind a404.
The Access outcome
Section titled “The Access outcome”pub enum Access<M> { Found(M), Denied, Missing,}A handler that does not use Bind (a service that authorizes its
own load, a worker job touching one row) returns Access<M> directly
and maps it to a wire shape:
match self.svc.access(Action::Read, id).await.map_err(ServiceError::from)? { Access::Found(user) => Ok(Json(User::from(&user))), Access::Denied => Err(Forbidden), Access::Missing => Err(NotFound),}Bind does this mapping for the HTTP transport. The GraphQL
analog (nest_rs::seaorm::graphql::bind) does the same for resolvers.
Updates and deletes — the by-id mutation path
Section titled “Updates and deletes — the by-id mutation path”For mutating actions, Bind<Update, S> (or Delete) loads the row
the caller is about to touch and refuses early if the policy says no:
#[delete("/:id")]async fn delete(&self, user: Bind<Delete, UsersService>) -> StatusCode { self.svc.delete_by_id(user.id).await?; StatusCode::NO_CONTENT}The combination of the load (Action::Delete checked against the
loaded row) and the Repo::scoped(Delete) filter inside
delete_by_id is defense in depth:
- The
Bindrefuses the route if the policy denies. - Even if the handler were called anyway,
Repo::scoped(Delete)appends the ability’scondition_for(Delete)to theDELETEstatement so a denied row deletes zero rows. The handler gets backRowsAffected::default()and the row stands.
Two layers, same predicate, the second catches what the first missed.
Hide existence behind a 404 — sometimes you want that
Section titled “Hide existence behind a 404 — sometimes you want that”The framework’s default — 403 when the row exists but is off-limits — is the right call for first-party APIs where org isolation is a business rule, not a secret. The id space is already large; learning “id X exists in tenant B” tells an attacker nothing they could not guess.
For a public-facing API where existence itself is sensitive (private content, draft media), collapse the 403 path into a 404 in your handler:
#[get("/:id")]async fn get(&self, id: Path<Uuid>) -> Result<Json<Post>, Error> { match self.svc.access(Action::Read, id.0).await.map_err(ServiceError::from)? { Access::Found(post) => Ok(Json(Post::from(&post))), Access::Denied | Access::Missing => Err(Error::from_status(NOT_FOUND)), }}The default Bind extractor is opinionated; the underlying access
is not.
Going further
Section titled “Going further”- Row-level filtering
— the layer underneath
Bind’s by-id load. - Response masking — the layer above, after the handler returns.
- Authorization — engine overview.