Last active
December 13, 2025 15:41
-
-
Save carlos-talavera/c97b7730045225b8d63e32b6e64f5c35 to your computer and use it in GitHub Desktop.
Example of implementing Permit.io in NestJS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // This is to retrieve all the available permissions so you can fetch them in the frontend | |
| import { AuthGuard } from '@/shared/auth/auth.guard'; | |
| import { Session } from '@/shared/decorators/session.decorator'; | |
| import { CheckPermissionsDto } from '@/shared/permit/dto/check-permissions.dto'; | |
| import { PermitService } from '@/shared/permit/permit.service'; | |
| import { SessionWithMetadata } from '@/shared/types'; | |
| import { Body, Controller, Post, Res, UseGuards } from '@nestjs/common'; | |
| import type { Response } from 'express'; | |
| @Controller('permit') | |
| export class PermitController { | |
| constructor(private readonly permitService: PermitService) {} | |
| @Post('check-permissions') | |
| @UseGuards(AuthGuard) | |
| public async checkPermissions( | |
| @Body() checkPermissionsDto: CheckPermissionsDto, | |
| @Session() session: SessionWithMetadata, | |
| @Res() res: Response, | |
| ): Promise<void> { | |
| const permit = this.permitService.getPermitClient(); | |
| const permittedList = await Promise.all( | |
| checkPermissionsDto.resourcesAndActions.map((resourceAndAction) => | |
| permit.check( | |
| session.metadata.uuid, | |
| resourceAndAction.action, | |
| resourceAndAction.resource, | |
| ), | |
| ), | |
| ); | |
| res.status(200).json({ permittedList }); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { PermitService } from '@/shared/permit/permit.service'; | |
| import { getSessionFromContext } from '@/shared/utils/session.util'; | |
| import { | |
| BadRequestException, | |
| CanActivate, | |
| ExecutionContext, | |
| ForbiddenException, | |
| Injectable, | |
| InternalServerErrorException, | |
| UnauthorizedException, | |
| } from '@nestjs/common'; | |
| import { Reflector } from '@nestjs/core'; | |
| @Injectable() | |
| export class PermitGuard implements CanActivate { | |
| constructor( | |
| private readonly reflector: Reflector, | |
| private readonly permitService: PermitService, | |
| ) {} | |
| public async canActivate(context: ExecutionContext): Promise<boolean> { | |
| const user = (await getSessionFromContext(context)).metadata; // Example using SuperTokens | |
| if (!user) { | |
| throw new UnauthorizedException('No user context found'); | |
| } | |
| const permission = this.reflector.get<{ action: string; resource: string }>( | |
| 'permission', | |
| context.getHandler(), | |
| ); | |
| if (!permission) { | |
| throw new BadRequestException('No permission found'); | |
| } | |
| try { | |
| const permit = this.permitService.getPermitClient(); | |
| const permitted = await permit.check( | |
| user.uuid, | |
| permission.action, | |
| permission.resource, | |
| ); | |
| if (!permitted) { | |
| throw new ForbiddenException('Insufficient permissions'); | |
| } | |
| return true; | |
| } catch (error) { | |
| if (error instanceof ForbiddenException) { | |
| throw error; | |
| } | |
| console.error(error); | |
| throw new InternalServerErrorException( | |
| 'An error occurred while checking permissions', | |
| ); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Environment from '@/shared/config/models/environment.enum'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { AREAS } from '@/shared/constants/area.constant'; | |
| import { Injectable, OnModuleInit } from '@nestjs/common'; | |
| import { isEqual, keys, omit } from 'lodash'; | |
| import { Permit, ResourceCreate, RoleCreate } from 'permitio'; | |
| import { kebabCase } from "lodash"; | |
| function removeAccentMarks(str: string) { | |
| const normalizedStr = str.normalize("NFD"); | |
| const accentRemovedStr = normalizedStr.replace(/[\u0300-\u036f]/g, ""); | |
| return accentRemovedStr; | |
| } | |
| function formatAreaNameForPermit(name: string) { | |
| return kebabCase(removeAccentMarks(name)); | |
| } | |
| const NODE_ENV_ENVIRONMENT_ID_MAP: Record<Environment, string> = { | |
| [Environment.LOCAL]: 'dev', | |
| [Environment.DEVELOPMENT]: 'dev', | |
| [Environment.STAGING]: 'staging', | |
| [Environment.PRODUCTION]: 'production', | |
| }; | |
| const RESOURCES: ResourceCreate[] = [ | |
| { | |
| key: 'user', | |
| name: 'user', | |
| actions: { | |
| read: {}, | |
| create: {}, | |
| update: {}, | |
| delete: {}, | |
| }, | |
| }, | |
| { | |
| key: 'client', | |
| name: 'client', | |
| actions: { | |
| read: {}, | |
| create: {}, | |
| update: {}, | |
| transfer: {}, | |
| }, | |
| }, | |
| { | |
| key: 'mdmx-invoice', | |
| name: 'mdmx-invoice', | |
| actions: { | |
| read: {}, | |
| create: {}, | |
| }, | |
| } | |
| ]; | |
| const MANAGEMENT_PERMISSIONS: string[] = [ | |
| 'user:read', | |
| 'user:create', | |
| 'user:update', | |
| 'user:delete', | |
| 'client:read', | |
| 'client:create', | |
| 'client:update', | |
| 'client:transfer', | |
| ]; | |
| const SALES_PERMISSIONS: string[] = [ | |
| 'client:read', | |
| 'client:create', | |
| 'client:update', | |
| ]; | |
| const ROLES: RoleCreate[] = [ | |
| { | |
| key: formatAreaNameForPermit(AREAS_NAME_TRANSLATED_NAME_MAP.MANAGEMENT), | |
| name: AREAS_NAME_TRANSLATED_NAME_MAP.MANAGEMENT, | |
| permissions: MANAGEMENT_PERMISSIONS, | |
| }, | |
| { | |
| key: formatAreaNameForPermit(AREAS_NAME_TRANSLATED_NAME_MAP.SALES), | |
| name: AREAS_NAME_TRANSLATED_NAME_MAP.SALES, | |
| permissions: SALES_PERMISSIONS, | |
| } | |
| ]; | |
| @Injectable() | |
| export class PermitService implements OnModuleInit { | |
| private permitClient: Permit; | |
| private projectId: string; | |
| private environmentId: string; | |
| constructor(private readonly configService: ConfigService) { | |
| this.permitClient = new Permit({ | |
| pdp: this.configService.get('PERMIT_IO_PDP_URL'), | |
| token: this.configService.get('PERMIT_IO_API_KEY'), | |
| }); | |
| this.projectId = 'default'; | |
| this.environmentId = NODE_ENV_ENVIRONMENT_ID_MAP[process.env.NODE_ENV]; | |
| } | |
| public async onModuleInit() { | |
| if ( | |
| this.configService.get('SEED_PERMIT_RESOURCES_FEATURE_ENABLED') === 'true' | |
| ) { | |
| await this.seedResources(); | |
| } | |
| if ( | |
| this.configService.get('SEED_PERMIT_ROLES_FEATURE_ENABLED') === 'true' | |
| ) { | |
| await this.seedRoles(); | |
| } | |
| } | |
| public getPermitClient() { | |
| return this.permitClient; | |
| } | |
| private async seedResources() { | |
| const response = await fetch( | |
| `https://api.permit.io/v2/schema/${this.projectId}/${this.environmentId}/resources`, | |
| { | |
| method: 'GET', | |
| headers: { | |
| Authorization: `Bearer ${this.configService.get('PERMIT_IO_API_KEY')}`, | |
| }, | |
| }, | |
| ); | |
| const existingResources = (await response.json()) as ResourceCreate[]; | |
| for (const resource of RESOURCES) { | |
| const existingResource = existingResources.find( | |
| (r) => r.key === resource.key, | |
| ); | |
| if (!existingResource) { | |
| await this.permitClient.api.createResource(resource); | |
| } else { | |
| const existingActionKeys = keys(existingResource.actions).sort(); | |
| const resourceActionKeys = keys(resource.actions).sort(); | |
| if (!isEqual(existingActionKeys, resourceActionKeys)) { | |
| await this.permitClient.api.updateResource( | |
| resource.key, | |
| omit(resource, ['key']), | |
| ); | |
| } | |
| } | |
| } | |
| for (const existingResource of existingResources) { | |
| if (!RESOURCES.some((r) => r.key === existingResource.key)) { | |
| await this.permitClient.api.deleteResource(existingResource.key); | |
| } | |
| } | |
| } | |
| private async seedRoles() { | |
| const response = await fetch( | |
| `https://api.permit.io/v2/schema/${this.projectId}/${this.environmentId}/roles`, | |
| { | |
| method: 'GET', | |
| headers: { | |
| Authorization: `Bearer ${this.configService.get('PERMIT_IO_API_KEY')}`, | |
| }, | |
| }, | |
| ); | |
| const existingRoles = (await response.json()) as RoleCreate[]; | |
| for (const role of ROLES) { | |
| const existingRole = existingRoles.find((r) => r.key === role.key); | |
| if (!existingRole) { | |
| await this.permitClient.api.createRole(role); | |
| } else { | |
| const existingPermissionKeys = keys(existingRole.permissions).sort(); | |
| const rolePermissionKeys = keys(role.permissions).sort(); | |
| if (!isEqual(existingPermissionKeys, rolePermissionKeys)) { | |
| await this.permitClient.api.updateRole(role.key, omit(role, ['key'])); | |
| } | |
| } | |
| } | |
| for (const existingRole of existingRoles) { | |
| if (!ROLES.some((r) => r.key === existingRole.key)) { | |
| await this.permitClient.api.deleteRole(existingRole.key); | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { UserCreateInput } from '@/api/users/dto/user-create.input'; | |
| import { UserSelect } from '@/api/users/models/user-select.model'; | |
| import { User } from '@/api/users/models/user.model'; | |
| import { UserService } from '@/api/users/user.service'; | |
| import { AuthGuard } from '@/shared/auth/auth.guard'; | |
| import { GraphQLFields } from '@/shared/decorators/graphql-fields.decorator'; | |
| import { Session } from '@/shared/decorators/session.decorator'; | |
| import { PermitGuard } from '@/shared/permit/permit.guard'; | |
| import { IGraphQLFields, SessionWithMetadata } from '@/shared/types'; | |
| import { SetMetadata, UseGuards } from '@nestjs/common'; | |
| import { Args, Mutation, Resolver } from '@nestjs/graphql'; | |
| @Resolver(() => User) | |
| export class UserResolver { | |
| constructor(private readonly userService: UserService) {} | |
| @UseGuards(AuthGuard, PermitGuard) | |
| @SetMetadata('permission', { action: 'create', resource: 'user' }) | |
| @Mutation(() => User) | |
| public async createUser( | |
| @Args('data') data: UserCreateInput, | |
| @GraphQLFields() { fields }: IGraphQLFields<UserSelect>, | |
| @Session() session: SessionWithMetadata, | |
| ): Promise<User> { | |
| return this.userService.create(data, fields, session.metadata); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Inside the create method | |
| const permit = this.permitService.getPermitClient(); | |
| await permit.api.syncUser({ | |
| key: user.uuid, | |
| email: user.email, | |
| first_name: user.name, | |
| role_assignments: [ | |
| { | |
| role: formatAreaNameForPermit(user.area.name), | |
| tenant: 'default', // Use tenant ID if dealing with multi-tenancy | |
| }, | |
| ], | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment