Guide/guides

Authorization

Authorization determines what an authenticated user is allowed to do. Guren provides a policy-based authorization system inspired by Laravel.

Authorization

Authorization determines what an authenticated user is allowed to do. Guren provides a policy-based authorization system inspired by Laravel.

The authorization gate is created automatically when your app boots and bound on the app's container as gate. A service provider reaches it with this.container.make('gate') to define abilities and register policies. No manual setup is required.

Gates

Gates are simple closures that determine if a user is authorized to perform a given action.

Defining Gates

Define gates in a service provider's boot(). The framework's own provider creates the gate during registration, so make('gate') throws before that:

import { ServiceProvider } from '@guren/core'

export default class AuthorizationProvider extends ServiceProvider {
  boot(): void {
    const gate = this.container.make('gate')

    // Simple gate
    gate.define('view-dashboard', (user) => {
      return user?.isAdmin === true
    })

    // Gate with a resource
    gate.define('update-post', (user, post) => {
      return user?.id === post.userId
    })

    // Async gate with database check
    gate.define('delete-comment', async (user, comment) => {
      const post = await Post.find(comment.postId)
      return user?.id === post?.userId
    })
  }
}

Register the provider in createApp({ providers }).

Using Gates

A controller has this.authorize() and this.can(), which bind the current user for you:

// Throws AuthorizationException (403) if denied
await this.authorize('update-post', post)

// Check without throwing
const canView = await this.can('view-dashboard')

Elsewhere, resolve the gate from the container that owns the call — this.make('gate') in a job or command, this.container.make('gate') in a provider, getRequestContainer(ctx).make('gate') in middleware — and bind a user with forUser():

const gate = this.make('gate').forUser(user)

// Check if allowed
const canView = await gate.allows('view-dashboard')

// Check if denied
const cannotView = await gate.denies('view-dashboard')

// With a resource
const canUpdate = await gate.allows('update-post', post)

// Authorize or throw
await gate.authorize('update-post', post)

Before Callbacks

Register a callback that runs before all gate checks, in the same boot():

gate.before((user, ability) => {
  // Super admins can do everything
  if (user?.isSuperAdmin) {
    return true
  }
  // Return undefined to continue to the gate
})

After Callbacks

Register a callback that runs after all gate checks:

gate.after((user, ability, result) => {
  // Log authorization attempts
  logger.info(`User ${user?.id} ${result ? 'allowed' : 'denied'} for ${ability}`)
})

Policies

Policies organize authorization logic around a particular model or resource.

Creating Policies

Scaffold a policy with the CLI:

bunx guren make:policy Post

Or write one by hand:

import { Policy, type AuthUser } from '@guren/core'
import type { PostRecord } from '../Models/Post'

export class PostPolicy extends Policy {
  /**
   * Determine if the user can view any posts.
   */
  viewAny(user: AuthUser | null): boolean {
    return true
  }

  /**
   * Determine if the user can view the post.
   */
  view(user: AuthUser | null, post: PostRecord): boolean {
    return post.published || user?.id === post.userId
  }

  /**
   * Determine if the user can create posts.
   */
  create(user: AuthUser | null): boolean {
    return user !== null
  }

  /**
   * Determine if the user can update the post.
   */
  update(user: AuthUser | null, post: PostRecord): boolean {
    return user?.id === post.userId
  }

  /**
   * Determine if the user can delete the post.
   */
  delete(user: AuthUser | null, post: PostRecord): boolean {
    return user?.id === post.userId
  }
}

Registering Policies

Register policies on the gate in the same boot():

// Register by model class
gate.policy(Post, PostPolicy)

// Or by string key
gate.policy('post', PostPolicy)

Using Policies

ORM queries return plain records without constructor information, so pass the model class alongside the record to resolve the policy:

const gate = this.make('gate').forUser(user)
const post = await Post.findOrFail(id)

// Pass [ModelClass, record] for ORM records
const canUpdate = await gate.allows('update', [Post, post])

// Abilities without a record take the bare class
const canCreate = await gate.allows('create', Post)

// String keys work the same way
const canDelete = await gate.allows('delete', ['post', post])

// Authorize with exception (throws AuthorizationException, 403)
await gate.authorize('update', [Post, post])

Class instances (objects created with new) resolve their policy automatically without the tuple:

const canView = await gate.allows('view', somePostInstance)

A plain record passed without the tuple resolves no policy. When no gate is defined for the ability either, the check throws an Error that names the ability and the [Model, record] fix, so the mistake surfaces as a 500 instead of a 403 for the record's own owner. A before() callback and a gate defined for the ability still receive the plain record. A class instance whose class has no policy, and a tuple whose model has none, are denied.

gate.any() and authorizeMiddleware() given an array check the abilities in order, so a plain record throws at the first ability that resolves nothing, even when a later one would allow. Pass the tuple there too, and have a modelResolver given to authorizeMiddleware() or authorizeResourceMiddleware() return [Model, record] rather than the record.

Policy Methods

Policies support these standard methods:

Method Description
viewAny Can view any/all resources
view Can view a specific resource
create Can create new resources
update Can update a resource
delete Can delete a resource
restore Can restore a soft-deleted resource
forceDelete Can permanently delete a resource

Before Method

Add a before method to intercept all policy checks:

export class PostPolicy extends Policy {
  before(user: AuthUser | null, ability: string): boolean | undefined {
    // Admins can do anything with posts
    if (user !== null && (user as { isAdmin?: boolean }).isAdmin) {
      return true
    }
    // Return undefined to continue to specific method
  }
}

Controller Integration

Controllers ship with authorize() and can() helpers. They resolve the current user from the auth context automatically (guests are null):

import { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
import { Post } from '@/app/Models/Post'
import { PostResource } from '@/app/Http/Resources/PostResource'

export default class PostController extends Controller {
  async show() {
    const { id } = this.validateParams(PostIdParamSchema)
    const post = await Post.findOrFail(id)

    // Throws AuthorizationException (403) when denied
    await this.authorize('view', [Post, post])

    return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
  }

  async update() {
    const { id } = this.validateParams(PostIdParamSchema)
    const post = await Post.findOrFail(id)

    // Check without throwing
    if (!(await this.can('update', [Post, post]))) {
      return this.json({ error: 'Unauthorized' }, 403)
    }

    // Update logic...
  }
}

Tip: bunx guren make:feature Post --policy scaffolds the policy and wires authorize() calls into store/update/destroy for you.

Middleware

Create authorization middleware for route-level checks:

import { type Router, getRequestContainer, AuthorizationException, defineMiddleware } from '@guren/core'

export function authorizeAbility(ability: string) {
  return defineMiddleware(async (ctx, next) => {
    const gate = getRequestContainer(ctx).make('gate')
    const user = await gate.resolveUser(ctx)

    if (await gate.forUser(user).denies(ability)) {
      throw new AuthorizationException()
    }

    await next()
  })
}

// Usage in routes
export function registerWebRoutes(router: Router): void {
  router.get('/admin', [AdminController, 'index'], authorizeAbility('access-admin'))
}

gate.resolveUser(ctx) reads the signed-in user from the request's auth context. A userResolver passed to createGate() takes precedence. The built-in authorizeMiddleware('access-admin') resolves the user the same way.

Best Practices

  1. Use policies for model-specific logic - Keep authorization organized by model.
  2. Keep gates simple - Use gates for abilities not tied to a specific model.
  3. Cache expensive checks - If authorization requires database queries, consider caching.
  4. Use before callbacks sparingly - They can make debugging harder if overused.
  5. Test authorization - Write tests for your gates and policies.

Testing Authorization

Instantiate a fresh Gate per test instead of relying on the global instance:

import { describe, it, expect, beforeEach } from 'bun:test'
import { Gate } from '@guren/core'
import { PostPolicy } from '../app/Policies/PostPolicy'

describe('PostPolicy', () => {
  let gate: Gate

  beforeEach(() => {
    gate = new Gate()
    gate.policy('post', PostPolicy)
  })

  it('allows owner to update post', async () => {
    const user = { id: 1 }
    const post = { id: 1, userId: 1 }

    expect(await gate.forUser(user).allows('update', ['post', post])).toBe(true)
  })

  it('denies non-owner from updating post', async () => {
    const user = { id: 2 }
    const post = { id: 1, userId: 1 }

    expect(await gate.forUser(user).denies('update', ['post', post])).toBe(true)
  })
})