Authentication answers "who are you?"; authorization answers "are you
allowed to do this?" Suprnova ships a Laravel-shaped Gate facade plus the
#[policy] macro for resource-oriented wiring, with sync and async variants
of every check so the same surface works whether your policy body needs a DB
hit or just a struct-field comparison.
Quick start
use ;
// Lets users opt into the `user.can(action, &resource)` ergonomics.
// Wire one ability:
;
let alice = User ;
let own_post = Post ;
let foreign_post = Post ;
assert!;
assert!;
// Return 403 directly from a handler:
alice.authorize?;
The Gate surface
Defining abilities
// Sync closure - invoked directly, no boxed future.
;
// Async closure - the future must be owned (no borrows past closure return).
;
Type-erased internally; the registry keys on (action, TypeId<U>, TypeId<R>).
A User action gate and a Comment action gate of the same name live
independently - Gate::has::<User, Post>("publish") and
Gate::has::<User, Comment>("publish") answer separately.
Checking abilities
| Method | Returns | Use |
|---|---|---|
Gate::allows(action, &user, &resource) |
bool |
Quick branch |
Gate::denies(action, &user, &resource) |
bool |
Inverse |
Gate::authorize(action, &user, &resource) |
Result<(), FrameworkError> |
403 on a bare deny; a rich denial carries its own status/message (see Rich decisions) - short-circuits a handler with ? |
Gate::inspect(action, &user, &resource) |
Response |
Full decision: allowed + message + code + HTTP status |
Gate::raw(action, &user, &resource) |
Option<Response> |
Like inspect, but None = no rule defined (vs an explicit deny) |
Gate::any(&[...], &user, &resource) |
bool |
True if any allow |
Gate::none(&[...], &user, &resource) |
bool |
True if none allow |
Gate::check(&[...], &user, &resource) |
bool |
True if all allow |
Gate::inspect_current(action, &resource).await |
Result<Response, FrameworkError> |
inspect_async for the user of the route's guard (see Check the route's user) |
Gate::none_current(&[...], &resource).await |
Result<bool, FrameworkError> |
none_async for the user of the route's guard |
Every method in the first eight rows has an _async sibling that works for
both sync- and async-registered gates, so handlers don't need to know which
kind of closure backs the action. The last two are async only.
Check the route's user
Gate::inspect_current and Gate::none_current find the user themselves,
as Laravel's Gate::inspect and Gate::none do. They ask the route's
guard, the guard #[authorize] asks (see Authorize a
handler), so you don't pass a user:
use text;
use ;
async
async
The checks follow these rules:
- The user is checked as its concrete type. The gates, policies, and hooks
you registered for that type answer, async ones included, and
inspect_currentanswers whatGate::inspect_asyncanswers for the same user. - With no user signed in, a gate defined with
Gate::define_optionalorGate::define_optional_with, or a policy method that takesOption<&User>, receivesNone. Every other ability answers the default denial (see Default denial response). A guest is not an error: you get a deniedResponse, never the401that#[authorize]answers. none_currentstops at the first action that allows. An empty list answerstrue.- Both return the error the route's guard returns when it cannot resolve the user, such as a user provider that cannot reach its database. They don't guess an answer for a user they could not resolve.
Introspection
// Is an ability defined?
; // bool
// What abilities exist? (sorted + deduped by action name)
let all: = abilities;
abilities() dedupes across resource types: registering "view" for
both User-on-Post and User-on-Comment yields a single "view"
entry. Useful for admin pickers and Inertia shared-data.
Missing-gate semantics
Calling allows / denies / authorize on an action that was never
registered defaults to deny. Same for calling the sync API on an
async-registered gate (the sync path can't await - defaulting deny
surfaces the bug in logs via tracing::warn! rather than silently
passing). Async-registered gates respond correctly from the
_async paths.
Policies with #[policy]
When a resource type has several abilities, group them into a policy struct
and let #[policy] register every method as a gate:
use policy;
use Response;
;
Each method becomes one inventory::submit!. Server::run drains the
inventory via init_policies() at boot, so by the time the first request
arrives every action is registered (see Bootstrap for where
this slots into the boot sequence). init_policies() lives at
suprnova::authorization::init_policies and is idempotent - call it manually
in tests that exercise policy registration without standing up a server.
Policy methods are stateless associated functions taking (user, resource) -
the same shape as Laravel's update(User $user, Post $post), where $this is
the stateless policy object. Every method takes both arguments for a uniform
gate signature; view_any / create simply ignore the resource (_post).
Methods you don't write aren't registered, and an unregistered action
default-denies.
You accept a guest by declaring the user as Option<&User> in a policy
method. The method receives None for a guest and Some(user) for an
authenticated request. You return true to allow the guest or false to
answer 403. A rich denial keeps its status, including 404.
You can register the same shape directly with Gate::define_optional or
Gate::define_optional_with. Authenticated checks keep the hook pipeline.
If several nullable policies name the same ability and resource for
different user types, your guest check denies because it cannot select a
user type.
Method-name → action mapping
Method name is used directly as the action's verb segment, with the resource kebab-cased and suffixed:
| Method | Action |
|---|---|
view on Post |
"view-post" |
view_any on Post |
"view_any-post" |
force_delete on UserProfile |
"force_delete-user-profile" |
This diverges from Laravel's camelCase action names (viewAny,
forceDelete) to keep the Rust surface idiomatic - every action
string mirrors the method identifier you'd autocomplete in your
editor.
Return type: bool or Response
A policy method's return type selects how it registers - and what a denial can carry:
| Return type | Registers via | Denial surfaces as |
|---|---|---|
bool |
Gate::define |
bare 403 (This action is unauthorized.) |
Response |
Gate::define_with |
the message, code, and HTTP status the Response carries |
Return bool for a simple yes/no. Return a Response (imported from
suprnova::authorization::Response) when a denial should carry a reason or a
non-403 status - Response::deny_with("…") for a message, or
Response::deny_as_not_found() to answer 404 and hide the resource's
existence. Both compile to the same type-erased gate (a bool is wrapped into
a bare allow/deny). Any other return type - or a missing one - is a compile
error.
The Authorizable trait
Drop-in user-side sugar for the Gate calls:
use Authorizable;
// Sync sugar
if alice.can
if alice.cannot
alice.authorize?; // 403 on deny
// Async sugar
if alice.can_async.await
alice.authorize_async.await?;
Every method has a default body that delegates to the matching Gate
method, so impl Authorizable for User {} (no body) is enough.
Opt-in rather than blanket-impl: not every type that can be passed to
Gate::allows is meant to be the subject of .can - most often
it's your application's User.
Composition patterns
Gating route groups
use ;
// Middleware checks the auth user; the handler authorizes the action.
group!
.middleware
.routes;
async
Many-action checks
A "list all the things this user can do on this resource" page:
let actions = ;
let mut allowed = Vecnew;
for action in &actions
// Or short-circuit:
let can_do_anything = any;
let is_locked_out = none;
Multi-gate authorization
// Only allow if the user can do ALL of these actions on the resource.
authorize_async.await?;
if check_async.await
Gating resource routes
When a Router::resource surface exists, authorize_resource::<U, R>()
wires the conventional ability check onto all seven routes at once, so you
do not depend on every controller method remembering to authorize:
;
;
;
;
let router: Router = new
.resource
. // index/show→view, store→create, …
.into;
A denied ability returns 403 before the handler runs; an unauthenticated
request fails closed. The check runs against the user of the route's guard,
the same user #[authorize] checks (see Authorize a
handler). The full action → ability table lives in
the routing chapter.
Authorize a handler
A gate check inside the handler body works only if nobody forgets it.
#[authorize] on a #[handler] declares the check instead, the way
Laravel's #[Authorize] controller attribute does:
use text;
use ;
use cratePost;
use crateStorePost;
// Route: post!("/posts", controllers::post::store)
pub async
// Route: put!("/posts/{post}", controllers::post::update)
pub async
The second argument is either a parameter or a type:
- A single name that starts with a lowercase letter, such as
post, is a parameter of the handler. The check runs against the value the route binds to it, or the path value it reads (id: i64). For aRouteParam<Post>parameter, that is thePostinside. The name can also be the binding of a pattern, as inRouteParam(post): RouteParam<Post>. A name the handler does not take is a compile error. - Anything else, such as
Postorpost::Model, is a type. The gate is keyed by type, so the check runs againstPost::default(), the same stand-inauthorize_resourceuses. Every#[suprnova::model]struct implementsDefault.
The ability goes to the gate as written. A #[policy(User, Post)] method
update registers the ability update-post, so a handler that relies on
that policy names "update-post". An ability registered with
Gate::define or Gate::define_async is named the way it was registered.
You can also write #[authorize(Ability::Update, post)]. Your enum
implements Into<String> and returns the ability name, such as
"update-post". The converted name reaches the same async gate as a string.
The check runs at a fixed point in the request:
- The route parameters are bound and the path values read. A model that
does not exist answers
404 Not Found, whatever the gate would decide. - Each
#[authorize]runs, in the order written. The first one that fails ends the request. - The request body is read and validated, so a denied user never sees a validation error.
- The handler body runs.
The check asks the async gate about the user of the route's guard.
Policies, async gates, async before hooks, and the
permission bridge all answer it.
The route's guard is the guard that the last AuthMiddleware to pass the
request on checked. For AuthMiddleware::new().for_guard("api"), that is
the api guard, so the check runs against the user the api guard
authenticated. With no for_guard, or with no AuthMiddleware at all,
it is the default guard, and the user is the one Auth::user() resolves:
use ;
// `update` carries `#[authorize("update-post", post)]`. The check asks the
// `api` guard for the user, not the default guard.
When the route's guard has no user, your nullable policy receives None.
A policy that requires &User answers 401, even if another guard has one. A user of the default guard never stands in for the
user of the route's guard. This also holds under
AuthMiddleware::optional().for_guard("api"), which lets a guest through.
When the check fails, it answers the request with one of these statuses:
| Situation | Status |
|---|---|
| No user on the route's guard and the policy requires a user | 401 Unauthorized, {"message": "Unauthenticated."} |
| The gate denies | 403 Forbidden |
A rich denial with a status, such as Response::deny_as_not_found() |
That status, 404 Not Found here |
#[authorize] may sit above or below #[handler], and a handler may carry
several. Without #[handler] it is a compile error. The handler must be an
async fn, and the parameter it names must come from the route: its type
must implement RouteBinding, as every #[suprnova::model] does, or be a
primitive path value read through FromParam, such as id: i64. A form
request or a Request reads the body, and the check runs before the body
is read, so naming one is a compile error too.
Why Suprnova diverges
You receive 401 Unauthorized when the policy requires a user. Laravel
answers 403 in this case. Your nullable policies run for guests in both
frameworks.
In Laravel, auth:api makes api the default guard for the rest of the
request, so Auth::user() and the can middleware both read the api
user. In Suprnova, AuthMiddleware::for_guard("api") doesn't change the
default guard: Auth::user() still reads the default guard. The route
checks follow the route's guard: #[authorize],
EnsureEmailVerifiedMiddleware, RoleMiddleware and
PermissionMiddleware. To read the same user in the handler body, call
Auth::guard("api")?.user().
Laravel tells a model class from a route parameter by the backslash in
Post::class. Rust has no class strings, so Suprnova uses Rust's naming
conventions instead: a single lowercase name is a parameter, and anything
else is a type. For the type form, Laravel calls a policy method that takes
no model. Suprnova's gate always passes a resource, so the type form passes
a default value of the type.
Async semantics
Gate::define_async's closure must return an owned future - the
type-erased registry cannot let &user or &resource references
outlive the closure return. Copy or clone any fields you need inside
the async move {} block before returning it:
;
Sync gates work transparently from the async path (Gate::allows_async
dispatches them without an .await), so a codebase can register
sync gates today and migrate individual abilities to async later
without changing call sites.
Lock-poison posture
The Gate registry uses an RwLock internally. If the lock is ever
poisoned (a thread panicked while holding the write guard), the
registry safe-denies - every subsequent authorize call returns
Unauthorized rather than panicking. Registration calls log to
tracing::error! and continue. This matches the broader framework
policy: a poisoned lock never aborts the process.
Rich decisions: Response, inspect, raw
A bare bool gate answers only allow/deny. For a denial that carries a
message, a machine code, or a non-403 HTTP status, register the gate
with define_with (or define_async_with) and return a Response:
use Response; // re-exported at the crate root as `GateResponse`
;
// Hide a resource's existence rather than admit it exists:
;
Inspect the full decision with Gate::inspect (sync) / Gate::inspect_async:
let decision = inspect;
decision.allowed; // bool
decision.message; // Option<&str> - Some("You do not own this post.")
decision.status; // Option<u16> - None here; Some(404) after deny_as_not_found
Response constructors mirror Laravel: allow(), deny(),
deny_with(msg), deny_with_status(status, msg), deny_as_not_found(),
plus with_message / with_code / with_status / as_not_found builders.
How a denial becomes an error
Gate::authorize collapses the decision through Response::authorize():
| Decision | authorize result |
|---|---|
| allowed | Ok(()) |
bare deny() (no message/code/status) - what an unconfigured default denial response falls back to |
FrameworkError::Unauthorized (403, "This action is unauthorized.") |
denial with a code (with_code) |
FrameworkError::Denial { message, status_code, code } |
| any other rich denial (message and/or status set) - including a configured default denial response that carries one | FrameworkError::Domain { message, status_code } |
So deny_as_not_found() surfaces as a 404, deny_with_status(422, "…") as a
422, and deny_with("…") as a 403 carrying your message. A denial's code
travels through authorize, as Laravel's AuthorizationException keeps
it. Read it with FrameworkError::code() after ?:
use ;
async
The denial's status and message reach the client as they do for any other denial. The code stays on the server: the response body carries the message only, as Laravel renders a denial.
Whichever status a denial lands on, it reaches the client as the framework's JSON error body. An Inertia app should also name an error page - without one, the Inertia client treats that body as a non-Inertia response and shows its full-screen error modal instead of rendering anything, so a user with the wrong role sees a crash rather than "you cannot do that".
raw: "denied" vs "undefined"
Gate::raw (and raw_async) returns Option<Response>: None means no
rule applied - no before hook fired, no gate is registered, no after
hook filled in - as distinct from an explicit Some(deny). inspect
normalizes that None to the configured default denial response (a bare
deny unless Gate::default_denial_response has set something else); raw
preserves the None for diagnostics ("is this action governed at all?").
Default denial response
Laravel's Gate::defaultDenialResponse($response) reshapes what an
undecided denial looks like - not every denial, only the ones that would
otherwise fall back to the bare Response::deny(). Set it once, typically
in bootstrap::register():
use Response;
use Gate;
default_denial_response;
After that call, two kinds of outcome pick up the new shape: a bare
false - from a bool gate (define/define_async, including a #[policy]
method returning bool), or from a before/after hook that decided
false -
and an evaluation nothing else decided at all: an undefined ability with no
hook opinion either. All of those used to surface as a bare
Response::deny() (a 403); now they surface as whatever
default_denial_response was given - a 404 in the example above. That is
the standard "hide the resource's existence from a user who may not view
it" move (see the Secret example earlier in this chapter), applied once
for the whole application instead of gate by gate.
The default applies to bare false only. A gate registered with
define_with (or define_async_with) already returned the Response it
wanted - Response::deny_with("…"), Response::deny_as_not_found(), even
an explicit bare Response::deny() - and every one of those passes through
inspect untouched. This mirrors Laravel's own rule: Gate::inspect only
substitutes the default for a truly falsy callback result, never for a
Response object the callback built itself.
before / after hooks
Gate::before registers a check that runs before any gate; the first hook
to return Some(decision) short-circuits everything. The canonical use is a
global override:
// Administrators may do anything.
;
Gate::after runs after the gate. Following Laravel's ??= semantic, an
after hook can only fill in an undecided result (no gate matched and no
before hook fired) - it can never override an allow/deny already produced.
Every after hook still runs, so it doubles as the audit-logging seam:
;
Hooks are keyed by the user type U, not by resource - a hook fires for
every (action, U, R). Put resource-specific logic in the gate. A before
hook is a synchronous predicate and applies to the async evaluation path too;
for async authorization logic in a gate, use define_async /
define_async_with.
An after hook that needs the resource, as Laravel's after callbacks receive
$arguments, is registered with Gate::after_with_arguments::<U, R>. It
receives the resource as a fourth argument and runs only for checks whose
resource is an R. Otherwise it follows the rules of Gate::after: it runs
on every such check and can only fill in an undecided result. It answers
Option<bool>, never a Response:
// When no gate decides, an author may act on their own post.
;
Async before hooks
A hook that has to wait on I/O, such as a database read, cannot be a
synchronous closure. Register it with Gate::before_async. The closure must
return an owned future, as define_async requires:
// Staff, as the directory service lists them, may do anything.
;
Sync and async before hooks share one list per user type. Evaluation walks
the list in the order you registered the hooks, and the first Some(decision)
wins. Only the async forms of the gate (allows_async, denies_async,
authorize_async, inspect_async, raw_async and the async multi-action
methods) wait on an async hook. Never block a thread on I/O inside a
synchronous hook to get around this: it stalls a runtime worker on every check.
A denial here is not enforced everywhere
A hook that answers Some(false) denies only on the async forms. The forms
that cannot wait skip the hook and go on as if it had answered nothing, so a
gate that allows still allows there. These forms are Gate::allows,
denies, authorize, inspect, raw, any, none and check, and can,
cannot and authorize on Authorizable. A hook
that must deny belongs in Gate::before. Otherwise every check your
application makes has to use an async form.
The GateEvaluated event
Every check dispatches one GateEvaluated event for each action it
evaluates, after the before hooks, the gate, and the after hooks run, as
Laravel's Gate::raw does. That covers inspect, raw, their async
siblings, and every check built on them: allows, denies, authorize,
any, none, check, the Authorizable methods, #[authorize],
inspect_current, and none_current. The event carries these fields:
| Field | Value |
|---|---|
user_type |
std::any::type_name of the user, None for a guest |
user_id |
The user's identifier when the check found the user itself (inspect_current, none_current, #[authorize]), else None |
action |
The action |
resource_type |
std::any::type_name of the resource |
decision |
Some(true), Some(false), or None when nothing decided |
Listen for it to keep an audit trail of every decision:
use Arc;
use ;
;
.await;
An async check waits for the listeners. A sync check inside a Tokio runtime must not block the runtime's thread, so it runs the dispatch once in place. A listener that finishes without waiting on anything has run when the check returns, and the rest continues on a task spawned on that runtime. Outside a Tokio runtime, a sync check runs the dispatch to completion on a single-threaded runtime built for it, so every listener has run when the check returns. A listener's error is logged and never changes the decision. When nothing listens and no fake is installed, the check builds no event at all.
A check that finds the user itself names the user's type through
Authenticatable::auth_type_name, which every implementation inherits, so
the event names a signed-in user's own type even when that type registered
no gate, policy, or hook.
In a test, assert on the events through the events fake:
use dispatched;
use ;
let _events = fake;
assert!;
let events = ;
assert_eq!;
assert_eq!;
Why Suprnova diverges
Laravel's Gate::forUser($user)->allows(...) rebinds the gate's implicit
current-user resolver so the next check evaluates as that user. Suprnova's
gate takes the user explicitly on every call, so "check as a different
user" is just Gate::allows(action, &other_user, &resource). Only
inspect_current and none_current find the user themselves, from the
route's guard, so there is no resolver to rebind and forUser has nothing
to do.
inspect_current and none_current return a Result. Laravel's
Gate::inspect throws when its user resolver throws; Suprnova returns that
error instead of an answer.
Laravel's GateEvaluated holds the user object and the check's arguments.
A Suprnova gate is generic over the user and the resource, so the event
names their types and carries the user's identifier only when the check
found the user itself. One listener then serves every gate, and no user or
resource is cloned into the event. For the same reason,
after_with_arguments takes the resource's type and runs only for checks
about that type.
The same reasoning applies to Laravel's policy auto-discovery by class name.
Suprnova ties policy methods to the type-erased (action, U, R) key at
registration time, so a Post policy and a Comment policy with the same
method name register two distinct gates without a naming convention or a
discovery scan.
Gate::default_denial_response also diverges from Laravel in one respect:
passing it an allow-shaped Response::allow() is logged and ignored rather
than accepted. Laravel's defaultDenialResponse has no such guard, but this
is a denial default - accepting an allow-shaped one would silently invert
every bare false gate result to allowed, the one fail-open direction on
this surface.
Roles and permissions
Suprnova ships a small role and permission system in suprnova::rbac. It is
optional. You add the tables, implement one trait on your user model, and
assign roles and permissions to users.
- A permission is a name, such as
"posts.publish". - A role is a named set of permissions, such as
"editor". - A user holds a permission directly, or through a role that carries it.
Add suprnova::rbac::migrations::CreateRbacTables and RbacToSpatieLayout
to your migrator. The tables take spatie/laravel-permission's layout:
roles, permissions, model_has_roles, model_has_permissions and
role_has_permissions. On a database where spatie's migration created them,
the RBAC reads the roles, permissions and assignments spatie recorded, and
the migrations leave those tables as they are. model_id follows
RBAC_MODEL_KEY: int (the default), uuid or ulid. RbacToSpatieLayout
moves the tables an earlier release created (role_permissions,
model_roles, model_permissions, and a display_name on roles and
permissions) into spatie's layout with every row; the display names move to
suprnova_role_details and suprnova_permission_details. Every role and
permission has a guard name. The helpers that take no guard use "web",
and so does every check on this page.
An assignment is stored with the user model's morph_type as its
model_type, as spatie stores the model's morph class. Declare
#[model(morph_type = "App\\Models\\User")] on your user model to share
assignments with a Laravel application; without a morph_type, the
model_type is the model's Rust type path. The HasRoles methods also
read and remove assignments stored under the model's morph_aliases and
under its Rust type path, the default before it followed morph_type, so
the roles an earlier release assigned still apply; rbac_model_types()
lists them all. See
Running on a Laravel Database.
Implement HasRoles on the user model. It has no required methods:
use HasRoles;
use ;
// Setup, for example in a seeder:
create_role.await?;
give_permission_to_role.await?;
user.assign_role.await?;
user.give_permission_to.await?;
user.has_role.await?; // true
user.has_permission_to.await?; // true, through the role
user.has_permission_to.await?; // true, held directly
The helpers that create and grant are idempotent. A second call leaves the one row the first call wrote, and so do two requests that make the same grant at once: both succeed, on SQLite, Postgres and MySQL alike.
For a route, RoleMiddleware::<User>::new("editor") and
PermissionMiddleware::<User>::new("posts.publish") require the role or the
permission. Put them after AuthMiddleware. A user who lacks it gets a 403,
or a redirect if you build the middleware with redirect_to. A failed
database read also refuses the request.
Both check the user of the route's guard, the guard the last
AuthMiddleware to pass the request on checked, as #[authorize] does.
Behind the default guard, they read the grants the HasRoles methods write.
Behind another guard, they read that guard's grants, the ones the
*_on_guard helpers write:
use assign_role_to_model_on_guard;
use ;
// Grant `editor` on the `admin` guard, for example in a seeder.
async
A default-guard user signed in on the same session never passes for the admin route's user, whatever roles that user holds.
Answering the gate with permissions
Without more setup, the gate does not know about permissions:
Gate::allows_async("posts.publish", &user, &post) asks only the gate
definitions and the policies. Call register_gate_bridge once, in
bootstrap::register(), to connect the two:
;
// Allowed when the user holds "posts.publish", directly or through a role.
if allows_async.await
The bridge is a before hook of the gate, built on Gate::before_async. It
follows these rules:
- It allows and never denies. If the ability is a permission the user holds, the gate allows. For any other ability the bridge has no opinion, and the gate goes on to its definitions and policies. A user who does not hold the permission can still be allowed by a gate or a policy.
- A
beforehook answers first. A held permission allows the ability of the same name even where a gate definition or a policy would deny it. This is why the bridge is opt-in: keep permission names and policy ability names apart unless you want them to overlap. - Only the async forms see permissions. The bridge reads the database,
so
allows_async,authorize_async,inspect_asyncand the other async forms use it.allows,authorizeandinspectskip it, and a permission never answers them. - One read per request. The bridge reads all the permissions of a user with one query the first time a check in a request needs them, and answers the rest of the request from that set. It keeps nothing after the request. A grant or a revocation made before the first check of a user in a request is seen by that check. After that check, it is seen from the next request.
- A check in a transaction reads for itself. Inside
DB::transactiona check neither uses the set nor adds to it. It reads on the transaction, so it sees what the transaction changed, and it keeps nothing. - A unit of work with its own scope reads for itself. A job that the sync
queue driver runs inline, or a future you run with
App::run_scoped, opens its own container scope inside the request. Its checks read the database again and leave nothing for the request. - Outside a request, every check reads the database. A queued job, a console command or a task you spawn from a handler is not inside the request.
- A failed read allows nothing. The bridge logs the error, without the id of the user, and answers nothing. The gate then denies unless a definition or a policy allows.
- The guard is
"web". Permissions on another guard do not answer the gate.
register_gate_bridge also adds GateBridgeMiddleware to the front of the
global middleware, which holds the per-request set. Calling it twice for the
same user type changes nothing. If you never call it, nothing changes: the gate
answers as before.
Why Suprnova diverges
The gate takes the user explicitly and has a sync surface and an async
surface. A permission is a database read, so it can answer only the async
surface. Use allows_async and authorize_async wherever a permission
decides.
Next
- Authentication - the user-side half: guards,
Auth::user(),Auth::user_as::<T>() - Bootstrap - where
init_policies()runs in the boot sequence, plus how to register before/after hooks - Middleware - pairing
AuthMiddlewarewith route-level authorization - Error Model - how a gate denial collapses into a 403, a
404, or a custom-status
FrameworkError::Domain - Events - listening for
GateEvaluatedto audit every decision, and the events fake that asserts on it
