Authenticate and authorize
This is the feature behind See the security payoff
— now you build it. You’re now building inside the reference api’s
users feature; your blog already carries the same two guards from
Expose over HTTP, with one blanket Manage rule standing in
for a real policy. Here that policy gets teeth: the framework filters every read by their
organisation, gates every by-id load against their ability, and masks
the response before it leaves the process. By the end of this page, a
cross-tenant GET /users/:id returns 403, a plain user sees only
their org, and the email column disappears from a non-admin listing.
Two layers, two guards
Section titled “Two layers, two guards”Authentication and authorization are different jobs handled by different crates. The reference feature wires both:
AuthnGuardlives innest-rs-authn. AStrategyturns the request into a principal — for a resource server, that’sJwtStrategy<Claims>verifying aBearertoken.AuthzGuardlives innest-rs-authz. It seeds the ambientAbilityfrom the principal and anAbilityFactory.
Two crates, one binding on the controller. The reference feature
re-exports both under crates/features/src/authn/ and authz/; the
tutorial reuses those aliases.
The principal
Section titled “The principal”A resource server pins a single alias once. The reference feature’s
Claims carries an org id and a role set — exactly what authz reads
back.
pub type AppJwtStrategy = JwtStrategy<Claims>;
pub type AuthnGuard = nest_rs_authn::AuthnGuard<AppJwtStrategy>;JwtStrategy<Claims> is provided by nest-rs-authn. On a missing or
expired token it returns 401 automatically — the controller never
sees the request.
Both aliases, their modules, and the Claims above are what
nestrs g auth writes. They are app
code by necessity — the framework is generic over the principal and the policy,
so it cannot ship a Claims or an AppAbility for you.
The policy
Section titled “The policy”AppAbility is the single source of truth for what each role may do.
Admins manage their org’s users; plain users read a scoped, redacted
view. The reference policy fits in one method:
use nest_rs_authz::{AbilityBuilder, AbilityFactory, Action};use nest_rs_core::injectable;
use crate::Claims;use crate::users as user;
#[injectable]#[derive(Default)]pub struct AppAbility;
impl AbilityFactory for AppAbility { type Actor = Claims;
fn define(&self, actor: &Claims, ab: &mut AbilityBuilder) { if actor.is_admin() { ab.can(Action::Manage, user::Entity) .when(|p| p.eq(user::Column::OrgId, actor.org_id)); } else { ab.can(Action::Read, user::Entity) .when(|p| p.eq(user::Column::OrgId, actor.org_id)) .fields([user::Column::Id, user::Column::Name]); ab.can(Action::Create, user::Entity) .when(|p| p.eq(user::Column::OrgId, actor.org_id)); } }}Two patterns to read here:
.when(|p| p.eq(Column::OrgId, actor.org_id))is the row-level rule — every read and every by-id write the caller issues filters by that condition. Cross-tenant access becomes structurally impossible..fields([...])is the field-level rule — when a plain user reads aUser, only the listed columns survive the response masker;emailis dropped before the body leaves the process.
Bind the guards on the controller
Section titled “Bind the guards on the controller”The controller declaration changes by two attributes — one for the guard chain, one for the by-id extractor.
use std::sync::Arc;
use nest_rs_authz::http::Authorize;use nest_rs_authz::{Create, Read};use nest_rs_http::{Ctx, Valid, controller, crud};use nest_rs_seaorm::Bind;use poem::Result;use poem::web::Json;
use crate::Claims;use crate::authn::AuthnGuard;use crate::authz::AuthzGuard;use crate::users::{CreateUser, Entity as UserEntity, UpdateUser, User, UsersService};
#[controller(path = "/users")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct UsersController { #[inject] svc: Arc<UsersService>,}
#[crud(/* same as before */)]impl UsersController { #[post("/")] async fn create( &self, _authz: Authorize<Create, UserEntity>, auth: Ctx<Claims>, body: Valid<Json<CreateUser>>, ) -> Result<Json<User>> { let user = self.svc.create_in_org(body.into_inner(), auth.org_id).await?; Ok(Json(User::from(&user))) }
#[get("/:id")] async fn get(&self, user: Bind<UsersService, Read>) -> Json<User> { Json(User::from(&*user)) }}Four things changed:
| Change | Job |
|---|---|
#[use_guards(AuthnGuard, AuthzGuard)] | Run authn then authz on every route in the controller |
auth: Ctx<Claims> | Read the principal that AuthnGuard attached to the request |
_authz: Authorize<Create, UserEntity> | Check the ability allows Create on UserEntity before the body runs |
user: Bind<UsersService, Read> | Parse the :id, load the row through the service, return 403 if the ability refuses it |
The org id comes from the caller’s claims, never from the body — there is no placeholder to fill in and no way to spoof the tenant.
Import the authz module
Section titled “Import the authz module”The HTTP adapter now needs the authz bridge:
use nest_rs_core::module;
use super::controller::UsersController;use crate::authz::AuthzHttpModule;use crate::users::UsersModule;
#[module( imports = [UsersModule, AuthzHttpModule], providers = [UsersController],)]pub struct UsersHttpModule;AuthzHttpModule carries the AuthzGuard provider and the response
masker. Forgetting the import is caught at boot by the access graph —
the binary fails to start with a clear “missing guard” error rather
than running silently unprotected.
The app root — api, not blog — imports AuthnModule and
AuthzHttpModule once:
use features::authn::AuthnModule;use features::authz::AuthzHttpModule;use features::users::UsersHttpModule;
#[module( imports = [ DatabaseModule::for_root(None), HttpModule::for_root(HttpConfig { port: 3002, ..Default::default() }), AuthnModule, AuthzHttpModule, UsersHttpModule, ],)]pub struct ApiModule;What changed at runtime
Section titled “What changed at runtime”Three behaviours land without writing them in the handler.
Row-level filter on every read. A plain user issuing
GET /users gets WHERE org_id = $caller_org_id appended to the
SQL — the Repo::scoped(Action::Read) CrudService::list uses joins
the ambient ability’s condition_for(Read).
By-id load checks access. Bind<UsersService, Read> parses the
:id path segment, loads the row through the service, runs the
ability check. Three failure shapes:
| Caller’s view | Status |
|---|---|
| The row doesn’t exist | 404 |
| The row exists but the ability refuses it | 403 |
The :id segment isn’t a valid UUID | 400 |
403 (rather than 404) on a denied-but-existing row discloses that the
row exists — the standard REST trade-off, chosen here so a client can
tell “not yours” from “not there”. Collapse the 403 into a 404 when
existence itself is confidential: see by-id
binding. A 500 would mean the ambient ability
didn’t install; that’s a wiring bug.
Response masking on every body. The masker runs after the handler
returns. It reconstructs the full User model from the body, walks
Ability::mask, and drops any column outside the caller’s
.fields([...]) grant. A plain user’s listing returns:
[{ "id": "018f…", "name": "Bob" }]— no email, even though the handler returned a Json<User> that
included it. Forgetting per-handler redaction is structurally
impossible.
See it for yourself
Section titled “See it for yourself”A short check from the reference e2e:
$ curl -i -H 'Authorization: Bearer <plain-user-token-for-org-A>' \ http://localhost:3002/users/<user-in-org-B>HTTP/1.1 403 Forbidden
$ curl -s -H 'Authorization: Bearer <plain-user-token>' \ http://localhost:3002/users[{"id":"018f…","name":"Bob"}]The email is gone, the cross-tenant load is refused, and the handler never had to know.
What you have now
Section titled “What you have now”- A
UsersControllerguarded byAuthnGuard+AuthzGuard— every route requires a valid JWT and a matching ability. - An
AppAbilitypolicy that scopes reads to the caller’s org and masks the email column for plain users. Bind<UsersService, Read>on the by-id route —403on a denied row,404on a missing one, both without manual checks.
Going further
Section titled “Going further”- Mirror on GraphQL — the next step: expose the same feature through a resolver.
- Add login — the reference page for issuing the JWT these guards verify.
Built by YV17labs