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:
@@ -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 {}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
permissions: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<PermissionCode | { mode?: 'all' | 'any' }>
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
@@ -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<PermissionsMeta | undefined>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string>(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<PermissionCode>();
|
||||
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<PermissionCode | string>,
|
||||
): 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<PermissionCode | string>,
|
||||
): 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';
|
||||
}
|
||||
Reference in New Issue
Block a user