Guards
This page continues blog from
Modules and
Providers — the composed stage before you
open the tutorial posts/ feature. Once PostsHttpModule mounts
PostsController, guards decide who reaches each handler — and often
attach the principal the handler reads back via Ctx<T>.
Four steps on this page:
- What a guard is — one trait, three transports,
Ok(())orDenial. - Secure the post API —
#[use_guards(AuthGuard, AuthzGuard)]onPostsController, with the modules that provide those guards. - Three scopes — global, controller, per-handler; additive, not substitutive.
- Declare on the provider — dedup by
TypeId; redeclare at the controller so security travels with the feature.
flowchart LR A["① Guard trait<br/>gate or pass"] --> B["② PostsController<br/>AuthGuard + AuthzGuard"] B --> C["③ three scopes<br/>global → controller → handler"] C --> D["④ declare on provider<br/>dedup, access graph"]
What a guard does
Section titled “What a guard does”A guard runs before the handler. It sees the request first and either
lets it through (Ok(())) or short-circuits with a typed Denial the
framework maps to 401 / 403 / 429. One trait, three transports — the same
#[injectable] impl is reachable from HTTP, GraphQL, and WS through the
Layer System.
Guard ships in
nest-rs-guards. It extends
Layer
so guards plug into dedup-by-TypeId and declaration-order chaining.
A minimal custom guard
Section titled “A minimal custom guard”use nest_rs_guards::prelude::*;use nest_rs_http::poem::Request as HttpRequest;
#[injectable]#[derive(Default)]pub struct RateLimitGuard;
impl Layer for RateLimitGuard {}
#[async_trait]impl Guard for RateLimitGuard { async fn check_http(&self, req: &mut HttpRequest) -> Result<(), Denial> { if over_quota(req) { return Err(Denial::rate_limited(60, "rate limited")); } Ok(()) }}A guard is an #[injectable] provider listed in some module’s
providers = [...]. The access graph (see
Providers)
ensures every controller / resolver / gateway that references it imports
a module that builds it.
The trait carries one method per transport. Unimplemented transports
inherit Ok(()) — that means “doesn’t apply here”, not “skip security”.
Other guards on the same route still run.
| Method | Transport | Request shape |
|---|---|---|
check_http | HTTP | &mut HttpRequest — gate and mutate (attach context in extensions) |
check_graphql | GraphQL | &GraphqlContext — read-only; seed context upstream |
check_ws_message | WS | per inbound message, after the upgrade |
#[async_trait]pub trait Guard: Layer { async fn check_http(&self, _req: &mut HttpRequest) -> Result<(), Denial> { Ok(()) } async fn check_graphql(&self, _ctx: &GraphqlContext<'_>) -> Result<(), Denial> { Ok(()) } async fn check_ws_message(&self, _client: &WsClient, _event: &str, _data: &Value) -> Result<(), Denial> { Ok(()) }}Secure the post API
Section titled “Secure the post API”After Modules, PostsController
lives under posts/http/. Binding guards on the struct is how you gate
every route on that controller — and declare the auth modules the access
graph must reach.
use std::sync::Arc;use nest_rs_authz::Read;use nest_rs_http::{controller, routes};use nest_rs_seaorm::Bind;use poem::web::{Json, Path};use poem::Result;use uuid::Uuid;
use crate::authn::AuthGuard;use crate::authz::AuthzGuard;use crate::posts::{AdminGuard, Post, PostsService};
#[controller(path = "/posts")]#[use_guards(AuthGuard, AuthzGuard)]pub struct PostsController { #[inject] svc: Arc<PostsService>,}
#[routes]impl PostsController { #[get("/:id")] async fn get(&self, post: Bind<PostsService, Read>) -> Json<Post> { Json(Post::from(&*post)) }
#[delete("/:id")] #[use_guards(AdminGuard)] async fn delete(&self, id: Path<Uuid>) -> Result<()> { self.svc.delete(*id).await }}AdminGuard runs on top of the controller’s two guards. Per-handler
binding is additive — it does not replace the controller list. A route
with no guards stays open by intent; omitting #[use_guards(...)] is
the explicit decision.
The HTTP adapter imports the auth bridges transitively — same pattern as
UsersHttpModule in the repo:
#[module( imports = [PostsModule, AuthzHttpModule], providers = [PostsController],)]pub struct PostsHttpModule;#[module( imports = [nest_rs_authn::AuthnModule::for_root(None)], providers = [AppJwtStrategy, AuthGuard],)]pub struct AuthnModule;#[module( imports = [AuthzModule], providers = [AuthzGuard],)]pub struct AuthzHttpModule;Directorycrates/features/src/
Directoryauthn/
- module.rs (AuthnModule — provides AuthGuard)
- guard.rs
Directoryauthz/
- module.rs (AuthzModule — AppAbility)
Directoryhttp/
- module.rs (AuthzHttpModule — provides AuthzGuard)
- guard.rs
Directoryposts/
Directoryhttp/
- module.rs (PostsHttpModule — imports AuthzHttpModule)
- controller.rs (#[use_guards(AuthGuard, AuthzGuard)])
flowchart TB
REQ[HTTP request] --> AG[AuthGuard]
AG --> AZ[AuthzGuard]
AZ --> AD{AdminGuard?}
AD -->|delete only| ADG[AdminGuard]
AD -->|other routes| H[PostsController handler]
ADG --> H
AG -. "insert Claims" .-> H
AZ -. "install Ability" .-> H
Solid arrows are the guard chain on GET /posts/:id. DELETE adds
AdminGuard on top. AuthGuard attaches Claims; AuthzGuard builds
the ambient Ability handlers and Bind read through.
The three scopes
Section titled “The three scopes”A guard binds at one of three scopes. The container resolves all three from the same provider — declare once, choose where it runs.
| Scope | Binding | Resolved by |
|---|---|---|
| Global | App::builder().use_guards_global([guard::<AuthGuard>()]) in main | Transport-level interceptor + per-route shaper |
| Controller / Resolver / Gateway | #[use_guards(AuthGuard, AuthzGuard)] on the struct | Container, at mount |
| Per-handler | #[use_guards(AdminGuard)] beside a verb / #[query] / #[subscribe_message] | Container, at mount |
Multiple guards in one attribute run in declaration order, outermost first — the first listed sees the request before the second.
use nest_rs_guards::{AppBuilderGuardsExt, guard};use crate::authn::AuthGuard;use crate::authz::AuthzGuard;
App::builder() .use_guards_global([guard::<AuthGuard>(), guard::<AuthzGuard>()]) .module::<BlogModule>()Across scopes the chain composes global → controller → handler,
outermost first. Layer::priority is an optional tiebreaker when
declaration order cannot express intent — most guards leave it at 0.
flowchart TB G[global guards] --> C[controller guards] C --> H[handler guards] H --> R[handler]
Ordering inside a route
Section titled “Ordering inside a route”Two levels stack: per-route nesting from #[routes], then global
wraps around the whole HTTP tree at transport configure time. Lists
below read inner → outer (closest to the handler first). On an
inbound request the outer side runs first.
Per-route nesting
Section titled “Per-route nesting”The #[routes] macro builds each handler inside-out:
handler→ ability shaper (`Authorize`, when declared)→ per-route interceptors (`#[use_interceptors]`)→ per-route filters (`#[use_filters]` — error path only)→ RouteShaper (guards + body pipes; exception filters on error)→ `#[meta]` / `#[public]` (route data — read by `Reflector`, not executed)Inside RouteShaper, guards run on the way in (declaration order),
then JSON body pipes, then next.run delegates inward. If the handler
returns Err, exception filters (#[use_exception_filters]) run
there — typed downcasts, separate from #[use_filters].
Inbound (per-route, outer → inner): RouteShaper guards/pipes → per-route filters* → per-route interceptors → ability shaper → handler
*Filter passes through on success; it only maps on the error path.
Global wraps
Section titled “Global wraps”The HTTP transport folds three priority bands around the assembled route tree:
(assembled routes)→ global guards (`use_guards_global`)→ global filters (`use_filters_global` — error path only)→ global interceptors (`use_interceptors_global`, `DbContext`, …)Inbound (global, outer → inner): global interceptors → global guards → route tree.
Global filters sit outside global guards (they see errors from
the whole inner tree). Per-route guards in RouteShaper sit
outside per-route filters — a guard denial returns before the
inner chain runs.
Why DbContext wraps the guards
Section titled “Why DbContext wraps the guards”DbContext(global interceptor) installs the executor, then callsnext.run(req).- Guards run inside it —
AuthGuardattachesClaims,AuthzGuardbuilds the ambientAbility. Both see the same executor. - Handler runs —
Reporeads both ambients. - Response returns —
DbContextcommits on 2xx/3xx, rolls back otherwise.
flowchart TB
subgraph global["Global (outer → inner on request)"]
GI[interceptors]
GF["filters (error path)"]
GG[guards]
end
subgraph route["Per-route"]
RS["RouteShaper<br/>guards · pipes · exception filters"]
PF["filters (error path)"]
PI[interceptors]
AS[ability shaper]
H[handler]
end
GI --> GG --> RS --> PF --> PI --> AS --> H
Attach context and route metadata
Section titled “Attach context and route metadata”Guards often do more than gate — they produce values the handler needs.
Attach on the way in; read with Ctx<T>:
#[async_trait]impl Guard for AuthGuard<MyStrategy> { async fn check_http(&self, req: &mut HttpRequest) -> Result<(), Denial> { let claims = self.strategy.authenticate(req).await?; req.extensions_mut().insert(claims); Ok(()) }}
#[get("/me")]async fn me(&self, auth: Ctx<Claims>) -> Json<Post> { Json(self.svc.find_by_author(auth.sub).await?)}Ctx<T> rejects with 500 if the value is absent — a missing context
means the guard that should have set it never ran. Store an Arc<_> if
the value is large.
AuthzGuard reads Claims left by AuthGuard to build the ambient
Ability. That is why order matters:
#[use_guards(AuthGuard, AuthzGuard)], not the reverse.
For per-route policy — required roles, rate-limit quotas — #[meta(...)]
attaches a typed payload; Reflector reads it inside the guard:
#[get("/admin/audit")]#[use_guards(AuthGuard, RolesGuard)]#[meta(Roles(&["admin", "auditor"]))]async fn audit(&self) -> &'static str { "ok" }#[meta(...)] only reaches guards at controller or per-handler scope. A
global guard runs before routing resolves a handler, so the reflector
finds nothing.
#[public] uses the same channel. The framework does not act on it —
each guard decides what “public” means (AuthGuard may authenticate when
a token is present but not reject anonymous callers; AuthzGuard may
still apply visitor rules). Marking a route public does not strip its
guards; it tells them to relax.
Declare on the provider
Section titled “Declare on the provider”The Layer System dedups every layer by
TypeId across
the three scopes. When the same guard is declared at several levels, the
broadest scope wins; narrower declarations log a debug line at boot
(deduped once on nest_rs::layers) but never re-execute.
App::builder() .use_guards_global([guard::<AuthGuard>(), guard::<AuthzGuard>()]) .module::<BlogModule>()#[controller(path = "/posts")]#[use_guards(AuthGuard, AuthzGuard)]pub struct PostsController { /* ... */ }AuthGuard and AuthzGuard run exactly once per request, not twice.
The controller’s two extra lines cost zero runtime overhead and one debug
line at boot.
Declare layers on the provider that needs them, not only at the app
boundary. A crates/features controller is designed to be portable — if
it relies only on use_guards_global([...]) to be secure, forgetting
that line in a second app turns every route public. The compiler will not
complain — but boot now does: with no global guard pool active, every
route that binds no controller/method guard and is not marked #[public]
is reported in a single warn on nest_rs::layers (an implicit access
decision). The fix is the same either way: redeclare on PostsController
to bind the policy to the code that needs it, or mark genuinely-open
routes #[public] on purpose. The same rule applies to interceptors and
filters.
flowchart TB MAIN["main: use_guards_global([AuthGuard, AuthzGuard])"] PC["PostsController: #[use_guards(AuthGuard, AuthzGuard)]"] MAIN -. "dedup — runs once" .-> RUN[per-request chain] PC -. "warn at boot, zero extra cost" .-> RUN
HTTP, GraphQL, and WebSockets
Section titled “HTTP, GraphQL, and WebSockets”For most apps the global HTTP declaration is enough: GraphQL
POST /graphql and the WS upgrade are both HTTP requests that pass
through the global chain.
Each transport method runs at a different seam:
check_http— on&mut HttpRequestbefore routing.check_graphql— inside the resolver’s per-operation chain (context is read-only).check_ws_message— once per inbound message, after the connection upgrade.
GraphQL and WS need a bridge one level above the resolver / gateway because the per-operation context does not carry the poem request:
- GraphQL — a
GraphqlOperationGuard(e.g.GraphqlAbilityBridge) re-runs the HTTP guard chain onPOST /graphqland seedsAbilityfor the operation. Per-resolver gating uses a marker likeGraphqlAuthGuard. - WS — a
SocketContext(e.g.WsDataContext) captures pool +Abilityon upgrade and re-installs them around each message. Per-message gating goes throughcheck_ws_message.
On a resolver, #[use_guards(...)] carries a marker guard — a
struct whose job is to declare a dependency on the seeded context so
the access graph can validate it. Omitting the matching authz module
fails boot with a clear error naming the missing guard.
#[resolver]#[use_guards(GraphqlAuthGuard)]pub struct PostResolver { /* ... */ }WebSockets need no marker type — the upgrade is a real HTTP GET, so
the gateway struct binds the real HTTP guards; they run once at
the upgrade and are access-graph-validated the same way (omit
AuthzWsModule and the guards are unreachable ⇒ boot fails). A
per-message check binds a real Guard beside the
#[subscribe_message]; it runs through check_ws_message — e.g.
AuthzGuard (the app’s AbilityGuard<AppAbility> alias) denies,
fail-closed, when no ambient ability is present.
#[gateway(path = "/posts/live")]#[use_guards(AuthGuard, AuthzGuard)] // real guards — run once, at the upgradepub struct PostGateway { /* ... */ }
#[messages]impl PostGateway { #[subscribe_message("comment")] #[use_guards(AuthzGuard)] // re-checked per message via check_ws_message async fn comment(&self, msg: CommentInput) -> Reply { /* ... */ }}See Security / per-transport bridges for the full HTTP / GraphQL / WS wiring, and WebSockets / Guards for the two WS scopes (connection vs message).
Going further
Section titled “Going further”- Providers — the access graph also governs
#[use_guards(...)]bindings. - Interceptors — the sibling layer; wraps the handler instead of gating it. Same dedup rules apply.
- Pipes — input transform / validation.
- Error handling — typed catch for handler errors.
- Security —
AuthGuard,AbilityGuard, the full chain.