Permissions
Built-in permission classes (AllowAny, IsAuthenticated, IsStaff, ReadOnly), the ReadOnly default, Or / And combinators, and how to write your own.
A permission class answers is this caller allowed to perform this action on this resource?. It runs after authentication has resolved an Identity (or None for anonymous), gets handed the Action (List, Retrieve, Create, Update, Delete, or Custom(name)), and returns Ok(()) to allow or a PermissionError that maps to 401 / 403.
pub trait Permission: Send + Sync + 'static { fn check(&self, action: &Action, identity: Option<&Identity>) -> Result<(), PermissionError>;}One permission class is attached per resource via ResourceConfig::permission(...). A resource without an explicit class uses the plugin-wide fallback, which defaults to ReadOnly (safe by default: anonymous reads pass, writes get 403). Change the fallback for every unconfigured table with RestPlugin::default_permission(...), e.g. .default_permission(AllowAny) to restore the old open behaviour.
Built-in classes
AllowAny
Every action allowed, anonymous OK. Use for public catalogues, healthchecks, fully open write endpoints.
IsAuthenticated
Require some identity. Anonymous gets 401, any authenticated user is allowed.
IsStaff
Require identity AND is_staff. Anonymous gets 401, non-staff 403, staff allowed. Most admin-adjacent endpoints.
ReadOnly
The default fallback. List / Retrieve open to everyone, Create / Update / Delete get 403. Public-read APIs.
use umbral_rest::{AllowAny, IsAuthenticated, IsStaff, ReadOnly, ResourceConfig}; RestPlugin::default() .resource(ResourceConfig::new("article").permission(IsAuthenticated)) .resource(ResourceConfig::new("audit_log").permission(IsStaff)) .resource(ResourceConfig::new("product").permission(ReadOnly));Combinators
OrPermission: pass if any child passes
use umbral_rest::{OrPermission, IsAuthenticated, IsStaff, ResourceConfig}; // Public-read, staff-writelet p = OrPermission::new(vec![Box::new(ReadOnly), Box::new(IsStaff)]);ResourceConfig::new("post").permission(p);OrPermission preserves the strongest error code from the failed children: a chain of [IsAuthenticated, IsStaff] on anonymous traffic surfaces as 401 (from IsAuthenticated) rather than 403 (from IsStaff). That matches the principle "tell the caller the more actionable thing first."
AndPermission: pass only if all children pass
use umbral_rest::{AndPermission, IsAuthenticated, IsStaff}; let p = AndPermission::new(vec![Box::new(IsAuthenticated), Box::new(IsStaff)]);// Equivalent in practice to IsStaff alone (IsStaff already 401s on anon).// The And shape is here for readability when you chain custom classes.Writing a custom Permission
Worked example: IsOwnerOrReadOnly. List and retrieve work for everyone, but write actions require an identity. A permission class can only enforce class-level framing — it is handed (action, identity) and never the row — so it cannot, on its own, decide "this user owns this record."
Object-level scoping (per-owner / per-tenant)
A permission class gates whether a caller may use an endpoint — it can't see the row, so on its own it lets any caller past the gate read or mutate any row by id (an IDOR / cross-tenant exposure). Restrict which rows a caller reaches with ResourceConfig::scope (or the owned_by shorthand). The scope is ANDed into every built-in CRUD action — list only returns in-scope rows, and retrieve/update/delete of an out-of-scope row returns 404 (never revealing it exists).
use umbral_rest::{ResourceConfig, ScopeDecision}; // Shorthand: only the row's owner (and superusers) may touch it; anonymous denied.ResourceConfig::new("order").owned_by("owner_id") // Full control: staff see everything, owners see their own, anonymous see nothing.ResourceConfig::new("order").scope(|identity| match identity { Some(id) if id.is_staff => ScopeDecision::All, Some(id) => ScopeDecision::Restrict(vec![("owner_id".into(), id.user_id.clone())]), None => ScopeDecision::None,})ScopeDecision::Restrict ANDs each (column, value) equality into the query (so you can scope by tenant and owner); All exposes every row; None exposes none. The scope runs alongside (not instead of) the permission class.
Creates are scoped too. A POST whose body sets the scoped column to a value outside the caller's scope is refused with the same 404 an out-of-scope read gets — a caller who cannot see project Q cannot plant a row into it either (RestrictIn(col, allowed) requires the body's col ∈ allowed; Restrict(pairs) requires each pair to match; None refuses every create). The check runs after owner_field / nested-parent injection, so server-injected values count. The bulk write endpoints (.bulk()) apply the scope exactly like their single-object twins.
use umbral_rest::{Action, Identity, Permission, PermissionError}; /// Class-level: writes require an identity; row-level enforcement/// belongs in the handler (the permission layer is action-aware/// but not row-aware by design).pub struct IsOwnerOrReadOnly; impl Permission for IsOwnerOrReadOnly { fn check( &self, action: &Action, identity: Option<&Identity>, ) -> Result<(), PermissionError> { if action.is_read() { return Ok(()); } match identity { Some(_) => Ok(()), None => Err(PermissionError::Unauthenticated), } }}Per-action gating without combinators
OrPermission and AndPermission short-circuit on the result of child checks but don't read the Action to decide which children to run. For per-verb gating, write a class that matches on &Action directly:
use umbral_rest::{Action, Identity, IsAuthenticated, IsStaff, Permission, PermissionError}; /// GET stays open; POST / PUT / PATCH require a staff user. DELETE/// stays open in this example (widen the match arm if you want it/// gated too).pub struct StaffWritesOnly; impl Permission for StaffWritesOnly { fn check( &self, action: &Action, identity: Option<&Identity>, ) -> Result<(), PermissionError> { match action { Action::Create | Action::Update => { IsAuthenticated.check(action, identity)?; IsStaff.check(action, identity) } _ => Ok(()), } }}PUT and PATCH both map to Action::Update; the plugin doesn't separate full and partial updates - a single Update action covers both.
Custom actions
A ResourceConfig::action("publish", ...) endpoint surfaces as Action::Custom("publish"). Match on it in your permission class:
match action { Action::Custom(name) if name == "publish" => { // Editors can publish; everyone else can't. IsStaff.check(action, identity) } _ => Ok(()),}Why check is sync
Permission classes inspect an already-resolved Identity against an in-memory rule set. They don't hit the database; the database hit happened in authentication. Keeping check synchronous makes the type signature smaller and prevents the easy mistake of "let me just sneak a query in here" (which produces N+1 patterns and surprising latency).
If you need row-level data to authorize ("only the author can edit this post"), the permission class alone can't do it — it enforces class-level policy and never sees the row. Instance-level policy has to live in a handler that loads the row, and on the built-in CRUD path there is no such handler. Enforce it in a custom .action(...) endpoint (which receives ctx.pk + ctx.identity) after disabling the corresponding built-in action with .views(...) — see the warning under Writing a custom Permission.
Where to look next
- The full trait + the built-in source:
plugins/umbral-rest/src/permission.rs. - Wiring permissions onto a resource:
ResourceConfig::permission. See the example app atexamples/derive-demo/src/main.rsfor an end-to-end wire-up. - The authentication side of the gate: Authentication.
- The third request-time gate, run after permission: Throttling.