Skip to content

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.

Authentication and authorization are different jobs handled by different crates. The reference feature wires both:

  • AuthnGuard lives in nest-rs-authn. A Strategy turns the request into a principal — for a resource server, that’s JwtStrategy<Claims> verifying a Bearer token.
  • AuthzGuard lives in nest-rs-authz. It seeds the ambient Ability from the principal and an AbilityFactory.

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.

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.

crates/features/src/authn/strategy.rs
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.

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:

crates/features/src/authz/ability.rs
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 a User, only the listed columns survive the response masker; email is dropped before the body leaves the process.

The controller declaration changes by two attributes — one for the guard chain, one for the by-id extractor.

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

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

The HTTP adapter now needs the authz bridge:

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

apps/api/src/module.rs (abridged)
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;

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 viewStatus
The row doesn’t exist404
The row exists but the ability refuses it403
The :id segment isn’t a valid UUID400

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.

A short check from the reference e2e:

Terminal window
$ 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.

  • A UsersController guarded by AuthnGuard + AuthzGuard — every route requires a valid JWT and a matching ability.
  • An AppAbility policy that scopes reads to the caller’s org and masks the email column for plain users.
  • Bind<UsersService, Read> on the by-id route — 403 on a denied row, 404 on a missing one, both without manual checks.
  • 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