Aviary

RBAC (roles & permissions)

The opt-in, persisted roles-and-permissions layer — coarse @Roles checks in the core, and database-backed providers via the typeorm / mikro-orm / prisma adapters.

Gates and policies answer fine-grained questions ("can this user update this post?"). RBAC — role-based access control — answers coarse-grained ones ("is this user an editor?", "does this user hold the posts.publish permission?"). authz splits these on purpose: the core ships the coarse check machinery with zero database, and persistence is an opt-in adapter you add only when you need stored roles.

This mirrors how Laravel keeps Gate/Policy in the framework while roles-and-permissions storage lives in a separate package like spatie/laravel-permission.

Two halves: the seam and the store

RBAC in authz is two cooperating pieces:

  1. The seam (in the core). The @Roles decorator + RolesGuard, and the RoleProvider / PermissionProvider interfaces behind the ROLE_PROVIDER / PERMISSION_PROVIDER tokens. These let your code ask for a user's roles and permissions without knowing where they're stored.
  2. The store (an adapter). A persisted implementation of those providers, backed by your database, shipped as @dudousxd/nestjs-authz-typeorm, -mikro-orm, or -prisma.

Crucially, the seam works on its own. With no adapter installed, coarse role checks still function — they read roles straight off the current user object (user.roles / user.role) via the built-in defaultRoleResolver. Nothing in the core touches a database; the adapter is what lets roles and permissions live in one and be edited at runtime.

Coarse checks with @Roles

The simplest RBAC tool is the @Roles decorator, enforced by the RolesGuard (registered as an APP_GUARD by AuthzModule.forRoot(), exactly like the CanGuard):

import { Controller, Post as HttpPost } from '@nestjs/common';
import { Roles } from '@dudousxd/nestjs-authz';

@Controller('admin')
export class AdminController {
  @HttpPost('rebuild-index')
  @Roles('staff', 'admin') // allowed if the current user holds ANY of these roles
  rebuildIndex() {}
}

The guard reads the current user (see Current user) and allows the request when the user holds any of the listed roles. By default the user's roles come straight off the user object — user.roles (a string[]) or user.role (a string or string[]) — so this works with zero RBAC tables. An unauthenticated request is denied, and like every authz guard it is inert on un-annotated routes.

// both users satisfy @Roles('admin') with no provider wired:
{ id: 1, roles: ['admin', 'editor'] }
{ id: 2, role: 'admin' }

You can check the same thing programmatically on the Gate:

await this.gate.hasRole('admin');
await this.gate.hasAnyRole(['staff', 'admin']);
await this.gate.forUser(someUser).hasAnyRole(['admin']); // explicit user

@Roles is a coarse gate. When a decision depends on the resource — "can edit this post" — reach for a policy instead. The two compose freely: use @Roles to fence off an admin area and policies for per-record rules inside it.

Where roles come from

Roles are resolved from up to two sources, and the gate takes the union of both:

  1. The user object (always on). The defaultRoleResolver reads user.roles (string[]) and user.role (string | string[]), normalizing both to a list. Non-string entries are filtered out. If your role lives under a different shape, override the resolver in the module:

    AuthzModule.forRoot({
      resolveRoles: (user) => user.membership?.roleNames ?? [],
    });
  2. A RoleProvider (optional). A persisted source registered under the ROLE_PROVIDER token — supplied by an RBAC adapter. Its getRoles(user) result is unioned with whatever the resolver returned.

So a user might carry role: 'editor' on their token and have admin granted in the database; @Roles('admin') and gate.hasRole('admin') both succeed.

The permission seam

Roles answer "is this user an editor?". Permissions answer "may this user posts.publish?" — a finer, action-named grant. The PermissionProvider seam (token PERMISSION_PROVIDER) lets a persisted layer answer permission abilities directly:

export interface PermissionProvider {
  hasPermission(user: User, ability: string, resource?: unknown): boolean | Promise<boolean>;
}

When a provider is registered, it is consulted inside the normal allows() flowbefore policies. So a permission-named ability just works through the same call you already use, with no special API:

// granted by the PermissionProvider → allowed (decision reason: "permission-provider")
if (await this.gate.allows('posts.publish')) {
  // ...
}

Both seams are optional and token-based, so the engine consults them when present and falls back cleanly when absent — the same "degrade gracefully" pattern authz uses for the current-user accessor. You rarely register these by hand; an RBAC adapter provides both:

import { ROLE_PROVIDER, PERMISSION_PROVIDER } from '@dudousxd/nestjs-authz';

// the adapter wires these for you; this is just the shape
{ provide: ROLE_PROVIDER, useClass: MyRoleProvider }
{ provide: PERMISSION_PROVIDER, useClass: MyPermissionProvider }

Adding persistence

When you want roles and permissions stored in your database, install the adapter for your ORM. It provides the RoleProvider/PermissionProvider implementations and manages the schema.

pnpm add @dudousxd/nestjs-authz-typeorm
pnpm add @dudousxd/nestjs-authz-mikro-orm
pnpm add @dudousxd/nestjs-authz-prisma

The store is a plain object

Following the ecosystem convention, the store is a POJO that receives your database connection in its constructor — it is not an @Injectable, and it owns no internal DI token. You construct it with the connection you already have, which keeps the adapter from fighting your ORM setup:

import { TypeOrmAuthzStore } from '@dudousxd/nestjs-authz-typeorm';
import { DataSource } from 'typeorm';

const store = new TypeOrmAuthzStore(dataSource, { /* options */ });

You then hand the store to the RBAC module, plugging in the connection through forRootAsync so the app — not the library — owns the connection token:

import { AuthzRbacModule } from '@dudousxd/nestjs-authz-typeorm';
import { TypeOrmAuthzStore } from '@dudousxd/nestjs-authz-typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';

@Module({
  imports: [
    AuthzRbacModule.forRootAsync({
      inject: [getDataSourceToken('auth')], // or [DataSource] for the default connection
      useFactory: (ds) => ({
        store: new TypeOrmAuthzStore(ds),
        autoCreateSchema: true, // default; non-destructive (see below)
        schema: 'auth', // optional Postgres schema (this is a DB schema, not a connection)
        tableNames: {
          roles: 'roles',
          permissions: 'permissions',
          roleUser: 'role_user',
          rolePermission: 'role_permission',
        },
      }),
    }),
  ],
})
export class AppModule {}

Schema management

The adapters expose schema helpers so you can let the library create tables or manage them yourself:

  • autoCreateSchema: true (default) runs ensureAuthzSchema at boot. It creates any missing table and adds any missing column — and it is strictly non-destructive: it never drops or alters existing data, and post-v1 columns are added nullable or with defaults. This is ideal for getting started.
  • autoCreateSchema: false leaves the schema entirely to you. Use the exported helpers (createAuthzTables, ensureAuthzSchema) inside your own migrations, or hand-roll the tables. The entity classes (RoleEntity, PermissionEntity, RolePermissionEntity, UserRoleEntity) are exported so you can register them with your ORM's migration tooling.

autoCreateSchema: true is convenient for development and small deployments, but for production with a migration workflow, set it to false and apply schema changes through your migration pipeline so changes are reviewed and versioned.

Adapter specifics:

  • TypeORMTypeOrmAuthzStore, schema via ensureAuthzSchema.
  • MikroORM — applies updates through getUpdateSchemaSQL({ safe: true }), so generated DDL is non-destructive by construction.
  • Prisma — consumer-managed: Prisma owns the schema, so you add the models to your schema.prisma and run prisma migrate; the adapter reads/writes through your PrismaClient.

Tenant-scoped permissions

Because authz reads the current tenant from @dudousxd/nestjs-context (Context.tenantId()), permission lookups can be scoped to the active tenant automatically — the same user can be an editor in one tenant and a viewer in another, and the provider resolves the right set for the request in flight. No per-call tenant argument required.

How it fits together

@Roles('admin') / gate.hasRole(...) / gate.allows('posts.publish')


   resolveRoles / defaultRoleResolver   ← user object (zero DB, always on)
        │  ∪ (union)

   ROLE_PROVIDER / PERMISSION_PROVIDER   ← optional token seam


   TypeOrmAuthzStore (POJO + your DataSource)   ← opt-in adapter


   roles / permissions / role_user / role_permission tables

Your policies and @Roles annotations never change when you add or remove persistence. Without an adapter, roles come from the user object and permission abilities simply have no grants; add an adapter and the same calls start consulting your database too.

RBAC is genuinely optional. Plenty of apps ship with gates and policies alone and never install an adapter. Add RBAC when "who can do what" becomes data your admins edit at runtime rather than logic you deploy.

On this page