Authorization
Role-based access control implemented in plain code on top of AdonisJS Bouncer — no external RBAC library. Four moving parts:
PERMISSIONS— a const catalog of every capability the app understands.ROLES— a const catalog of role slugs, each with a weight used to pick a display role.WithRoles— a mixin composed intoUserthat grantsassignRole/hasRole/hasPermission/syncRoles.BasePolicysubclasses — enforcehasPermission(...)at the controller.
On the frontend a typed GlobalPermissions object flows through Inertia's shared props and is consumed via useCan(). Adding a permission is a two-line change (PERMISSIONS const + wherever it's gated); adding a role is a config + seed touch.
Rules
- Single source of truth for capabilities in a
PERMISSIONSconst object under theusersmodule. Naming:<mod>.<action>(users.create,tokens.view_list). Adding a permission = adding one entry + referencing it wherever it's gated. A new module's capabilities go in this same const — the<mod>.<action>naming already namespaces them; don't fork a secondPERMISSIONS. One central catalog; the policies that consume it stay in each module. - Role slugs live in a
ROLESconst with a matchingROLE_WEIGHTSmap. The weights drivemainRole()for display ordering. Adding a role = new entry + a seed that grants its permissions. - Roles store their own permissions on the
Rolemodel as a JSON column (role.permissions: Permission[]). Seed withRole.updateOrCreate({ name }, { permissions: [...] }). WithRolesmixin composes intoUsernext towithAuthFinder. It exposes:- Mutations:
assignRole(role),assignRoles(roles),syncRoles(roles),revokeRole(role),revokeRoles(roles). - Checks:
hasRole(name),hasPermission(permission). - Reads:
getRoleNames(),getPermissions(),preloadedRoles(sync read of.load('roles')).
- Mutations:
- Policies subclass
BasePolicyand live atapp/<mod>/policies/<entity>_policy.ts. One method per gated action. Signature:currentUserfirst, resource second when applicable. Returnboolean | AuthorizerResponse. - Gate at the controller before validating:
await bouncer.with(EntityPolicy).authorize('methodName', resource?). On deny it throws → 403 (JSON) or 302 back (HTML/Inertia). Every mutation route (POST/PUT/PATCH/DELETE) must be gated by a Bouncer policy — no exceptions. - Frontend shared prop: an Inertia middleware computes a small
can: GlobalPermissionsobject (manageUsers,manageTokens, …) and shares it on every request. Read withuseCan(). Extending the object = adding a boolean field toglobal_permissions.tscomputed fromuser.hasPermission(PERMISSIONS.x);useCantypes update automatically. - Escalation guards are defense-in-depth on top of validators:
requireManageRoles(executor)— throws if the executor lacksusers.manage_roles.requireManageRolesIfEscalating(executor, desiredRoles)— enforces the check even when a validator accepted a non-default role. Call at create / invite paths.AdminLockoutException— prevents a user from removing their ownadminrole. Raised insideSyncUserRoleswhen target and executor are the same and admin is being dropped.
Repo refs
- Capabilities + roles:
app/users/enums/permission.ts,app/users/enums/role.ts. WithRolesmixin:app/users/mixins/with_roles.ts. Policy:app/users/policies/user_policy.ts.- Escalation guards:
app/users/actions/sync_user_roles.ts. - Shared
canprop → frontenduseCan:app/users/services/global_permissions.ts,app/common/ui/hooks/use_can.ts. - Central registries:
app/core/policies/main.ts,app/core/abilities/main.ts.
Doc refs
- AdonisJS Bouncer — https://docs.adonisjs.com/guides/security/authorization
Workflow
Add a new permission
- Add an entry to
PERMISSIONS:export const PERMISSIONS = { ..., invoicesCreate: 'invoices.create', } as const - Reference it in the policy method that gates it:
async create(user: User) { return user.hasPermission(PERMISSIONS.invoicesCreate) } - Grant it to the roles that need it (usually via the seeder for
admin). - If the frontend needs a boolean for the permission, extend
GlobalPermissions:canCreateInvoices: user?.hasPermission(PERMISSIONS.invoicesCreate) ?? false,
Add a new role
- Add slug to
ROLES+ weight toROLE_WEIGHTS. - Seed the role with its permissions in a seeder.
- Update
ensureBaseRoles()in the test RBAC helpers if it's a base role every test needs.
Gate a route (mutation)
public async store({ auth, bouncer, request, response }: HttpContext) {
await bouncer.with(InvoicePolicy).authorize('create')
const payload = await request.validateUsing(createInvoiceValidator)
await new CreateInvoice().handle({ ...payload, executor: auth.getUserOrFail() })
return response.redirect().toRoute('invoices.index')
}
Policy runs before the validator so a 403 doesn't leak field-level errors to unauthorized callers. See [[crud]].
Gate a UI element
const can = useCan()
if (!can.manageUsers) return null
Or use the built-in can: 'permissionKey' field on nav items — see [[layout-shells]].
Escalation-safe user creation / invite
In the action that creates a user with a non-default role:
await requireManageRolesIfEscalating(input.executor, [input.role])
The validator alone isn't enough — a user with users.create but not users.manage_roles could try to escalate. This guard is defense-in-depth.
Testing
Set roles per test with the RBAC helpers:
const admin = await UserFactory.create()
await withRole(admin, ROLES.ADMIN)
// or granular:
const editor = await UserFactory.create()
await withPermissions(editor, 'editor', [PERMISSIONS.invoicesCreate])
Cover the "unauth", "authenticated-but-forbidden", and "authorized" paths for each mutation. See [[testing]].
Anti-patterns
- ❌ Hardcoding role name strings (
'admin') — useROLES.ADMIN. - ❌ Hardcoding permission strings (
'users.create') — usePERMISSIONS.usersCreate. - ❌ Skipping the policy call because "the validator already checked" — validators check payload shape, not authority.
- ❌ Adding a permission to
PERMISSIONSand forgetting the seeder — nobody will actually have it. - ❌ Gating only on the frontend (
if (can.x)) — always mirror the check on the backend policy. Frontend gating is UX, not security. - ❌ Assigning role by slug string in tests instead of
withRole(user, ROLES.ADMIN)— drifts if the slug ever changes. - ❌ Removing an escalation guard "because the validator handles it" — it's defense in depth for a reason.
- ❌ Owner check via
.where('owner_id', user.id)in a query instead of the policy — the policy is the source of truth; a query filter is an unrelated concern.
Related skills
[[crud]] · [[testing]] · [[actions-events]] · [[layout-shells]]