nestjs-permissions
v0.1.1
Published
Composable permission-loading and permission-checking guards for NestJS
Maintainers
Readme
nestjs-permissions
Declarative, permission-based authorization for NestJS — two global guards that load a user's permissions and check them against per-route rules, driven entirely by decorators.
@Controller('apps')
@LoadPermissions('apps') // load the user's app permissions
export class AppsController {
@Patch(':id')
@CheckPermissions('apps.edit', 'apps.editableAppIds(:id)') // both must pass
update(@Param('id') id: string) { /* ... */ }
}No @UseGuards() boilerplate, no permission logic scattered through controllers — just say
what each route needs.
Contents
- Why
- How it works
- Requirements
- Installation
- Quick start
- Rules in depth
- Public routes
- Reading permissions in a handler
- Type-safe rules (optional)
- API
- Example app
- Development
- License
Why
This library is authorization only. It assumes something upstream (a JWT/Passport guard,
a session middleware, …) has already authenticated the request and set request.user. From
there it answers one question per route: is this user allowed to do this?
- Declarative — permissions are expressed as decorator metadata, next to the route.
- Two-phase — loading (fetch what the user can do) is separated from checking (enforce it), so you fetch permissions once and evaluate many rules against them.
- Portable — the guards depend only on
@nestjs/common/@nestjs/core. All app-specific logic (DB queries, caching) lives in yourPermissionsLoaderService. - Type-safe (opt-in) — describe your permission shape once and get compile-time errors for invalid rule strings.
How it works
Once registered, both guards run on every request, in order:
PermissionsLoaderGuardreads@LoadPermissions(...)metadata, calls yourPermissionsLoaderService.loadPermissions(user, keys, ctx), and merges the result ontorequest.user.permissions.PermissionsCheckGuardreads@CheckPermissions(...)metadata and evaluates each rule againstrequest.user.permissions(and route params), throwingForbiddenExceptionon the first failing rule.
A route with neither decorator is a no-op — the guards let it through. A route missing
request.user when a check is required is rejected with 403 (the loader guard, by contrast,
no-ops on a missing user and leaves rejection to your auth layer).
Requirements
- NestJS 10 or 11 (
@nestjs/common,@nestjs/core) reflect-metadataandrxjs(already present in every Nest app)
These are declared as peer dependencies, so the library uses whatever versions your app has.
Installation
npm install nestjs-permissionsQuick start
1. Implement a loader — this is where "what can this user do?" is answered.
import { Injectable } from '@nestjs/common';
import { PermissionsLoaderService } from 'nestjs-permissions';
@Injectable()
export class AppPermissionsLoader extends PermissionsLoaderService {
async loadPermissions(user: { id: number }, keys: string[]) {
const result: Record<string, any> = {};
if (keys.includes('apps')) {
// ...look up from your DB/cache...
result.apps = { edit: true, editableAppIds: [1, 2, 3] };
}
return result; // -> merged onto request.user.permissions
}
}2. Register the module once in AppModule — this wires up both global guards.
import { Module } from '@nestjs/common';
import { PermissionsModule } from 'nestjs-permissions';
import { AppPermissionsLoader } from './app-permissions-loader.service';
@Module({
imports: [PermissionsModule.forRoot(AppPermissionsLoader)],
})
export class AppModule {}If your loader needs injected dependencies (a Prisma service, a repository), use
forRootAsync:
PermissionsModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: (prisma: PrismaService) => new AppPermissionsLoader(prisma),
});3. Declare load + check rules on your controllers.
import { Controller, Get, Patch, Param } from '@nestjs/common';
import { LoadPermissions, CheckPermissions } from 'nestjs-permissions';
@Controller('apps')
@LoadPermissions('apps') // loaded once for every route in this controller
export class AppsController {
@Get()
@CheckPermissions('apps.view')
list() {}
@Patch(':id')
@CheckPermissions(
'apps.edit', // must have the edit flag …
'apps.editableAppIds(:id)', // … AND :id must be in the editable list
)
update(@Param('id') id: string) {}
}That's it — the guards enforce these on every request automatically.
Rules in depth
Rule syntax reference
| Rule | Meaning |
|---|---|
| 'apps.create' | dot-path lookup on user.permissions; passes only if strictly === true |
| 'apps.editableAppIds(:id)' | the path must resolve to an array, and the route's :id param must be in it (compared with String(), so numeric and string ids both work) |
| (user, request, context) => boolean | fully custom; may be async; throw to produce your own error instead of the generic ForbiddenException |
Multiple rules are AND-ed — @CheckPermissions('a', 'b') requires both. Passing a
function alone is fine (no leading string required):
@CheckPermissions((user, request) =>
user.role === 'owner' || request.body?.confirmed === true,
)Class-level + method-level (merged)
@LoadPermissions and @CheckPermissions can be applied at the class level, the method
level, or both. The two are merged and deduplicated, never overridden — a method's
declarations are added to the class's.
@Controller('apps')
@LoadPermissions('apps') // loaded for every method
@CheckPermissions('apps.access') // required for every method
export class AppsController {
@Get()
list() {} // load: ['apps'] · check: ['apps.access']
@Patch(':id')
@LoadPermissions('billing') // also loads 'billing'
@CheckPermissions('apps.edit') // also requires 'apps.edit'
update() {} // load: ['apps', 'billing'] · check: ['apps.access', 'apps.edit']
}Because checks are AND-ed across the merged set, a class rule is an additive constraint: a method can add requirements but can't loosen the class-level one.
Public routes
@Public() opts a route out of both guards entirely:
import { Public } from 'nestjs-permissions';
@Controller('apps')
@LoadPermissions('apps')
export class AppsController {
@Public()
@Get('health')
health() {} // no user required, nothing loaded, nothing checked
@Patch(':id')
@CheckPermissions('apps.edit')
update() {} // still enforced
}It works at class or method level and is read with getAllAndOverride (method wins),
matching the standard opt-out pattern used alongside a global auth guard.
⚠️ Sharp edge: because
@Public()short-circuits both guards, a@CheckPermissionson a method inside a@Public()class will never run. Don't combine them on the same route.
Reading permissions in a handler
@UserPermissions() injects request.user.permissions (the object the loader guard
populated) into a controller method:
import { UserPermissions } from 'nestjs-permissions';
@Get()
list(@UserPermissions() permissions: AppPermissions) {
return permissions.apps;
}Pass a dot-path to pluck a sub-key (resolved the same way @CheckPermissions string rules
are):
| Argument | Returns |
|---|---|
| @UserPermissions() | user.permissions |
| @UserPermissions('apps') | user.permissions.apps |
| @UserPermissions('apps.edit') | user.permissions.apps.edit |
It returns undefined when there's no request.user (an unauthenticated or @Public()
route) or when a path segment is missing — traversal never throws. A param decorator can't
enforce the parameter's type, so annotate it yourself.
Type-safe rules (optional)
By default a rule string is just a string, so a typo like 'apps.edt' compiles fine and
only fails at runtime. Describe your permission shape once with createPermissions and get
compile-time errors for any path or bucket that doesn't exist:
// permissions.ts
import { createPermissions } from 'nestjs-permissions';
export interface AppPermissions {
apps: { create: boolean; edit: boolean; editableAppIds: number[] };
billing: { manage: boolean };
}
export const { CheckPermissions, LoadPermissions } =
createPermissions<AppPermissions>();// controller — import from your permissions.ts, not the package
import { CheckPermissions, LoadPermissions } from '../permissions';
@LoadPermissions('apps', 'billing') // ok — top-level buckets
@LoadPermissions('reports') // ❌ compile error: not a bucket
@CheckPermissions('apps.edit') // ok
@CheckPermissions('apps.editableAppIds(:id)') // ok — array leaf, param form
@CheckPermissions((user, req) => user.role === 'owner') // ok — functions still allowed
@CheckPermissions('apps.delete') // ❌ compile error: not in AppPermissions
@CheckPermissions('apps.editableAppIds') // ❌ compile error: array leaf needs (:param)Schema → valid values:
@LoadPermissionsaccepts the top-level bucket keys ('apps','billing').@CheckPermissions: abooleanleaf → dot-path rule; an array leaf →(:param)rule (any param name); nested objects are walked recursively.
This is purely compile-time — the typed decorators emit the exact same metadata as the
package's CheckPermissions/LoadPermissions, so runtime behavior is unchanged. The untyped
exports remain available for callers that don't want a schema.
API
| Export | Kind | Purpose |
|---|---|---|
| PermissionsModule | module | forRoot(loader) / forRootAsync(options) — registers both global guards |
| PermissionsLoaderService | abstract class | extend + implement loadPermissions(user, keys, ctx) |
| @LoadPermissions(...keys) | decorator | declare which permission buckets to load |
| @CheckPermissions(...rules) | decorator | declare the rules a route requires (AND-ed) |
| @Public() | decorator | opt a route out of both guards |
| @UserPermissions(path?) | param decorator | inject request.user.permissions (or a sub-path) |
| createPermissions<T>() | factory | returns typed CheckPermissions / LoadPermissions |
| PermissionsLoaderGuard, PermissionsCheckGuard | guards | registered for you by the module |
| PermissionRule, PermissionRuleFn, PermissionPaths, … | types | for advanced/typed usage |
Example app
A complete, runnable NestJS app lives in example/: a PermissionsLoaderService,
typed decorators, every rule form (boolean / array-param / function), @Public(),
@UserPermissions(), and an end-to-end test suite that drives real HTTP requests through the
guards.
Development
npm install
npm run check # build + type fixtures + unit tests + example e2e (the full gate)| Script | What it does |
|---|---|
| npm run build | tsc compile to dist/ |
| npm run test:types | type-checks the type-tests/ @ts-expect-error fixtures |
| npm test | Jest unit suite (src/**/*.spec.ts) — guards, @UserPermissions(), module wiring |
| npm run test:example | e2e suite in example/, against the built dist/ |
| npm run check | all of the above, in order, stopping at the first failure |
Also: npm run test:watch, npm run test:cov.
Releases are published to npm automatically by GitHub Actions when a
GitHub Release is created (see
.github/workflows/npm-publish.yml), using npm provenance.
License
MIT © Midhun G S
