Skip to content

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 Model is 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”.

crates/features/src/users/http/controller.rs (from the demo)
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.

Bind’s extractor decides among four early returns before the handler ever runs:

Path idAbilityRowStatus
Not a UUID v7——400 BAD REQUEST
ValidMissing (wiring bug)—500 INTERNAL SERVER ERROR
ValidPresentAbsent in DB404 NOT FOUND
ValidPresentPresent, denied403 FORBIDDEN
ValidPresentPresent, allowedHandler 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.

Bind<A, S> calls S::access(action, id) from the service. The default CrudService::access is (simplified):

(expanded by #[crud] — you don't write this)
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:

  1. The authorization is decided in SQL. The scoped lookup filters the primary key by scope_for(action) — the exact condition_for the list path joins, evaluated by the same database. One source of truth: the row a list query returns and the row access grants cannot diverge. It is also what makes relational rules enforceable here — a p.related scope reaches through a join no in-memory check could evaluate without loading the parent.
  2. The authorized path costs one lookup. The second, unscoped one runs only after the first came back empty, purely to separate Denied from Missing — so the caller who is refused pays for the distinction, and the caller who is allowed does not. Collapsing the two into a single Repo::scoped(Read).find_by_id(id) would cost the same on the happy path and return None for both refusals, hiding the policy decision behind a 404.
nest_rs::seaorm::Access
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:

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

crates/features/src/users/http/controller.rs
#[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 Bind refuses the route if the policy denies.
  • Even if the handler were called anyway, Repo::scoped(Delete) appends the ability’s condition_for(Delete) to the DELETE statement so a denied row deletes zero rows. The handler gets back RowsAffected::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:

crates/features/src/posts/http/controller.rs
#[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.