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:
import type { PermissionStore, TenantScope, SubjectRef } from '@adonis-agora/authz'
export class RedisPermissionStore implements PermissionStore {
// ...
}Schema
| Method | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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
| Method | Contract |
|---|---|
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:
- Writes are idempotent and race-tolerant. Assigning the same role twice concurrently must leave one assignment and throw nothing.
- 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. - Direct user-permission grants are tenant-independent. They apply in every scope. Only role assignments carry a tenant.
- Names match exactly.
subjectHasPermissionandgetPermissionsForSubjectdo 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:
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.