feat: enforce role section permissions and add a leads permission

Unticking a section hides it from the menu, closes its routes and blocks its writes on the server; existing roles keep their current access via migration.
This commit is contained in:
2026-10-21 13:45:16 +03:30
parent 631a3267d1
commit 7ae6a5837a
23 changed files with 246 additions and 24 deletions
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -50,7 +51,8 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('activities')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('activities')
export class ActivitiesController {
constructor(
private readonly activitiesService: ActivitiesService,
@@ -5,6 +5,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { OrgModuleGuard } from '../billing/org-module.guard';
import { RequireOrgModule } from '../billing/org-module.decorator';
import { AuditLogsService } from './audit-logs.service';
@@ -42,9 +43,9 @@ class ListAuditQueryDto {
}
@Controller('audit-logs')
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('audit_logs')
@Roles('owner', 'admin')
@RequirePermission('audit', { reads: true })
export class AuditLogsController {
constructor(
private readonly auditLogs: AuditLogsService,
@@ -52,6 +53,7 @@ export class AuditLogsController {
) {}
@Get('attendance')
@Roles('owner', 'admin')
attendanceList(
@CurrentUser() user: { organizationId: string },
@Query() query: ListAuditQueryDto,
@@ -0,0 +1,73 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
SetMetadata,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { RolePermissionKey } from '@mento/shared';
import { ROLE_PERMISSION_ITEMS } from '@mento/shared';
import type { Actor } from '../common/record-scope';
import { PrismaService } from '../prisma/prisma.service';
const SECTION_PERMISSION_KEY = 'sectionPermission';
type SectionPermissionMeta = {
key: RolePermissionKey;
/** true: خواندن هم مجوز می‌خواهد. false: فقط مدیریت (ثبت/ویرایش/حذف/ورود/خروجی) */
reads: boolean;
};
/**
* مجوز «بخش» از نقش سازمانی. مالک و مدیر همیشه مجازند.
* پیش‌فرض فقط مدیریت را می‌بندد تا انتخاب رکورد در فرم‌های بخش‌های دیگر کار کند.
*/
export const RequirePermission = (key: RolePermissionKey, opts?: { reads?: boolean }) =>
SetMetadata(SECTION_PERMISSION_KEY, { key, reads: opts?.reads ?? false } satisfies SectionPermissionMeta);
function isReadRequest(method: string, url: string): boolean {
if (method !== 'GET' && method !== 'HEAD') return false;
const path = (url.split('?')[0] || '').replace(/\/+$/, '');
return !path.endsWith('/export');
}
@Injectable()
export class SectionPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const meta = this.reflector.getAllAndOverride<SectionPermissionMeta | undefined>(
SECTION_PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
if (!meta) return true;
const req = context.switchToHttp().getRequest<{
user?: Actor;
method?: string;
originalUrl?: string;
url?: string;
}>();
const actor = req.user;
const label = ROLE_PERMISSION_ITEMS.find((item) => item.key === meta.key)?.label ?? meta.key;
if (!actor) throw new ForbiddenException(`دسترسی به «${label}» مجاز نیست.`);
if (actor.role === 'owner' || actor.role === 'admin') return true;
const method = (req.method || 'GET').toUpperCase();
if (!meta.reads && isReadRequest(method, req.originalUrl || req.url || '')) return true;
const membership = await this.prisma.membership.findFirst({
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions ?? [];
if (!permissions.includes(meta.key)) {
throw new ForbiddenException(`دسترسی به «${label}» برای نقش شما مجاز نیست.`);
}
return true;
}
}
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { CompaniesService } from './companies.service';
@@ -31,7 +32,8 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('companies')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('customers')
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
+3 -1
View File
@@ -18,6 +18,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
@@ -33,8 +34,9 @@ const excelUpload = FileInterceptor('file', {
});
@Controller('people')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('customers')
@RequirePermission('customers')
export class PeopleController {
constructor(
private readonly peopleService: PeopleService,
@@ -9,6 +9,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -18,7 +19,8 @@ import { InventoryService } from './inventory.service';
type AuthUser = { organizationId: string };
@Controller('inventory')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('products')
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateInvoiceDto,
@@ -22,7 +23,8 @@ import { InvoicesService } from './invoices.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('invoices')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('billing')
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
+3 -1
View File
@@ -46,10 +46,12 @@ import {
import { LeadStagesService } from './lead-stages.service';
import { LeadsImportService } from './leads-import.service';
import { LeadsService } from './leads.service';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
@Controller('leads')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('leads')
@RequirePermission('leads', { reads: true })
export class LeadsController {
constructor(
private readonly leads: LeadsService,
+3 -1
View File
@@ -15,6 +15,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
import { NoteAiService } from './note-ai.service';
@@ -30,7 +31,8 @@ type AuthUser = {
};
@Controller('notes')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('notes')
export class NotesController {
constructor(
private readonly notesService: NotesService,
+1 -1
View File
@@ -41,7 +41,7 @@ export const SYSTEM_ROLE_SEEDS: Array<{
name: 'کارشناس فروش',
description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.',
color: 'teal',
permissions: [...ALL_ROLE_PERMISSIONS],
permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'),
},
{
systemKey: 'support',
+3 -1
View File
@@ -16,6 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { OrgModuleGuard } from '../billing/org-module.guard';
@@ -26,8 +27,9 @@ import { DealsExcelService } from './deals-excel.service';
import { brand } from '@mento/shared';
@Controller('deals')
@UseGuards(JwtAuthGuard, OrgModuleGuard)
@UseGuards(JwtAuthGuard, OrgModuleGuard, SectionPermissionGuard)
@RequireOrgModule('pipeline')
@RequirePermission('sales')
export class DealsController {
constructor(
private readonly dealsService: DealsService,
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -24,7 +25,8 @@ import {
import { PipelinesService } from './pipelines.service';
@Controller('pipelines')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('sales')
export class PipelinesController {
constructor(private readonly pipelinesService: PipelinesService) {}
+3 -1
View File
@@ -17,6 +17,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
@@ -27,7 +28,8 @@ import { ProductsExcelService } from './products-excel.service';
import { brand } from '@mento/shared';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, RolesGuard, SectionPermissionGuard)
@RequirePermission('products')
export class ProductsController {
constructor(
private readonly productsService: ProductsService,
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateQuoteDto,
@@ -21,7 +22,8 @@ import { QuotesService } from './quotes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('quotes')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('billing')
export class QuotesController {
constructor(private readonly quotesService: QuotesService) {}
+5 -1
View File
@@ -17,6 +17,7 @@ import {
MaxLength,
} from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { Actor } from '../common/record-scope';
import { SavedViewsService } from '../saved-views/saved-views.service';
@@ -52,7 +53,8 @@ class UpdateReportViewDto {
}
@Controller('reports')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('reports', { reads: true })
export class ReportsController {
constructor(
private readonly reportsService: ReportsService,
@@ -95,7 +97,9 @@ export class ReportsController {
});
}
/** داشبورد صفحهٔ خانه برای همه باز است */
@Get('home')
@RequirePermission('reports', { reads: false })
home(@CurrentUser() user: Actor) {
return this.reportsService.home(user);
}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RequirePermission, SectionPermissionGuard } from '../auth/section-permission.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateSalesGoalDto,
@@ -20,7 +21,8 @@ import {
import { SalesService } from './sales.service';
@Controller('sales')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, SectionPermissionGuard)
@RequirePermission('sales')
export class SalesController {
constructor(private readonly salesService: SalesService) {}