Commit dfe66e09 authored by ThinhNC's avatar ThinhNC

feat(rbac): refactor authorization to dynamic permission-based rbac and centralize constants

parent 879e6137
......@@ -76,6 +76,10 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
- Hệ thống log sử dụng LoggerService, đầu ra JSON ở production và text màu ở development, hỗ trợ ẩn thông tin nhạy cảm.
- Môi trường production được container hóa bằng Dockerfile (multi-stage) chạy với user phi quản trị và docker-compose.yml có thiết hành kiểm tra sức khoẻ (healthcheck) cho Postgres và Redis.
- Script seed (prisma/seed.ts) được mở rộng để tự động tạo dữ liệu mẫu demo phong phú (Wallets, Transactions, Budgets, SavingGoals, Contributions, Notifications, Reminders) cho tài khoản user@finwise.local.
- Dynamic RBAC (Role-Based Access Control) là kiến trúc phân quyền chính thức: Phân quyền dựa trên Permission (Role -> Permissions -> User), KHÔNG hardcode quyền theo tên Role.
- QUY TẮC BẮT BUỘC: Tất cả tên quyền (Permission names) PHẢI được định nghĩa tập trung trong `src/common/constants/permission.constant.ts` (ở cả BE và FE), TUYỆT ĐỐI KHÔNG hardcode chuỗi string permission rải rác trong code.
- Mọi route endpoint nghiệp vụ ở backend bắt buộc được bảo vệ bằng middleware `requirePermission(PERMISSIONS.*)`.
- Các vai trò hệ thống mặc định/bất biến (Bootstrap & System protection) được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts` (ví dụ `SYSTEM_ROLES.USER` cho vai trò đăng ký mặc định, `SYSTEM_ROLES.ADMIN` cho vai trò quản trị bất biến), không dùng `SYSTEM_ROLES` để kiểm tra phân quyền.
## Trạng thái đã biết
......
-- AlterTable
ALTER TABLE "roles" ADD COLUMN "description" TEXT;
ALTER TABLE "roles" ADD COLUMN "is_system" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "permissions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"resource" VARCHAR(100) NOT NULL,
"action" VARCHAR(100) NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"permission_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL,
"actor_id" UUID,
"action" VARCHAR(100) NOT NULL,
"target_type" VARCHAR(100) NOT NULL,
"target_id" VARCHAR(255),
"previous_state" JSONB,
"new_state" JSONB,
"ip_address" VARCHAR(100),
"user_agent" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "permissions_name_key" ON "permissions"("name");
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_role_id_permission_id_key" ON "role_permissions"("role_id", "permission_id");
CREATE INDEX "role_permissions_role_id_idx" ON "role_permissions"("role_id");
CREATE INDEX "role_permissions_permission_id_idx" ON "role_permissions"("permission_id");
-- CreateIndex
CREATE INDEX "audit_logs_actor_id_idx" ON "audit_logs"("actor_id");
CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action");
CREATE INDEX "audit_logs_target_type_target_id_idx" ON "audit_logs"("target_type", "target_id");
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
......@@ -142,15 +142,67 @@ model User {
}
model Role {
id String @id @default(uuid()) @db.Uuid
name String @unique
users User[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
isSystem Boolean @default(false) @map("is_system")
users User[]
rolePermissions RolePermission[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@map("roles")
}
model Permission {
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
resource String @db.VarChar(100)
action String @db.VarChar(100)
isSystem Boolean @default(false) @map("is_system")
rolePermissions RolePermission[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([resource])
@@map("permissions")
}
model RolePermission {
id String @id @default(uuid()) @db.Uuid
roleId String @map("role_id") @db.Uuid
permissionId String @map("permission_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@unique([roleId, permissionId])
@@index([roleId])
@@index([permissionId])
@@map("role_permissions")
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
actorId String? @map("actor_id") @db.Uuid
action String @db.VarChar(100)
targetType String @map("target_type") @db.VarChar(100)
targetId String? @map("target_id") @db.VarChar(255)
previousState Json? @map("previous_state")
newState Json? @map("new_state")
ipAddress String? @map("ip_address") @db.VarChar(100)
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@index([actorId])
@@index([action])
@@index([targetType, targetId])
@@index([createdAt])
@@map("audit_logs")
}
model Wallet {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
......
This diff is collapsed.
export * from './permission.constant';
export * from './system-role.constant';
export const PERMISSIONS = {
// USER
USER_READ: 'USER_READ',
USER_CREATE: 'USER_CREATE',
USER_UPDATE: 'USER_UPDATE',
USER_DELETE: 'USER_DELETE',
// ROLE
ROLE_READ: 'ROLE_READ',
ROLE_CREATE: 'ROLE_CREATE',
ROLE_UPDATE: 'ROLE_UPDATE',
ROLE_DELETE: 'ROLE_DELETE',
// PERMISSION
PERMISSION_READ: 'PERMISSION_READ',
ROLE_PERMISSION_ASSIGN: 'ROLE_PERMISSION_ASSIGN',
// AUDIT LOG
AUDIT_LOG_READ: 'AUDIT_LOG_READ',
// WALLET
WALLET_READ: 'WALLET_READ',
WALLET_CREATE: 'WALLET_CREATE',
WALLET_UPDATE: 'WALLET_UPDATE',
WALLET_DELETE: 'WALLET_DELETE',
// TRANSACTION
TRANSACTION_READ: 'TRANSACTION_READ',
TRANSACTION_CREATE: 'TRANSACTION_CREATE',
TRANSACTION_UPDATE: 'TRANSACTION_UPDATE',
TRANSACTION_DELETE: 'TRANSACTION_DELETE',
// TRANSFER
TRANSFER_READ: 'TRANSFER_READ',
TRANSFER_CREATE: 'TRANSFER_CREATE',
TRANSFER_DELETE: 'TRANSFER_DELETE',
// CATEGORY
CATEGORY_READ: 'CATEGORY_READ',
CATEGORY_CREATE: 'CATEGORY_CREATE',
CATEGORY_UPDATE: 'CATEGORY_UPDATE',
CATEGORY_DELETE: 'CATEGORY_DELETE',
// BUDGET
BUDGET_READ: 'BUDGET_READ',
BUDGET_CREATE: 'BUDGET_CREATE',
BUDGET_UPDATE: 'BUDGET_UPDATE',
BUDGET_DELETE: 'BUDGET_DELETE',
// SAVING GOAL
SAVING_GOAL_READ: 'SAVING_GOAL_READ',
SAVING_GOAL_CREATE: 'SAVING_GOAL_CREATE',
SAVING_GOAL_UPDATE: 'SAVING_GOAL_UPDATE',
SAVING_GOAL_DELETE: 'SAVING_GOAL_DELETE',
// REPORT
REPORT_READ: 'REPORT_READ',
// FORECAST
FORECAST_READ: 'FORECAST_READ',
// SIMULATION
SIMULATION_READ: 'SIMULATION_READ',
SIMULATION_EXECUTE: 'SIMULATION_EXECUTE',
// ANOMALY
ANOMALY_READ: 'ANOMALY_READ',
ANOMALY_EVALUATE: 'ANOMALY_EVALUATE',
// SUBSCRIPTION
SUBSCRIPTION_READ: 'SUBSCRIPTION_READ',
SUBSCRIPTION_MANAGE: 'SUBSCRIPTION_MANAGE',
// QUERY
QUERY_EXECUTE: 'QUERY_EXECUTE',
// RECURRING TRANSACTION
RECURRING_TRANSACTION_READ: 'RECURRING_TRANSACTION_READ',
RECURRING_TRANSACTION_CREATE: 'RECURRING_TRANSACTION_CREATE',
RECURRING_TRANSACTION_UPDATE: 'RECURRING_TRANSACTION_UPDATE',
RECURRING_TRANSACTION_DELETE: 'RECURRING_TRANSACTION_DELETE',
// NOTIFICATION
NOTIFICATION_READ: 'NOTIFICATION_READ',
NOTIFICATION_UPDATE: 'NOTIFICATION_UPDATE',
// REMINDER
REMINDER_READ: 'REMINDER_READ',
REMINDER_CREATE: 'REMINDER_CREATE',
REMINDER_UPDATE: 'REMINDER_UPDATE',
REMINDER_DELETE: 'REMINDER_DELETE',
// AI ASSISTANT
AI_ASSISTANT_USE: 'AI_ASSISTANT_USE',
// UPLOAD
UPLOAD_FILE: 'UPLOAD_FILE',
} as const;
export type PermissionName = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const ROLES = {
export const SYSTEM_ROLES = {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
USER: 'USER',
MANAGER: 'MANAGER',
} as const;
export type Role = keyof typeof ROLES;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
......@@ -41,6 +41,11 @@ export const ERROR_CODE = {
CRAWL_JOB_ALREADY_COMPLETED: 'CRAWL_JOB_ALREADY_COMPLETED',
PRIVATE_IP_BLOCKED: 'PRIVATE_IP_BLOCKED',
INVALID_URL: 'INVALID_URL',
ROLE_SYSTEM_PROTECTED: 'ROLE_SYSTEM_PROTECTED',
ROLE_IN_USE: 'ROLE_IN_USE',
LAST_ADMIN_PROTECTED: 'LAST_ADMIN_PROTECTED',
ROLE_NOT_FOUND: 'ROLE_NOT_FOUND',
PERMISSION_NOT_FOUND: 'PERMISSION_NOT_FOUND',
} as const;
export type ErrorCode = keyof typeof ERROR_CODE;
......@@ -4,7 +4,9 @@ declare global {
user: {
id: string;
email: string;
roleId?: string;
role: string;
permissions?: string[];
};
}
}
......
......@@ -57,7 +57,20 @@ export const swaggerSpec = {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', enum: ['ADMIN', 'MANAGER', 'USER'] },
name: { type: 'string', example: 'ADMIN' },
description: { type: 'string', nullable: true },
isSystem: { type: 'boolean', example: true },
},
},
Permission: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', example: 'USER_READ' },
description: { type: 'string', nullable: true },
resource: { type: 'string', example: 'USER' },
action: { type: 'string', example: 'READ' },
isSystem: { type: 'boolean', example: true },
},
},
User: {
......@@ -72,6 +85,7 @@ export const swaggerSpec = {
phoneNumber: { type: 'string', nullable: true, example: '0912345678' },
roleId: { type: 'string', format: 'uuid' },
role: { $ref: '#/components/schemas/Role' },
permissions: { type: 'array', items: { type: 'string' } },
isActive: { type: 'boolean' },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
......
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
import { PermissionName } from '../common/constants/permission.constant';
import { rbacService } from '../modules/rbac/rbac.service';
/**
* Enforces that the authenticated user possesses ALL of the specified permissions.
*/
export function requirePermission(...permissions: (PermissionName | string)[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
try {
let userPermissions = req.user.permissions;
if (!userPermissions) {
userPermissions = await rbacService.getUserPermissions(req.user.id);
req.user.permissions = userPermissions;
}
const hasAll = permissions.every((p) => userPermissions.includes(p));
if (!hasAll) {
next(
new AppError(
`Forbidden: Yêu cầu quyền [${permissions.join(', ')}]`,
403,
ERROR_CODE.FORBIDDEN
)
);
return;
}
next();
} catch (error) {
next(error);
}
};
}
/**
* Enforces that the authenticated user possesses AT LEAST ONE of the specified permissions.
*/
export function requireAnyPermission(...permissions: string[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
try {
let userPermissions = req.user.permissions;
if (!userPermissions) {
userPermissions = await rbacService.getUserPermissions(req.user.id);
req.user.permissions = userPermissions;
}
const hasAny = permissions.some((p) => userPermissions.includes(p));
if (!hasAny) {
next(
new AppError(
`Forbidden: Yêu cầu ít nhất một trong các quyền [${permissions.join(', ')}]`,
403,
ERROR_CODE.FORBIDDEN
)
);
return;
}
next();
} catch (error) {
next(error);
}
};
}
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../common/errors/app-error';
import { ERROR_CODE } from '../common/errors/error-code';
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) {
next(new AppError('Unauthorized', 401, ERROR_CODE.UNAUTHORIZED));
return;
}
if (!roles.includes(req.user.role)) {
next(new AppError('Forbidden', 403, ERROR_CODE.FORBIDDEN));
return;
}
next();
};
}
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { aiRateLimitMiddleware } from '../../middlewares/ai-rate-limit.middleware';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { receiptUploadMiddleware } from '../transactions/transaction-upload.middleware';
import { AIAssistantController } from './ai-assistant.controller';
......@@ -17,6 +19,7 @@ const controller = new AIAssistantController();
router.use(authMiddleware);
router.use(aiRateLimitMiddleware);
router.use(requirePermission(PERMISSIONS.AI_ASSISTANT_USE));
router.post(
'/categorize',
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { AnomalyController } from './anomaly.controller';
const router = Router();
......@@ -7,7 +9,7 @@ const controller = new AnomalyController();
router.use(authMiddleware);
router.post('/evaluate', controller.evaluate);
router.get('/recent', controller.getRecent);
router.post('/evaluate', requirePermission(PERMISSIONS.ANOMALY_EVALUATE), controller.evaluate);
router.get('/recent', requirePermission(PERMISSIONS.ANOMALY_READ), controller.getRecent);
export default router;
......@@ -26,7 +26,10 @@ export interface MeDto {
role: {
id: string;
name: string;
description?: string | null;
isSystem?: boolean;
};
permissions: string[];
isActive: boolean;
createdAt: Date;
updatedAt: Date;
......
......@@ -7,8 +7,9 @@ import { ERROR_CODE } from '../../common/errors/error-code';
import { jwtConfig } from '../../config/jwt.config';
import { LoginDto, LoginResponseDto, AuthTokensDto, MeDto, RegisterDto, UpdateProfileDto, UpdatePasswordDto, ForgotPasswordDto, ResetPasswordDto, ResendVerificationDto } from './auth.dto';
import { MailService } from '../../common/services/mail.service';
import { ROLES } from '../../common/constants/role.constant';
import { generateDeviceHash, parseUserAgent } from '../../common/helpers/user-agent.helper';
import { rbacService } from '../rbac/rbac.service';
import { SYSTEM_ROLES } from '../../common/constants';
export class AuthService {
private readonly repository = new AuthRepository();
......@@ -87,6 +88,8 @@ export class AuthService {
}
}
const permissions = await rbacService.getUserPermissions(user.id);
return {
accessToken,
refreshToken,
......@@ -102,7 +105,10 @@ export class AuthService {
role: {
id: user.role.id,
name: user.role.name,
description: user.role.description,
isSystem: user.role.isSystem,
},
permissions,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
......@@ -172,6 +178,8 @@ export class AuthService {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
}
const permissions = await rbacService.getUserPermissions(user.id);
return {
id: user.id,
email: user.email,
......@@ -184,7 +192,10 @@ export class AuthService {
role: {
id: user.role.id,
name: user.role.name,
description: user.role.description,
isSystem: user.role.isSystem,
},
permissions,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
......@@ -201,7 +212,7 @@ export class AuthService {
const passwordHash = await bcrypt.hash(password, 10);
const defaultRole = await this.repository.findRoleByName(ROLES.USER);
const defaultRole = await this.repository.findRoleByName(SYSTEM_ROLES.USER);
if (!defaultRole) {
throw new AppError('Default role not found', 500, ERROR_CODE.NOT_FOUND);
}
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { BudgetController } from './budget.controller';
import {
......@@ -14,22 +16,25 @@ const controller = new BudgetController();
router.use(authMiddleware);
router.get('/', validate(findBudgetsSchema, 'query'), controller.findAll);
router.post('/', validate(createBudgetSchema), controller.create);
router.get('/:id', validate(budgetParamsSchema, 'params'), controller.findById);
router.get('/', requirePermission(PERMISSIONS.BUDGET_READ), validate(findBudgetsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.BUDGET_CREATE), validate(createBudgetSchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.BUDGET_READ), validate(budgetParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
requirePermission(PERMISSIONS.BUDGET_UPDATE),
validate(budgetParamsSchema, 'params'),
validate(updateBudgetSchema),
controller.update,
);
router.patch(
'/:id/restore',
requirePermission(PERMISSIONS.BUDGET_UPDATE),
validate(budgetParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
requirePermission(PERMISSIONS.BUDGET_DELETE),
validate(budgetParamsSchema, 'params'),
controller.archive,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { CategoryController } from './category.controller';
import {
......@@ -15,23 +17,26 @@ const controller = new CategoryController();
router.use(authMiddleware);
router.get('/', validate(findCategoriesSchema, 'query'), controller.findAll);
router.get('/tree', validate(categoryTreeSchema, 'query'), controller.findTree);
router.post('/', validate(createCategorySchema), controller.create);
router.get('/:id', validate(categoryParamsSchema, 'params'), controller.findById);
router.get('/', requirePermission(PERMISSIONS.CATEGORY_READ), validate(findCategoriesSchema, 'query'), controller.findAll);
router.get('/tree', requirePermission(PERMISSIONS.CATEGORY_READ), validate(categoryTreeSchema, 'query'), controller.findTree);
router.post('/', requirePermission(PERMISSIONS.CATEGORY_CREATE), validate(createCategorySchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.CATEGORY_READ), validate(categoryParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
requirePermission(PERMISSIONS.CATEGORY_UPDATE),
validate(categoryParamsSchema, 'params'),
validate(updateCategorySchema),
controller.update,
);
router.patch(
'/:id/restore',
requirePermission(PERMISSIONS.CATEGORY_UPDATE),
validate(categoryParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
requirePermission(PERMISSIONS.CATEGORY_DELETE),
validate(categoryParamsSchema, 'params'),
controller.archive,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { ForecastController } from './forecast.controller';
const router = Router();
const controller = new ForecastController();
router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.FORECAST_READ));
router.get('/runway', controller.getRunway);
router.get('/budget-depletion', controller.getBudgetDepletion);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { NotificationController } from './notification.controller';
import {
......@@ -13,20 +15,22 @@ const controller = new NotificationController();
router.use(authMiddleware);
router.get('/', validate(findNotificationsSchema, 'query'), controller.findAll);
router.get('/unread-count', controller.unreadCount);
router.patch('/read-all', controller.markAllRead);
router.get('/settings', controller.getSetting);
router.get('/', requirePermission(PERMISSIONS.NOTIFICATION_READ), validate(findNotificationsSchema, 'query'), controller.findAll);
router.get('/unread-count', requirePermission(PERMISSIONS.NOTIFICATION_READ), controller.unreadCount);
router.patch('/read-all', requirePermission(PERMISSIONS.NOTIFICATION_UPDATE), controller.markAllRead);
router.get('/settings', requirePermission(PERMISSIONS.NOTIFICATION_READ), controller.getSetting);
router.put(
'/settings',
requirePermission(PERMISSIONS.NOTIFICATION_UPDATE),
validate(updateNotificationSettingSchema),
controller.updateSetting,
);
router.patch(
'/:id/read',
requirePermission(PERMISSIONS.NOTIFICATION_UPDATE),
validate(notificationParamsSchema, 'params'),
controller.markRead,
);
router.delete('/:id', validate(notificationParamsSchema, 'params'), controller.remove);
router.delete('/:id', requirePermission(PERMISSIONS.NOTIFICATION_UPDATE), validate(notificationParamsSchema, 'params'), controller.remove);
export default router;
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { QueryController } from './query.controller';
const router = Router();
const controller = new QueryController();
router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.QUERY_EXECUTE));
router.post('/parse', controller.parse);
router.post('/execute', controller.execute);
......
import { Request, Response, NextFunction } from 'express';
import { RbacService } from './rbac.service';
import {
CreateRoleDto,
UpdateRoleDto,
RoleQueryDto,
AssignRolePermissionsDto,
PermissionQueryDto,
AuditLogQueryDto,
UserRoleUpdateDto,
} from './rbac.dto';
export class RbacController {
private readonly service = new RbacService();
// ==========================================
// ROLES
// ==========================================
findAllRoles = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as RoleQueryDto;
const result = await this.service.findAllRoles(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
findRoleById = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.findRoleById(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
createRole = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as CreateRoleDto;
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.createRole(body, actorId, metadata);
res.status(201).json({ success: true, data: result });
} catch (error) {
next(error);
}
};
updateRole = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateRoleDto;
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.updateRole(req.params.id, body, actorId, metadata);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
deleteRole = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.deleteRole(req.params.id, actorId, metadata);
res.json(result);
} catch (error) {
next(error);
}
};
// ==========================================
// PERMISSIONS
// ==========================================
findAllPermissions = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as PermissionQueryDto;
const result = await this.service.findAllPermissions(query);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
getRolePermissions = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.getRolePermissions(req.params.id);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
assignRolePermissions = async (req: Request, res: Response, next: NextFunction) => {
try {
const { permissionIds } = req.body as AssignRolePermissionsDto;
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.assignRolePermissions(req.params.id, permissionIds, actorId, metadata);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
removeRolePermission = async (req: Request, res: Response, next: NextFunction) => {
try {
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.removeRolePermission(
req.params.id,
req.params.permissionId,
actorId,
metadata
);
res.json(result);
} catch (error) {
next(error);
}
};
// ==========================================
// USER ROLE ASSIGNMENT
// ==========================================
updateUserRole = async (req: Request, res: Response, next: NextFunction) => {
try {
const { roleId } = req.body as UserRoleUpdateDto;
const targetUserId = req.params.id;
const actorId = req.user?.id;
const metadata = {
ipAddress: req.ip || (req.headers['x-forwarded-for'] as string),
userAgent: req.headers['user-agent'],
};
const result = await this.service.updateUserRole(targetUserId, roleId, actorId, metadata);
res.json({ success: true, data: result });
} catch (error) {
next(error);
}
};
// ==========================================
// AUDIT LOGS
// ==========================================
findAllAuditLogs = async (req: Request, res: Response, next: NextFunction) => {
try {
const query = req.query as unknown as AuditLogQueryDto;
const result = await this.service.findAllAuditLogs(query);
res.json({ success: true, ...result });
} catch (error) {
next(error);
}
};
}
export interface CreateRoleDto {
name: string;
description?: string;
permissionIds?: string[];
}
export interface UpdateRoleDto {
name?: string;
description?: string;
}
export interface RoleQueryDto {
name?: string;
isSystem?: boolean;
page?: number;
limit?: number;
sortBy?: string;
order?: 'asc' | 'desc';
}
export interface AssignRolePermissionsDto {
permissionIds: string[];
}
export interface PermissionQueryDto {
resource?: string;
action?: string;
search?: string;
}
export interface AuditLogQueryDto {
actorId?: string;
action?: string;
targetType?: string;
targetId?: string;
page?: number;
limit?: number;
sortBy?: string;
order?: 'asc' | 'desc';
}
export interface UserRoleUpdateDto {
roleId: string;
}
import { Prisma } from '@prisma/client';
import { prisma } from '../../database/prisma.client';
import { RoleQueryDto, PermissionQueryDto, AuditLogQueryDto } from './rbac.dto';
export class RbacRepository {
async findAllRoles(query: RoleQueryDto) {
const { name, isSystem, page = 1, limit = 20, sortBy = 'createdAt', order = 'asc' } = query;
const where: Prisma.RoleWhereInput = {
...(name ? { name: { contains: name, mode: 'insensitive' } } : {}),
...(isSystem !== undefined ? { isSystem } : {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.role.findMany({
where,
include: {
_count: {
select: { users: true, rolePermissions: true },
},
rolePermissions: {
include: {
permission: true,
},
},
},
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.role.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
async findRoleById(id: string) {
return prisma.role.findUnique({
where: { id },
include: {
_count: {
select: { users: true, rolePermissions: true },
},
rolePermissions: {
include: {
permission: true,
},
orderBy: [{ permission: { resource: 'asc' } }, { permission: { action: 'asc' } }],
},
},
});
}
async findRoleByName(name: string) {
return prisma.role.findUnique({
where: { name },
});
}
async createRole(data: { name: string; description?: string; isSystem?: boolean; permissionIds?: string[] }) {
const { name, description, isSystem = false, permissionIds = [] } = data;
return prisma.$transaction(async (tx) => {
const role = await tx.role.create({
data: {
name,
description,
isSystem,
},
});
if (permissionIds.length > 0) {
await tx.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({
roleId: role.id,
permissionId,
})),
skipDuplicates: true,
});
}
return tx.role.findUnique({
where: { id: role.id },
include: {
rolePermissions: {
include: { permission: true },
},
},
});
});
}
async updateRole(id: string, data: { name?: string; description?: string }) {
return prisma.role.update({
where: { id },
data,
include: {
rolePermissions: {
include: { permission: true },
},
},
});
}
async deleteRole(id: string) {
return prisma.role.delete({
where: { id },
});
}
async findAllPermissions(query: PermissionQueryDto) {
const { resource, action, search } = query;
const where: Prisma.PermissionWhereInput = {
...(resource ? { resource } : {}),
...(action ? { action } : {}),
...(search
? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ resource: { contains: search, mode: 'insensitive' } },
],
}
: {}),
};
return prisma.permission.findMany({
where,
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
}
async findPermissionById(id: string) {
return prisma.permission.findUnique({
where: { id },
});
}
async findPermissionsByIds(ids: string[]) {
return prisma.permission.findMany({
where: { id: { in: ids } },
});
}
async getRolePermissions(roleId: string) {
const rolePermissions = await prisma.rolePermission.findMany({
where: { roleId },
include: { permission: true },
orderBy: [{ permission: { resource: 'asc' } }, { permission: { action: 'asc' } }],
});
return rolePermissions.map((rp) => rp.permission);
}
async getPermissionNamesByRoleId(roleId: string): Promise<string[]> {
const rolePermissions = await prisma.rolePermission.findMany({
where: { roleId },
select: { permission: { select: { name: true } } },
});
return rolePermissions.map((rp) => rp.permission.name);
}
async getUserRoleAndPermissions(userId: string) {
const user = await prisma.user.findFirst({
where: { id: userId, deletedAt: null },
select: {
id: true,
email: true,
isActive: true,
roleId: true,
role: {
select: {
id: true,
name: true,
isSystem: true,
rolePermissions: {
select: {
permission: {
select: {
name: true,
},
},
},
},
},
},
},
});
if (!user || !user.role) return null;
const permissions = user.role.rolePermissions.map((rp) => rp.permission.name);
return {
userId: user.id,
email: user.email,
isActive: user.isActive,
roleId: user.role.id,
roleName: user.role.name,
isSystemRole: user.role.isSystem,
permissions,
};
}
async assignRolePermissions(roleId: string, permissionIds: string[]) {
return prisma.$transaction(async (tx) => {
// Remove existing permissions
await tx.rolePermission.deleteMany({
where: { roleId },
});
// Insert new permissions
if (permissionIds.length > 0) {
await tx.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({
roleId,
permissionId,
})),
});
}
return tx.role.findUnique({
where: { id: roleId },
include: {
rolePermissions: {
include: { permission: true },
},
},
});
});
}
async removeRolePermission(roleId: string, permissionId: string) {
return prisma.rolePermission.deleteMany({
where: { roleId, permissionId },
});
}
async countUsersWithRole(roleId: string) {
return prisma.user.count({
where: { roleId, deletedAt: null, isActive: true },
});
}
async countActiveAdminUsers(): Promise<number> {
// Find roles that have ROLE_PERMISSION_ASSIGN or ROLE_UPDATE
const adminRoles = await prisma.role.findMany({
where: {
rolePermissions: {
some: {
permission: {
name: { in: ['ROLE_PERMISSION_ASSIGN', 'USER_UPDATE'] },
},
},
},
},
select: { id: true },
});
const roleIds = adminRoles.map((r) => r.id);
if (roleIds.length === 0) return 0;
return prisma.user.count({
where: {
roleId: { in: roleIds },
deletedAt: null,
isActive: true,
},
});
}
async findRolesWithPermissions(permissionNames: string[]) {
return prisma.role.findMany({
where: {
rolePermissions: {
some: {
permission: {
name: { in: permissionNames },
},
},
},
},
include: {
_count: {
select: { users: true },
},
},
});
}
async updateUserRole(userId: string, roleId: string) {
return prisma.user.update({
where: { id: userId },
data: { roleId },
include: { role: true },
});
}
async createAuditLog(data: {
actorId?: string;
action: string;
targetType: string;
targetId?: string;
previousState?: any;
newState?: any;
ipAddress?: string;
userAgent?: string;
}) {
return prisma.auditLog.create({
data: {
actorId: data.actorId,
action: data.action,
targetType: data.targetType,
targetId: data.targetId,
previousState: data.previousState ? JSON.stringify(data.previousState) : undefined,
newState: data.newState ? JSON.stringify(data.newState) : undefined,
ipAddress: data.ipAddress,
userAgent: data.userAgent,
},
});
}
async findAllAuditLogs(query: AuditLogQueryDto) {
const { actorId, action, targetType, targetId, page = 1, limit = 20, sortBy = 'createdAt', order = 'desc' } = query;
const where: Prisma.AuditLogWhereInput = {
...(actorId ? { actorId } : {}),
...(action ? { action } : {}),
...(targetType ? { targetType } : {}),
...(targetId ? { targetId } : {}),
};
const skip = (page - 1) * limit;
const [data, total] = await prisma.$transaction([
prisma.auditLog.findMany({
where,
orderBy: { [sortBy]: order },
skip,
take: limit,
}),
prisma.auditLog.count({ where }),
]);
return {
data,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
}
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { RbacController } from './rbac.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import {
createRoleSchema,
updateRoleSchema,
roleQuerySchema,
roleParamsSchema,
rolePermissionParamsSchema,
assignRolePermissionsSchema,
permissionQuerySchema,
auditLogQuerySchema,
} from './rbac.validation';
const router = Router();
const controller = new RbacController();
// ==========================================
// ROLES ROUTES (/api/v1/roles)
// ==========================================
export const rolesRouter = Router();
rolesRouter.get(
'/',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_READ),
validate(roleQuerySchema, 'query'),
controller.findAllRoles
);
rolesRouter.post(
'/',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_CREATE),
validate(createRoleSchema),
controller.createRole
);
rolesRouter.get(
'/:id',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_READ),
validate(roleParamsSchema, 'params'),
controller.findRoleById
);
rolesRouter.put(
'/:id',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_UPDATE),
validate(roleParamsSchema, 'params'),
validate(updateRoleSchema),
controller.updateRole
);
rolesRouter.delete(
'/:id',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_DELETE),
validate(roleParamsSchema, 'params'),
controller.deleteRole
);
rolesRouter.get(
'/:id/permissions',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_READ),
validate(roleParamsSchema, 'params'),
controller.getRolePermissions
);
rolesRouter.put(
'/:id/permissions',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_PERMISSION_ASSIGN),
validate(roleParamsSchema, 'params'),
validate(assignRolePermissionsSchema),
controller.assignRolePermissions
);
rolesRouter.post(
'/:id/permissions',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_PERMISSION_ASSIGN),
validate(roleParamsSchema, 'params'),
validate(assignRolePermissionsSchema),
controller.assignRolePermissions
);
rolesRouter.delete(
'/:id/permissions/:permissionId',
authMiddleware,
requirePermission(PERMISSIONS.ROLE_PERMISSION_ASSIGN),
validate(rolePermissionParamsSchema, 'params'),
controller.removeRolePermission
);
// ==========================================
// PERMISSIONS ROUTES (/api/v1/permissions)
// ==========================================
export const permissionsRouter = Router();
permissionsRouter.get(
'/',
authMiddleware,
requirePermission(PERMISSIONS.PERMISSION_READ),
validate(permissionQuerySchema, 'query'),
controller.findAllPermissions
);
// ==========================================
// AUDIT LOGS ROUTES (/api/v1/audit-logs)
// ==========================================
export const auditLogsRouter = Router();
auditLogsRouter.get(
'/',
authMiddleware,
requirePermission(PERMISSIONS.AUDIT_LOG_READ),
validate(auditLogQuerySchema, 'query'),
controller.findAllAuditLogs
);
export default router;
This diff is collapsed.
import { z } from 'zod';
export const createRoleSchema = z.object({
name: z
.string()
.min(2, 'Tên vai trò phải có ít nhất 2 ký tự')
.max(50, 'Tên vai trò tối đa 50 ký tự')
.regex(/^[A-Z0-9_]+$/, 'Tên vai trò chỉ bao gồm chữ in hoa, chữ số và dấu gạch dưới (ví dụ: AUDITOR, SUPPORT_AGENT)'),
description: z.string().max(255, 'Mô tả tối đa 255 ký tự').optional(),
permissionIds: z.array(z.string().uuid('Định dạng permissionId không hợp lệ')).optional(),
});
export const updateRoleSchema = z.object({
name: z
.string()
.min(2, 'Tên vai trò phải có ít nhất 2 ký tự')
.max(50, 'Tên vai trò tối đa 50 ký tự')
.regex(/^[A-Z0-9_]+$/, 'Tên vai trò chỉ bao gồm chữ in hoa, chữ số và dấu gạch dưới')
.optional(),
description: z.string().max(255, 'Mô tả tối đa 255 ký tự').optional(),
});
export const roleQuerySchema = z.object({
name: z.string().optional(),
isSystem: z
.string()
.transform((val) => val === 'true')
.optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('asc'),
});
export const roleParamsSchema = z.object({
id: z.string().uuid('Định dạng Role ID không hợp lệ'),
});
export const rolePermissionParamsSchema = z.object({
id: z.string().uuid('Định dạng Role ID không hợp lệ'),
permissionId: z.string().uuid('Định dạng Permission ID không hợp lệ'),
});
export const assignRolePermissionsSchema = z.object({
permissionIds: z.array(z.string().uuid('Định dạng Permission ID không hợp lệ')),
});
export const permissionQuerySchema = z.object({
resource: z.string().optional(),
action: z.string().optional(),
search: z.string().optional(),
});
export const auditLogQuerySchema = z.object({
actorId: z.string().uuid().optional(),
action: z.string().optional(),
targetType: z.string().optional(),
targetId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['createdAt']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
});
export const userRoleUpdateSchema = z.object({
roleId: z.string().uuid('Định dạng Role ID không hợp lệ'),
});
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { RecurringTransactionController } from './recurring-transaction.controller';
import {
......@@ -15,14 +17,14 @@ const router = Router();
const controller = new RecurringTransactionController();
router.use(authMiddleware);
router.get('/', validate(findRecurringTransactionsSchema, 'query'), controller.findAll);
router.post('/', validate(createRecurringTransactionSchema), controller.create);
router.get('/:id/preview', validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionPreviewSchema, 'query'), controller.preview);
router.get('/:id/history', validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionHistorySchema, 'query'), controller.history);
router.post('/:id/pause', validate(recurringTransactionParamsSchema, 'params'), controller.pause);
router.post('/:id/resume', validate(recurringTransactionParamsSchema, 'params'), controller.resume);
router.get('/:id', validate(recurringTransactionParamsSchema, 'params'), controller.findById);
router.patch('/:id', validate(recurringTransactionParamsSchema, 'params'), validate(updateRecurringTransactionSchema), controller.update);
router.delete('/:id', validate(recurringTransactionParamsSchema, 'params'), controller.remove);
router.get('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(findRecurringTransactionsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_CREATE), validate(createRecurringTransactionSchema), controller.create);
router.get('/:id/preview', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionPreviewSchema, 'query'), controller.preview);
router.get('/:id/history', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(recurringTransactionParamsSchema, 'params'), validate(recurringTransactionHistorySchema, 'query'), controller.history);
router.post('/:id/pause', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_UPDATE), validate(recurringTransactionParamsSchema, 'params'), controller.pause);
router.post('/:id/resume', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_UPDATE), validate(recurringTransactionParamsSchema, 'params'), controller.resume);
router.get('/:id', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_READ), validate(recurringTransactionParamsSchema, 'params'), controller.findById);
router.patch('/:id', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_UPDATE), validate(recurringTransactionParamsSchema, 'params'), validate(updateRecurringTransactionSchema), controller.update);
router.delete('/:id', requirePermission(PERMISSIONS.RECURRING_TRANSACTION_DELETE), validate(recurringTransactionParamsSchema, 'params'), controller.remove);
export default router;
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { ReminderController } from './reminder.controller';
import {
......@@ -14,15 +16,16 @@ const controller = new ReminderController();
router.use(authMiddleware);
router.get('/', validate(findRemindersSchema, 'query'), controller.findAll);
router.post('/', validate(createReminderSchema), controller.create);
router.get('/:id', validate(reminderParamsSchema, 'params'), controller.findById);
router.get('/', requirePermission(PERMISSIONS.REMINDER_READ), validate(findRemindersSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.REMINDER_CREATE), validate(createReminderSchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.REMINDER_READ), validate(reminderParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
requirePermission(PERMISSIONS.REMINDER_UPDATE),
validate(reminderParamsSchema, 'params'),
validate(updateReminderSchema),
controller.update,
);
router.delete('/:id', validate(reminderParamsSchema, 'params'), controller.remove);
router.delete('/:id', requirePermission(PERMISSIONS.REMINDER_DELETE), validate(reminderParamsSchema, 'params'), controller.remove);
export default router;
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { ReportController } from './report.controller';
import { reportQuerySchema } from './report.validation';
......@@ -8,6 +10,7 @@ const router = Router();
const controller = new ReportController();
router.use(authMiddleware);
router.use(requirePermission(PERMISSIONS.REPORT_READ));
router.get('/overview', validate(reportQuerySchema, 'query'), controller.overview);
router.get('/cash-flow', validate(reportQuerySchema, 'query'), controller.cashFlow);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { SavingGoalController } from './saving-goal.controller';
import {
......@@ -18,49 +20,57 @@ const controller = new SavingGoalController();
router.use(authMiddleware);
router.get('/', validate(findSavingGoalsSchema, 'query'), controller.findAll);
router.post('/', validate(createSavingGoalSchema), controller.create);
router.get('/', requirePermission(PERMISSIONS.SAVING_GOAL_READ), validate(findSavingGoalsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.SAVING_GOAL_CREATE), validate(createSavingGoalSchema), controller.create);
router.get(
'/:id/contributions',
requirePermission(PERMISSIONS.SAVING_GOAL_READ),
validate(savingGoalParamsSchema, 'params'),
validate(findSavingContributionsSchema, 'query'),
controller.findContributions,
);
router.post(
'/:id/contributions',
requirePermission(PERMISSIONS.SAVING_GOAL_UPDATE),
validate(savingGoalParamsSchema, 'params'),
validate(createSavingContributionSchema),
controller.createContribution,
);
router.put(
'/:id/contributions/:contributionId',
requirePermission(PERMISSIONS.SAVING_GOAL_UPDATE),
validate(savingContributionParamsSchema, 'params'),
validate(updateSavingContributionSchema),
controller.updateContribution,
);
router.delete(
'/:id/contributions/:contributionId',
requirePermission(PERMISSIONS.SAVING_GOAL_UPDATE),
validate(savingContributionParamsSchema, 'params'),
controller.deleteContribution,
);
router.get(
'/:id',
requirePermission(PERMISSIONS.SAVING_GOAL_READ),
validate(savingGoalParamsSchema, 'params'),
controller.findById,
);
router.put(
'/:id',
requirePermission(PERMISSIONS.SAVING_GOAL_UPDATE),
validate(savingGoalParamsSchema, 'params'),
validate(updateSavingGoalSchema),
controller.update,
);
router.patch(
'/:id/restore',
requirePermission(PERMISSIONS.SAVING_GOAL_UPDATE),
validate(savingGoalParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
requirePermission(PERMISSIONS.SAVING_GOAL_DELETE),
validate(savingGoalParamsSchema, 'params'),
controller.archive,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { SimulationController } from './simulation.controller';
const router = Router();
......@@ -7,7 +9,7 @@ const controller = new SimulationController();
router.use(authMiddleware);
router.post('/run', controller.run);
router.get('/presets', controller.presets);
router.post('/run', requirePermission(PERMISSIONS.SIMULATION_EXECUTE), controller.run);
router.get('/presets', requirePermission(PERMISSIONS.SIMULATION_READ), controller.presets);
export default router;
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { SubscriptionController } from './subscription.controller';
import { validate } from '../../middlewares/validate.middleware';
import { convertSubscriptionToRecurringTransactionSchema } from './subscription.validation';
......@@ -9,10 +11,11 @@ const controller = new SubscriptionController();
router.use(authMiddleware);
router.get('/discover', controller.discover);
router.post('/convert-to-reminder', controller.convertToReminder);
router.get('/discover', requirePermission(PERMISSIONS.SUBSCRIPTION_READ), controller.discover);
router.post('/convert-to-reminder', requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.REMINDER_CREATE), controller.convertToReminder);
router.post(
'/convert-to-recurring-transaction',
requirePermission(PERMISSIONS.SUBSCRIPTION_MANAGE, PERMISSIONS.RECURRING_TRANSACTION_CREATE),
validate(convertSubscriptionToRecurringTransactionSchema),
controller.convertToRecurringTransaction,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { TransactionController } from './transaction.controller';
import { receiptUploadMiddleware } from './transaction-upload.middleware';
......@@ -15,33 +17,38 @@ const controller = new TransactionController();
router.use(authMiddleware);
router.get('/', validate(findTransactionsSchema, 'query'), controller.findAll);
router.post('/', validate(createTransactionSchema), controller.create);
router.get('/:id', validate(transactionParamsSchema, 'params'), controller.findById);
router.get('/', requirePermission(PERMISSIONS.TRANSACTION_READ), validate(findTransactionsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.TRANSACTION_CREATE), validate(createTransactionSchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.TRANSACTION_READ), validate(transactionParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
requirePermission(PERMISSIONS.TRANSACTION_UPDATE),
validate(transactionParamsSchema, 'params'),
validate(updateTransactionSchema),
controller.update,
);
router.delete(
'/:id',
requirePermission(PERMISSIONS.TRANSACTION_DELETE),
validate(transactionParamsSchema, 'params'),
controller.delete,
);
router.put(
'/:id/receipt',
requirePermission(PERMISSIONS.TRANSACTION_UPDATE, PERMISSIONS.UPLOAD_FILE),
validate(transactionParamsSchema, 'params'),
receiptUploadMiddleware,
controller.uploadReceipt,
);
router.get(
'/:id/receipt',
requirePermission(PERMISSIONS.TRANSACTION_READ),
validate(transactionParamsSchema, 'params'),
controller.getReceipt,
);
router.delete(
'/:id/receipt',
requirePermission(PERMISSIONS.TRANSACTION_UPDATE),
validate(transactionParamsSchema, 'params'),
controller.deleteReceipt,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { TransferController } from './transfer.controller';
import {
......@@ -13,10 +15,11 @@ const controller = new TransferController();
router.use(authMiddleware);
router.get('/', validate(findTransfersSchema, 'query'), controller.findAll);
router.post('/', validate(createTransferSchema), controller.create);
router.get('/', requirePermission(PERMISSIONS.TRANSFER_READ), validate(findTransfersSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.TRANSFER_CREATE), validate(createTransferSchema), controller.create);
router.delete(
'/:id',
requirePermission(PERMISSIONS.TRANSFER_DELETE),
validate(transferParamsSchema, 'params'),
controller.delete,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { UploadController } from './upload.controller';
import { createPresignedUploadSchema } from './upload.validation';
......@@ -10,6 +12,7 @@ const controller = new UploadController();
router.post(
'/presign',
authMiddleware,
requirePermission(PERMISSIONS.UPLOAD_FILE),
validate(createPresignedUploadSchema),
controller.createPresignedUpload,
);
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { UserController } from './user.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requireRole } from '../../middlewares/role.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { createUserSchema, findAllUserSchema, updateUserSchema, userParamsSchema } from './user.validation';
import { ROLES } from '../../common/constants/role.constant';
const router = Router();
const controller = new UserController();
// GET /users?email=...&fullName=...&roleName=...&isActive=...&sortBy=...&order=...&page=...&limit=...
router.get('/', authMiddleware, requireRole(ROLES.ADMIN), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.findById);
router.post('/', authMiddleware, requireRole(ROLES.ADMIN), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requireRole(ROLES.ADMIN), validate(userParamsSchema, 'params'), controller.softDelete);
router.get('/', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(findAllUserSchema, 'query'), controller.findAll);
router.get('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_READ), validate(userParamsSchema, 'params'), controller.findById);
router.post('/', authMiddleware, requirePermission(PERMISSIONS.USER_CREATE), validate(createUserSchema), controller.create);
router.put('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_UPDATE), validate(userParamsSchema, 'params'), validate(updateUserSchema), controller.update);
router.delete('/:id', authMiddleware, requirePermission(PERMISSIONS.USER_DELETE), validate(userParamsSchema, 'params'), controller.softDelete);
export default router;
......@@ -3,9 +3,12 @@ import { UserRepository } from './user.repository';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { UserQueryDto, CreateUserDto, UpdateUserDto } from './user.dto';
import { rbacService } from '../rbac/rbac.service';
import { RbacRepository } from '../rbac/rbac.repository';
export class UserService {
private readonly repository = new UserRepository();
private readonly rbacRepository = new RbacRepository();
async findAll(query: UserQueryDto) {
return this.repository.findAll(query);
......@@ -39,16 +42,48 @@ export class UserService {
}
async update(id: string, data: UpdateUserDto) {
await this.findById(id);
const user = await this.findById(id);
return this.repository.update(id, {
// If role is changing, delegate to rbacService for safety checks
if (data.roleId && data.roleId !== user.roleId) {
await rbacService.updateUserRole(id, data.roleId);
}
// If deactivating user, check last admin protection
if (data.isActive === false && user.isActive) {
const userRBAC = await this.rbacRepository.getUserRoleAndPermissions(id);
const isAdmin = userRBAC?.permissions.includes('ROLE_PERMISSION_ASSIGN') || userRBAC?.permissions.includes('USER_UPDATE');
if (isAdmin) {
const activeAdminCount = await this.rbacRepository.countActiveAdminUsers();
if (activeAdminCount <= 1) {
throw new AppError('Không thể vô hiệu hóa tài khoản quản trị viên hoạt động duy nhất', 400, ERROR_CODE.LAST_ADMIN_PROTECTED);
}
}
}
const updated = await this.repository.update(id, {
isActive: data.isActive,
roleId: data.roleId,
});
await rbacService.invalidateUserCache(id);
return updated;
}
async softDelete(id: string, adminId: string) {
await this.findById(id);
return this.repository.softDelete(id, adminId);
const user = await this.findById(id);
const userRBAC = await this.rbacRepository.getUserRoleAndPermissions(id);
const isAdmin = userRBAC?.permissions.includes('ROLE_PERMISSION_ASSIGN') || userRBAC?.permissions.includes('USER_UPDATE');
if (isAdmin) {
const activeAdminCount = await this.rbacRepository.countActiveAdminUsers();
if (activeAdminCount <= 1) {
throw new AppError('Không thể xóa tài khoản quản trị viên hoạt động duy nhất', 400, ERROR_CODE.LAST_ADMIN_PROTECTED);
}
}
const result = await this.repository.softDelete(id, adminId);
await rbacService.invalidateUserCache(id);
return result;
}
}
import { z } from 'zod';
import { ROLES } from '../../common/constants/role.constant';
export const findAllUserSchema = z.object({
email: z.string().optional(),
fullName: z.string().optional(),
roleName: z.enum([ROLES.ADMIN, ROLES.MANAGER, ROLES.USER]).optional(),
roleName: z.string().optional(),
isActive: z
.enum(['true', 'false'])
.transform((v) => v === 'true')
......
import { Router } from 'express';
import { PERMISSIONS } from '../../common/constants';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { requirePermission } from '../../middlewares/permission.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { WalletController } from './wallet.controller';
import {
......@@ -14,27 +16,31 @@ const controller = new WalletController();
router.use(authMiddleware);
router.get('/', validate(findWalletsSchema, 'query'), controller.findAll);
router.post('/', validate(createWalletSchema), controller.create);
router.get('/:id', validate(walletParamsSchema, 'params'), controller.findById);
router.get('/', requirePermission(PERMISSIONS.WALLET_READ), validate(findWalletsSchema, 'query'), controller.findAll);
router.post('/', requirePermission(PERMISSIONS.WALLET_CREATE), validate(createWalletSchema), controller.create);
router.get('/:id', requirePermission(PERMISSIONS.WALLET_READ), validate(walletParamsSchema, 'params'), controller.findById);
router.put(
'/:id',
requirePermission(PERMISSIONS.WALLET_UPDATE),
validate(walletParamsSchema, 'params'),
validate(updateWalletSchema),
controller.update,
);
router.patch(
'/:id/default',
requirePermission(PERMISSIONS.WALLET_UPDATE),
validate(walletParamsSchema, 'params'),
controller.setDefault,
);
router.patch(
'/:id/restore',
requirePermission(PERMISSIONS.WALLET_UPDATE),
validate(walletParamsSchema, 'params'),
controller.restore,
);
router.delete(
'/:id',
requirePermission(PERMISSIONS.WALLET_DELETE),
validate(walletParamsSchema, 'params'),
controller.archive,
);
......
......@@ -18,6 +18,7 @@ import anomalyRoute from '../modules/anomalies/anomaly.route';
import subscriptionRoute from '../modules/subscriptions/subscription.route';
import queryRoute from '../modules/query/query.route';
import recurringTransactionRoute from '../modules/recurring-transactions/recurring-transaction.route';
import { rolesRouter, permissionsRouter, auditLogsRouter } from '../modules/rbac/rbac.route';
import { healthCheck } from './health.controller';
......@@ -27,6 +28,9 @@ router.get('/health', healthCheck);
router.use('/auth', authRoute);
router.use('/users', userRoute);
router.use('/roles', rolesRouter);
router.use('/permissions', permissionsRouter);
router.use('/audit-logs', auditLogsRouter);
router.use('/wallets', walletRoute);
router.use('/categories', categoryRoute);
router.use('/transactions', transactionRoute);
......
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { getPrismaClient } from '../src/database/prisma.client';
describe('GET /api/v1/health', () => {
it('should return 200 and status ok if database is up', async () => {
// Đảm bảo prisma hoạt động
jest.spyOn(prisma, '$queryRaw').mockResolvedValueOnce([1]);
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(200);
......@@ -17,14 +14,19 @@ describe('GET /api/v1/health', () => {
});
it('should return 503 and status error if database is down', async () => {
// Giả lập lỗi truy vấn database
jest.spyOn(prisma, '$queryRaw').mockRejectedValueOnce(new Error('Connection failed'));
const client = getPrismaClient();
const originalQueryRaw = client.$queryRaw;
client.$queryRaw = jest.fn().mockRejectedValueOnce(new Error('Connection failed'));
const res = await request(app).get('/api/v1/health');
try {
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(503);
expect(res.body).toHaveProperty('success', false);
expect(res.body).toHaveProperty('status', 'error');
expect(res.body.database).toHaveProperty('status', 'down');
expect(res.status).toBe(503);
expect(res.body).toHaveProperty('success', false);
expect(res.body).toHaveProperty('status', 'error');
expect(res.body.database).toHaveProperty('status', 'down');
} finally {
client.$queryRaw = originalQueryRaw;
}
});
});
import request from 'supertest';
import app from '../src/app';
import { prisma } from '../src/database/prisma.client';
import { rbacService } from '../src/modules/rbac/rbac.service';
import jwt from 'jsonwebtoken';
import { jwtConfig } from '../src/config/jwt.config';
import { SYSTEM_ROLES } from '../src/common/constants';
describe('Dynamic RBAC Integration Tests', () => {
let adminToken: string;
let userToken: string;
let adminUserId: string;
let normalUserId: string;
let adminRoleId: string;
let userRoleId: string;
let createdRoleId: string;
beforeAll(async () => {
// Find system roles
const adminRole = await prisma.role.findUnique({ where: { name: SYSTEM_ROLES.ADMIN } });
const userRole = await prisma.role.findUnique({ where: { name: SYSTEM_ROLES.USER } });
adminRoleId = adminRole!.id;
userRoleId = userRole!.id;
// Find or create admin user
let admin = await prisma.user.findFirst({
where: { email: 'admin@finwise.local' },
});
if (!admin) {
admin = await prisma.user.create({
data: {
email: 'admin@finwise.local',
password: 'dummy',
roleId: adminRoleId,
isActive: true,
},
});
}
adminUserId = admin.id;
// Create a normal test user
const testUserEmail = `test_rbac_user_${Date.now()}@finwise.local`;
const normalUser = await prisma.user.create({
data: {
email: testUserEmail,
password: 'dummy',
roleId: userRoleId,
isActive: true,
},
});
normalUserId = normalUser.id;
// Generate JWT tokens
adminToken = jwt.sign(
{ id: adminUserId, email: admin.email, role: 'ADMIN', roleId: adminRoleId },
jwtConfig.accessSecret,
{ expiresIn: '1h' }
);
userToken = jwt.sign(
{ id: normalUserId, email: normalUser.email, role: 'USER', roleId: userRoleId },
jwtConfig.accessSecret,
{ expiresIn: '1h' }
);
});
afterAll(async () => {
// Clean up created role if any
if (createdRoleId) {
await prisma.rolePermission.deleteMany({ where: { roleId: createdRoleId } });
await prisma.role.deleteMany({ where: { id: createdRoleId } });
}
// Clean up normal user
if (normalUserId) {
await prisma.user.deleteMany({ where: { id: normalUserId } });
}
});
describe('1. Role & Permission Listing', () => {
it('should list all system and custom roles for users with ROLE_READ', async () => {
const res = await request(app)
.get('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(3); // ADMIN, MANAGER, USER
});
it('should reject role listing for users without ROLE_READ permission', async () => {
const res = await request(app)
.get('/api/v1/roles')
.set('Authorization', `Bearer ${userToken}`);
expect(res.status).toBe(403);
expect(res.body.success).toBe(false);
});
it('should list all permissions in the system for authorized users', async () => {
const res = await request(app)
.get('/api/v1/permissions')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body.data.length).toBeGreaterThanOrEqual(20);
});
});
describe('2. Custom Role Creation & Management', () => {
const customRoleName = `TEST_ROLE_${Date.now()}`;
it('should allow creating a new custom role with permissions', async () => {
// Pick 2 permissions
const perms = await prisma.permission.findMany({ take: 2 });
const permIds = perms.map((p) => p.id);
const res = await request(app)
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: customRoleName,
description: 'Vai trò kiểm thử tự động',
permissionIds: permIds,
});
expect(res.status).toBe(201);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe(customRoleName);
expect(res.body.data.isSystem).toBe(false);
expect(res.body.data.rolePermissions.length).toBe(2);
createdRoleId = res.body.data.id;
});
it('should prevent creating a duplicate role name', async () => {
const res = await request(app)
.post('/api/v1/roles')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: customRoleName,
description: 'Duplicate',
});
expect(res.status).toBe(409);
expect(res.body.success).toBe(false);
});
it('should allow updating description of a custom role', async () => {
const res = await request(app)
.put(`/api/v1/roles/${createdRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
description: 'Mô tả cập nhật mới',
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.description).toBe('Mô tả cập nhật mới');
});
it('should update permissions assigned to the custom role', async () => {
const allPerms = await prisma.permission.findMany({ take: 4 });
const newPermIds = allPerms.map((p) => p.id);
const res = await request(app)
.put(`/api/v1/roles/${createdRoleId}/permissions`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
permissionIds: newPermIds,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.rolePermissions.length).toBe(4);
});
});
describe('3. System Role Protection & Invariants', () => {
it('should prevent deleting protected system roles (ADMIN, USER, MANAGER)', async () => {
const res = await request(app)
.delete(`/api/v1/roles/${adminRoleId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
expect(res.body.code).toBe('ROLE_SYSTEM_PROTECTED');
});
it('should prevent renaming the ADMIN system role', async () => {
const res = await request(app)
.put(`/api/v1/roles/${adminRoleId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'SUPER_ADMIN_RENAMED',
});
expect(res.status).toBe(400);
expect(res.body.code).toBe('ROLE_SYSTEM_PROTECTED');
});
});
describe('4. Last Administrator Protection Invariant', () => {
it('should prevent soft-deleting the last active administrator', async () => {
const res = await request(app)
.delete(`/api/v1/users/${adminUserId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(400);
expect(res.body.code).toBe('LAST_ADMIN_PROTECTED');
});
});
describe('5. Audit Logging Verification', () => {
it('should record audit logs for RBAC actions', async () => {
const res = await request(app)
.get('/api/v1/audit-logs')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.data)).toBe(true);
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment