Agora

Query scopes

Register a scope filter for a resource so accessibleBy() constrains a collection to the rows a user may see — the scopes config key, ScopeRegistry.register, and the eq/where/whereIn/and/or DSL.

accessibleBy constrains a Lucid query to the rows a user may access — the Pundit policy_scope / Cerbos query-plan concept. But accessibleBy is only the consumer. Until you register a scope filter for a resource, that resource is deny-all (fail-closed): accessibleBy returns no rows. This page is the producer side — how to teach authz what "accessible" means for each resource.

A resource with no registered scope sees nothing. AuthzService.scope resolves an unregistered resource to deny-all, matching how authz treats an unknown permission. If accessibleBy(Post.query(), …) is returning zero rows for everyone, you almost certainly haven't registered a scope for Post yet.

Resolution order

A registered filter is only consulted after the privileged short-circuits, so scoping stays consistent with single-resource can() decisions. For scope(user, resource, { action }):

  1. Super-admin (hook or global super-admin role) → allow-all (no filter).
  2. A wildcard permission grant for action (default 'viewAny') → allow-all.
  3. The resource's registered scope filter → its constraint, fed the user's effective roles/permissions/tenant so it derives from the SAME authz data.
  4. Otherwise (anonymous, or no scope registered) → deny-all (fail-closed).

So a scope filter only runs for a non-privileged user — it answers "which rows may this ordinary user see?", never "is this user an admin?".

The scopes config key

Register filters via the scopes key in config/authz.ts. It accepts either a pre-built ScopeRegistry, or — more commonly — a builder callback that registers onto a fresh registry the provider creates for you:

config/authz.ts
import { defineConfig, stores } from '@adonis-agora/authz'
import { and, eq, or } from '@adonis-agora/authz/scope'
import Post from '#models/post'
import Invoice from '#models/invoice'

export default defineConfig({
  default: 'lucid',
  stores: { lucid: stores.lucid() },

  scopes: (registry) => {
    // A user sees their own posts, plus any that are published.
    registry.register(Post, ({ user }) =>
      // OR: owner OR published — see the DSL below.
      or(eq('author_id', user.id), eq('published', true)),
    )

    // Managers see every invoice in their tenant; everyone else sees their own.
    registry.register(Invoice, ({ user, roles, tenant }) => {
      if (roles.includes('manager')) return true // allow-all
      return and(
        eq('user_id', user.id),
        ...(tenant ? [eq('tenant_id', tenant.tenantId)] : []),
      )
    })
  },
})

Prefer the model class as the resource key — register(Post, …) matches the same Post you pass to accessibleBy(Post.query(), …) by reference. A string key (register('post', …)) lets a host register a scope without importing the model; then call accessibleBy(query, authz, user, 'post').

ScopeRegistry.register

ScopeRegistry is the resource → filter map. You rarely construct it yourself (the scopes builder hands you one), but the API is:

import { ScopeRegistry } from '@adonis-agora/authz/scope'

const registry = new ScopeRegistry()

registry
  .register(Post, (ctx) => eq('author_id', ctx.user.id))
  .register('comment', (ctx) => eq('author_id', ctx.user.id)) // chainable

registry.has(Post)        // true
registry.resolve(Post)    // the filter, or undefined

register(resource, filter) returns the registry for chaining. It is fail-closed by omission: a resource you never register resolves to deny-all.

The filter — ScopeFilter

A filter is (ctx: ScopeFilterContext) => ScopeResult | Promise<ScopeResult>. It receives everything needed to derive a WHERE from the SAME authorization data can / hasRole consult — without re-querying the store:

ScopeFilterContext fieldMeaning
userThe resolved { type, id } user reference.
actionThe action/ability being scoped (e.g. viewAny, posts.read).
permissionsThe user's effective permission names for the active tenant — the store's grants (role-derived ∪ direct) unioned with whatever roleGrants maps onto roles. These are the granted patterns, wildcards included, so test them with permissionSatisfied rather than includes.
rolesThe user's effective role names for the active tenant.
tenantThe active TenantScope, or undefined (global).

A filter may return:

  • a ScopeConstraint built with the DSL below,
  • true → allow-all (sugar for scopeAll),
  • false / null / undefined → deny-all (sugar for scopeNone).

It may be async — e.g. to look up the user's team ids before scoping.

registry.register(Document, async ({ user, roles }) => {
  if (roles.includes('auditor')) return true // sees everything
  const teamIds = await TeamMember.query().where('user_id', user.id).select('team_id')
  return whereIn('team_id', teamIds.map((t) => t.teamId))
})

The constraint DSL

Import the builders from @adonis-agora/authz/scope. They produce a small, pure-data condition AST (no callbacks, fully serializable) that the Lucid accessibleBy adapter compiles into a parameterized, identifier-safe WHERE. Column names are validated as safe identifiers; values are always bound, never interpolated.

Terminals

import { scopeAll, scopeNone } from '@adonis-agora/authz/scope'

scopeAll  // { kind: 'all' }  — every row visible, no WHERE added
scopeNone // { kind: 'none' } — no rows, an always-false predicate is applied

scopeAll / scopeNone are the explicit forms of the true / false sugar — return whichever reads better.

Leaf conditions

import { eq, where, whereIn } from '@adonis-agora/authz/scope'

eq('author_id', user.id)                 // author_id = ?
whereIn('team_id', [1, 2, 3])            // team_id IN (?, ?, ?)
where('status', 'ne', 'archived')        // status != ?
where('deleted_at', 'isNull')            // deleted_at IS NULL  (value ignored)
where('views', 'gte', 100)               // views >= ?

where(field, op, value?) is the general leaf; eq and whereIn are shorthands for the two most common cases. Operators: eq, ne, gt, gte, lt, lte, in, nin, isNull, isNotNull.

Boolean groups

import { and, or } from '@adonis-agora/authz/scope'

// AND — owner AND in-tenant
and(eq('author_id', user.id), eq('tenant_id', tenant.tenantId))

// OR — owner OR published
or(eq('author_id', user.id), eq('published', true))

// nested — (owner OR published) AND not-archived
and(
  or(eq('author_id', user.id), eq('published', true)),
  where('status', 'ne', 'archived'),
)

Empty-group identities: and() with no nodes is allow-all (the AND identity); or() with no nodes is deny-all (the OR zero). A single-node and/or returns that node as-is. This lets you build groups conditionally — and(...conditions) where conditions may be empty — without special-casing.

Consuming the scope

Once registered, filter any collection with accessibleBy — see the full contract and the orWhere caveat in Concepts → Query scopes:

accessibleBy returns the query builder with the scope applied, not the rows — awaiting it resolves the constraint, awaiting the builder runs the query:

import { accessibleBy } from '@adonis-agora/authz/scope'

// Only the posts this user may see (owner ∪ published, per the filter above).
const scoped = await accessibleBy(Post.query(), authz, user, Post)
const posts = await scoped

// Scope by a specific action (defaults to 'viewAny'), then keep chaining:
const editable = await accessibleBy(Post.query(), authz, user, Post, { action: 'posts.edit' })
const recent = await editable.orderBy('created_at', 'desc').limit(20)

Splitting resolve from apply

accessibleBy is resolve-then-apply in one call. The two halves are also exported separately: authz.scope(user, resource, options) resolves the ScopeConstraint on its own, and applyScopeConstraint(query, constraint) compiles an already-resolved constraint onto a Lucid query.

Reach for the split when the constraint is worth more than a single query — you want to inspect it, cache it, serialize it as a query plan, or apply the same one to several queries:

import { applyScopeConstraint } from '@adonis-agora/authz/scope'
import authz from '@adonis-agora/authz/services/main'

const constraint = await authz.scope(user, Post)
// { kind: 'none' } → skip the query entirely, you know it returns nothing
if (constraint.kind === 'none') return { posts: [], count: 0 }

// One resolution, two queries:
const posts = await applyScopeConstraint(Post.query(), constraint).limit(20)
const [{ count }] = await applyScopeConstraint(Post.query(), constraint).count('* as count')

applyScopeConstraint is synchronous and returns the same builder, so it chains directly. It carries the identical orWhere contract as accessibleBy — apply it before any top-level orWhere.

On this page