From 7ae6a5837ac1c37ef23e33321610b95abb64571d Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 21 Oct 2026 13:45:16 +0330 Subject: [PATCH] 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. --- .../migration.sql | 14 ++++ .../src/activities/activities.controller.ts | 4 +- .../src/audit-logs/audit-logs.controller.ts | 6 +- apps/api/src/auth/section-permission.guard.ts | 73 +++++++++++++++++++ apps/api/src/contacts/companies.controller.ts | 4 +- apps/api/src/contacts/people.controller.ts | 4 +- .../api/src/inventory/inventory.controller.ts | 4 +- apps/api/src/invoices/invoices.controller.ts | 4 +- apps/api/src/leads/leads.controller.ts | 4 +- apps/api/src/notes/notes.controller.ts | 4 +- apps/api/src/org-roles/role-defaults.ts | 2 +- apps/api/src/pipelines/deals.controller.ts | 4 +- .../api/src/pipelines/pipelines.controller.ts | 4 +- apps/api/src/products/products.controller.ts | 4 +- apps/api/src/quotes/quotes.controller.ts | 4 +- apps/api/src/reports/reports.controller.ts | 6 +- apps/api/src/sales/sales.controller.ts | 4 +- apps/web/src/layouts/AppLayout.vue | 51 +++++++++++-- apps/web/src/lib/section-permissions.ts | 49 +++++++++++++ apps/web/src/router/index.ts | 8 ++ apps/web/src/views/AuditLogsView.vue | 8 +- apps/web/src/views/TeamSettingsView.vue | 1 + packages/shared/src/index.ts | 4 +- 23 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql create mode 100644 apps/api/src/auth/section-permission.guard.ts create mode 100644 apps/web/src/lib/section-permissions.ts diff --git a/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql b/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql new file mode 100644 index 0000000..e53d62d --- /dev/null +++ b/apps/api/prisma/migrations/20261021120000_role_permissions_enforced/migration.sql @@ -0,0 +1,14 @@ +-- تیک‌های مجوز نقش از این نسخه واقعاً اعمال می‌شوند. +-- تا امروز همهٔ نقش‌ها عملاً به این بخش‌ها دسترسی داشتند؛ همان دسترسی را برای نقش‌های موجود حفظ می‌کنیم. +UPDATE "OrgRole" +SET "permissions" = ARRAY( + SELECT DISTINCT p + FROM unnest( + "permissions" || ARRAY['customers', 'leads', 'sales', 'billing', 'products', 'activities', 'notes', 'reports'] + ) AS p +); + +-- لاگ فعالیت کاربران تا امروز فقط برای مالک و مدیر باز بود (مستقل از تیک)؛ همان را حفظ می‌کنیم. +UPDATE "OrgRole" +SET "permissions" = array_remove("permissions", 'audit') +WHERE "systemKey" IS NULL OR "systemKey" NOT IN ('owner', 'admin'); diff --git a/apps/api/src/activities/activities.controller.ts b/apps/api/src/activities/activities.controller.ts index 481c9d2..38696cb 100644 --- a/apps/api/src/activities/activities.controller.ts +++ b/apps/api/src/activities/activities.controller.ts @@ -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, diff --git a/apps/api/src/audit-logs/audit-logs.controller.ts b/apps/api/src/audit-logs/audit-logs.controller.ts index 38e93ce..8720d76 100644 --- a/apps/api/src/audit-logs/audit-logs.controller.ts +++ b/apps/api/src/audit-logs/audit-logs.controller.ts @@ -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, diff --git a/apps/api/src/auth/section-permission.guard.ts b/apps/api/src/auth/section-permission.guard.ts new file mode 100644 index 0000000..49b8c82 --- /dev/null +++ b/apps/api/src/auth/section-permission.guard.ts @@ -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 { + const meta = this.reflector.getAllAndOverride( + 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; + } +} diff --git a/apps/api/src/contacts/companies.controller.ts b/apps/api/src/contacts/companies.controller.ts index 6741eb9..893a4c8 100644 --- a/apps/api/src/contacts/companies.controller.ts +++ b/apps/api/src/contacts/companies.controller.ts @@ -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, diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index a440b98..c8fad31 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -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, diff --git a/apps/api/src/inventory/inventory.controller.ts b/apps/api/src/inventory/inventory.controller.ts index 5e8873f..fff2023 100644 --- a/apps/api/src/inventory/inventory.controller.ts +++ b/apps/api/src/inventory/inventory.controller.ts @@ -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) {} diff --git a/apps/api/src/invoices/invoices.controller.ts b/apps/api/src/invoices/invoices.controller.ts index 7fc32ce..dec8b37 100644 --- a/apps/api/src/invoices/invoices.controller.ts +++ b/apps/api/src/invoices/invoices.controller.ts @@ -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) {} diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 8077c29..26b3919 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -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, diff --git a/apps/api/src/notes/notes.controller.ts b/apps/api/src/notes/notes.controller.ts index 942790f..22749c0 100644 --- a/apps/api/src/notes/notes.controller.ts +++ b/apps/api/src/notes/notes.controller.ts @@ -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, diff --git a/apps/api/src/org-roles/role-defaults.ts b/apps/api/src/org-roles/role-defaults.ts index 38da870..fa1fefe 100644 --- a/apps/api/src/org-roles/role-defaults.ts +++ b/apps/api/src/org-roles/role-defaults.ts @@ -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', diff --git a/apps/api/src/pipelines/deals.controller.ts b/apps/api/src/pipelines/deals.controller.ts index aa803f3..e609ba3 100644 --- a/apps/api/src/pipelines/deals.controller.ts +++ b/apps/api/src/pipelines/deals.controller.ts @@ -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, diff --git a/apps/api/src/pipelines/pipelines.controller.ts b/apps/api/src/pipelines/pipelines.controller.ts index aca5697..e7d26f8 100644 --- a/apps/api/src/pipelines/pipelines.controller.ts +++ b/apps/api/src/pipelines/pipelines.controller.ts @@ -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) {} diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts index cf46dc7..3838c2f 100644 --- a/apps/api/src/products/products.controller.ts +++ b/apps/api/src/products/products.controller.ts @@ -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, diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts index ad86f98..e54f612 100644 --- a/apps/api/src/quotes/quotes.controller.ts +++ b/apps/api/src/quotes/quotes.controller.ts @@ -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) {} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index d622da8..597db8a 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -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); } diff --git a/apps/api/src/sales/sales.controller.ts b/apps/api/src/sales/sales.controller.ts index 99eb699..4d495b1 100644 --- a/apps/api/src/sales/sales.controller.ts +++ b/apps/api/src/sales/sales.controller.ts @@ -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) {} diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue index 7e42ca0..e8f22d5 100644 --- a/apps/web/src/layouts/AppLayout.vue +++ b/apps/web/src/layouts/AppLayout.vue @@ -19,6 +19,11 @@ import type { NotificationsListDto, } from '@mento/shared'; import { canManageSettings, canViewAllRecords } from '@/lib/roles'; +import { + canOpenPath, + hasSectionPermission, + sectionPermissionForPath, +} from '@/lib/section-permissions'; import { refreshUnreadTotal, useOrgChat } from '@/lib/useOrgChat'; const auth = useAuthStore(); @@ -145,6 +150,11 @@ const moreSectionActive = computed( const enabledModules = computed(() => auth.user?.enabledModules ?? null); const subscription = computed(() => auth.user?.subscription ?? null); +/** لینک‌های مستقیم منو (بیرون از هاب‌ها) با مجوز بخش */ +function canOpen(path: string) { + return canOpenPath(auth.user?.role, auth.user?.permissions, path); +} + function moduleAllowed(module?: string) { if (!module) return true; if (!enabledModules.value) return true; @@ -163,7 +173,16 @@ function cardAllowedForNav(card: { || Boolean(auth.user?.permissions?.includes('treasury')) || Boolean(auth.user?.permissions?.includes('treasury_payroll')); if (card.to?.startsWith('/finance')) return canTreasury; - if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value) return false; + const section = card.to ? sectionPermissionForPath(card.to) : null; + const hasSection = section + ? hasSectionPermission(auth.user?.role, auth.user?.permissions, section) + : true; + if (!hasSection) return false; + // لاگ فعالیت با تیک «لاگ فعالیت کاربران» برای غیرمدیر هم باز می‌شود + const grantedBySection = section === 'audit' && hasSection; + if (card.admin && !canSettings.value && !canOpenTeamWhileViewing.value && !grantedBySection) { + return false; + } if (!moduleAllowed(card.module)) return false; if ( card.admin && @@ -607,6 +626,7 @@ onUnmounted(() => { { باشگاه مشتریان - + گزارش‌ها @@ -807,14 +833,24 @@ onUnmounted(() => {
- + - + @@ -897,6 +933,7 @@ onUnmounted(() => { داشبورد