Skip to content

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 — feature http
  • 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)

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).

BridgeProvidesWhat it bridges
AuthzHttpModuleAuthzGuard (AbilityGuard<AppAbility>)The HTTP request — guard runs on &mut Request, attaches Arc<Ability>
AuthzGraphqlModuleAppGraphqlGuard (dyn OperationGuard), GraphqlAuthnGuard (resolver marker), 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
AuthzMcpModuleAppMcpGuard (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
#[module(
imports = [
DatabaseModule::for_root(None),
AuthnModule,
AuthzHttpModule,
AuthzGraphqlModule,
AuthzWsModule,
UsersHttpModule,
UsersGraphqlModule,
UsersWsModule,
],
)]
pub struct ApiModule;

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.

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.

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

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.

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.

Built by YV17labs