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
+21 -11
View File
@@ -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);
}
}
+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 { AdminsController } from './admins.controller';
import { AdminsService } from './admins.service';
@Module({
imports: [NotificationsModule],
imports: [NotificationsModule, AuthModule],
controllers: [AdminsController],
providers: [AdminsService],
exports: [AdminsService],
+129 -35
View File
@@ -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,
+26
View File
@@ -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 {