Skip to content

Per-transport bridges

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
  • nest_rs_seaorm::ws — the WS data-context (split avoids a circular dep)

HTTP, GraphQL, and WS each ship a one-import Authz<Transport>Module in crates/features/src/authz/. MCP has the framework bridge (nest_rs_authz::mcp::McpAbilityBridge) but no packaged AuthzMcpModule yet — an app registers the bridge directly as dyn McpOperationGuard (see MCP below).

BridgeProvidesWhat it bridges
AuthzHttpModuleAuthzGuard (AbilityGuard<AppAbility>)The HTTP request — guard runs on &mut Request, attaches Arc<Ability>
AuthzGraphqlModuleAppGraphqlGuard (dyn OperationGuard), GraphqlAuthGuard (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
McpAbilityBridge (framework, wired directly)dyn McpOperationGuardRe-runs the HTTP guard chain on the MCP endpoint, installs ambient ability for the tool call

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(AuthGuard, AuthzGuard)]
pub struct UsersController { /* ... */ }

AbilityGuard reads the Claims an AuthGuard 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(GraphqlAuthGuard)]
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 (AuthGuard, then AbilityGuard) on the GraphQL request — best-effort: a failed authn leaves no ability and the resolvers’ authorize/bind refuse. 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(AuthGuard, AuthzGuard)]
pub struct ChatGateway { /* ... */ }
#[messages]
impl ChatGateway {
#[subscribe_message]
#[use_guards(AuthzGuard)]
async fn send(&self, ...) -> Result<()> { /* ... */ }
}

AuthGuard + 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.

McpAbilityBridge<A, G> is registered as dyn McpOperationGuard — on each MCP HTTP request it runs the same A then G chain controllers use, then installs the caller’s ambient Ability for the tool call. A failed authn yields 401; a failed authz returns the guard’s denial.

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

For apps that wrap the endpoint beyond before, with_request_ability(req, inner) re-installs the ambient ability around the inner future:

let response = with_request_ability(&req, async move {
handle_tool_call(...).await
}).await;

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