Security
Authorization
Decide what a signed-in user may do: write gates for single checks and policies for the actions on a model, then check them in your controllers, routes and pages.
Introduction#
Authentication tells you who a user is; authorization tells you what they may do, such as
whether they may update a post. Oriel has two ways to write those rules, as Laravel does. Gates are functions,
each for one ability, and suit checks that don't belong to a model, such as reaching the admin dashboard. Policies
are classes that group the rules for one model, such as PostPolicy for Post. Most apps use both.
Every check is async, since a rule may read the database: await it.
Gates#
Writing gates#
Define a gate with Gate.define(), in a service provider's boot(). A gate receives the signed-in user, then the
arguments of the check, and returns whether the user may go ahead:
import { Gate, ServiceProvider } from '@orieljs/framework'import type { Post } from '../Models/Post'import type { User } from '../Models/User'export class AppServiceProvider extends ServiceProvider { override boot(): void { Gate.define('update-post', (user, post) => (user as User).id === (post as Post).author_id) }}A gate can also be a policy method, given as a class and a method name:
Gate.define('update-post', [PostPolicy, 'update']). A gate may be async.
Authorizing actions#
Gate.allows() and Gate.denies() check a gate for the signed-in user. You don't pass the user: Oriel gives the
gate the current one.
import { abort, Gate, redirect } from '@orieljs/framework'import type { Post } from '../../Models/Post'import { Controller } from './Controller'export class PostController extends Controller { /** Update the given post. */ async update(post: Post) { if (await Gate.denies('update-post', post)) { abort(403) } // Update the post... return redirect('/posts') }}Gate.forUser(user) checks for another user. Gate.any() is true when any of a list of abilities is allowed, and
Gate.none() when none is; Gate.check() takes one ability or a list, all of which must be allowed:
if (await Gate.forUser(user).allows('update-post', post)) { // The user can update the post...}if (await Gate.any(['update-post', 'delete-post'], post)) { // The user can update or delete the post...}if (await Gate.none(['update-post', 'delete-post'], post)) { // The user can't update or delete the post...}An ability that no gate or policy defines is denied.
Authorizing or throwing exceptions#
Gate.authorize() checks a gate and throws an AuthorizationError when it denies, which Oriel renders as a 403
response, "This action is unauthorized.":
await Gate.authorize('update-post', post)// The action is authorized...Supplying additional context#
A gate receives every argument after the ability's name, in order:
Gate.define('create-post', (user, category, pinned) => (user as User).id > 0 && Number(category) > 0 && pinned !== true)if (await Gate.allows('create-post', category, pinned)) { // The user can create the post...}Gate responses#
A gate can return a GateResponse, with a message, in place of a boolean:
Gate.define('edit-settings', (user) => (user as User).email.endsWith('@example.com') ? GateResponse.allow() : GateResponse.deny('You must be an administrator.'),)Gate.inspect() returns the response itself, so you can read its message; Gate.authorize() throws an
AuthorizationError with it:
const response = await Gate.inspect('edit-settings')if (response.allowed()) { // The action is authorized...} else { const message = response.message()}Customizing the HTTP response status#
A denial answers 403. GateResponse.denyWithStatus() chooses another status, and GateResponse.denyAsNotFound()
answers 404, which hides from the user that the resource exists:
Gate.define('edit-settings', (user) => (user as User).email.endsWith('@example.com') ? GateResponse.allow() : GateResponse.denyAsNotFound(),)Intercepting gate checks#
Gate.before() runs before every check. A function that returns true or false decides the check; one that
returns nothing lets the check go on:
Gate.before((user) => { if ((user as User | undefined)?.email === 'admin@example.com') { return true }})Gate.after() runs after every check, with its result. Its own result counts only when the check had none, such as an
ability nobody defined.
Creating policies#
Generating policies#
make:policy writes a policy in app/. With --model, it writes a method for each of the model's usual
actions, typed with your User and the model, each denying until you write it:
node oriel make:policy PostPolicy --model=PostRegistering policies#
Policy discovery#
When your app boots, Oriel reads the files at the top of app/ whose names end in Policy, and uses each
for the model of the same name: app/ authorizes Post. The policy is the file's default
export, or its export named after the file. You don't register it.
Manually registering policies#
Gate.policy() registers a policy for a model, in a service provider's boot(), which wins over discovery:
import { Gate, ServiceProvider } from '@orieljs/framework'import { Order } from '../Models/Order'import { OrderPolicy } from '../Policies/OrderPolicy'export class AppServiceProvider extends ServiceProvider { override boot(): void { Gate.policy(Order, OrderPolicy) }}Writing policies#
Policy methods#
A policy's methods are named after the abilities they check, and receive the user and the model. The ability
update calls update(), and a kebab-case ability such as view-any calls viewAny():
import type { Post } from '../Models/Post'import type { User } from '../Models/User'export class PostPolicy { /** Determine if the given post can be updated by the user. */ update(user: User, post: Post): boolean { return user.id === post.author_id }}Oriel makes the policy through the container for each check, so a method can take a service with inject().
Policy responses#
A policy method can return a GateResponse, as a gate can:
import { GateResponse } from '@orieljs/framework'import type { Post } from '../Models/Post'import type { User } from '../Models/User'export class PostPolicy { /** Determine if the given post can be updated by the user. */ update(user: User, post: Post): GateResponse { return user.id === post.author_id ? GateResponse.allow() : GateResponse.deny('You do not own this post.') }}GateResponse.denyAsNotFound() and denyWithStatus() work here too.
Methods without models#
Some actions have no model yet, such as create. Check them with the model's class in place of a model: the class
chooses the policy, and the method receives only the user:
class PostPolicy { create(user: User): boolean { return user.email_verified_at !== null }}if (await user.can('create', Post)) { // ...}Guest users#
A check made without a signed-in user is denied, and the gate or the policy method isn't called. To let guests
through a gate, wrap its function in allowGuests(); a policy lets them reach all its methods with
static allowGuests = true, or some of them with a list. The user is then undefined:
class PostPolicy { static allowGuests = ['view'] view(user: User | undefined, post: Post): boolean { return post.published_at !== null || user?.id === post.author_id }}Gate.define('view-posts', allowGuests(() => true))Policy filters#
A policy's before() method runs before its other methods. Return true or false to decide, or nothing to let the
method decide. It runs for every ability checked with the policy's model, even one the policy has no method for, which
Laravel skips:
class PostPolicy { before(user: User, ability: string): boolean | undefined { if (user.email === 'admin@example.com') { return true } return undefined }}Authorizing actions using policies#
Via the user model#
The Authorizable mixin, which a new app's User has, gives the user can() and cannot(). When the model has a
policy, the check runs its method:
import { abort, redirect, request } from '@orieljs/framework'import type { Post } from '../../Models/Post'import type { User } from '../../Models/User'import { Controller } from './Controller'export class PostController extends Controller { /** Update the given post. */ async update(post: Post) { const user = (await request().user<User>())! if (await user.cannot('update', post)) { abort(403) } // Update the post... return redirect('/posts') }}cant() is cannot(), and canAny() takes a list of abilities. A model without a policy is checked against the
gate of that ability.
Via controller helpers#
The base controller has authorize(), which throws an AuthorizationError, a 403, when the user may not:
import { redirect } from '@orieljs/framework'import type { Post } from '../../Models/Post'import { Controller } from './Controller'export class PostController extends Controller { /** Update the given blog post. */ async update(post: Post) { await this.authorize('update', post) // The current user can update the blog post... return redirect('/posts') }}Pass the model's class for an action without a model: await this.authorize('create', Post).
authorizeForUser(user, ability, ...) checks for another user.
Via middleware#
The can middleware authorizes a request before it reaches your route. Its first parameter is the ability, and the
next ones are route parameters, whose bound models it passes on:
import { Route } from '@orieljs/framework'import type { Post } from '../app/Models/Post'Route.put('/post/{post}', (post: Post) => { // The current user may update the post... return post}).middleware(['auth', 'can:update,post'])A parameter that isn't a route parameter names a model, for an action without one: can:create,Post. A quoted one
is passed as text: can:publish,post,"draft".
Via form requests#
A form request's authorize() runs before its rules; returning false answers 403:
import { FormRequest, Gate, request } from '@orieljs/framework'import type { Post } from '../../Models/Post'export class UpdatePostRequest extends FormRequest { override async authorize(): Promise<boolean> { return Gate.allows('update', request().route<Post>('post')) } override rules() { return { title: 'required|string|max:255' } as const }}In views and pages#
Oriel's views are TSX, so there is no @can: work out what the user may do in your route or controller, and pass
the answers to the view or the page as props:
import { inertia, request } from '@orieljs/framework'import type { Post } from '../../Models/Post'import type { User } from '../../Models/User'import { Controller } from './Controller'export class PostController extends Controller { /** Show the given post. */ async show(post: Post) { const user = await request().user<User>() return inertia('Posts/Show', { post, can: { update: (await user?.can('update', post)) ?? false }, }) }}In a Live component, the route's can middleware runs again on every update (see
Middleware on updates).