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 <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 15:29:04 +03:30
co-authored by Cursor
parent 4b3a8a1d6c
commit af1e217a68
28 changed files with 532 additions and 75 deletions
+62 -13
View File
@@ -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('شما به این محصول دسترسی ندارید');
}
}
}
+2 -1
View File
@@ -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],