Skip to main content

@permify-toolkit/nestjs

A NestJS wrapper for Permify — drop-in module, guard, and decorators for authorization.

NPM Version

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 GuardPermifyGuard enforces permissions on routes
  • Multi-Permission Checks — AND/OR logic for complex authorization
  • Typed Permission Names — bind @CheckPermission to your schema DSL so invalid names fail at compile time

Module Setup

Import PermifyModule into your root AppModule. Three configuration methods:

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:

  1. Route-level @PermifyResolvers({ tenant: ... }) override
  2. Controller-level @PermifyResolvers({ tenant: ... }) override
  3. Global resolvers.tenant function in forRoot
  4. tenant field from permify.config.ts (static fallback)
  5. 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:

  1. Route Level (Highest Priority)
  2. Controller Level
  3. Global Level
  4. 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
}
}
info

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.

info

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

  1. Resolves Tenant, Subject, and Resource from configured resolvers
  2. Evaluates permissions concurrently using AND/OR mode
  3. Passes { depth: 20 } as default metadata if none is configured
  4. Throws ForbiddenException with 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;
}
info

@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. admin vs editor to 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

ExportDescription
PermifyModuleNestJS dynamic module (forRoot / forRootAsync)
PermifyServiceInjectable service for manual permission checks
PermifyGuardRoute 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
PermissionCheckResultType for each entry in the results array
createPermifyDecorators()Create a @CheckPermission typed against a schema DSL definition