Policy

The policy module provides authorization via policy classes and an authorize() middleware factory. Inspired by Laravel's policy pattern — define what each user can do, then enforce it with a single middleware call.

Quick start

import { authorize, allow, deny } from '@strav/http/policy'
import type { PolicyResult } from '@strav/http/policy'

// Define a policy
const userPolicy = {
  canView:   (actor: User) => allow(),
  canCreate: (actor: User) => actor.role === 'admin' ? allow() : deny(),
  canUpdate: (actor: User, user: User) =>
    actor.id === user.id || actor.role === 'admin' ? allow() : deny(),
  canDelete: (actor: User, user: User) =>
    actor.role === 'admin' ? allow() : deny(403, 'Only admins can delete users'),
}

// Use as route middleware
router.get('/users/:id', showUser)
router.put('/users/:id',
  authorize(userPolicy, 'canUpdate', async (ctx) => User.find(ctx.params.id)),
  updateUser,
)

authorize()

function authorize(
  policy: Record PolicyResult | Promise>,
  method: string,
  loadResource?: (ctx: Context) => Promise,
): Middleware

The middleware:

  • Reads the authenticated user from ctx.get('user') — returns 401 if missing.
  • If loadResource is provided, calls it and stores the result in ctx.set('resource', ...).
  • Calls policy[method](user, resource?) — returns error response if the method doesn't exist or the policy denies access.
  • Calls next() on success.
  • PolicyResult

    Policy methods must return a PolicyResult object:

    interface PolicyResult {
      allowed: boolean
      status: number
      reason: string
    }
    

    Helper functions

    Use these helper functions to create policy results:

    import { allow, deny } from '@strav/http/policy'
    
    // Allow access
    function allow(): PolicyResult
    
    // Deny access with optional custom status and reason
    function deny(status = 403, reason = 'Action forbidden'): PolicyResult
    

    Examples

    // Simple allow/deny
    canView: (actor: User) => allow()
    canCreate: (actor: User) => actor.role === 'admin' ? allow() : deny()
    
    // Custom error message
    canDelete: (actor: User, post: Post) =>
      actor.role === 'admin' || actor.id === post.authorId
        ? allow()
        : deny(403, 'You can only delete your own posts')
    
    // Custom status code
    canModerate: (actor: User) =>
      actor.hasPermission('moderate')
        ? allow()
        : deny(401, 'Insufficient permissions')
    

    Usage with routes

    Without a resource

    For actions that don't operate on a specific resource (like listing or creating):

    router.get('/users', authorize(userPolicy, 'canList'), listUsers)
    router.post('/users', authorize(userPolicy, 'canCreate'), createUser)
    

    With a resource

    For actions on a specific resource (view, update, delete), provide a loader:

    const loadUser = async (ctx: Context) => User.find(ctx.params.id)
    
    router.get('/users/:id', authorize(userPolicy, 'canView', loadUser), showUser)
    router.put('/users/:id', authorize(userPolicy, 'canUpdate', loadUser), updateUser)
    router.delete('/users/:id', authorize(userPolicy, 'canDelete', loadUser), destroyUser)
    
    The loaded resource is available in the handler via ctx.get('resource'):
    async function showUser(ctx: Context) {
      const user = ctx.get('resource') as User
      return ctx.json(user)
    }
    

    Async policies

    Policy methods can be async:

    const postPolicy = {
      async canEdit(actor: User, post: Post) {
        if (actor.role === 'admin') return allow()
    
        // Check async condition
        const hasEditAccess = await checkEditPermissions(actor, post)
        return hasEditAccess ? allow() : deny(403, 'Cannot edit this post')
      },
    }
    

    Generated policies

    Running bun strav generate:api creates policy skeleton files per schema. Methods vary by archetype:
    Archetype Policy methods
    entity canList, canView, canCreate, canUpdate, canDelete
    attribute canList, canView, canCreate, canUpdate, canDelete
    reference canList, canView, canCreate, canUpdate, canDelete
    contribution canList, canView, canCreate, canUpdate, canDelete, canModerate
    component canList, canView, canUpdate
    event canList, canView, canAppend
    configuration canView, canUpsert, canReset
    Generated policies default to return allow() — customize the logic to match your authorization rules:
    // app/policies/user_policy.ts — Generated by Strav
    import { allow, deny } from '@strav/http/policy'
    import type { PolicyResult } from '@strav/http/policy'
    
    export default class UserPolicy {
      static canList(actor: any): PolicyResult {
        return allow()
      }
    
      static canView(actor: any, user: any): PolicyResult {
        return allow()
      }
    
      static canCreate(actor: any): PolicyResult {
        return actor.role === 'admin' ? allow() : deny()  // customize here
      }
    
      static canUpdate(actor: any, user: any): PolicyResult {
        return actor.id === user.id || actor.role === 'admin'
          ? allow()
          : deny()  // customize here
      }
    
      static canDelete(actor: any, user: any): PolicyResult {
        return actor.role === 'admin' ? allow() : deny()  // customize here
      }
    }
    

    Response format

    The middleware returns JSON error responses based on the policy result:

    // 401 - When user is not authenticated
    { "error": "Unauthorized" }
    
    // 403 - When policy denies access (default message)
    { "error": "Action forbidden" }
    
    // Custom error message and status code
    { "error": "Only admins can delete users" }
    

    The response uses:

    • Status code: From PolicyResult.status (defaults to 403)
    • Error message: From PolicyResult.reason (customizable via deny())