Authorization

@tekir/authorize provides a gate-and-policy authorization system. Define named abilities, organize them into policy classes, and check permissions anywhere in your application.

Overview

The AuthorizeProvider registers an Authorize instance in the DI container. Access it via #services like any other service:

services.ts
import { service } from '@tekir/core'
import type { Authorize } from '@tekir/authorize'

export const authorize = service<Authorize>('authorize')

Defining Abilities

Call authorize.define(name, callback) to register a named ability. The callback receives the authenticated user as its first argument followed by any resource arguments you pass at check time. It can return a boolean, an AuthorizationResponse, null, or undefined, all of which are normalized internally.

start/abilities.ts
import type { TekirApp } from '@tekir/core'
import { authorize } from '#services'

export default function({ app }: TekirApp) {
  // Simple boolean callback
  authorize.define('editPost', (user, post) => {
    return user.id === post.userId
  })

  // Async callback
  authorize.define('publishPost', async (user, post) => {
    const role = await db.queryOne('SELECT role FROM user_roles WHERE user_id = ?', [user.id])
    return role?.name === 'editor' || user.id === post.userId
  })

  // Multiple resource arguments
  authorize.define('transferFunds', (user, fromAccount, toAccount) => {
    return fromAccount.ownerId === user.id && toAccount.active === true
  })
}

Before Hooks

Before-hooks run before every ability check and policy method and receive the ability name as a second argument, so a hook decides per-ability. Return true to grant the named ability, false to deny it, an AuthorizationResponse, or undefined to defer to the normal ability callback. A false now denies only the ability it was evaluated for; it no longer short-circuits the whole system. Only a strict boolean or an AuthorizationResponse counts as a decision, a stray truthy non-boolean is ignored (with a warning) and falls through to the ability. Multiple hooks can be registered, the first one that returns a decision wins.

import { authorize } from '#services'

// Admins bypass every ability check
authorize.before((user, ability) => {
  if (user.role === 'admin') return true   // grant immediately
  return undefined                          // undefined = continue to the ability callback
})

// You can also deny early: e.g. ban suspended accounts from everything
authorize.before((user) => {
  if (user.suspended) return false          // deny immediately
  return undefined
})

Checking Abilities

There are three methods for checking abilities, each with a different contract:

  • authorize.allows(name, user, ...args): resolves to true if the ability is granted, false otherwise.
  • authorize.denies(name, user, ...args): the inverse of allows().
  • authorize.authorize(name, user, ...args): resolves silently if granted, throws ForbiddenException (HTTP 403) if denied.
import { authorize } from '#services'

// allows(): returns true/false
const canEdit = await authorize.allows('editPost', auth.user, post)
if (!canEdit) {
  return response.forbidden()
}

// denies(): inverse of allows()
const blocked = await authorize.denies('editPost', auth.user, post)
if (blocked) {
  return response.forbidden()
}
core/controllers/post_controller.ts
import type { HttpContext } from '@tekir/core'
import { authorize, db } from '#services'

export async function update({ params, body, auth, response }: HttpContext) {
  const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])

  // Throws ForbiddenException (HTTP 403) if the ability is denied
  await authorize.authorize('editPost', auth.user, post)

  await db.run('UPDATE posts SET title = ? WHERE id = ?', [body.title, params.id])
  return response.ok({ id: params.id, ...body })
}

AuthorizationResponse

Instead of returning a plain boolean, ability callbacks can return an AuthorizationResponse. This lets you attach a human-readable message to a denial, which surfaces as the ForbiddenException message when authorize() throws:

import { AuthorizationResponse } from '@tekir/authorize'
import { authorize } from '#services'

// Return an explicit allow: equivalent to returning true
authorize.define('viewPost', (user, post) => {
  if (post.public) return AuthorizationResponse.allow()
  if (user.id === post.userId) return AuthorizationResponse.allow()
  return AuthorizationResponse.deny('You do not have access to this post.')
})

// The deny message surfaces on ForbiddenException.message when authorize() is called:
// useful for returning descriptive 403 responses to API clients

AuthorizationResponse.allow() and AuthorizationResponse.deny(message?) are the two factory methods. The allowed boolean and message properties are publicly readable on the returned instance.

Policies

Policies group related abilities for a single resource type into a class. Each method on the policy corresponds to one ability and follows the same callback signature as authorize.define().

BasePolicy

Extend BasePolicy and declare your ability methods. Return a boolean, an AuthorizationResponse, or a Promise of either. Before-hooks registered on the global authorize instance still run before every policy method.

core/policies/post_policy.ts
import { BasePolicy, AuthorizationResponse } from '@tekir/authorize'

export class PostPolicy extends BasePolicy {
  // Each method receives (user, resource, ...extra): same signature as define() callbacks

  view(user: any, post: any): boolean {
    return post.public || user.id === post.userId
  }

  create(user: any): boolean {
    return user.role !== 'guest'
  }

  edit(user: any, post: any): boolean {
    return user.id === post.userId
  }

  delete(user: any, post: any): AuthorizationResponse {
    if (user.id !== post.userId) {
      return AuthorizationResponse.deny('Only the post author can delete it.')
    }
    return AuthorizationResponse.allow()
  }

  async publish(user: any, post: any): Promise<boolean> {
    const subscription = await db.queryOne(
      'SELECT * FROM subscriptions WHERE user_id = ? AND active = true',
      [user.id]
    )
    return !!subscription
  }
}

Registering Policies

Register a policy class under a resource name with authorize.registerPolicy(resource, PolicyClass). The class is instantiated lazily on first use and the instance is cached for the lifetime of the process.

start/abilities.ts
import type { TekirApp } from '@tekir/core'
import { authorize } from '#services'
import { PostPolicy } from '~/policies/post_policy'
import { CommentPolicy } from '~/policies/comment_policy'

export default function({ app }: TekirApp) {
  // Register a policy class under a resource name
  authorize.registerPolicy('post', PostPolicy)
  authorize.registerPolicy('comment', CommentPolicy)

  // The class is instantiated lazily and cached: one instance per resource name
}

PolicyProxy

authorize.policy(resource) returns a PolicyProxy that wraps the policy instance and exposes the same allows / denies / authorize methods as the top-level gate. Before-hooks still run.

import type { HttpContext } from '@tekir/core'
import { authorize, db } from '#services'

export async function destroy({ params, auth, response }: HttpContext) {
  const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])

  // authorize.policy(resource) returns a PolicyProxy with the same
  // allows / denies / authorize interface as the top-level gate
  await authorize.policy('post').authorize('delete', auth.user, post)

  await db.run('DELETE FROM posts WHERE id = ?', [params.id])
  return response.ok({ success: true })
}

// You can also check without throwing
// const canEdit = await authorize.policy('post').allows('edit', auth.user, post)
// const cantDelete = await authorize.policy('post').denies('delete', auth.user, post)

can() Middleware

can(ability, ...args) is a middleware factory that reads auth.user and calls authorize.authorize(ability, user, ...args). It throws ForbiddenException if the user is not authenticated or if the ability is denied. Authentication is checked via auth.isAuthenticated: a request where isAuthenticated is false is rejected even if auth.user happens to be populated. Always place it after authenticate() in the middleware chain.

import { can } from '@tekir/authorize'
import { authenticate, silentAuth, guest } from '@tekir/auth'

// can() is a middleware factory for user-level abilities (no resource needed).
// It reads auth.user automatically and checks the named ability.

// Example: only admins can access admin routes
// Define the ability first:
// authorize.define('accessAdmin', (user) => user.role === 'admin')

router.group(() => {
  router.get('/admin/dashboard', AdminController.dashboard)
  router.get('/admin/users', AdminController.users)
}).prefix('/api').use([authenticate(), can('accessAdmin')])

// For resource-level checks (where you need the actual post/record),
// use authorize.authorize() inside the handler instead:
export async function update({ params, body, auth, response }: HttpContext) {
  const post = await db.queryOne('SELECT * FROM posts WHERE id = ?', [params.id])
  await authorize.authorize('editPost', auth.user, post)
  // ...
}

// can() throws ForbiddenException if not authenticated,
// so it must run after authenticate()