Authorization on GraphQL, WS & MCP
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 AppAbility factory stays the single source of
truth across every transport.
The bridges live in:
nest_rs_authz::http— featurehttpnest_rs_authz::graphql— featuregraphqlnest_rs_authz::mcp— featuremcp; the matching data context isnest_rs_seaorm::mcp::McpDataContext(featuremcp)nest_rs_seaorm::ws— the WS data-context (split avoids a circular dep)
HTTP, GraphQL, WS, and MCP each ship 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).
The bridges
Section titled “The bridges”| Bridge | Provides | What it bridges |
|---|---|---|
AuthzHttpModule | AuthzGuard (AbilityGuard<AppAbility>) | The HTTP request — guard runs on &mut Request, attaches Arc<Ability> |
AuthzGraphqlModule | AppGraphqlGuard (dyn OperationGuard), GraphqlAuthnGuard (resolver marker), LoaderScope (dyn BatchContext) | Re-runs the HTTP guard chain on /graphql, scopes the operation, snapshots ability around dataloader batches |
AuthzWsModule | WsDataContext (dyn SocketContext) | Re-establishes the pool + ability per WS message |
AuthzMcpModule | AppMcpGuard (McpAbilityBridge<AuthnGuard, AuthzGuard>) as dyn McpOperationGuard, McpDataContext as dyn McpToolContext | Re-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.
#[module( imports = [ DatabaseModule::for_root(None), AuthnModule, AuthzHttpModule, AuthzGraphqlModule, AuthzWsModule, UsersHttpModule, UsersGraphqlModule, UsersWsModule, ],)]pub struct ApiModule;HTTP — the direct case
Section titled “HTTP — the direct case”HTTP is the simplest because guards run on the actual request before the handler:
#[controller(path = "/users")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct UsersController { /* ... */ }AbilityGuard reads the Claims an AuthnGuard attached, calls
AppAbility::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:
#[resolver]#[use_guards(GraphqlAuthnGuard)]impl UsersResolver { /* #[authorize(Action, Entity)] or #[public] per op */ }That’s the whole resolver-side surface. The marker exists for the access
graph: HTTP guards run on &mut Request before the handler — they
are the auth chain. GraphQL instead runs authn/ability in-band per
operation and seeds the ability into per-operation context; the marker
turns “this resolver depends on the seeded ability” into an #[inject]
the access graph validates — omit the authz module and boot fails
naming the missing guard, never a silently unauthenticated schema.
Under the hood: the operation bridge
Section titled “Under the hood: the operation bridge”The seeding is done by GraphqlAbilityBridge, registered as the
dyn GraphqlOperationGuard:
#[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 and fails closed: a failed authn
leaves no ability, so every authorize/bind in the operation refuses.
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:
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.
WebSockets — per-message scope
Section titled “WebSockets — per-message scope”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:
#[module( imports = [AuthzHttpModule, 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 — 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:
#[gateway(path = "/ws")]#[use_guards(AuthnGuard, AuthzGuard)]pub struct ChatGateway { /* ... */ }
#[messages]impl ChatGateway { #[subscribe_message] #[use_guards(AuthzGuard)] async fn send(&self, ...) -> Result<()> { /* ... */ }}AuthnGuard + AuthzGuard run at the upgrade. The upgrade’s
task-locals unwind before any message handler runs, so WsDataContext
re-seeds the executor and ability around each message; a guard bound
beside a #[subscribe_message] then runs through
Guard::check_ws_message. AuthzGuard’s implementation fails closed:
no ambient ability (say the app imported AuthzHttpModule instead of
AuthzWsModule, so nothing re-seeded it) ⇒ the message is denied with
a 401-shaped error frame, never silently passed through.
MCP — same shape as HTTP
Section titled “MCP — same shape as HTTP”AuthzMcpModule registers AppMcpGuard
(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.
#[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:
// 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.
Public handlers — opt out per transport
Section titled “Public handlers — opt out per transport”A handler is “public” by not binding the transport’s authz
module’s guard. The whole module import then becomes optional — the
app lists AuthzHttpModule only if other handlers need it.
#[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.
Going further
Section titled “Going further”- Authorization — the
AbilityFactorythe bridges all read. - Row-level filtering
— what runs inside
with_ability(...)after the bridge installs. - Response masking — the HTTP-side shaper that wraps each request.
Built by YV17labs