Skip to content

Security

Two concerns, two layers:

  • Authenticationwho the caller is. A Strategy turns a request into a principal (or returns a challenge — a 401 / an OAuth redirect).
  • Authorizationwhat they may do. An Ability declares the allowed actions on subjects; a guard builds it from the principal; the framework applies it everywhere downstream (queries, by-id loads, response bodies).

Once both are mounted, every read goes through a filtered Repo, every by-id write fails-closed on a denied row, and every response is masked before it leaves the process. There is no per-handler checklist.

apps/api/src/app.rs
#[module(
imports = [
DatabaseModule::for_root(None),
AuthnCoreModule,
AuthzHttpModule, // ← HTTP authz
AuthzGraphqlModule, // ← GraphQL authz
UsersHttpModule,
UsersGraphqlModule,
// ...
],
)]
pub struct AppModule;
crates/features/src/users/http/controller.rs
#[controller(path = "/users")]
#[use_guards(AuthGuard, AppAbilityGuard)] // ← bind both layers
pub struct UsersController {
#[inject]
svc: Arc<UsersService>,
}

That’s the whole opt-in surface. From there, the framework does the rest.

A resource-server app declares a single alias:

crates/features/src/authn/core.rs
pub type AuthGuard = nestrs_authn::AuthGuard<JwtStrategy<Claims>>;

JwtStrategy<C> is provided by the framework; it verifies an Authorization: Bearer <token> against the configured public key and puts Claims on the request. A 401 is returned automatically on a missing or invalid token.

A handler reads the principal with Ctx<Claims>:

async fn create(&self, auth: Ctx<Claims>, body: Valid<Json<CreateUserInput>>)
-> Result<Json<User>>
{
Ok(Json(
self.svc.create_in_org(body.into_inner(), auth.org_id).await?,
))
}
crates/features/src/authz/core.rs
impl Ability<Claims> for AppAbility {
fn build(claims: &Claims) -> Ability<Self> {
let mut a = Ability::new();
match claims.role.as_str() {
"admin" => {
a.can(Action::Manage, Subject::All);
}
_ => {
a.can(Action::Read, Subject::Entity::<UserEntity>())
.when(|claims, row: &UserEntity::Model| row.org_id == claims.org_id);
a.can(Action::Update, Subject::Entity::<UserEntity>())
.when(|claims, row: &UserEntity::Model| row.id == claims.sub);
}
}
a
}
}

AppAbility is the single source of truth: it covers HTTP, GraphQL, and WebSockets through the three transport bridges (AuthzHttpModule, AuthzGraphqlModule, AuthzWsModule).

Repo::<Users>::scoped(Action::Read).filter(...).all(&conn) joins the ambient ability’s condition_for(Read) into the SQL. A user with role user querying Users::find() automatically gets WHERE org_id = $caller_org_id appended.

The CrudService::list, page, access methods all go through Repo::scoped(...) — controllers cannot bypass this.

#[get("/:id")]
async fn get(&self, user: Bind<UsersService, Read>) -> Json<User> {
Json(User::from(&*user))
}

Bind<S, A> calls S::access(id) which:

  • loads through Repo::<S::Entity>::scoped(A);
  • returns Err(404) if the row does not exist within the caller’s scope;
  • returns Err(403) if the row exists but is outside scope (denied).

A 404 vs 403 distinction here is a policy decision, not an implementation detail.

3. Response masking before the body leaves

Section titled “3. Response masking before the body leaves”

After a successful handler returning Json<T>, the Authorize shaper:

  1. parses the JSON body;
  2. reconstructs the Model (filling #[expose(skip)] columns from WireModelDefaults);
  3. runs Ability::mask on the model — fields the caller cannot read become null;
  4. retain_wire_keys strips back any field that was not in the original wire DTO. An unrestricted field grant cannot leak a #[expose(skip)] column (like password_hash).

If the body cannot be reconciled with Model, the shaper fails closed with a 500 — never unmasked data.

Terminal window
# Mint a token for Ada (role: user, org: A)
$ TOKEN=$(curl -sX POST http://localhost:3002/token \
-d 'grant_type=password&username=ada@example.com&password=password' \
| jq -r .access_token)
# Ada can see her org's users
$ curl -s http://localhost:3003/users \
-H "Authorization: Bearer $TOKEN" | jq '.[].name'
"Ada Lovelace"
"Bob Engineer" # same org
# Ada cannot reach a user in another org
$ curl -s -o /dev/null -w "%{http_code}\n" \
http://localhost:3003/users/0193c1ee-...other-org \
-H "Authorization: Bearer $TOKEN"
403
# password_hash is never in the body — it's #[expose(skip)]
$ curl -s http://localhost:3003/users -H "Authorization: Bearer $TOKEN" \
| jq '.[0] | keys'
[ "email", "id", "name", "org_id" ]

apps/auth issues tokens (signs with the private EdDSA key); apps/api verifies them (holds only the public key). They share the same Claims type via the identity crate, never RPC each other, and the resource server cannot mint a token even if it wanted to.

Terminal window
just dev auth # token issuer on :3002
just dev api # resource server on :3003

When one route needs stricter rules than the controller default:

#[delete("/:id")]
#[use_guards(AdminGuard)]
async fn delete(&self, ...) { /* ... */ }

#[meta] + Reflector for declarative policies

Section titled “#[meta] + Reflector for declarative policies”
#[get("/admin")]
#[use_guards(RolesGuard)]
#[meta(Roles(["admin", "auditor"]))]
async fn admin(&self) -> &'static str { "ok" }

The guard reads Reflector::new(req).get::<Roles>() and decides.

  • crates/features/src/authn/Strategy, Outcome, the JWT alias.
  • crates/features/src/authz/AppAbility, the three transport modules.
  • crates/features/src/oauth/ — the OAuth2 authorization-code flow.
  • crates/nestrs-authn/ — the trait, the strategies, JWT signing/verifying.
  • crates/nestrs-authz/Ability, mask, condition_for, the shaper.