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 |
Every method 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.
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::serve 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.
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 full action → ability table lives in the
routing chapter.
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) |
FrameworkError::Unauthorized (403, "This action is unauthorized.") |
| rich denial (message and/or status set) | 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. The code is
readable on the inspected Response but does not travel through
authorize - FrameworkError has no code field; read it from inspect() if
you need it.
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 a default deny; raw preserves it for diagnostics
("is this action governed at all?").
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. Hooks are
synchronous predicates and apply to the async evaluation path too; for async
authorization logic, use define_async / define_async_with.
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). There is no
implicit resolver to rebind - the explicit API is strictly more general,
which makes forUser redundant rather than missing.
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.
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 on policy outcomes via
Gate::afterfor audit logging
