Manual contentsSecurityBrowse 113 chapters
Manual 20 min read

Authorization

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 suprnova::{Authorizable, Gate};

#[derive(Debug)]
struct User { id: i64, is_admin: bool }
#[derive(Debug)]
struct Post { id: i64, author_id: i64, is_public: bool }

// Lets users opt into the `user.can(action, &resource)` ergonomics.
impl Authorizable for User {}

// Wire one ability:
Gate::define::<User, Post>("update", |user, post| {
    user.is_admin || post.author_id == user.id
});

let alice = User { id: 1, is_admin: false };
let own_post = Post { id: 10, author_id: 1, is_public: false };
let foreign_post = Post { id: 11, author_id: 99, is_public: false };

assert!(alice.can("update", &own_post));
assert!(alice.cannot("update", &foreign_post));

// Return 403 directly from a handler:
alice.authorize("update", &foreign_post)?;

The Gate surface

Defining abilities

// Sync closure - invoked directly, no boxed future.
Gate::define::<User, Post>("view", |user, post| post.is_public || user.id == post.author_id);

// Async closure - the future must be owned (no borrows past closure return).
Gate::define_async::<User, Post, _, _>("publish", |user, post| {
    let user_is_admin = user.is_admin;
    let post_id = post.id;
    async move {
        // ...DB lookup, RPC call, etc.
        user_is_admin || check_publish_permission(post_id).await
    }
});

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?
Gate::has::<User, Post>("publish");  // bool

// What abilities exist? (sorted + deduped by action name)
let all: Vec<String> = Gate::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 suprnova::policy;
use suprnova::authorization::Response;

struct User { id: i64, is_admin: bool }
struct Post { id: i64, author_id: i64, is_public: bool }
struct PostPolicy;

#[policy(User, Post)]
impl PostPolicy {
    // A `-> bool` method is a plain allow/deny gate.
    fn view_any(_user: &User, _post: &Post) -> bool {
        true // anyone can list posts
    }
    fn view(user: &User, post: &Post) -> bool {
        post.is_public || post.author_id == user.id || user.is_admin
    }

    // A `-> Response` method can carry a message + HTTP status on denial.
    fn update(user: &User, post: &Post) -> Response {
        if post.author_id == user.id || user.is_admin {
            Response::allow()
        } else {
            Response::deny_with("You may only edit your own posts.")
        }
    }
    fn delete(user: &User, post: &Post) -> Response {
        if user.is_admin {
            Response::allow()
        } else {
            Response::deny_as_not_found() // hide the post from non-admins
        }
    }
}

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.

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 suprnova::Authorizable;

impl Authorizable for User {}

// Sync sugar
if alice.can("update", &post)    { /* ... */ }
if alice.cannot("delete", &post) { /* ... */ }
alice.authorize("update", &post)?;  // 403 on deny

// Async sugar
if alice.can_async("publish", &post).await    { /* ... */ }
alice.authorize_async("publish", &post).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 suprnova::{group, get, Auth, AuthMiddleware, FrameworkError, Request, Response};

// Middleware checks the auth user; the handler authorizes the action.
group!("/posts")
    .middleware(AuthMiddleware::new())
    .routes([
        get!("/{id}/edit", edit_form),
    ]);

async fn edit_form(req: Request) -> Response {
    let user: User = Auth::user_as::<User>()
        .await?
        .ok_or(FrameworkError::Unauthorized)?;
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| FrameworkError::param_parse("id", "i64"))?;
    let post = Post::find(id).await?
        .ok_or_else(|| FrameworkError::not_found("Post"))?;
    user.authorize("update", &post)?;
    // ... render edit form
}

Many-action checks

A "list all the things this user can do on this resource" page:

let actions = ["view", "update", "delete", "restore", "force_delete"];
let mut allowed = Vec::new();
for action in &actions {
    if user.can(action, &post) {
        allowed.push(*action);
    }
}
// Or short-circuit:
let can_do_anything = Gate::any(&actions, &user, &post);
let is_locked_out   = Gate::none(&actions, &user, &post);

Multi-gate authorization

// Only allow if the user can do ALL of these actions on the resource.
Gate::authorize_async("publish", &user, &post).await?;
if Gate::check_async(&["update", "view"], &user, &post).await {
    // Combine checks.
}

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:

Gate::define::<User, Post>("view",   |u, _p| u.is_member);
Gate::define::<User, Post>("create", |u, _p| u.is_author);
Gate::define::<User, Post>("update", |u, _p| u.is_author);
Gate::define::<User, Post>("delete", |u, _p| u.is_admin);

let router: Router = Router::new()
    .resource("posts", PostsCtl)
    .authorize_resource::<User, Post>()   // 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:

Gate::define_async::<User, Post, _, _>("publish", |user, post| {
    let user_id = user.id;        // copy primitive
    let post_id = post.id;
    let admin   = user.is_admin;
    async move {
        // No `user` / `post` references here - only the captured copies.
        admin || check_can_publish(user_id, post_id).await
    }
});

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 suprnova::authorization::Response;  // re-exported at the crate root as `GateResponse`

Gate::define_with::<User, Post>("update", |user, post| {
    if post.author_id == user.id {
        Response::allow()
    } else {
        Response::deny_with("You do not own this post.")
    }
});

// Hide a resource's existence rather than admit it exists:
Gate::define_with::<User, Secret>("view", |user, secret| {
    if user.can_see(secret) {
        Response::allow()
    } else {
        Response::deny_as_not_found()  // a 404, not a 403
    }
});

Inspect the full decision with Gate::inspect (sync) / Gate::inspect_async:

let decision = Gate::inspect("update", &user, &post);
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.")
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. 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.

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 suprnova::authorization::Response;
use suprnova::Gate;

Gate::default_denial_response(Response::deny_as_not_found());

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::before::<User>(|user, _action| user.is_admin.then_some(true));

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:

Gate::after::<User>(|user, action, decided| {
    audit_log(user.id, action, decided);   // observe every evaluation
    None                                    // record-only; don't change the result
});

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.

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.
Gate::before_async::<User, _, _>(|user, _action| {
    let id = user.id;
    async move { is_staff(id).await.then_some(true) }
});

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.

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.

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 to your migrator. It creates the roles, permissions, role_permissions, model_roles and model_permissions tables. Every role and permission has a guard name. The helpers that take no guard use "web", and so does every check on this page.

Implement HasRoles on the user model. It has no required methods:

use suprnova::HasRoles;
use suprnova::rbac::{create_role, give_permission_to_role};

impl HasRoles for User {}

// Setup, for example in a seeder:
create_role("editor").await?;
give_permission_to_role("editor", "posts.publish").await?;

user.assign_role("editor").await?;
user.give_permission_to("posts.delete").await?;

user.has_role("editor").await?;                  // true
user.has_permission_to("posts.publish").await?;  // true, through the role
user.has_permission_to("posts.delete").await?;   // true, held directly

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.

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:

suprnova::rbac::register_gate_bridge::<User>();

// Allowed when the user holds "posts.publish", directly or through a role.
if Gate::allows_async("posts.publish", &user, &post).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 before hook 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_async and the other async forms use it. allows, authorize and inspect skip 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::transaction a 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 AuthMiddleware with 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::after for audit logging