@permify-toolkit/nestjs
A NestJS wrapper for Permify — drop-in module, guard, and decorators for authorization.
Installation
pnpm add @permify-toolkit/nestjs @permify-toolkit/core
Features
- Flexible Configuration — initialize with
permify.config.ts, config objects, or direct client options - Global Configuration — configure client and resolvers once at the module level
- Hierarchical Resolvers — define resolvers globally, override at controller or route level
- Optional Tenant Resolver — set a static tenant in config, no resolver needed
- Authorization Guard —
PermifyGuardenforces permissions on routes - Multi-Permission Checks — AND/OR logic for complex authorization
- Typed Permission Names — bind
@CheckPermissionto your schema DSL so invalid names fail at compile time
Module Setup
Import PermifyModule into your root AppModule. Three configuration methods:
Option 1: Using permify.config.ts (Recommended)
import { Module } from "@nestjs/common";
import { PermifyModule } from "@permify-toolkit/nestjs";
@Module({
imports: [
PermifyModule.forRoot({
configFile: true, // Auto-loads permify.config.ts from CWD
resolvers: {
subject: (context) => context.switchToHttp().getRequest().user?.id
}
})
]
})
export class AppModule {}
Custom config path:
PermifyModule.forRoot({
configFile: true,
configFilePath: "./config/permify.config.ts"
});
Option 2: Importing Config Object
import { Module } from "@nestjs/common";
import { PermifyModule } from "@permify-toolkit/nestjs";
import permifyConfig from "../permify.config";
@Module({
imports: [
PermifyModule.forRoot({
config: permifyConfig,
resolvers: {
subject: (context) => context.switchToHttp().getRequest().user?.id
}
})
]
})
export class AppModule {}
Option 3: Direct Client Options
import { Module } from "@nestjs/common";
import { PermifyModule } from "@permify-toolkit/nestjs";
import { clientOptionsFromEnv } from "@permify-toolkit/core";
@Module({
imports: [
PermifyModule.forRoot({
client: clientOptionsFromEnv(),
resolvers: {
tenant: (context) =>
context.switchToHttp().getRequest().headers["x-tenant-id"],
subject: (context) => context.switchToHttp().getRequest().user?.id
}
})
]
})
export class AppModule {}
Precedence: config > configFile > client
Async Configuration
Use forRootAsync to inject dependencies like ConfigService:
import { Module } from "@nestjs/common";
import { PermifyModule } from "@permify-toolkit/nestjs";
import { ConfigModule, ConfigService } from "@nestjs/config";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PermifyModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
client: {
endpoint: config.get<string>("PERMIFY_ENDPOINT"),
insecure: config.get<boolean>("PERMIFY_INSECURE", true),
interceptor: {
authToken: config.get<string>("PERMIFY_AUTH_TOKEN")
}
},
resolvers: {
tenant: (ctx) =>
ctx.switchToHttp().getRequest().headers["x-tenant-id"],
subject: (ctx) => ctx.switchToHttp().getRequest().user?.id,
resource: (ctx) => ctx.switchToHttp().getRequest().params.id
}
})
})
]
})
export class AppModule {}
Tenant Resolution
The tenant resolver is optional when tenant is set in your config.
Resolution order:
- Route-level
@PermifyResolvers({ tenant: ... })override - Controller-level
@PermifyResolvers({ tenant: ... })override - Global
resolvers.tenantfunction inforRoot tenantfield frompermify.config.ts(static fallback)- Error if none provides a tenant
For single-tenant apps, set tenant once in your config:
// permify.config.ts
export default defineConfig({
tenant: "my-tenant",
client: { endpoint: "localhost:3478", insecure: true },
schema: schema({ ... })
});
// app.module.ts — no tenant resolver needed
PermifyModule.forRoot({
configFile: true,
resolvers: {
subject: (ctx) => ctx.switchToHttp().getRequest().user?.id
}
})
Hierarchical Resolvers
Resolvers follow strict precedence:
- Route Level (Highest Priority)
- Controller Level
- Global Level
- Config (Lowest Priority, tenant only)
Use the @PermifyResolvers decorator to override at any level:
import { Controller, Get } from "@nestjs/common";
import { PermifyResolvers } from "@permify-toolkit/nestjs";
@PermifyResolvers({
tenant: () => "controller-tenant-id",
resource: (ctx) => "controller-resource",
metadata: (ctx) => ({
depth: 20,
schemaVersion: "v1"
})
})
@Controller("cats")
export class CatsController {
@Get()
findAll() {
// Uses controller-level resolvers
}
@PermifyResolvers({
tenant: () => "route-tenant-id",
subject: () => "route-subject-id",
resource: (ctx) => "route-resource",
metadata: (ctx) => ({
snapToken: ctx.switchToHttp().getRequest().headers["x-snap-token"],
depth: 5
})
})
@Get("specific")
findSpecific() {
// Uses route-level overrides
}
}
There is no merging between levels. If you override at a level, you replace the resolution logic for that scope. Missing resolvers fall back to the next level.
Authorization Guard
Basic Usage
import { Controller, Get, UseGuards } from "@nestjs/common";
import {
PermifyGuard,
CheckPermission,
PermifyResolvers
} from "@permify-toolkit/nestjs";
@PermifyResolvers({
subject: (ctx) => ctx.switchToHttp().getRequest().user?.id,
resource: (ctx) => ctx.switchToHttp().getRequest().params.id
})
@Controller("documents")
export class DocumentsController {
@UseGuards(PermifyGuard)
@CheckPermission("document.view")
@Get(":id")
view() {
return "You have access!";
}
}
Multi-Permission Checks
The @CheckPermission decorator supports multiple permissions:
// Single permission
@CheckPermission("document.view")
// AND mode (default) — ALL must pass
@CheckPermission(["document.view", "document.edit"])
// OR mode — at least ONE must pass
@CheckPermission(["document.view", "document.edit"], { mode: "OR" })
Typed Permission Names
@CheckPermission accepts any string. If your schema is defined with the core DSL (see schema()), you can bind the decorator to it with createPermifyDecorators — then only names that exist in the schema compile, and your editor autocompletes them.
// auth.ts — bind once, re-export
import { createPermifyDecorators } from "@permify-toolkit/nestjs";
import { appSchema } from "./permify.config";
export const { CheckPermission } = createPermifyDecorators<typeof appSchema>();
// documents.controller.ts
import { CheckPermission } from "./auth";
@CheckPermission("document.edit") // ✅ compiles
@CheckPermission(["document.view", "edit"]) // ✅ qualified and bare forms both work
@CheckPermission("document.rename") // ❌ compile error — not in the schema
Both permissions and relations are accepted, in qualified ("document.edit") and bare ("edit") form the same set the guard resolves at runtime. Options like { mode: "OR" } work identically to the untyped decorator. The factory also returns a typed PermissionResult, so @PermissionResult("document.edit") gets the same compile-time name checking.
Typed names require a DSL-defined schema. If you use schemaFile() with a .perm file, keep using the untyped @CheckPermission or mirror the schema in the DSL to get typing (see the simulator for an example).
How the Guard Works
- Resolves Tenant, Subject, and Resource from configured resolvers
- Evaluates permissions concurrently using AND/OR mode
- Passes
{ depth: 20 }as default metadata if none is configured - Throws
ForbiddenExceptionwith a descriptive message on failure
Accessing Permission Results
When a route has multiple permissions checked with OR mode, you often need to know which ones passed, to conditionally include data, set flags, or write audit logs. @PermissionResult() injects the guard's results directly into the handler, with no second round-trip to Permify.
Pass a permission name to inject a single boolean — the common case:
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import {
CheckPermission,
PermifyGuard,
PermissionResult
} from "@permify-toolkit/nestjs";
@Controller("documents")
@UseGuards(PermifyGuard)
export class DocumentsController {
@Get(":id")
@CheckPermission(["document.view", "document.edit"], { mode: "OR" })
async getDocument(
@Param("id") id: string,
@PermissionResult("document.edit") canEdit: boolean
) {
return {
document: await this.documentService.findOne(id),
editable: canEdit // surface to frontend — no second gRPC call
};
}
}
Qualified ("document.edit") and bare ("edit") names both match the guard's results. A name that was never checked on the route injects false — unknown names fail closed.
Without an argument, @PermissionResult() injects the full results array — useful for audit logging or when you need several flags at once:
async getDocument(
@Param("id") id: string,
@PermissionResult() permissions: PermissionCheckResult[]
) {
const canEdit = permissions.find((p) => p.permission === "edit")?.allowed;
// ...
}
PermissionCheckResult is:
interface PermissionCheckResult {
permission: string; // e.g. "view", "edit"
allowed: boolean;
}
@PermissionResult() requires PermifyGuard to have already run on the route. On public routes where no guard ran, the array form returns [] and the boolean form returns false.
Common uses:
- OR-mode checks where you need to know which permission was granted (e.g.
adminvseditorto shape the response) - Returning permission flags alongside resource data so the frontend doesn't need a separate permissions call
- Audit logging which permissions were checked and what the outcome was
API Reference
Exports
| Export | Description |
|---|---|
PermifyModule | NestJS dynamic module (forRoot / forRootAsync) |
PermifyService | Injectable service for manual permission checks |
PermifyGuard | Route guard implementing CanActivate |
@CheckPermission() | Decorator to specify required permissions |
@PermifyResolvers() | Decorator to override resolvers per controller/route |
@PermissionResult() | Param decorator to inject guard's check results |
PermissionCheckResult | Type for each entry in the results array |
createPermifyDecorators() | Create a @CheckPermission typed against a schema DSL definition |