Agora

Testing

Use the memory store and the shared store contract suite to test authorization deterministically.

The memory store

For tests, point default at the memory store (or build one inline). It has the same semantics as the Lucid store but keeps state in-process, so tests stay fast and isolated.

import { AuthzService, MemoryPermissionStore } from '@adonis-agora/authz'

const store = new MemoryPermissionStore()
await store.givePermissionToRole('editor', 'posts.*')
await store.assignRole({ type: 'user', id: '1' }, 'editor')

const authz = new AuthzService({ store })
await authz.can({ id: '1' }, 'posts.edit') // true (wildcard)

Testing a Bouncer ability

import { Bouncer } from '@adonisjs/bouncer'
import { AuthzService, MemoryPermissionStore, defineAuthzAbilities } from '@adonis-agora/authz'

const store = new MemoryPermissionStore()
await store.assignRole({ type: 'user', id: '1' }, 'admin')

const abilities = defineAuthzAbilities(new AuthzService({ store }))
const bouncer = new Bouncer({ id: '1' }, abilities)

await bouncer.allows('hasRole', 'admin')   // true
await bouncer.denies('can', 'posts.edit')  // true (no grant)

Writing your own store

PermissionStore is the whole surface authz asks of a backend — Redis, Mongo, a remote authorization service, an in-house table layout. Implement these twenty methods and every feature in the package works against it unchanged:

app/authz/redis_store.ts
import type { PermissionStore, TenantScope, SubjectRef } from '@adonis-agora/authz'

export class RedisPermissionStore implements PermissionStore {
  // ...
}

Schema

MethodContract
ensureSchema()Create or upgrade whatever backing structures you need. Called on first use; a no-op is fine for a store with nothing to create.

Roles & permissions

MethodContract
createRole(name)Idempotently create the role; return its id.
createPermission(name)Idempotently create the permission; return its id.
givePermissionToRole(role, permission)Attach a permission to a role, creating either by name as needed.
revokePermissionFromRole(role, permission)Detach it. A no-op when either side is absent.
getRolePermissions(role)The permission names attached to a role.
getPermissionsForRoles(roles){ [role]: sorted permission names } in one query. Every existing role is a key ([] without grants); unknown roles are absent.
syncRolePermissions(role, permissions)Replace the role's grants with exactly this list, creating role/permissions by name. Atomic.
listRoles()Every role name known to the store.
listPermissions()Every permission name known to the store.
deleteRole(role)Remove a role outright — revokes its grants, every user assignment (all tenants and sources), and the role row, atomically. Returns whether the role existed; an unknown name is a no-op returning false. Whether to refuse a still-populated role is the host's call (countSubjectsForRole first).

Assignments & grants

MethodContract
assignRole(user, role, scope?)Assign a role to a user, optionally scoped to a tenant.
removeRole(user, role, scope?)Remove the assignment matching that exact tenant scope.
giveSubjectPermission(user, permission)Grant a permission straight to a user.
revokeSubjectPermission(user, permission)Revoke that direct grant. Role-derived permissions survive.
removeSubject(user)Delete every role assignment (all tenants and sources) and direct grant of the user, atomically.
listRoleAssignments(filter?)Raw { subjectType, subjectId, role, source, tenantId } rows, filtered by tenantId (omitted = all, null = global only, a string = only that tenant's scoped rows), role (name or list), subject, source; ordered by (subjectType, subjectId, role, source, tenantId).

Reads

MethodContract
getRolesForSubject(user, scope?)The user's role names, tenant-filtered.
getSubjectsForRole(role, scope?)The reverse: every { type, id } holding the role, tenant-filtered the same way.
countSubjectsForRole(role, scope?)The same visibility as getSubjectsForRole, reduced to a count — the "N users" KPI without transferring one ref per member.
countSubjectsByRole(scope?)Every role mapped to its member count in one pass (unheld roles count as 0), for a whole roles matrix.
getPermissionsForSubject(user, scope?)The user's effective permission names — role-derived ∪ direct.
subjectHasPermission(user, permission, scope?)Exact-name membership check.

Transaction seam

MethodContract
withClient(client)A view of the store whose SQL runs on client (see config → Joining a transaction). A store with no connection to scope to — like the memory store — returns this.

Four invariants hold across all of them, and a store that breaks any one of them will pass casual testing and fail in production:

  1. Writes are idempotent and race-tolerant. Assigning the same role twice concurrently must leave one assignment and throw nothing.
  2. Tenant visibility is asymmetric. A global request (no tenant, or '') sees only global rows. A tenant request sees global rows and that tenant's. A tenant-scoped assignment must never surface in an unscoped check or in another tenant's.
  3. Direct user-permission grants are tenant-independent. They apply in every scope. Only role assignments carry a tenant.
  4. Names match exactly. subjectHasPermission and getPermissionsForSubject do no wildcard expansion — the store returns what was granted, posts.* included, and the service runs the matcher over it. A store that expands wildcards itself will double-expand.

The shared store contract suite

Do not verify those invariants by hand — run the same suite the built-in stores pass. It is exported from the @adonis-agora/authz/testing subpath and runs under Vitest:

tests/unit/redis_store.spec.ts
import { runPermissionStoreContract } from '@adonis-agora/authz/testing'
import { RedisPermissionStore } from '#authz/redis_store'

runPermissionStoreContract('RedisPermissionStore', () => new RedisPermissionStore())

The suite covers idempotency, role/permission grants and revokes, direct grants, per-user isolation, tenant visibility, polymorphic user types, role deletion, member counts (per role and per matrix, tenant-scoped), and a trailing { client } option accepted by every data method.

The contract factory must return a fresh, isolated store per call so cases do not bleed into one another.

On this page

Partner

Your product as the official integration for Authz, right here.

Become a partner →