Skip to content

Authorization on GraphQL, WS & MCP

AuthzModule, AuthzGraphqlModule, AuthzWsModule and AuthzMcpModule re-establish the ambient ability at each transport's dispatch point.

HTTP handlers are not the only place a request reaches your code. GraphQL operations multiplex over a single POST /graphql. WebSocket messages multiplex over a single connection upgrade. MCP tool calls multiplex over their own JSON-RPC. For each, the framework ships an authz bridge that re-establishes the ambient Ability at the right dispatch point. Your AuthzAbility factory stays the single source of truth across every transport.

The bridges live in:

  • nest_rs::authz — the guard itself, under any transport feature
  • nest_rs::authz::http — feature http, the HTTP extractors and shaper
  • nest_rs::authz::graphql — feature graphql
  • nest_rs::authz::mcp — feature mcp; the matching data context is nest_rs::seaorm::mcp::McpDataContext (feature mcp)
  • nest_rs::seaorm::ws — the WS data-context (split avoids a circular dep)

GraphQL, WS and MCP each get a one-import Authz<Transport>Module in crates/features/src/authz/. Each wraps the framework bridge for its transport and registers it against the access graph, so an app imports the module and lists nothing else (see MCP below). HTTP has no such module: the guard those bridges re-run is AbilityGuard, which answers every transport, so AuthzModule provides it directly.

You do not write them: nestrs g auth writes authz/module.rs and authz/guard.rs, and nestrs g graphql|ws|mcp writes that transport’s bridge alongside the adapter and wires it into the app. The blocks below are what those generators produce — read them to understand the wiring, copy them only into a workspace that was not scaffolded.

BridgeProvidesWhat it bridges
AuthzModuleAuthzGuard (AbilityGuard<AuthzAbility>)The HTTP request — guard runs on &mut Request, attaches Arc<Ability>
AuthzGraphqlModuleAuthzGraphqlBridge (dyn OperationGuard), LoaderScope (dyn BatchContext)Re-runs the HTTP guard chain on /graphql, scopes the operation, snapshots ability around dataloader batches
AuthzWsModuleWsDataContext (dyn SocketContext)Re-establishes the pool + ability per WS message — which is what the per-message #[authorize] gate and reply mask read
AuthzMcpModuleAuthzMcpBridge (McpAbilityBridge<AuthnGuard, AuthzGuard>) as dyn McpOperationGuard, McpDataContext as dyn McpToolContextRe-runs the HTTP guard chain on the MCP endpoint and installs the ability inside each tool dispatch; the data context adds the executor + per-operation transaction

A controller imports the matching module along with its feature module. The transports transitively bring every layer the feature needs.

apps/api/src/module.rs (from the demo)
#[module(
imports = [
SeaOrmModule::for_root(None),
SeaOrmDatabaseModule,
AuthnModule,
AuthzModule,
AuthzGraphqlModule,
AuthzWsModule,
UsersHttpModule,
UsersGraphqlModule,
UsersWsModule,
],
)]
pub struct ApiModule;

HTTP is the simplest because guards run on the actual request before the handler:

crates/features/src/users/http/controller.rs
#[controller(path = "/users")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct UsersController { /* ... */ }

AbilityGuard reads the Claims an AuthnGuard attached, calls AuthzAbility::define(&actor, &mut builder), and inserts Arc<Ability> into request extensions. The Authorize shaper later installs that ability as a task-local via with_ability so Repo::scoped sees it.

GraphQL — marker guards over a shared dispatch

Section titled “GraphQL — marker guards over a shared dispatch”

A GraphQL POST /graphql is one HTTP request that multiplexes many operations, so per-operation auth cannot run as plain HTTP guards. What you write is unchanged in spirit: import AuthzGraphqlModule, bind the marker on the resolver, and declare each operation’s posture:

crates/features/src/users/graphql/resolver.rs
#[resolver]
#[use_guards(AuthzGuard)]
pub struct UsersResolver { /* ... */ }
#[operations]
impl UsersResolver { /* #[authorize(Action, Entity)] or #[public] per op */ }

AuthzGuard is the app’s AbilityGuard<AuthzAbility> alias, and it is what #[use_guards] binds: it has a check_graphql, so it satisfies the GraphqlGuard bound #[resolver] emits. An AuthnGuard does not belong in that list — it has no check_graphql, so on a resolver it would run nothing; the bridge below already authenticated the request in band.

The module those three providers live in is app code, like the rest of authz/: nestrs g graphql <feature> writes it into crates/features/src/authz/graphql/ the first time a resource gets a GraphQL adapter, and imports it from that adapter’s module.rs. The four files are the ones sketched below — demo/crates/features/src/authz/graphql/ is the same thing under version control.

That’s the whole resolver-side surface. HTTP guards run on &mut Request before the handler; they are the auth chain. GraphQL instead runs authn/ability in-band per operation, seeds the ability into per-operation context, and forwards the principal with forward_principal!(Claims).

That forward binds nothing and gates on nothing, because it cannot fire without the chain that feeds it: it copies Claims off the request only when the authn guard has attached them, and that guard is reachable only through the module that declares it. Omit the authz module and the chain never runs, so there is nothing to copy — the schema is anonymous because it was never authenticated, not because a marker was missing.

The seeding is done by GraphqlAbilityBridge, registered as the dyn GraphqlOperationGuard:

nest_rs::authz::graphql::GraphqlAbilityBridge
#[injectable]
pub struct GraphqlAbilityBridge<A: Guard, G: Guard> {
#[inject] auth: Arc<A>,
#[inject] ability: Arc<G>,
}
impl<A: Guard, G: Guard> GraphqlOperationGuard for GraphqlAbilityBridge<A, G> {
fn before<'a>(&'a self, req: &'a mut Request) -> BoxFuture<'a, ()> { /* ... */ }
fn around<'a>(&'a self, req: &'a Request, inner: BoxFuture<'a, Response>)
-> BoxFuture<'a, Response> { /* ... */ }
}

before runs the same HTTP guard chain (AuthnGuard, then AbilityGuard) on the GraphQL request. /graphql carries the Public marker — one endpoint, posture declared per operation — so an anonymous caller is admitted here and takes the ability guard’s visitor branch. The line is held one step later, by the operation’s own posture: #[authorize(...)] refuses an ability with no principal behind it (UNAUTHENTICATED), whatever define_visitor granted, and only #[public] operations read those grants. around installs the resulting ability via with_ability for the duration of the operation. The marker itself is a dyn GraphqlResolverGuard that fails closed if Arc<Ability> is absent from the operation’s data:

nest_rs::authz::graphql::GraphqlAbilityBridge
async fn check(&self, ctx: &Context<'_>) -> Result<()> {
match ctx.data_opt::<Arc<Ability>>() {
Some(_) => Ok(()),
None => Err(Error::new("unauthenticated")
.extend_with(|_, e| e.set("code", "UNAUTHENTICATED"))),
}
}

The bridge also registers a LoaderScope as dyn BatchContext so dataloaders that fan out across batches snapshot the ability + pool executor per batch — relations stay scoped without the resolver threading anything.

A WS connection is one HTTP upgrade; messages arrive over a long- lived socket and dispatch into separate handlers. Each message needs its own ambient ability:

crates/features/src/authz/ws/module.rs (from the demo)
#[module(
imports = [AuthzModule, WsModule],
providers = [WsDataContext as dyn SocketContext],
)]
pub struct AuthzWsModule;

WsDataContext is a dyn SocketContext that installs the pool executor and the ambient Ability for every message — no per-message transaction (mutations are explicit in your service), no shared state across messages.

Unlike GraphQL, WS has no marker type and no bridge — a gateway is EdgePosture::Guarded, so its upgrade already carried the real chain and there is nothing to re-run in band. It reuses the HTTP Guard trait directly. The gateway struct binds the real guards; because the upgrade is an HTTP GET, they run once on it, and the access graph validates them like any HTTP binding (omit AuthzWsModule ⇒ the guards are unreachable ⇒ boot fails). An optional per-message check binds a real Guard beside the message:

crates/features/src/chat/ws/gateway.rs
#[gateway(path = "/ws")]
#[use_guards(AuthnGuard, AuthzGuard)]
pub struct ChatGateway { /* ... */ }
#[messages]
impl ChatGateway {
#[subscribe_message("users.list")]
#[authorize(Read, users::Entity)]
async fn list(&self) -> Result<Vec<User>, ServiceError> {
Ok(self.svc.list().await?.iter().map(User::from).collect())
}
}

AuthnGuard + AuthzGuard run at the upgrade — those are real HTTP guards on a real GET, so they attest HttpGuard, not WsGuard; #[gateway] emits that bound, and WsGuard is what a guard bound beside a #[subscribe_message] owes instead. The upgrade’s task-locals unwind before any message handler runs, so WsDataContext re-seeds the executor and ability around each message.

Above that, a message declares its posture like an operation on any other transport, and it is mandatory: #[authorize(Action, Entity)] or #[public], or it does not compile. #[authorize] emits the class gate (nest_rs::authz::ws::authorize) before the payload is deserialized and the reply mask afterwards — so a handler answering with entity rows writes no masking call. It fails closed when no ambient ability is present, which is exactly the state of a gateway whose app imported AuthzModule instead of AuthzWsModule.

The mask acts on the serialized reply, so a withheld column is absent from the frame rather than an error — HTTP’s behaviour, not GraphQL’s, because an envelope promises no schema. See WebSockets / Messages for the full contract.

An optional per-message #[use_guards(...)] still adds event-level checks; such a guard runs Guard::check_ws_message and must declare WsGuard — see WebSockets / Guards.

AuthzMcpModule registers AuthzMcpBridge (McpAbilityBridge<AuthnGuard, AuthzGuard>) as dyn McpOperationGuard, plus McpDataContext as dyn McpToolContext — on each MCP HTTP request it runs the same A then G chain controllers use (run_ability_chain, the same function the GraphQL bridge calls), then installs the caller’s ambient Ability for the tool call. Either leg’s denial is returned as raised, so a 401, a 403 and a throttler’s 429 + Retry-After all reach the client intact. Import AuthzMcpModule beside the feature module, the same shape as the other transports.

nest_rs::authz::mcp::McpAbilityBridge
#[injectable]
pub struct McpAbilityBridge<A: Guard, G: Guard> {
#[inject] auth: Arc<A>,
#[inject] ability: Arc<G>,
}

The ability is installed by the guard’s around, which runs inside rmcp’s spawned dispatch — the same seam GraphqlOperationGuard uses, so “who scopes the operation” has one answer on both transports. A tool body is therefore scoped whether or not the app also registered McpDataContext (the data context adds the executor and the per-operation transaction; it is not what installs the ability). Any custom McpOperationGuard can wrap the dispatch the same way — the default impl is a pass-through:

nest_rs::authz::mcp::McpAbilityBridge
// Snapshot on the request, while it still exists…
fn capture(&self, req: &Request) -> Option<Captured> {
req.extensions()
.get::<Arc<Ability>>()
.cloned()
.map(|ability| ability as Captured)
}
// …install it inside rmcp's dispatch, where the request is long gone.
fn around<'a>(
&'a self,
captured: &'a Captured,
inner: BoxFuture<'a, OperationOutcome>,
) -> BoxFuture<'a, OperationOutcome> {
Box::pin(async move {
match captured.clone().downcast::<Ability>() {
Ok(ability) => with_ability(ability, inner).await,
Err(_) => inner.await,
}
})
}

Both default to “install nothing” (capture returns None, around is a pass-through), so an existing guard is unaffected. The split is the same one McpToolContext uses — capture on the request, install inside the dispatch — so the two seams in the crate have one shape.

With no bridge registered the endpoint falls back to the global guard pool (use_guards_global(...)) rather than going straight to deny-all — so a global ThrottlerGuard rate-limits tool calls, exactly as it does on /graphql. With no pool either, /mcp stays deny-all: the fallback only ever widens what the app declared, and unlike /graphql the MCP endpoint carries no Public marker, so a pooled AuthnGuard still refuses an anonymous tool call.

What that endpoint runs is check_http — it holds a request, not an operation. A pooled guard’s check_mcp runs in the per-operation chain, which folds the pool whether or not a bridge is registered, so a global guard written against MCP gates every tool call even when the bridge already authenticated the caller who sent it.

That fallback is compiled in by nest-rs-guards’ mcp feature, which nestrs g mcp turns on. Without it there is nothing to fall back to: the boot line stays no operation guard registered — mcp endpoint is deny-all mode="deny_all" and a pooled guard is never consulted. Fail-closed either way — check the boot line for mode="global_guard_pool" when the pool is what you meant to gate with.

A handler is “public” by not binding the transport’s authz module’s guard. The whole module import then becomes optional — the app lists AuthzModule only if other handlers need it.

crates/features/src/users/http/controller.rs
#[get("/health")]
#[public]
async fn health(&self) -> &'static str { "ok" }

#[public] makes AuthnGuard non-rejecting (anonymous requests pass through with no claims). The AbilityGuard then builds an empty Ability for the visitor; the row-level filter returns nothing unless AbilityFactory granted the visitor branch explicitly. See Authorization for the visitor-rule pattern.