Skip to main content

Guards & Decorators

Intellicon CRM uses NestJS guards and custom decorators to enforce authentication and authorization across all API endpoints.

Guard Overview

GuardPurposeLocation
JwtAuthGuardValidates JWT token, populates req.usercommon/guards/jwt-auth.guard.ts
PermissionGuardChecks module permissions from @RequirePermissioncommon/guards/permissions.guard.ts

JwtAuthGuard

The JwtAuthGuard extends Passport's AuthGuard('jwt'). It:

  1. Extracts the JWT from the Authorization: Bearer <token> header
  2. Validates the token signature against JWT_SECRET
  3. Checks token expiration
  4. Populates req.user with the decoded JwtPayload
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Usage

@UseGuards(JwtAuthGuard)
@Controller('contacts')
export class ContactsController {
@Get()
findAll(@Request() req: { user: JwtPayload }) {
// req.user is guaranteed to be populated here
console.log(req.user.tenantSchema); // "tenant_acme"
console.log(req.user.sub); // "user-uuid"
}
}

Error Response (401)

When authentication fails:

{
"statusCode": 401,
"message": "Unauthorized",
"error": "Unauthorized"
}

PermissionGuard

The PermissionGuard reads metadata set by @RequirePermission and checks it against req.user.permissions.

@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredPermission = this.reflector.getAllAndOverride<{
module: string;
action: string;
}>('permission', [context.getHandler(), context.getClass()]);

if (!requiredPermission) return true; // No permission required

const { user } = context.switchToHttp().getRequest();

// Admin bypass — roleLevel >= 100 always passes
if (user.roleLevel >= 100) return true;

const modulePerms = user.permissions[requiredPermission.module];
if (!modulePerms) return false;

return modulePerms[requiredPermission.action] === true;
}
}

Error Response (403)

When permission check fails:

{
"statusCode": 403,
"message": "Insufficient permissions",
"error": "Forbidden"
}

@RequirePermission Decorator

Sets metadata that the PermissionGuard reads to determine required permissions.

import { SetMetadata } from '@nestjs/common';

export const RequirePermission = (module: string, action: string) =>
SetMetadata('permission', { module, action });

Usage

@Controller('leads')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class LeadsController {
@Get()
@RequirePermission('leads', 'view')
findAll() { /* ... */ }

@Post()
@RequirePermission('leads', 'create')
create() { /* ... */ }

@Put(':id')
@RequirePermission('leads', 'edit')
update() { /* ... */ }

@Delete(':id')
@RequirePermission('leads', 'delete')
remove() { /* ... */ }

@Get('export')
@RequirePermission('leads', 'export')
exportCsv() { /* ... */ }

@Post('import')
@RequirePermission('leads', 'import')
importCsv() { /* ... */ }
}

Available Actions

ActionTypical Use
viewGET endpoints (list and detail)
createPOST endpoints
editPUT / PATCH endpoints
deleteDELETE endpoints
exportExport/download endpoints
importBulk import endpoints
inviteUser invitation (users module only)

@AdminOnly Decorator

Shortcut decorator that requires roleLevel >= 100:

export const AdminOnly = () => SetMetadata('adminOnly', true);

The PermissionGuard checks for this metadata:

// Inside PermissionGuard
const isAdminOnly = this.reflector.getAllAndOverride<boolean>('adminOnly', [
context.getHandler(),
context.getClass(),
]);

if (isAdminOnly && user.roleLevel < 100) {
throw new ForbiddenException('Admin access required');
}

Usage

@Put('settings')
@AdminOnly()
async updateSettings(@Request() req: { user: JwtPayload }, @Body() body: any) {
// Only admins (roleLevel >= 100) can access
}

@Delete('all')
@AdminOnly()
async purgeRecords(@Request() req: { user: JwtPayload }) {
// Dangerous operation — admin only
}

Guard Execution Order

Guards execute in the order they are listed in @UseGuards():

@UseGuards(JwtAuthGuard, PermissionGuard)
// ▲ First ▲ Second
Request → JwtAuthGuard → PermissionGuard → Controller Method
│ │
│ 401 Unauthorized│ 403 Forbidden
▼ ▼
(rejected) (rejected)
Always List JwtAuthGuard First

PermissionGuard depends on req.user being populated by JwtAuthGuard. If the order is reversed, the permission guard will fail with a null reference.

// CORRECT
@UseGuards(JwtAuthGuard, PermissionGuard)

// WRONG — PermissionGuard cannot read req.user
@UseGuards(PermissionGuard, JwtAuthGuard)

Controller-Level vs Method-Level Guards

Guards can be applied at the controller level (all methods) or method level (specific methods):

// Controller-level — applies to ALL methods
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller('leads')
export class LeadsController {

// Method-level permission — varies per endpoint
@Get()
@RequirePermission('leads', 'view')
findAll() { /* ... */ }

@Post()
@RequirePermission('leads', 'create')
create() { /* ... */ }

// Admin-only settings endpoint
@Put('settings')
@AdminOnly()
updateSettings() { /* ... */ }
}

Custom Guard Patterns

Creating a Module-Specific Guard

@Injectable()
export class LeadOwnerGuard implements CanActivate {
constructor(private readonly dataSource: DataSource) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { user } = request;
const leadId = request.params.id;

if (user.roleLevel >= 100) return true; // Admin bypass

const [lead] = await this.dataSource.query(
`SELECT created_by, assigned_to FROM "${user.tenantSchema}".leads
WHERE id = $1 AND deleted_at IS NULL`,
[leadId],
);

if (!lead) throw new NotFoundException('Lead not found');

return lead.created_by === user.sub || lead.assigned_to === user.sub;
}
}

Combining Multiple Guards

@Put(':id')
@UseGuards(JwtAuthGuard, PermissionGuard, LeadOwnerGuard)
@RequirePermission('leads', 'edit')
async update(@Param('id') id: string, @Body() body: any) {
// Must pass: JWT valid → has edit permission → is lead owner
}

Public Endpoints (No Guards)

Some endpoints are intentionally unguarded:

@Controller('auth')
export class AuthController {
@Post('login') // No guard — unauthenticated
login() { /* ... */ }

@Post('register') // No guard — creating new tenant
register() { /* ... */ }

@Post('refresh') // No guard — uses refresh token
refresh() { /* ... */ }

@Post('forgot-password') // No guard — unauthenticated
forgotPassword() { /* ... */ }
}
tip

Apply guards at the controller level and omit them only on specific public methods, rather than applying per-method on many endpoints. This prevents accidentally exposing an unguarded endpoint.