From af1e217a6855cc59d2ed178e4449d4dd45f78287 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 10 Aug 2026 15:29:04 +0330 Subject: [PATCH] feat(auth): add module read/write admin RBAC Introduce permission catalog, PermissionsGuard, JWT/me payload with role+permissions, and gate all module controllers. Admins CRUD accepts role/permissions with safety rules; migrate existing users to super_admin. Co-authored-by: Cursor --- .../migration.sql | 25 +++ prisma/schema.prisma | 4 +- src/admins/admins.controller.ts | 32 ++-- src/admins/admins.module.ts | 3 +- src/admins/admins.service.ts | 164 ++++++++++++++---- src/admins/dto/admin.dto.ts | 26 +++ src/audit/audit.controller.ts | 6 +- src/audit/audit.module.ts | 2 + src/auth/auth.module.ts | 5 +- src/auth/auth.service.ts | 8 + src/auth/jwt.strategy.ts | 1 + src/auth/permissions.decorator.ts | 21 +++ src/auth/permissions.guard.ts | 46 +++++ src/auth/permissions.ts | 102 +++++++++++ src/notifications/notifications.module.ts | 2 + .../runtime-settings.controller.ts | 6 +- src/plugins/plugins.controller.ts | 12 +- src/plugins/plugins.module.ts | 3 +- src/servers/servers.controller.ts | 15 +- src/servers/servers.module.ts | 3 +- .../settings-proxy.controller.ts | 6 +- src/settings-proxy/settings-proxy.module.ts | 2 + src/sms/sms.controller.ts | 17 +- src/sms/sms.module.ts | 3 +- src/support/support.controller.ts | 12 +- src/support/support.module.ts | 3 +- src/tenants/tenants.controller.ts | 75 ++++++-- src/tenants/tenants.module.ts | 3 +- 28 files changed, 532 insertions(+), 75 deletions(-) create mode 100644 prisma/migrations/20260810150000_admin_permissions/migration.sql create mode 100644 src/auth/permissions.decorator.ts create mode 100644 src/auth/permissions.guard.ts create mode 100644 src/auth/permissions.ts diff --git a/prisma/migrations/20260810150000_admin_permissions/migration.sql b/prisma/migrations/20260810150000_admin_permissions/migration.sql new file mode 100644 index 0000000..f95cb5d --- /dev/null +++ b/prisma/migrations/20260810150000_admin_permissions/migration.sql @@ -0,0 +1,25 @@ +-- Replace unused UserRole.viewer with admin + permissions JSON + +CREATE TYPE "UserRole_new" AS ENUM ('super_admin', 'admin'); + +ALTER TABLE "users" ALTER COLUMN "role" DROP DEFAULT; + +ALTER TABLE "users" + ALTER COLUMN "role" TYPE "UserRole_new" + USING ( + CASE + WHEN "role"::text = 'viewer' THEN 'admin'::"UserRole_new" + ELSE "role"::text::"UserRole_new" + END + ); + +DROP TYPE "UserRole"; + +ALTER TYPE "UserRole_new" RENAME TO "UserRole"; + +ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'super_admin'::"UserRole"; + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "permissions" JSONB NOT NULL DEFAULT '[]'; + +-- Existing managers keep full access +UPDATE "users" SET "role" = 'super_admin', "permissions" = '[]'::jsonb; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bb0a67..4240ee7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,7 +21,7 @@ enum KhanegiUiVariant { enum UserRole { super_admin - viewer + admin } enum SmsLedgerType { @@ -39,6 +39,8 @@ model User { email String? @unique passwordHash String @map("password_hash") role UserRole @default(super_admin) + /// Permission codes for role=admin (ignored when role=super_admin) + permissions Json @default("[]") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/admins/admins.controller.ts b/src/admins/admins.controller.ts index 50efd55..e919809 100644 --- a/src/admins/admins.controller.ts +++ b/src/admins/admins.controller.ts @@ -9,47 +9,57 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { AdminsService } from './admins.service'; import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto'; @Controller('admins') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class AdminsController { constructor(private readonly adminsService: AdminsService) {} @Get() + @RequirePermissions('admins:read') list() { return this.adminsService.list(); } @Post() + @RequirePermissions('admins:write') create( @Body() dto: CreateAdminDto, - @CurrentUser() user: { id: string }, + @CurrentUser() + user: { id: string; role?: string; permissions?: unknown }, ) { - return this.adminsService.create(dto, user.id); + return this.adminsService.create(dto, user); } @Patch(':id') + @RequirePermissions('admins:write') update( @Param('id') id: string, @Body() dto: UpdateAdminDto, - @CurrentUser() user: { id: string }, + @CurrentUser() + user: { id: string; role?: string; permissions?: unknown }, ) { - return this.adminsService.update(id, dto, user.id); + return this.adminsService.update(id, dto, user); } @Delete(':id') - remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { - return this.adminsService.remove(id, user.id); + @RequirePermissions('admins:write') + remove( + @Param('id') id: string, + @CurrentUser() + user: { id: string; role?: string; permissions?: unknown }, + ) { + return this.adminsService.remove(id, user); } @Post(':id/test-sms') - testSms( - @Param('id') id: string, - @Body() dto: TestSmsDto, - ) { + @RequirePermissions('admins:write') + testSms(@Param('id') id: string, @Body() dto: TestSmsDto) { return this.adminsService.sendTestSms(id, dto.message); } } diff --git a/src/admins/admins.module.ts b/src/admins/admins.module.ts index ab1f58f..33bcb35 100644 --- a/src/admins/admins.module.ts +++ b/src/admins/admins.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { AdminsController } from './admins.controller'; import { AdminsService } from './admins.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [AdminsController], providers: [AdminsService], exports: [AdminsService], diff --git a/src/admins/admins.service.ts b/src/admins/admins.service.ts index e147818..d1a76df 100644 --- a/src/admins/admins.service.ts +++ b/src/admins/admins.service.ts @@ -1,18 +1,31 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, UserRole } from '@prisma/client'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; import { CentralSmsService } from '../notifications/central-sms.service'; import { displayName, normalizeMobile } from '../common/mobile.util'; +import { + isFullAccessRole, + normalizePermissionList, + parsePermissions, + userHasAllPermissions, +} from '../auth/permissions'; import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto'; +type Actor = { + id: string; + role?: string; + permissions?: unknown; +}; + @Injectable() export class AdminsService { constructor( @@ -23,33 +36,46 @@ export class AdminsService { ) {} list() { - return this.prisma.user.findMany({ - orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], - select: { - id: true, - mobile: true, - firstName: true, - lastName: true, - email: true, - role: true, - isActive: true, - createdAt: true, - updatedAt: true, - }, - }).then((rows) => - rows.map((row) => ({ - ...row, - name: displayName(row.firstName, row.lastName), - })), - ); + return this.prisma.user + .findMany({ + orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], + select: { + id: true, + mobile: true, + firstName: true, + lastName: true, + email: true, + role: true, + permissions: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }) + .then((rows) => + rows.map((row) => ({ + ...row, + permissions: parsePermissions(row.permissions), + isFullAccess: isFullAccessRole(row.role), + name: displayName(row.firstName, row.lastName), + })), + ); } - async create(dto: CreateAdminDto, actorId?: string) { + async create(dto: CreateAdminDto, actor: Actor) { const mobile = normalizeMobile(dto.mobile); if (!mobile) { throw new BadRequestException('شماره موبایل نامعتبر است'); } + const role = dto.role ?? UserRole.admin; + const permissions = + role === UserRole.super_admin + ? [] + : normalizePermissionList(dto.permissions ?? []); + + this.assertCanAssign(actor, role, permissions); + try { const passwordHash = await bcrypt.hash(dto.password, 10); const user = await this.prisma.user.create({ @@ -58,7 +84,8 @@ export class AdminsService { firstName: dto.firstName.trim(), lastName: dto.lastName.trim(), passwordHash, - role: 'super_admin', + role, + permissions, isActive: true, }, }); @@ -67,7 +94,7 @@ export class AdminsService { action: 'admin.create', entityType: 'user', entityId: user.id, - userId: actorId, + userId: actor.id, after: this.publicUser(user) as unknown as Prisma.InputJsonValue, }); @@ -82,7 +109,7 @@ export class AdminsService { } } - async update(id: string, dto: UpdateAdminDto, actorId?: string) { + async update(id: string, dto: UpdateAdminDto, actor: Actor) { const before = await this.prisma.user.findUnique({ where: { id } }); if (!before) throw new NotFoundException('مدیر یافت نشد'); @@ -96,20 +123,48 @@ export class AdminsService { await this.ensureNotLastActive(id); } + const nextRole = dto.role ?? before.role; + const nextPermissions = + dto.permissions !== undefined + ? nextRole === UserRole.super_admin + ? [] + : normalizePermissionList(dto.permissions) + : nextRole === UserRole.super_admin + ? [] + : parsePermissions(before.permissions); + + if (dto.role !== undefined || dto.permissions !== undefined) { + const roleChanged = nextRole !== before.role; + const beforePerms = parsePermissions(before.permissions); + const permsChanged = + nextRole === UserRole.super_admin + ? before.role !== UserRole.super_admin + : JSON.stringify([...beforePerms].sort()) !== + JSON.stringify([...nextPermissions].sort()); + + if (roleChanged || permsChanged) { + this.assertCanAssign(actor, nextRole, nextPermissions); + } + if ( + before.role === UserRole.super_admin && + nextRole !== UserRole.super_admin + ) { + await this.ensureNotLastSuperAdmin(id); + } + } + try { - const data: { - firstName?: string; - lastName?: string; - mobile?: string; - isActive?: boolean; - passwordHash?: string; - } = {}; + const data: Prisma.UserUpdateInput = {}; if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); if (mobile) data.mobile = mobile; if (dto.isActive !== undefined) data.isActive = dto.isActive; if (dto.password) data.passwordHash = await bcrypt.hash(dto.password, 10); + if (dto.role !== undefined) data.role = nextRole; + if (dto.role !== undefined || dto.permissions !== undefined) { + data.permissions = nextPermissions; + } const user = await this.prisma.user.update({ where: { id }, @@ -120,7 +175,7 @@ export class AdminsService { action: 'admin.update', entityType: 'user', entityId: user.id, - userId: actorId, + userId: actor.id, before: this.publicUser(before) as unknown as Prisma.InputJsonValue, after: this.publicUser(user) as unknown as Prisma.InputJsonValue, }); @@ -136,13 +191,16 @@ export class AdminsService { } } - async remove(id: string, actorId?: string) { + async remove(id: string, actor: Actor) { const before = await this.prisma.user.findUnique({ where: { id } }); if (!before) throw new NotFoundException('مدیر یافت نشد'); - if (actorId && actorId === id) { + if (actor.id === id) { throw new BadRequestException('نمی‌توانید خودتان را حذف کنید'); } await this.ensureNotLastActive(id); + if (before.role === UserRole.super_admin) { + await this.ensureNotLastSuperAdmin(id); + } await this.prisma.user.delete({ where: { id } }); @@ -150,7 +208,7 @@ export class AdminsService { action: 'admin.delete', entityType: 'user', entityId: id, - userId: actorId, + userId: actor.id, before: this.publicUser(before) as unknown as Prisma.InputJsonValue, }); @@ -176,6 +234,26 @@ export class AdminsService { return { ok: true, mobile: user.mobile }; } + private assertCanAssign( + actor: Actor, + role: UserRole | string, + permissions: string[], + ) { + if (isFullAccessRole(actor.role)) return; + + if (role === UserRole.super_admin) { + throw new ForbiddenException( + 'فقط مدیر کامل می‌تواند دسترسی کامل بدهد', + ); + } + + if (!userHasAllPermissions(actor, permissions)) { + throw new ForbiddenException( + 'نمی‌توانید مجوزی بالاتر از دسترسی خودتان بدهید', + ); + } + } + private async ensureNotLastActive(id: string) { const activeCount = await this.prisma.user.count({ where: { isActive: true }, @@ -186,6 +264,18 @@ export class AdminsService { } } + private async ensureNotLastSuperAdmin(id: string) { + const count = await this.prisma.user.count({ + where: { role: UserRole.super_admin, isActive: true }, + }); + const target = await this.prisma.user.findUnique({ where: { id } }); + if (target?.role === UserRole.super_admin && count <= 1) { + throw new BadRequestException( + 'حداقل یک مدیر با دسترسی کامل باید باقی بماند', + ); + } + } + private publicUser(user: { id: string; mobile: string; @@ -193,10 +283,12 @@ export class AdminsService { lastName: string; email: string | null; role: string; + permissions?: unknown; isActive: boolean; createdAt: Date; updatedAt: Date; }) { + const permissions = parsePermissions(user.permissions); return { id: user.id, mobile: user.mobile, @@ -205,6 +297,8 @@ export class AdminsService { name: displayName(user.firstName, user.lastName), email: user.email, role: user.role, + permissions, + isFullAccess: isFullAccessRole(user.role), isActive: user.isActive, createdAt: user.createdAt, updatedAt: user.updatedAt, diff --git a/src/admins/dto/admin.dto.ts b/src/admins/dto/admin.dto.ts index 7277b9d..f349371 100644 --- a/src/admins/dto/admin.dto.ts +++ b/src/admins/dto/admin.dto.ts @@ -1,10 +1,16 @@ import { + ArrayUnique, + IsArray, IsBoolean, + IsEnum, + IsIn, IsOptional, IsString, MaxLength, MinLength, } from 'class-validator'; +import { UserRole } from '@prisma/client'; +import { PERMISSIONS } from '../../auth/permissions'; export class CreateAdminDto { @IsString() @@ -26,6 +32,16 @@ export class CreateAdminDto { @MinLength(6) @MaxLength(72) password!: string; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsIn([...PERMISSIONS], { each: true }) + permissions?: string[]; } export class UpdateAdminDto { @@ -56,6 +72,16 @@ export class UpdateAdminDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @IsOptional() + @IsEnum(UserRole) + role?: UserRole; + + @IsOptional() + @IsArray() + @ArrayUnique() + @IsIn([...PERMISSIONS], { each: true }) + permissions?: string[]; } export class TestSmsDto { diff --git a/src/audit/audit.controller.ts b/src/audit/audit.controller.ts index 7827cd3..033e94f 100644 --- a/src/audit/audit.controller.ts +++ b/src/audit/audit.controller.ts @@ -1,18 +1,22 @@ import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { AuditService } from './audit.service'; @Controller('audit') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class AuditController { constructor(private readonly auditService: AuditService) {} @Get() + @RequirePermissions('audit:read') findAll(@Query('limit') limit?: string) { return this.auditService.findAll(limit ? Number(limit) : 50); } @Get(':id') + @RequirePermissions('audit:read') findOne(@Param('id') id: string) { return this.auditService.findOne(id); } diff --git a/src/audit/audit.module.ts b/src/audit/audit.module.ts index 88eb9fd..0080477 100644 --- a/src/audit/audit.module.ts +++ b/src/audit/audit.module.ts @@ -1,9 +1,11 @@ import { Global, Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { AuditController } from './audit.controller'; import { AuditService } from './audit.service'; @Global() @Module({ + imports: [AuthModule], controllers: [AuditController], providers: [AuditService], exports: [AuditService], diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 3f366b5..d7f7693 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { PermissionsGuard } from './permissions.guard'; @Module({ imports: [ @@ -22,7 +23,7 @@ import { JwtStrategy } from './jwt.strategy'; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy], - exports: [AuthService, JwtModule], + providers: [AuthService, JwtStrategy, PermissionsGuard], + exports: [AuthService, JwtModule, PermissionsGuard], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2dff871..eb9eca6 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -4,6 +4,7 @@ import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; import { displayName, normalizeMobile } from '../common/mobile.util'; import { LoginDto } from './dto/login.dto'; +import { isFullAccessRole, parsePermissions } from './permissions'; @Injectable() export class AuthService { @@ -32,6 +33,7 @@ export class AuthService { } const name = displayName(user.firstName, user.lastName); + const permissions = parsePermissions(user.permissions); const payload = { sub: user.id, mobile: user.mobile, @@ -47,6 +49,8 @@ export class AuthService { lastName: user.lastName, name, role: user.role, + permissions, + isFullAccess: isFullAccessRole(user.role), }, }; } @@ -61,6 +65,7 @@ export class AuthService { lastName: true, email: true, role: true, + permissions: true, isActive: true, createdAt: true, }, @@ -70,8 +75,11 @@ export class AuthService { throw new UnauthorizedException(); } + const permissions = parsePermissions(user.permissions); return { ...user, + permissions, + isFullAccess: isFullAccessRole(user.role), name: displayName(user.firstName, user.lastName), }; } diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 2addae9..04ab614 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -33,6 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { firstName: true, lastName: true, role: true, + permissions: true, isActive: true, }, }); diff --git a/src/auth/permissions.decorator.ts b/src/auth/permissions.decorator.ts new file mode 100644 index 0000000..117e21a --- /dev/null +++ b/src/auth/permissions.decorator.ts @@ -0,0 +1,21 @@ +import { SetMetadata } from '@nestjs/common'; +import type { PermissionCode } from './permissions'; + +export const PERMISSIONS_KEY = 'required_permissions'; + +export type PermissionsMeta = { + codes: PermissionCode[]; + mode: 'all' | 'any'; +}; + +export const RequirePermissions = ( + ...args: Array +) => { + const codes: PermissionCode[] = []; + let mode: 'all' | 'any' = 'all'; + for (const arg of args) { + if (typeof arg === 'string') codes.push(arg); + else if (arg && typeof arg === 'object' && arg.mode) mode = arg.mode; + } + return SetMetadata(PERMISSIONS_KEY, { codes, mode } satisfies PermissionsMeta); +}; diff --git a/src/auth/permissions.guard.ts b/src/auth/permissions.guard.ts new file mode 100644 index 0000000..42ab1a5 --- /dev/null +++ b/src/auth/permissions.guard.ts @@ -0,0 +1,46 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + PERMISSIONS_KEY, + type PermissionsMeta, +} from './permissions.decorator'; +import { + userHasAllPermissions, + userHasAnyPermission, +} from './permissions'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const meta = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + if (!meta?.codes?.length) return true; + + const request = context.switchToHttp().getRequest(); + const user = request.user as + | { role?: string; permissions?: unknown } + | undefined; + if (!user) { + throw new ForbiddenException('دسترسی غیرمجاز'); + } + + const ok = + meta.mode === 'any' + ? userHasAnyPermission(user, meta.codes) + : userHasAllPermissions(user, meta.codes); + + if (!ok) { + throw new ForbiddenException('شما به این بخش دسترسی ندارید'); + } + return true; + } +} diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts new file mode 100644 index 0000000..8cfd60d --- /dev/null +++ b/src/auth/permissions.ts @@ -0,0 +1,102 @@ +/** Permission codes for SuperAdmin panel RBAC */ +export const PERMISSIONS = [ + 'sms:read', + 'sms:write', + 'servers:read', + 'servers:write', + 'plugins:read', + 'plugins:write', + 'support:read', + 'support:write', + 'hamkar:read', + 'hamkar:write', + 'khanegi:read', + 'khanegi:write', + 'admins:read', + 'admins:write', + 'audit:read', + 'settings:write', +] as const; + +export type PermissionCode = (typeof PERMISSIONS)[number]; + +export const PERMISSION_SET = new Set(PERMISSIONS); + +export type PermissionModule = { + key: string; + label: string; + read?: PermissionCode; + write?: PermissionCode; +}; + +export const PERMISSION_MODULES: PermissionModule[] = [ + { key: 'sms', label: 'پیامک', read: 'sms:read', write: 'sms:write' }, + { key: 'servers', label: 'سرور', read: 'servers:read', write: 'servers:write' }, + { key: 'plugins', label: 'افزونه', read: 'plugins:read', write: 'plugins:write' }, + { key: 'support', label: 'پشتیبانی', read: 'support:read', write: 'support:write' }, + { key: 'hamkar', label: 'همکار', read: 'hamkar:read', write: 'hamkar:write' }, + { key: 'khanegi', label: 'خانگی', read: 'khanegi:read', write: 'khanegi:write' }, + { key: 'admins', label: 'مدیران', read: 'admins:read', write: 'admins:write' }, + { key: 'audit', label: 'لاگ', read: 'audit:read' }, + { key: 'settings', label: 'تنظیمات حساس', write: 'settings:write' }, +]; + +export function parsePermissions(raw: unknown): PermissionCode[] { + if (!Array.isArray(raw)) return []; + const out: PermissionCode[] = []; + for (const item of raw) { + const code = String(item || '').trim(); + if (PERMISSION_SET.has(code)) out.push(code as PermissionCode); + } + return [...new Set(out)]; +} + +/** write implies matching read for the same module */ +export function normalizePermissionList(codes: string[]): PermissionCode[] { + const set = new Set(); + for (const code of parsePermissions(codes)) { + set.add(code); + if (code.endsWith(':write')) { + const read = code.replace(/:write$/, ':read'); + if (PERMISSION_SET.has(read)) set.add(read as PermissionCode); + } + } + return [...set]; +} + +export function isFullAccessRole(role: string | null | undefined) { + return role === 'super_admin'; +} + +export function userHasPermission( + user: { role?: string | null; permissions?: unknown }, + code: PermissionCode | string, +): boolean { + if (isFullAccessRole(user.role)) return true; + const list = parsePermissions(user.permissions); + return list.includes(code as PermissionCode); +} + +export function userHasAnyPermission( + user: { role?: string | null; permissions?: unknown }, + codes: Array, +): boolean { + if (isFullAccessRole(user.role)) return true; + return codes.some((code) => userHasPermission(user, code)); +} + +export function userHasAllPermissions( + user: { role?: string | null; permissions?: unknown }, + codes: Array, +): boolean { + if (isFullAccessRole(user.role)) return true; + return codes.every((code) => userHasPermission(user, code)); +} + +export function productReadPermission(product: string): PermissionCode { + return product === 'hamkar' ? 'hamkar:read' : 'khanegi:read'; +} + +export function productWritePermission(product: string): PermissionCode { + return product === 'hamkar' ? 'hamkar:write' : 'khanegi:write'; +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index 6f542b2..ff5a826 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { AdminNotifyService } from './admin-notify.service'; import { CentralSmsService } from './central-sms.service'; import { RuntimeSettingsController } from './runtime-settings.controller'; import { RuntimeSettingsService } from './runtime-settings.service'; @Module({ + imports: [AuthModule], controllers: [RuntimeSettingsController], providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], diff --git a/src/notifications/runtime-settings.controller.ts b/src/notifications/runtime-settings.controller.ts index 440c4d9..3dfe885 100644 --- a/src/notifications/runtime-settings.controller.ts +++ b/src/notifications/runtime-settings.controller.ts @@ -1,19 +1,23 @@ import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { UpdateAdminSmsDto } from './dto/update-admin-sms.dto'; import { RuntimeSettingsService } from './runtime-settings.service'; @Controller('settings') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class RuntimeSettingsController { constructor(private readonly settings: RuntimeSettingsService) {} @Get('admin-sms') + @RequirePermissions('settings:write') getAdminSms() { return this.settings.getPublic(); } @Patch('admin-sms') + @RequirePermissions('settings:write') setAdminSms(@Body() dto: UpdateAdminSmsDto) { this.settings.setAdminSmsEnabled(dto.enabled); return this.settings.getPublic(); diff --git a/src/plugins/plugins.controller.ts b/src/plugins/plugins.controller.ts index 71d0c87..e3e521f 100644 --- a/src/plugins/plugins.controller.ts +++ b/src/plugins/plugins.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreatePluginDto, @@ -19,26 +21,30 @@ import { import { PluginsService } from './plugins.service'; @Controller('plugins') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class PluginsController { constructor(private readonly pluginsService: PluginsService) {} @Get('tenants') + @RequirePermissions('plugins:read') listTenants() { return this.pluginsService.listTenants(); } @Get('expiring') + @RequirePermissions('plugins:read') expiring(@Query('limit') limit?: string) { return this.pluginsService.getExpiringSoon(Number(limit) || 5); } @Get('tenants/:tenantId') + @RequirePermissions('plugins:read') getTenantPlugins(@Param('tenantId') tenantId: string) { return this.pluginsService.getTenantPlugins(tenantId); } @Post('tenants/:tenantId') + @RequirePermissions('plugins:write') create( @Param('tenantId') tenantId: string, @Body() dto: CreatePluginDto, @@ -48,11 +54,13 @@ export class PluginsController { } @Get('items/:id') + @RequirePermissions('plugins:read') getOne(@Param('id') id: string) { return this.pluginsService.getPlugin(id); } @Patch('items/:id') + @RequirePermissions('plugins:write') update( @Param('id') id: string, @Body() dto: UpdatePluginDto, @@ -62,6 +70,7 @@ export class PluginsController { } @Post('items/:id/renew') + @RequirePermissions('plugins:write') renew( @Param('id') id: string, @Body() dto: RenewPluginDto, @@ -71,6 +80,7 @@ export class PluginsController { } @Delete('items/:id') + @RequirePermissions('plugins:write') remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { return this.pluginsService.remove(id, user.id); } diff --git a/src/plugins/plugins.module.ts b/src/plugins/plugins.module.ts index 2460e59..62cd914 100644 --- a/src/plugins/plugins.module.ts +++ b/src/plugins/plugins.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { PluginsController } from './plugins.controller'; import { PluginsService } from './plugins.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [PluginsController], providers: [PluginsService], exports: [PluginsService], diff --git a/src/servers/servers.controller.ts b/src/servers/servers.controller.ts index c2b9469..3658585 100644 --- a/src/servers/servers.controller.ts +++ b/src/servers/servers.controller.ts @@ -10,36 +10,43 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto'; import { ServersService } from './servers.service'; @Controller('servers') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class ServersController { constructor(private readonly serversService: ServersService) {} @Get('tenants') + @RequirePermissions('servers:read') listTenants() { return this.serversService.listTenants(); } @Get('expiring') + @RequirePermissions('servers:read') expiring(@Query('limit') limit?: string) { return this.serversService.getExpiringSoon(Number(limit) || 5); } @Post('check-alerts') + @RequirePermissions('servers:write') checkAlerts() { return this.serversService.runExpiryAlerts().then(() => ({ ok: true })); } @Get('tenants/:id') + @RequirePermissions('servers:read') getTenantServers(@Param('id') id: string) { return this.serversService.getTenantServers(id); } @Post('tenants/:id/servers') + @RequirePermissions('servers:write') createServer( @Param('id') id: string, @Body() dto: UpdateServerAccountDto, @@ -50,6 +57,7 @@ export class ServersController { /** @deprecated prefer PATCH /servers/accounts/:accountId */ @Patch('tenants/:id') + @RequirePermissions('servers:write') updateAccountLegacy( @Param('id') id: string, @Body() dto: UpdateServerAccountDto, @@ -60,6 +68,7 @@ export class ServersController { /** @deprecated prefer POST /servers/accounts/:accountId/renew */ @Post('tenants/:id/renew') + @RequirePermissions('servers:write') renewLegacy( @Param('id') id: string, @Body() dto: RenewServerDto, @@ -69,11 +78,13 @@ export class ServersController { } @Get('accounts/:accountId') + @RequirePermissions('servers:read') getServerAccount(@Param('accountId') accountId: string) { return this.serversService.getServerAccount(accountId); } @Patch('accounts/:accountId') + @RequirePermissions('servers:write') updateServerAccount( @Param('accountId') accountId: string, @Body() dto: UpdateServerAccountDto, @@ -83,6 +94,7 @@ export class ServersController { } @Delete('accounts/:accountId') + @RequirePermissions('servers:write') deleteServerAccount( @Param('accountId') accountId: string, @CurrentUser() user: { id: string }, @@ -91,6 +103,7 @@ export class ServersController { } @Post('accounts/:accountId/renew') + @RequirePermissions('servers:write') renewAccount( @Param('accountId') accountId: string, @Body() dto: RenewServerDto, diff --git a/src/servers/servers.module.ts b/src/servers/servers.module.ts index 5df092b..83b88c3 100644 --- a/src/servers/servers.module.ts +++ b/src/servers/servers.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { ServersController } from './servers.controller'; import { ServersService } from './servers.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [ServersController], providers: [ServersService], exports: [ServersService], diff --git a/src/settings-proxy/settings-proxy.controller.ts b/src/settings-proxy/settings-proxy.controller.ts index 544ec2f..d559563 100644 --- a/src/settings-proxy/settings-proxy.controller.ts +++ b/src/settings-proxy/settings-proxy.controller.ts @@ -1,19 +1,23 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { SettingsProxyService } from './settings-proxy.service'; @Controller('tenants/:tenantId/settings') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class SettingsProxyController { constructor(private readonly settingsProxy: SettingsProxyService) {} @Get() + @RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' }) getSettings(@Param('tenantId') tenantId: string) { return this.settingsProxy.getSettings(tenantId); } @Post() + @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' }) updateSettings( @Param('tenantId') tenantId: string, @Body() body: Record, diff --git a/src/settings-proxy/settings-proxy.module.ts b/src/settings-proxy/settings-proxy.module.ts index fed60c2..d04968c 100644 --- a/src/settings-proxy/settings-proxy.module.ts +++ b/src/settings-proxy/settings-proxy.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { SettingsProxyController } from './settings-proxy.controller'; import { SettingsProxyService } from './settings-proxy.service'; @Module({ + imports: [AuthModule], controllers: [SettingsProxyController], providers: [SettingsProxyService], }) diff --git a/src/sms/sms.controller.ts b/src/sms/sms.controller.ts index d55a612..87e1767 100644 --- a/src/sms/sms.controller.ts +++ b/src/sms/sms.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { ChargeSmsWalletDto, @@ -20,31 +22,36 @@ import { import { SmsService } from './sms.service'; @Controller('sms') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class SmsController { constructor(private readonly smsService: SmsService) {} @Get('tenants') + @RequirePermissions('sms:read') listTenants() { return this.smsService.listTenants(); } @Get('lowest') + @RequirePermissions('sms:read') lowest(@Query('limit') limit?: string) { return this.smsService.getLowestBalances(Number(limit) || 5); } @Get('tenants/:id') + @RequirePermissions('sms:read') getAccount(@Param('id') id: string) { return this.smsService.getAccount(id); } @Get('tenants/:id/charge-info') + @RequirePermissions('sms:read') getChargeInfo(@Param('id') id: string) { return this.smsService.getChargeInfo(id); } @Put('tenants/:id/charge-info') + @RequirePermissions('sms:write') updateChargeInfo( @Param('id') id: string, @Body() dto: UpdateSmsChargeInfoDto, @@ -54,6 +61,7 @@ export class SmsController { } @Get('tenants/:id/report') + @RequirePermissions('sms:read') report( @Param('id') id: string, @Query('fromDate') fromDate?: string, @@ -63,6 +71,7 @@ export class SmsController { } @Get('tenants/:id/report/charges') + @RequirePermissions('sms:read') chargeReport( @Param('id') id: string, @Query('fromDate') fromDate?: string, @@ -72,16 +81,19 @@ export class SmsController { } @Post('tenants/:id/logs/table') + @RequirePermissions('sms:read') logsTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { return this.smsService.getLogsTable(id, dto); } @Post('tenants/:id/ledger/table') + @RequirePermissions('sms:read') ledgerTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { return this.smsService.getLedgerTable(id, dto); } @Patch('tenants/:id') + @RequirePermissions('sms:write') updateAccount( @Param('id') id: string, @Body() dto: UpdateSmsAccountDto, @@ -91,6 +103,7 @@ export class SmsController { } @Post('tenants/:id/charge') + @RequirePermissions('sms:write') charge( @Param('id') id: string, @Body() dto: ChargeSmsWalletDto, @@ -100,11 +113,13 @@ export class SmsController { } @Post('tenants/:id/block') + @RequirePermissions('sms:write') block(@Param('id') id: string, @CurrentUser() user: { id: string }) { return this.smsService.setBlocked(id, true, user.id); } @Post('tenants/:id/unblock') + @RequirePermissions('sms:write') unblock(@Param('id') id: string, @CurrentUser() user: { id: string }) { return this.smsService.setBlocked(id, false, user.id); } diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index 38c864b..9d0e805 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard'; import { RemoteSmsController } from './remote-sms.controller'; @@ -6,7 +7,7 @@ import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [SmsController, RemoteSmsController], providers: [SmsService, RemoteSmsAuthGuard], exports: [SmsService], diff --git a/src/support/support.controller.ts b/src/support/support.controller.ts index 031e3a4..729119b 100644 --- a/src/support/support.controller.ts +++ b/src/support/support.controller.ts @@ -10,6 +10,8 @@ import { UseGuards, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; import { CreateSupportDto, @@ -19,26 +21,30 @@ import { import { SupportService } from './support.service'; @Controller('support') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class SupportController { constructor(private readonly supportService: SupportService) {} @Get('tenants') + @RequirePermissions('support:read') listTenants() { return this.supportService.listTenants(); } @Get('expiring') + @RequirePermissions('support:read') expiring(@Query('limit') limit?: string) { return this.supportService.getExpiringSoon(Number(limit) || 5); } @Get('tenants/:tenantId') + @RequirePermissions('support:read') getTenantSupports(@Param('tenantId') tenantId: string) { return this.supportService.getTenantSupports(tenantId); } @Post('tenants/:tenantId') + @RequirePermissions('support:write') create( @Param('tenantId') tenantId: string, @Body() dto: CreateSupportDto, @@ -48,11 +54,13 @@ export class SupportController { } @Get('items/:id') + @RequirePermissions('support:read') getOne(@Param('id') id: string) { return this.supportService.getSupport(id); } @Patch('items/:id') + @RequirePermissions('support:write') update( @Param('id') id: string, @Body() dto: UpdateSupportDto, @@ -62,6 +70,7 @@ export class SupportController { } @Post('items/:id/renew') + @RequirePermissions('support:write') renew( @Param('id') id: string, @Body() dto: RenewSupportDto, @@ -71,6 +80,7 @@ export class SupportController { } @Delete('items/:id') + @RequirePermissions('support:write') remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { return this.supportService.remove(id, user.id); } diff --git a/src/support/support.module.ts b/src/support/support.module.ts index 737364b..42c4a86 100644 --- a/src/support/support.module.ts +++ b/src/support/support.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { SupportController } from './support.controller'; import { SupportService } from './support.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [SupportController], providers: [SupportService], exports: [SupportService], diff --git a/src/tenants/tenants.controller.ts b/src/tenants/tenants.controller.ts index b556af6..f34815c 100644 --- a/src/tenants/tenants.controller.ts +++ b/src/tenants/tenants.controller.ts @@ -3,6 +3,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, Param, Patch, @@ -18,7 +19,14 @@ import { ProductType } from '@prisma/client'; import { diskStorage } from 'multer'; import { extname } from 'path'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; import { CurrentUser } from '../auth/current-user.decorator'; +import { + productReadPermission, + productWritePermission, + userHasPermission, +} from '../auth/permissions'; import { AVATARS_DIR, ensureUploadsDirs, @@ -30,8 +38,10 @@ import { TenantsService } from './tenants.service'; const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i; +type AuthUser = { id: string; role?: string; permissions?: unknown }; + @Controller('tenants') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, PermissionsGuard) export class TenantsController { constructor( private readonly tenantsService: TenantsService, @@ -39,11 +49,23 @@ export class TenantsController { ) {} @Get() - findAll(@Query('product') product?: ProductType) { - return this.tenantsService.findAll(product); + @RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' }) + async findAll( + @Query('product') product?: ProductType, + @CurrentUser() user?: AuthUser, + ) { + if (product) { + this.assertProductAccess(user, product, 'read'); + return this.tenantsService.findAll(product); + } + const rows = await this.tenantsService.findAll(); + return rows.filter((row) => + userHasPermission(user || {}, productReadPermission(row.product)), + ); } @Post('upload-avatar') + @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' }) @UseInterceptors( FileInterceptor('file', { storage: diskStorage({ @@ -60,7 +82,12 @@ export class TenantsController { limits: { fileSize: 2 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (!IMAGE_MIME.test(file.mimetype)) { - cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false); + cb( + new BadRequestException( + 'فقط تصویر (jpg/png/gif/webp/svg) مجاز است', + ) as any, + false, + ); return; } cb(null, true); @@ -76,29 +103,51 @@ export class TenantsController { } @Get(':id') - findOne(@Param('id') id: string) { - return this.tenantsService.findOne(id); + @RequirePermissions('hamkar:read', 'khanegi:read', { mode: 'any' }) + async findOne(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const tenant = await this.tenantsService.findOne(id); + this.assertProductAccess(user, tenant.product, 'read'); + return tenant; } @Post() - create( - @Body() dto: CreateTenantDto, - @CurrentUser() user: { id: string }, - ) { + @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' }) + create(@Body() dto: CreateTenantDto, @CurrentUser() user: AuthUser) { + this.assertProductAccess(user, dto.product, 'write'); return this.tenantsService.create(dto, user.id); } @Patch(':id') - update( + @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' }) + async update( @Param('id') id: string, @Body() dto: UpdateTenantDto, - @CurrentUser() user: { id: string }, + @CurrentUser() user: AuthUser, ) { + const tenant = await this.tenantsService.findOne(id); + this.assertProductAccess(user, tenant.product, 'write'); return this.tenantsService.update(id, dto, user.id); } @Delete(':id') - remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { + @RequirePermissions('hamkar:write', 'khanegi:write', { mode: 'any' }) + async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) { + const tenant = await this.tenantsService.findOne(id); + this.assertProductAccess(user, tenant.product, 'write'); return this.tenantsService.remove(id, user.id); } + + private assertProductAccess( + user: AuthUser | undefined, + product: string, + mode: 'read' | 'write', + ) { + const code = + mode === 'read' + ? productReadPermission(product) + : productWritePermission(product); + if (!userHasPermission(user || {}, code)) { + throw new ForbiddenException('شما به این محصول دسترسی ندارید'); + } + } } diff --git a/src/tenants/tenants.module.ts b/src/tenants/tenants.module.ts index 06bce0e..dfde743 100644 --- a/src/tenants/tenants.module.ts +++ b/src/tenants/tenants.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { TenantsController } from './tenants.controller'; import { TenantsService } from './tenants.service'; @Module({ - imports: [NotificationsModule], + imports: [NotificationsModule, AuthModule], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService],