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 sixteen
methods and every feature in the package works against it unchanged:
import type { PermissionStore, TenantScope, UserRef } 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. |
listRoles() | Every role name known to the store. |
listPermissions() | Every permission name known to the store. |
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. |
giveUserPermission(user, permission) | Grant a permission straight to a user. |
revokeUserPermission(user, permission) | Revoke that direct grant. Role-derived permissions survive. |
Reads
| Method | Contract |
|---|---|
getRolesForUser(user, scope?) | The user's role names, tenant-filtered. |
getUsersForRole(role, scope?) | The reverse: every { type, id } holding the role, tenant-filtered the same way. |
getPermissionsForUser(user, scope?) | The user's effective permission names — role-derived ∪ direct. |
userHasPermission(user, permission, scope?) | Exact-name membership check. |
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.
userHasPermissionandgetPermissionsForUserdo 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 and polymorphic user types.
The contract factory must return a fresh, isolated store per call so cases do not bleed into one another.