Skip to content

Route model binding

Bind<S, A> 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 unscoped and then asks the ambient Ability whether the caller may perform A on it. 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
use nest_rs_authz::Read;
use nest_rs_seaorm::Bind;
#[get("/:id")]
async fn get(&self, user: Bind<UsersService, Read>) -> 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<UsersService, Read> 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 service (not the entity) and the action marker — 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 v7400 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<S, A> 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()?;
// Unscoped live load: does the row exist at all?
let Some(model) = Repo::<E>::unscoped_by_id(id)
.filter(live_read_filter())
.one(&conn).await?
else {
return Ok(Access::Missing);
};
// Scoped re-load: is it inside the caller's grant for `action`?
let allowed = Repo::<E>::unscoped_by_id(id)
.filter(scope_for::<E>(action))
.filter(live_read_filter())
.one(&conn).await?
.is_some();
if allowed {
Ok(Access::Found(model))
} else {
Ok(Access::Denied)
}
}

Two things to notice:

  1. The first load is unscoped. It reaches every live row in the table — no condition_for joined. That is what lets the framework distinguish “missing” from “denied”. A Repo::scoped(Read).find_by_id(id) would return None for both, collapsing them to 404 and hiding the policy decision.
  2. The authorization is decided in SQL. The second lookup filters the same 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. The cost is one extra primary-key lookup on the binding path.
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<S, Update> (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<UsersService, Delete>) -> 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:

#[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.

Built by YV17labs