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— featurehttpnest_rs_authz::graphql— featuregraphqlnest_rs_authz::mcp— featuremcpnest_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).
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), GraphqlAuthGuard (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 |
McpAbilityBridge (framework, wired directly) | dyn McpOperationGuard | Re-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.
#[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(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.
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 (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.
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(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.
MCP — same shape as HTTP
Section titled “MCP — same shape as HTTP”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;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 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.
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.