Commit 513a30d8 authored by ThinhNC's avatar ThinhNC

feat(auth): implement dynamic RBAC and privilege escalation defense

parent d8221337
This diff is collapsed.
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"assigned_by" UUID,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"permission_id" UUID NOT NULL,
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "roles_slug_key" ON "roles"("slug");
-- CreateIndex
CREATE INDEX "roles_slug_idx" ON "roles"("slug");
-- CreateIndex
CREATE INDEX "roles_is_active_idx" ON "roles"("is_active");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_slug_key" ON "permissions"("slug");
-- CreateIndex
CREATE INDEX "permissions_slug_idx" ON "permissions"("slug");
-- CreateIndex
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
-- CreateIndex
CREATE INDEX "user_roles_user_id_idx" ON "user_roles"("user_id");
-- CreateIndex
CREATE INDEX "user_roles_role_id_idx" ON "user_roles"("role_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_user_id_role_id_key" ON "user_roles"("user_id", "role_id");
-- CreateIndex
CREATE INDEX "role_permissions_role_id_idx" ON "role_permissions"("role_id");
-- CreateIndex
CREATE INDEX "role_permissions_permission_id_idx" ON "role_permissions"("permission_id");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_role_id_permission_id_key" ON "role_permissions"("role_id", "permission_id");
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 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;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
...@@ -105,6 +105,7 @@ model User { ...@@ -105,6 +105,7 @@ model User {
auditLogs AuditLog[] auditLogs AuditLog[]
apiKeys ApiKey[] apiKeys ApiKey[]
webhookConfigs WebhookConfig[] webhookConfigs WebhookConfig[]
userRoles UserRoleAssignment[]
@@map("users") @@map("users")
} }
...@@ -394,3 +395,70 @@ model CrawlSchedule { ...@@ -394,3 +395,70 @@ model CrawlSchedule {
@@map("crawl_schedules") @@map("crawl_schedules")
} }
model Role {
id String @id @default(uuid()) @db.Uuid
name String
slug String @unique
description String?
isSystem Boolean @default(false) @map("is_system")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
userRoles UserRoleAssignment[]
rolePermissions RolePermission[]
@@index([slug])
@@index([isActive])
@@map("roles")
}
model Permission {
id String @id @default(uuid()) @db.Uuid
name String
slug String @unique
description String?
resource String
action String
isSystem Boolean @default(true) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
rolePermissions RolePermission[]
@@index([slug])
@@index([resource])
@@map("permissions")
}
model UserRoleAssignment {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
roleId String @map("role_id") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at")
assignedBy String? @map("assigned_by") @db.Uuid
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
@@map("user_roles")
}
model RolePermission {
id String @id @default(uuid()) @db.Uuid
roleId String @map("role_id") @db.Uuid
permissionId String @map("permission_id") @db.Uuid
assignedAt DateTime @default(now()) @map("assigned_at")
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")
}
import { PrismaClient, UserRole } from "@prisma/client"; import { PrismaClient, UserRole } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import {
SYSTEM_ROLE_SLUGS,
SYSTEM_ROLES_METADATA,
SystemRoleSlug,
} from "../src/common/constants/system-role.constant";
import {
SYSTEM_PERMISSIONS_CATALOG,
SYSTEM_ROLE_DEFAULT_PERMISSIONS,
} from "../src/common/constants/permission.constant";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { async function seedPermissions(): Promise<Map<string, string>> {
console.log("Seeding system permissions...");
const permissionMap = new Map<string, string>(); // slug -> id
for (const perm of SYSTEM_PERMISSIONS_CATALOG) {
const record = await prisma.permission.upsert({
where: { slug: perm.slug },
update: {
name: perm.name,
description: perm.description,
resource: perm.resource,
action: perm.action,
isSystem: perm.isSystem,
},
create: {
name: perm.name,
slug: perm.slug,
description: perm.description,
resource: perm.resource,
action: perm.action,
isSystem: perm.isSystem,
},
});
permissionMap.set(record.slug, record.id);
}
return permissionMap;
}
async function seedRoles(
permissionMap: Map<string, string>,
): Promise<Map<string, string>> {
console.log("Seeding system roles & binding permissions...");
const roleMap = new Map<string, string>(); // slug -> id
for (const slug of Object.values(SYSTEM_ROLE_SLUGS)) {
const meta = SYSTEM_ROLES_METADATA[slug as SystemRoleSlug];
const role = await prisma.role.upsert({
where: { slug },
update: {
name: meta.name,
description: meta.description,
isSystem: meta.isSystem,
isActive: true,
},
create: {
name: meta.name,
slug: meta.slug,
description: meta.description,
isSystem: meta.isSystem,
isActive: true,
},
});
roleMap.set(role.slug, role.id);
// Bind default permissions
const defaultPermSlugs =
SYSTEM_ROLE_DEFAULT_PERMISSIONS[slug as SystemRoleSlug] || [];
for (const permSlug of defaultPermSlugs) {
const permId = permissionMap.get(permSlug);
if (permId) {
await prisma.rolePermission.upsert({
where: {
roleId_permissionId: {
roleId: role.id,
permissionId: permId,
},
},
update: {},
create: {
roleId: role.id,
permissionId: permId,
},
});
}
}
}
return roleMap;
}
async function seedUsers(roleMap: Map<string, string>) {
console.log("Seeding base users and user role assignments...");
const adminPasswordHash = await bcrypt.hash("Admin@123456", 10); const adminPasswordHash = await bcrypt.hash("Admin@123456", 10);
const crawlerPasswordHash = await bcrypt.hash("Crawler@123456", 10); const crawlerPasswordHash = await bcrypt.hash("Crawler@123456", 10);
const viewerPasswordHash = await bcrypt.hash("Viewer@123456", 10); const viewerPasswordHash = await bcrypt.hash("Viewer@123456", 10);
await prisma.user.upsert({ const adminUser = await prisma.user.upsert({
where: { email: "admin@crawl.local" }, where: { email: "admin@crawl.local" },
update: {}, update: {},
create: { create: {
email: "admin@crawl.local", email: "admin@crawl.local",
passwordHash: adminPasswordHash, passwordHash: adminPasswordHash,
fullName: "System Admin", fullName: "System Super Admin",
role: UserRole.ADMIN, role: UserRole.ADMIN,
isActive: true, isActive: true,
}, },
}); });
await prisma.user.upsert({ const crawlerUser = await prisma.user.upsert({
where: { email: "crawl@crawl.local" }, where: { email: "crawl@crawl.local" },
update: {}, update: {},
create: { create: {
...@@ -35,7 +126,7 @@ async function main() { ...@@ -35,7 +126,7 @@ async function main() {
}, },
}); });
await prisma.user.upsert({ const viewerUser = await prisma.user.upsert({
where: { email: "viewer@crawl.local" }, where: { email: "viewer@crawl.local" },
update: {}, update: {},
create: { create: {
...@@ -50,12 +141,92 @@ async function main() { ...@@ -50,12 +141,92 @@ async function main() {
}, },
}); });
console.log("Seed completed"); // Assign roles
const superAdminRoleId = roleMap.get(SYSTEM_ROLE_SLUGS.SUPER_ADMIN);
const adminRoleId = roleMap.get(SYSTEM_ROLE_SLUGS.ADMIN);
const crawlerRoleId = roleMap.get(SYSTEM_ROLE_SLUGS.CRAWLER_USER);
const viewerRoleId = roleMap.get(SYSTEM_ROLE_SLUGS.VIEWER);
if (adminRoleId) {
await prisma.userRoleAssignment.upsert({
where: {
userId_roleId: { userId: adminUser.id, roleId: adminRoleId },
},
update: {},
create: { userId: adminUser.id, roleId: adminRoleId },
});
}
if (superAdminRoleId) {
await prisma.userRoleAssignment.upsert({
where: {
userId_roleId: { userId: adminUser.id, roleId: superAdminRoleId },
},
update: {},
create: { userId: adminUser.id, roleId: superAdminRoleId },
});
}
if (crawlerRoleId) {
await prisma.userRoleAssignment.upsert({
where: {
userId_roleId: { userId: crawlerUser.id, roleId: crawlerRoleId },
},
update: {},
create: { userId: crawlerUser.id, roleId: crawlerRoleId },
});
}
if (viewerRoleId) {
await prisma.userRoleAssignment.upsert({
where: {
userId_roleId: { userId: viewerUser.id, roleId: viewerRoleId },
},
update: {},
create: { userId: viewerUser.id, roleId: viewerRoleId },
});
}
// Backfill existing users in database
console.log("Backfilling legacy users into UserRoleAssignment...");
const allUsers = await prisma.user.findMany({
include: { userRoles: true },
});
for (const user of allUsers) {
if (user.userRoles.length === 0) {
let targetRoleId: string | undefined;
if (user.role === UserRole.ADMIN) {
targetRoleId = adminRoleId;
} else if (user.role === UserRole.VIEWER) {
targetRoleId = viewerRoleId;
} else {
targetRoleId = crawlerRoleId;
}
if (targetRoleId) {
await prisma.userRoleAssignment.upsert({
where: {
userId_roleId: { userId: user.id, roleId: targetRoleId },
},
update: {},
create: { userId: user.id, roleId: targetRoleId },
});
}
}
}
}
async function main() {
const permissionMap = await seedPermissions();
const roleMap = await seedRoles(permissionMap);
await seedUsers(roleMap);
console.log("Seed completed successfully!");
} }
main() main()
.catch((error) => { .catch((error) => {
console.error(error); console.error("Seed error:", error);
process.exit(1); process.exit(1);
}) })
.finally(async () => { .finally(async () => {
......
...@@ -22,6 +22,15 @@ export const AUDIT_ACTIONS = { ...@@ -22,6 +22,15 @@ export const AUDIT_ACTIONS = {
REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK", REDELIVER_WEBHOOK: "REDELIVER_WEBHOOK",
REQUEST_DEACTIVATE_ACCOUNT: "REQUEST_DEACTIVATE_ACCOUNT", REQUEST_DEACTIVATE_ACCOUNT: "REQUEST_DEACTIVATE_ACCOUNT",
CONFIRM_DEACTIVATE_ACCOUNT: "CONFIRM_DEACTIVATE_ACCOUNT", CONFIRM_DEACTIVATE_ACCOUNT: "CONFIRM_DEACTIVATE_ACCOUNT",
ROLE_CREATED: "ROLE_CREATED",
ROLE_UPDATED: "ROLE_UPDATED",
ROLE_DELETED: "ROLE_DELETED",
ROLE_ASSIGNED: "ROLE_ASSIGNED",
ROLE_REVOKED: "ROLE_REVOKED",
PERMISSION_ASSIGNED: "PERMISSION_ASSIGNED",
PERMISSION_REVOKED: "PERMISSION_REVOKED",
SUPER_ADMIN_ASSIGN_ATTEMPT: "SUPER_ADMIN_ASSIGN_ATTEMPT",
PRIVILEGE_ESCALATION_BLOCKED: "PRIVILEGE_ESCALATION_BLOCKED",
} as const; } as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS]; export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
...@@ -10,3 +10,5 @@ export * from "./schedule-frequency.constant"; ...@@ -10,3 +10,5 @@ export * from "./schedule-frequency.constant";
export * from "./asset-type.constant"; export * from "./asset-type.constant";
export * from "./crawl-page-status.constant"; export * from "./crawl-page-status.constant";
export * from "./webhook.constant"; export * from "./webhook.constant";
export * from "./system-role.constant";
export * from "./permission.constant";
This diff is collapsed.
export const SYSTEM_ROLE_SLUGS = {
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
CRAWLER_USER: "crawler_user",
VIEWER: "viewer",
} as const;
export type SystemRoleSlug =
(typeof SYSTEM_ROLE_SLUGS)[keyof typeof SYSTEM_ROLE_SLUGS];
export interface SystemRoleDefinition {
name: string;
slug: SystemRoleSlug;
description: string;
isSystem: boolean;
}
export const SYSTEM_ROLES_METADATA: Record<
SystemRoleSlug,
SystemRoleDefinition
> = {
[SYSTEM_ROLE_SLUGS.SUPER_ADMIN]: {
name: "Super Administrator",
slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
description:
"Tài khoản quản trị cấp cao nhất, toàn quyền quản lý hệ thống, vai trò và phân quyền.",
isSystem: true,
},
[SYSTEM_ROLE_SLUGS.ADMIN]: {
name: "Administrator",
slug: SYSTEM_ROLE_SLUGS.ADMIN,
description:
"Quản trị viên vận hành hệ thống, quản lý người dùng, job, lịch trình và custom roles.",
isSystem: true,
},
[SYSTEM_ROLE_SLUGS.CRAWLER_USER]: {
name: "Crawler User",
slug: SYSTEM_ROLE_SLUGS.CRAWLER_USER,
description:
"Người dùng thông thường, có quyền tạo và quản lý tác vụ cào dữ liệu của chính mình.",
isSystem: true,
},
[SYSTEM_ROLE_SLUGS.VIEWER]: {
name: "Viewer",
slug: SYSTEM_ROLE_SLUGS.VIEWER,
description:
"Người dùng chỉ có quyền xem dữ liệu và kết quả báo cáo cào dữ liệu.",
isSystem: true,
},
};
...@@ -26,6 +26,11 @@ export const ERROR_CODE = { ...@@ -26,6 +26,11 @@ export const ERROR_CODE = {
WEBHOOK_CONFIG_NOT_FOUND: "WEBHOOK_CONFIG_NOT_FOUND", WEBHOOK_CONFIG_NOT_FOUND: "WEBHOOK_CONFIG_NOT_FOUND",
CRAWL_SCHEDULE_NOT_FOUND: "CRAWL_SCHEDULE_NOT_FOUND", CRAWL_SCHEDULE_NOT_FOUND: "CRAWL_SCHEDULE_NOT_FOUND",
DIFF_REPORT_NOT_FOUND: "DIFF_REPORT_NOT_FOUND", DIFF_REPORT_NOT_FOUND: "DIFF_REPORT_NOT_FOUND",
ROLE_NOT_FOUND: "ROLE_NOT_FOUND",
PERMISSION_NOT_FOUND: "PERMISSION_NOT_FOUND",
PRIVILEGE_ESCALATION_DENIED: "PRIVILEGE_ESCALATION_DENIED",
SYSTEM_ROLE_PROTECTED: "SYSTEM_ROLE_PROTECTED",
CANNOT_REMOVE_LAST_SUPER_ADMIN: "CANNOT_REMOVE_LAST_SUPER_ADMIN",
} as const; } as const;
export type ErrorCode = keyof typeof ERROR_CODE; export type ErrorCode = keyof typeof ERROR_CODE;
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class AuthorizationCache {
private readonly permissionCache = new Map<string, CacheEntry<string[]>>();
private readonly roleCache = new Map<string, CacheEntry<string[]>>();
private readonly defaultTtlMs = 60 * 1000; // 60 seconds
getCachedPermissions(userId: string): string[] | null {
const entry = this.permissionCache.get(userId);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.permissionCache.delete(userId);
return null;
}
return entry.data;
}
setCachedPermissions(
userId: string,
permissions: string[],
ttlMs: number = this.defaultTtlMs,
): void {
this.permissionCache.set(userId, {
data: permissions,
expiresAt: Date.now() + ttlMs,
});
}
getCachedRoles(userId: string): string[] | null {
const entry = this.roleCache.get(userId);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.roleCache.delete(userId);
return null;
}
return entry.data;
}
setCachedRoles(
userId: string,
roles: string[],
ttlMs: number = this.defaultTtlMs,
): void {
this.roleCache.set(userId, {
data: roles,
expiresAt: Date.now() + ttlMs,
});
}
invalidateUser(userId: string): void {
this.permissionCache.delete(userId);
this.roleCache.delete(userId);
}
invalidateAll(): void {
this.permissionCache.clear();
this.roleCache.clear();
}
}
export const authorizationCache = new AuthorizationCache();
...@@ -10,5 +10,9 @@ export type { ...@@ -10,5 +10,9 @@ export type {
WebhookDelivery, WebhookDelivery,
AuditLog, AuditLog,
RefreshToken, RefreshToken,
Role,
Permission,
UserRoleAssignment,
RolePermission,
Prisma, Prisma,
} from "@prisma/client"; } from "@prisma/client";
...@@ -7,6 +7,8 @@ declare global { ...@@ -7,6 +7,8 @@ declare global {
id: string; id: string;
email: string; email: string;
role: UserRole; role: UserRole;
roles?: string[];
permissions?: string[];
}; };
} }
} }
......
...@@ -2336,4 +2336,316 @@ export const swaggerPaths: Record<string, any> = { ...@@ -2336,4 +2336,316 @@ export const swaggerPaths: Record<string, any> = {
}, },
}, },
}, },
"/roles": {
get: {
tags: ["Roles"],
summary: "Danh sách Roles",
description:
"Lấy danh sách các vai trò (Roles) trong hệ thống kèm phân trang và tìm kiếm.",
parameters: [
{ name: "search", in: "query", schema: { type: "string" } },
{ name: "isSystem", in: "query", schema: { type: "boolean" } },
{ name: "isActive", in: "query", schema: { type: "boolean" } },
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
{
name: "limit",
in: "query",
schema: { type: "integer", default: 20 },
},
],
responses: {
200: { description: "Lấy danh sách thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.read" },
},
},
post: {
tags: ["Roles"],
summary: "Tạo Role tùy chỉnh",
description:
"Tạo mới một vai trò tùy chỉnh (Custom Role) trong hệ thống.",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CreateRoleRequest" },
},
},
},
responses: {
201: { description: "Tạo Role thành công" },
400: { description: "Dữ liệu không hợp lệ" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.create" },
409: { description: "Role slug đã tồn tại" },
},
},
},
"/roles/{id}": {
get: {
tags: ["Roles"],
summary: "Chi tiết Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Lấy thông tin Role thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.read" },
404: { description: "Không tìm thấy Role" },
},
},
patch: {
tags: ["Roles"],
summary: "Cập nhật Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/UpdateRoleRequest" },
},
},
},
responses: {
200: { description: "Cập nhật Role thành công" },
400: { description: "Không thể vô hiệu hóa system role" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.update" },
404: { description: "Không tìm thấy Role" },
},
},
delete: {
tags: ["Roles"],
summary: "Xóa Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Xóa Role thành công" },
400: { description: "Không thể xóa system role" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.delete" },
404: { description: "Không tìm thấy Role" },
},
},
},
"/roles/{id}/permissions": {
get: {
tags: ["Roles"],
summary: "Xem danh sách Permissions của Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Lấy danh sách permissions thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.permissions.read" },
404: { description: "Không tìm thấy Role" },
},
},
put: {
tags: ["Roles"],
summary: "Gán danh sách Permissions cho Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
$ref: "#/components/schemas/AssignRolePermissionsRequest",
},
},
},
},
responses: {
200: { description: "Gán permissions thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.permissions.assign" },
404: { description: "Không tìm thấy Role" },
},
},
},
"/roles/{id}/users": {
get: {
tags: ["Roles"],
summary: "Danh sách Users thuộc Role",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Lấy danh sách thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền roles.read" },
},
},
},
"/permissions": {
get: {
tags: ["Permissions"],
summary: "Danh mục Permissions hệ thống",
parameters: [
{ name: "resource", in: "query", schema: { type: "string" } },
{ name: "search", in: "query", schema: { type: "string" } },
],
responses: {
200: { description: "Lấy danh mục permissions thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền permissions.read" },
},
},
},
"/permissions/{id}": {
get: {
tags: ["Permissions"],
summary: "Chi tiết Permission",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Lấy chi tiết permission thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền permissions.read" },
404: { description: "Không tìm thấy Permission" },
},
},
},
"/users/{id}/roles": {
get: {
tags: ["Users"],
summary: "Xem Roles của User",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Lấy roles thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Không có quyền users.roles.read" },
},
},
put: {
tags: ["Users"],
summary: "Cập nhật toàn bộ Roles của User",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/AssignUserRolesRequest" },
},
},
},
responses: {
200: { description: "Gán roles thành công" },
400: { description: "Không thể thu hồi Super Admin cuối cùng" },
401: { description: "Chưa xác thực" },
403: {
description: "Không được phép tự gán hoặc gán trái phép Super Admin",
},
404: { description: "Không tìm thấy User hoặc Role" },
},
},
},
"/users/{id}/roles/{roleId}": {
post: {
tags: ["Users"],
summary: "Gán thêm một Role cho User",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
{
name: "roleId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Gán role thành công" },
401: { description: "Chưa xác thực" },
403: { description: "Bị từ chối nâng quyền trái phép" },
404: { description: "Không tìm thấy User hoặc Role" },
},
},
delete: {
tags: ["Users"],
summary: "Gỡ một Role khỏi User",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
{
name: "roleId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
},
],
responses: {
200: { description: "Gỡ role thành công" },
400: { description: "Không thể gỡ Super Admin cuối cùng" },
401: { description: "Chưa xác thực" },
403: { description: "Bị từ chối nâng quyền trái phép" },
404: { description: "Không tìm thấy User hoặc Role" },
},
},
},
}; };
This diff is collapsed.
...@@ -624,6 +624,77 @@ const rawSchemas = { ...@@ -624,6 +624,77 @@ const rawSchemas = {
}, },
}, },
}, },
Role: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
slug: { type: "string" },
description: { type: "string", nullable: true },
isSystem: { type: "boolean" },
isActive: { type: "boolean" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
Permission: {
type: "object",
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
slug: { type: "string" },
description: { type: "string", nullable: true },
resource: { type: "string" },
action: { type: "string" },
isSystem: { type: "boolean" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
},
CreateRoleRequest: {
type: "object",
required: ["name", "slug"],
properties: {
name: { type: "string", example: "Finance Auditor" },
slug: { type: "string", example: "finance_auditor" },
description: {
type: "string",
example: "Audits financial records and reports",
},
permissionIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
},
},
UpdateRoleRequest: {
type: "object",
properties: {
name: { type: "string" },
description: { type: "string" },
isActive: { type: "boolean" },
},
},
AssignRolePermissionsRequest: {
type: "object",
required: ["permissionIds"],
properties: {
permissionIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
},
},
AssignUserRolesRequest: {
type: "object",
required: ["roleIds"],
properties: {
roleIds: {
type: "array",
items: { type: "string", format: "uuid" },
},
},
},
}; };
const outputFile = "./src/docs/swagger.json"; const outputFile = "./src/docs/swagger.json";
......
import { Request, Response, NextFunction } from "express";
import {
requirePermission,
requireAnyPermission,
requireAllPermissions,
} from "../permission.middleware";
import { PermissionService } from "../../modules/permissions/permission.service";
import { PERMISSIONS } from "../../common/constants/permission.constant";
jest.mock("../../modules/permissions/permission.service");
describe("Permission Middleware", () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: jest.MockedFunction<NextFunction>;
beforeEach(() => {
jest.clearAllMocks();
mockReq = {
user: {
id: "user-1",
email: "user@example.com",
role: "CRAWLER_USER",
permissions: [PERMISSIONS.USERS_READ, PERMISSIONS.CRAWL_JOBS_CREATE],
} as any,
};
mockRes = {};
mockNext = jest.fn();
});
describe("requirePermission", () => {
it("should call next() when user has the required permission", async () => {
const middleware = requirePermission(PERMISSIONS.USERS_READ);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith();
});
it("should return 403 when user lacks the required permission", async () => {
const middleware = requirePermission(PERMISSIONS.USERS_DELETE);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
code: "FORBIDDEN",
}),
);
});
it("should return 401 when request has no user", async () => {
delete mockReq.user;
const middleware = requirePermission(PERMISSIONS.USERS_READ);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 401,
code: "UNAUTHORIZED",
}),
);
});
});
describe("requireAnyPermission", () => {
it("should call next() if user has at least one permission", async () => {
const middleware = requireAnyPermission(
PERMISSIONS.USERS_DELETE,
PERMISSIONS.USERS_READ, // user has this
);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith();
});
it("should return 403 if user lacks all permissions", async () => {
const middleware = requireAnyPermission(
PERMISSIONS.USERS_DELETE,
PERMISSIONS.ROLES_CREATE,
);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
code: "FORBIDDEN",
}),
);
});
});
describe("requireAllPermissions", () => {
it("should call next() if user has all required permissions", async () => {
const middleware = requireAllPermissions(
PERMISSIONS.USERS_READ,
PERMISSIONS.CRAWL_JOBS_CREATE,
);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith();
});
it("should return 403 if user is missing at least one permission", async () => {
const middleware = requireAllPermissions(
PERMISSIONS.USERS_READ,
PERMISSIONS.USERS_DELETE, // user lacks this
);
await middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 403,
code: "FORBIDDEN",
}),
);
});
});
});
...@@ -4,8 +4,10 @@ import { jwtConfig } from "../config/jwt.config"; ...@@ -4,8 +4,10 @@ import { jwtConfig } from "../config/jwt.config";
import { AppError } from "../common/errors/app-error"; import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code"; import { ERROR_CODE } from "../common/errors/error-code";
import { UserRepository } from "../modules/users/user.repository"; import { UserRepository } from "../modules/users/user.repository";
import { PermissionService } from "../modules/permissions/permission.service";
const userRepository = new UserRepository(); const userRepository = new UserRepository();
const permissionService = new PermissionService();
export async function authMiddleware( export async function authMiddleware(
req: Request, req: Request,
...@@ -45,10 +47,17 @@ export async function authMiddleware( ...@@ -45,10 +47,17 @@ export async function authMiddleware(
return; return;
} }
const [roles, permissions] = await Promise.all([
permissionService.getUserRoles(user.id),
permissionService.getUserPermissions(user.id),
]);
req.user = { req.user = {
id: user.id, id: user.id,
email: user.email, email: user.email,
role: user.role, role: user.role,
roles,
permissions,
}; };
next(); next();
......
import { Request, Response, NextFunction } from "express";
import { PermissionSlug } from "../common/constants/permission.constant";
import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code";
import { PermissionService } from "../modules/permissions/permission.service";
const permissionService = new PermissionService();
async function resolveUserPermissions(req: Request): Promise<string[]> {
if (req.user.permissions && Array.isArray(req.user.permissions)) {
return req.user.permissions;
}
const permissions = await permissionService.getUserPermissions(req.user.id);
req.user.permissions = permissions;
if (!req.user.roles) {
req.user.roles = await permissionService.getUserRoles(req.user.id);
}
return permissions;
}
export function requirePermission(permission: PermissionSlug) {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
if (!req.user) {
next(new AppError("Unauthorized", 401, ERROR_CODE.UNAUTHORIZED));
return;
}
const userPermissions = await resolveUserPermissions(req);
if (!userPermissions.includes(permission)) {
next(
new AppError(
`Forbidden: Missing required permission [${permission}]`,
403,
ERROR_CODE.FORBIDDEN,
),
);
return;
}
next();
} catch (error) {
next(error);
}
};
}
export function requireAnyPermission(...permissions: PermissionSlug[]) {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
if (!req.user) {
next(new AppError("Unauthorized", 401, ERROR_CODE.UNAUTHORIZED));
return;
}
if (permissions.length === 0) {
next(
new AppError(
"Forbidden: No permissions specified",
403,
ERROR_CODE.FORBIDDEN,
),
);
return;
}
const userPermissions = await resolveUserPermissions(req);
const hasAny = permissions.some((perm) => userPermissions.includes(perm));
if (!hasAny) {
next(
new AppError(
`Forbidden: Requires at least one of [${permissions.join(", ")}]`,
403,
ERROR_CODE.FORBIDDEN,
),
);
return;
}
next();
} catch (error) {
next(error);
}
};
}
export function requireAllPermissions(...permissions: PermissionSlug[]) {
return async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
if (!req.user) {
next(new AppError("Unauthorized", 401, ERROR_CODE.UNAUTHORIZED));
return;
}
const userPermissions = await resolveUserPermissions(req);
const hasAll = permissions.every((perm) =>
userPermissions.includes(perm),
);
if (!hasAll) {
next(
new AppError(
`Forbidden: Requires all permissions [${permissions.join(", ")}]`,
403,
ERROR_CODE.FORBIDDEN,
),
);
return;
}
next();
} catch (error) {
next(error);
}
};
}
...@@ -3,14 +3,22 @@ import { Role } from "../common/constants/role.constant"; ...@@ -3,14 +3,22 @@ import { Role } from "../common/constants/role.constant";
import { AppError } from "../common/errors/app-error"; import { AppError } from "../common/errors/app-error";
import { ERROR_CODE } from "../common/errors/error-code"; import { ERROR_CODE } from "../common/errors/error-code";
export function requireRole(...roles: Role[]) { export function requireRole(...roles: (Role | string)[]) {
return (req: Request, res: Response, next: NextFunction): void => { return (req: Request, res: Response, next: NextFunction): void => {
if (!req.user) { if (!req.user) {
next(new AppError("Unauthorized", 401, ERROR_CODE.UNAUTHORIZED)); next(new AppError("Unauthorized", 401, ERROR_CODE.UNAUTHORIZED));
return; return;
} }
if (!roles.includes(req.user.role)) { const normalizedRequired = roles.map((r) => r.toLowerCase());
const userLegacyRole = req.user.role ? req.user.role.toLowerCase() : "";
const userDynamicRoles = (req.user.roles || []).map((r) => r.toLowerCase());
const hasRole =
normalizedRequired.includes(userLegacyRole) ||
userDynamicRoles.some((r) => normalizedRequired.includes(r));
if (!hasRole) {
next(new AppError("Forbidden", 403, ERROR_CODE.FORBIDDEN)); next(new AppError("Forbidden", 403, ERROR_CODE.FORBIDDEN));
return; return;
} }
......
import { PermissionService } from "../permission.service";
import { PermissionRepository } from "../permission.repository";
import { authorizationCache } from "../../../common/helpers/authorization-cache.helper";
import { AppError } from "../../../common/errors/app-error";
jest.mock("../permission.repository");
describe("PermissionService", () => {
let service: PermissionService;
let repository: jest.Mocked<PermissionRepository>;
beforeEach(() => {
jest.clearAllMocks();
authorizationCache.invalidateAll();
service = new PermissionService();
repository = (service as any).repository;
});
describe("findAll", () => {
it("should return formatted permission list", async () => {
const mockPermissions = [
{
id: "p-1",
name: "Read Users",
slug: "users.read",
description: "Read user list",
resource: "users",
action: "read",
isSystem: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
repository.findAll.mockResolvedValue(mockPermissions as any);
const result = await service.findAll({ resource: "users" });
expect(repository.findAll).toHaveBeenCalledWith({ resource: "users" });
expect(result).toHaveLength(1);
expect(result[0].slug).toBe("users.read");
});
});
describe("findById", () => {
it("should return permission if found", async () => {
const mockPermission = {
id: "p-1",
name: "Read Users",
slug: "users.read",
description: "Read user list",
resource: "users",
action: "read",
isSystem: true,
createdAt: new Date(),
updatedAt: new Date(),
};
repository.findById.mockResolvedValue(mockPermission as any);
const result = await service.findById("p-1");
expect(result.slug).toBe("users.read");
});
it("should throw AppError 404 if permission not found", async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById("non-existing")).rejects.toThrow(AppError);
});
});
describe("getUserPermissions and caching", () => {
it("should fetch from repository on cache miss and cache result", async () => {
repository.findUserPermissions.mockResolvedValue([
"users.read",
"roles.read",
]);
const perms1 = await service.getUserPermissions("user-1");
expect(perms1).toEqual(["users.read", "roles.read"]);
expect(repository.findUserPermissions).toHaveBeenCalledTimes(1);
// Second call should hit cache
const perms2 = await service.getUserPermissions("user-1");
expect(perms2).toEqual(["users.read", "roles.read"]);
expect(repository.findUserPermissions).toHaveBeenCalledTimes(1);
});
it("should fetch from repository again after cache invalidation", async () => {
repository.findUserPermissions.mockResolvedValue(["users.read"]);
await service.getUserPermissions("user-1");
authorizationCache.invalidateUser("user-1");
await service.getUserPermissions("user-1");
expect(repository.findUserPermissions).toHaveBeenCalledTimes(2);
});
});
describe("getUserRoles and caching", () => {
it("should fetch user role slugs and cache result", async () => {
repository.findUserRoleSlugs.mockResolvedValue(["admin", "crawler_user"]);
const roles1 = await service.getUserRoles("user-1");
expect(roles1).toEqual(["admin", "crawler_user"]);
expect(repository.findUserRoleSlugs).toHaveBeenCalledTimes(1);
const roles2 = await service.getUserRoles("user-1");
expect(roles2).toEqual(["admin", "crawler_user"]);
expect(repository.findUserRoleSlugs).toHaveBeenCalledTimes(1);
});
});
});
import { Request, Response, NextFunction } from "express";
import { PermissionService } from "./permission.service";
import { PermissionQueryDto } from "./permission.dto";
export class PermissionController {
private readonly service = new PermissionService();
findAll = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const query: PermissionQueryDto = {
resource: req.query.resource as string | undefined,
search: req.query.search as string | undefined,
};
const result = await this.service.findAll(query);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
findById = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.findById(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
}
export interface PermissionResponseDto {
id: string;
name: string;
slug: string;
description: string | null;
resource: string;
action: string;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface PermissionQueryDto {
resource?: string;
search?: string;
}
import { prisma } from "../../database/prisma.client";
import { Permission } from "../../common/types/database.types";
export class PermissionRepository {
async findAll(query?: {
resource?: string;
search?: string;
}): Promise<Permission[]> {
const where: {
resource?: string;
OR?: Array<{
name?: { contains: string; mode: "insensitive" };
slug?: { contains: string; mode: "insensitive" };
description?: { contains: string; mode: "insensitive" };
}>;
} = {};
if (query?.resource) {
where.resource = query.resource;
}
if (query?.search) {
where.OR = [
{ name: { contains: query.search, mode: "insensitive" } },
{ slug: { contains: query.search, mode: "insensitive" } },
{ description: { contains: query.search, mode: "insensitive" } },
];
}
return prisma.permission.findMany({
where,
orderBy: [{ resource: "asc" }, { action: "asc" }],
});
}
async findById(id: string): Promise<Permission | null> {
return prisma.permission.findUnique({
where: { id },
});
}
async findBySlug(slug: string): Promise<Permission | null> {
return prisma.permission.findUnique({
where: { slug },
});
}
async findUserPermissions(userId: string): Promise<string[]> {
const assignments = await prisma.userRoleAssignment.findMany({
where: {
userId,
role: { isActive: true },
},
include: {
role: {
include: {
rolePermissions: {
include: {
permission: true,
},
},
},
},
},
});
const permissionSet = new Set<string>();
for (const assignment of assignments) {
for (const rp of assignment.role.rolePermissions) {
if (rp.permission?.slug) {
permissionSet.add(rp.permission.slug);
}
}
}
return Array.from(permissionSet);
}
async findUserRoleSlugs(userId: string): Promise<string[]> {
const assignments = await prisma.userRoleAssignment.findMany({
where: {
userId,
role: { isActive: true },
},
include: {
role: true,
},
});
return assignments.map((a) => a.role.slug);
}
}
import { Router } from "express";
import { PermissionController } from "./permission.controller";
import { authMiddleware } from "../../middlewares/auth.middleware";
import { requirePermission } from "../../middlewares/permission.middleware";
import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router();
const controller = new PermissionController();
router.get(
"/",
authMiddleware,
requirePermission(PERMISSIONS.PERMISSIONS_READ),
controller.findAll,
);
router.get(
"/:id",
authMiddleware,
requirePermission(PERMISSIONS.PERMISSIONS_READ),
controller.findById,
);
export default router;
import { PermissionRepository } from "./permission.repository";
import { PermissionResponseDto, PermissionQueryDto } from "./permission.dto";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
import { authorizationCache } from "../../common/helpers/authorization-cache.helper";
export class PermissionService {
private readonly repository = new PermissionRepository();
async findAll(query?: PermissionQueryDto): Promise<PermissionResponseDto[]> {
const permissions = await this.repository.findAll(query);
return permissions.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
description: p.description,
resource: p.resource,
action: p.action,
isSystem: p.isSystem,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
}));
}
async findById(id: string): Promise<PermissionResponseDto> {
const permission = await this.repository.findById(id);
if (!permission) {
throw new AppError(
"Permission not found",
404,
ERROR_CODE.PERMISSION_NOT_FOUND,
);
}
return {
id: permission.id,
name: permission.name,
slug: permission.slug,
description: permission.description,
resource: permission.resource,
action: permission.action,
isSystem: permission.isSystem,
createdAt: permission.createdAt,
updatedAt: permission.updatedAt,
};
}
async getUserPermissions(userId: string): Promise<string[]> {
const cached = authorizationCache.getCachedPermissions(userId);
if (cached) {
return cached;
}
const permissions = await this.repository.findUserPermissions(userId);
authorizationCache.setCachedPermissions(userId, permissions);
return permissions;
}
async getUserRoles(userId: string): Promise<string[]> {
const cached = authorizationCache.getCachedRoles(userId);
if (cached) {
return cached;
}
const roles = await this.repository.findUserRoleSlugs(userId);
authorizationCache.setCachedRoles(userId, roles);
return roles;
}
}
import { UserService } from "../../users/user.service";
import { UserRepository } from "../../users/user.repository";
import { RoleRepository } from "../role.repository";
import { AuditLogService } from "../../audit-logs/audit-log.service";
import { SYSTEM_ROLE_SLUGS } from "../../../common/constants/system-role.constant";
import { AUDIT_ACTIONS } from "../../../common/constants/audit-action.constant";
jest.mock("../../users/user.repository");
jest.mock("../role.repository");
jest.mock("../../audit-logs/audit-log.service");
describe("Privilege Escalation Defense Suite", () => {
let userService: UserService;
let userRepository: jest.Mocked<UserRepository>;
let roleRepository: jest.Mocked<RoleRepository>;
let auditLogService: jest.Mocked<AuditLogService>;
const superAdminRole = {
id: "role-super-admin-id",
name: "Super Administrator",
slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
isSystem: true,
};
const adminRole = {
id: "role-admin-id",
name: "Administrator",
slug: SYSTEM_ROLE_SLUGS.ADMIN,
isSystem: true,
};
const customRole = {
id: "role-custom-id",
name: "Custom Manager",
slug: "custom_manager",
isSystem: false,
};
beforeEach(() => {
jest.clearAllMocks();
userService = new UserService();
userRepository = (userService as any).repository;
roleRepository = (userService as any).roleRepository;
auditLogService = (userService as any).auditLogService;
});
describe("1. Self Privilege Escalation Defense", () => {
it("denies user attempting to assign roles to self via assignUserRoles", async () => {
await expect(
userService.assignUserRoles(
"user-1", // actorId
["admin"], // actorRoles
"user-1", // targetUserId (same as actor)
[adminRole.id],
{ actorId: "user-1" },
),
).rejects.toThrow("cannot modify your own roles");
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
}),
);
expect(userRepository.assignUserRoles).not.toHaveBeenCalled();
});
it("denies user attempting to assign a single role to self", async () => {
await expect(
userService.assignSingleRole(
"user-1",
["crawler_user"],
"user-1",
adminRole.id,
{ actorId: "user-1" },
),
).rejects.toThrow("cannot assign roles to yourself");
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
}),
);
});
it("denies user attempting to revoke a role from self", async () => {
await expect(
userService.revokeSingleRole(
"user-1",
["admin"],
"user-1",
adminRole.id,
),
).rejects.toThrow("cannot revoke roles from yourself");
});
});
describe("2. Super Admin Isolation Defense", () => {
it("denies regular admin attempting to assign Super Admin role to another user", async () => {
userRepository.findById.mockResolvedValue({
id: "user-target",
email: "target@example.com",
} as any);
roleRepository.findById.mockResolvedValue(superAdminRole as any);
await expect(
userService.assignSingleRole(
"actor-admin",
["admin"], // not super_admin
"user-target",
superAdminRole.id,
{ actorId: "actor-admin" },
),
).rejects.toThrow(
"Only Super Administrators can assign the Super Admin role",
);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-admin",
action: AUDIT_ACTIONS.SUPER_ADMIN_ASSIGN_ATTEMPT,
}),
);
expect(userRepository.assignSingleRole).not.toHaveBeenCalled();
});
it("denies regular admin attempting to modify roles of an existing Super Admin", async () => {
userRepository.findById.mockResolvedValue({
id: "user-super-target",
email: "super@example.com",
} as any);
roleRepository.findById.mockResolvedValue(customRole as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
await expect(
userService.assignSingleRole(
"actor-admin",
["admin"],
"user-super-target",
customRole.id,
),
).rejects.toThrow(
"Only Super Administrators can modify roles of a Super Administrator",
);
});
it("denies regular admin attempting to delete a Super Admin user", async () => {
userRepository.findById.mockResolvedValue({
id: "user-super-target",
} as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
await expect(
userService.delete("user-super-target", "actor-admin", ["admin"]),
).rejects.toThrow(
"Only Super Administrators can delete a Super Administrator account",
);
expect(userRepository.delete).not.toHaveBeenCalled();
});
it("denies regular admin attempting to deactivate a Super Admin user", async () => {
userRepository.findById.mockResolvedValue({
id: "user-super-target",
} as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
await expect(
userService.update(
"user-super-target",
{ isActive: false },
"actor-admin",
["admin"],
),
).rejects.toThrow(
"Only Super Administrators can modify a Super Administrator account",
);
});
});
describe("3. Last Active Super Admin Protection", () => {
it("prevents revoking the last active Super Admin role", async () => {
userRepository.findById.mockResolvedValue({
id: "target-super-1",
} as any);
roleRepository.findById.mockResolvedValue(superAdminRole as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
userRepository.countActiveSuperAdmins.mockResolvedValue(1); // Only 1 active super admin left
await expect(
userService.revokeSingleRole(
"actor-super-2",
[SYSTEM_ROLE_SLUGS.SUPER_ADMIN],
"target-super-1",
superAdminRole.id,
),
).rejects.toThrow("Cannot revoke the last active Super Admin role");
expect(userRepository.revokeSingleRole).not.toHaveBeenCalled();
});
it("prevents deleting the last active Super Admin account", async () => {
userRepository.findById.mockResolvedValue({
id: "target-super-1",
} as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
userRepository.countActiveSuperAdmins.mockResolvedValue(1);
await expect(
userService.delete("target-super-1", "actor-super-2", [
SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
]),
).rejects.toThrow("Cannot delete the last active Super Admin account");
expect(userRepository.delete).not.toHaveBeenCalled();
});
it("prevents deactivating the last active Super Admin account", async () => {
userRepository.findById.mockResolvedValue({
id: "target-super-1",
} as any);
userRepository.isUserSuperAdmin.mockResolvedValue(true);
userRepository.countActiveSuperAdmins.mockResolvedValue(1);
await expect(
userService.update(
"target-super-1",
{ isActive: false },
"actor-super-2",
[SYSTEM_ROLE_SLUGS.SUPER_ADMIN],
),
).rejects.toThrow(
"Cannot deactivate the last active Super Admin account",
);
});
});
describe("4. Legitimate Super Admin & Admin Operations", () => {
it("allows Super Admin to assign Super Admin role when multiple exist", async () => {
userRepository.findById.mockResolvedValue({
id: "user-target",
} as any);
roleRepository.findById.mockResolvedValue(superAdminRole as any);
userRepository.isUserSuperAdmin.mockResolvedValue(false);
userRepository.assignSingleRole.mockResolvedValue({
userId: "user-target",
roleId: superAdminRole.id,
} as any);
const result = await userService.assignSingleRole(
"actor-super",
[SYSTEM_ROLE_SLUGS.SUPER_ADMIN],
"user-target",
superAdminRole.id,
{ actorId: "actor-super" },
);
expect(result).toBeDefined();
expect(userRepository.assignSingleRole).toHaveBeenCalledWith(
"user-target",
superAdminRole.id,
"actor-super",
);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-super",
action: AUDIT_ACTIONS.ROLE_ASSIGNED,
}),
);
});
it("allows Admin to assign custom role to user", async () => {
userRepository.findById.mockResolvedValue({
id: "user-target",
} as any);
roleRepository.findById.mockResolvedValue(customRole as any);
userRepository.isUserSuperAdmin.mockResolvedValue(false);
userRepository.assignSingleRole.mockResolvedValue({
userId: "user-target",
roleId: customRole.id,
} as any);
const result = await userService.assignSingleRole(
"actor-admin",
["admin"],
"user-target",
customRole.id,
{ actorId: "actor-admin" },
);
expect(result).toBeDefined();
expect(userRepository.assignSingleRole).toHaveBeenCalledWith(
"user-target",
customRole.id,
"actor-admin",
);
});
});
});
import { RoleService } from "../role.service";
import { RoleRepository } from "../role.repository";
import { AuditLogService } from "../../audit-logs/audit-log.service";
import { AppError } from "../../../common/errors/app-error";
import { SYSTEM_ROLE_SLUGS } from "../../../common/constants/system-role.constant";
import { AUDIT_ACTIONS } from "../../../common/constants/audit-action.constant";
jest.mock("../role.repository");
jest.mock("../../audit-logs/audit-log.service");
describe("RoleService", () => {
let service: RoleService;
let repository: jest.Mocked<RoleRepository>;
let auditLogService: jest.Mocked<AuditLogService>;
beforeEach(() => {
jest.clearAllMocks();
service = new RoleService();
repository = (service as any).repository;
auditLogService = (service as any).auditLogService;
});
describe("findAll", () => {
it("should return formatted list with meta", async () => {
repository.findAll.mockResolvedValue({
items: [
{
id: "role-1",
name: "Custom Role",
slug: "custom_role",
description: "Custom",
isSystem: false,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
_count: { userRoles: 3 },
} as any,
],
total: 1,
page: 1,
limit: 20,
});
const result = await service.findAll();
expect(result.items).toHaveLength(1);
expect(result.items[0].slug).toBe("custom_role");
expect(result.items[0].userCount).toBe(3);
expect(result.meta.total).toBe(1);
});
});
describe("findById", () => {
it("should return role if exists", async () => {
repository.findById.mockResolvedValue({
id: "role-1",
name: "Admin",
slug: "admin",
description: "Admin",
isSystem: true,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
} as any);
const result = await service.findById("role-1");
expect(result.slug).toBe("admin");
});
it("should throw 404 if role not found", async () => {
repository.findById.mockResolvedValue(null);
await expect(service.findById("non-existent")).rejects.toThrow(AppError);
});
});
describe("create", () => {
it("should reject creating role with reserved system slug", async () => {
await expect(
service.create({
name: "Super Admin Clone",
slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
}),
).rejects.toThrow("reserved system slug");
});
it("should reject creating duplicate slug", async () => {
repository.findBySlug.mockResolvedValue({
id: "r-existing",
slug: "existing_slug",
} as any);
await expect(
service.create({
name: "Existing Role",
slug: "existing_slug",
}),
).rejects.toThrow("already exists");
});
it("should create role and log audit", async () => {
repository.findBySlug.mockResolvedValue(null);
repository.create.mockResolvedValue({
id: "r-new",
name: "Content Manager",
slug: "content_manager",
description: "Manages content",
isSystem: false,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
} as any);
const result = await service.create(
{
name: "Content Manager",
slug: "content_manager",
description: "Manages content",
},
{ actorId: "actor-1" },
);
expect(result.slug).toBe("content_manager");
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-1",
action: AUDIT_ACTIONS.ROLE_CREATED,
}),
);
});
});
describe("update", () => {
it("should reject deactivating a system role", async () => {
repository.findById.mockResolvedValue({
id: "r-sys",
name: "Admin",
slug: "admin",
isSystem: true,
isActive: true,
} as any);
await expect(
service.update("r-sys", { isActive: false }, { actorId: "actor-1" }),
).rejects.toThrow("System roles cannot be deactivated");
});
it("should update custom role successfully and log audit", async () => {
repository.findById.mockResolvedValue({
id: "r-cust",
name: "Old Name",
slug: "custom_role",
isSystem: false,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
} as any);
repository.update.mockResolvedValue({
id: "r-cust",
name: "New Name",
slug: "custom_role",
isSystem: false,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
} as any);
const result = await service.update(
"r-cust",
{ name: "New Name" },
{ actorId: "actor-1" },
);
expect(result.name).toBe("New Name");
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-1",
action: AUDIT_ACTIONS.ROLE_UPDATED,
}),
);
});
});
describe("delete", () => {
it("should reject deleting a system role", async () => {
repository.findById.mockResolvedValue({
id: "r-sys",
name: "Admin",
slug: "admin",
isSystem: true,
} as any);
await expect(
service.delete("r-sys", { actorId: "actor-1" }),
).rejects.toThrow("System roles cannot be deleted");
});
it("should delete custom role successfully and log audit", async () => {
repository.findById.mockResolvedValue({
id: "r-cust",
name: "Custom Role",
slug: "custom_role",
isSystem: false,
} as any);
await service.delete("r-cust", { actorId: "actor-1" });
expect(repository.delete).toHaveBeenCalledWith("r-cust");
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-1",
action: AUDIT_ACTIONS.ROLE_DELETED,
}),
);
});
});
describe("setRolePermissions", () => {
it("should reject non-super-admin modifying super_admin permissions", async () => {
repository.findById.mockResolvedValue({
id: "r-super",
slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
} as any);
await expect(
service.setRolePermissions(
"r-super",
["p-1"],
["admin"], // actor has only 'admin' role
{ actorId: "actor-admin" },
),
).rejects.toThrow(
"Only Super Administrators can modify Super Admin permissions",
);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-admin",
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
}),
);
});
it("should allow super-admin to modify permissions and log audit", async () => {
repository.findById.mockResolvedValue({
id: "r-cust",
slug: "custom_role",
} as any);
repository.setRolePermissions.mockResolvedValue({
id: "r-cust",
name: "Custom",
slug: "custom_role",
isSystem: false,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
rolePermissions: [],
} as any);
await service.setRolePermissions("r-cust", ["p-1", "p-2"], ["admin"], {
actorId: "actor-admin",
});
expect(repository.setRolePermissions).toHaveBeenCalledWith("r-cust", [
"p-1",
"p-2",
]);
expect(auditLogService.log).toHaveBeenCalledWith(
expect.objectContaining({
userId: "actor-admin",
action: AUDIT_ACTIONS.PERMISSION_ASSIGNED,
}),
);
});
});
});
import { Request, Response, NextFunction } from "express";
import { RoleService } from "./role.service";
import {
CreateRoleDto,
UpdateRoleDto,
RoleQueryDto,
AssignRolePermissionsDto,
} from "./role.dto";
export class RoleController {
private readonly service = new RoleService();
findAll = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const query: RoleQueryDto = req.query;
const result = await this.service.findAll(query);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
findById = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.findById(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
create = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const dto: CreateRoleDto = req.body;
const result = await this.service.create(dto, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.status(201).json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
update = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const dto: UpdateRoleDto = req.body;
const result = await this.service.update(req.params.id, dto, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
delete = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
await this.service.delete(req.params.id, {
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
});
res.json({
success: true,
message: "Role deleted successfully",
});
} catch (error) {
next(error);
}
};
getRolePermissions = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.getRolePermissions(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
setRolePermissions = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const dto: AssignRolePermissionsDto = req.body;
const result = await this.service.setRolePermissions(
req.params.id,
dto.permissionIds,
req.user.roles || [],
{
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
},
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
getRoleUsers = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.getRoleUsers(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
}
export interface CreateRoleDto {
name: string;
slug: string;
description?: string;
permissionIds?: string[];
}
export interface UpdateRoleDto {
name?: string;
description?: string;
isActive?: boolean;
}
export interface RoleQueryDto {
search?: string;
isSystem?: boolean | string;
isActive?: boolean | string;
page?: number | string;
limit?: number | string;
}
export interface AssignRolePermissionsDto {
permissionIds: string[];
}
export interface RolePermissionItemDto {
id: string;
name: string;
slug: string;
resource: string;
action: string;
}
export interface RoleResponseDto {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
permissions?: RolePermissionItemDto[];
userCount?: number;
}
import { prisma } from "../../database/prisma.client";
import { Prisma } from "@prisma/client";
import { RoleQueryDto } from "./role.dto";
export class RoleRepository {
async findAll(query: RoleQueryDto = {}) {
const where: Prisma.RoleWhereInput = {};
if (query.isSystem !== undefined) {
where.isSystem =
typeof query.isSystem === "boolean"
? query.isSystem
: query.isSystem === "true";
}
if (query.isActive !== undefined) {
where.isActive =
typeof query.isActive === "boolean"
? query.isActive
: query.isActive === "true";
}
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: "insensitive" } },
{ slug: { contains: query.search, mode: "insensitive" } },
{ description: { contains: query.search, mode: "insensitive" } },
];
}
const page = Math.max(1, Number(query.page) || 1);
const limit = Math.min(Math.max(1, Number(query.limit) || 20), 100);
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
prisma.role.findMany({
where,
include: {
rolePermissions: {
include: {
permission: true,
},
},
_count: {
select: { userRoles: true },
},
},
orderBy: [{ isSystem: "desc" }, { name: "asc" }],
skip,
take: limit,
}),
prisma.role.count({ where }),
]);
return {
items,
total,
page,
limit,
};
}
async findById(id: string) {
return prisma.role.findUnique({
where: { id },
include: {
rolePermissions: {
include: {
permission: true,
},
},
_count: {
select: { userRoles: true },
},
},
});
}
async findBySlug(slug: string) {
return prisma.role.findUnique({
where: { slug },
include: {
rolePermissions: {
include: {
permission: true,
},
},
_count: {
select: { userRoles: true },
},
},
});
}
async create(data: {
name: string;
slug: string;
description?: string;
isSystem?: boolean;
permissionIds?: string[];
}) {
return prisma.$transaction(async (tx) => {
const role = await tx.role.create({
data: {
name: data.name,
slug: data.slug,
description: data.description,
isSystem: data.isSystem ?? false,
isActive: true,
},
});
if (data.permissionIds && data.permissionIds.length > 0) {
await tx.rolePermission.createMany({
data: data.permissionIds.map((permissionId) => ({
roleId: role.id,
permissionId,
})),
skipDuplicates: true,
});
}
return tx.role.findUniqueOrThrow({
where: { id: role.id },
include: {
rolePermissions: {
include: { permission: true },
},
_count: {
select: { userRoles: true },
},
},
});
});
}
async update(
id: string,
data: {
name?: string;
description?: string;
isActive?: boolean;
},
) {
return prisma.role.update({
where: { id },
data,
include: {
rolePermissions: {
include: { permission: true },
},
_count: {
select: { userRoles: true },
},
},
});
}
async delete(id: string) {
return prisma.role.delete({
where: { id },
});
}
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 setRolePermissions(roleId: string, permissionIds: string[]) {
return prisma.$transaction(async (tx) => {
await tx.rolePermission.deleteMany({
where: { roleId },
});
if (permissionIds.length > 0) {
await tx.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({
roleId,
permissionId,
})),
skipDuplicates: true,
});
}
return tx.role.findUniqueOrThrow({
where: { id: roleId },
include: {
rolePermissions: {
include: { permission: true },
},
_count: {
select: { userRoles: true },
},
},
});
});
}
async getUsersWithRole(roleId: string) {
const assignments = await prisma.userRoleAssignment.findMany({
where: { roleId },
include: {
user: {
select: {
id: true,
email: true,
fullName: true,
avatarUrl: true,
isActive: true,
},
},
},
});
return assignments.map((a) => a.user);
}
}
import { Router } from "express";
import { RoleController } from "./role.controller";
import { authMiddleware } from "../../middlewares/auth.middleware";
import { requirePermission } from "../../middlewares/permission.middleware";
import { validate, validateQuery } from "../../middlewares/validate.middleware";
import {
createRoleSchema,
updateRoleSchema,
assignRolePermissionsSchema,
listRolesQuerySchema,
} from "./role.validation";
import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router();
const controller = new RoleController();
router.get(
"/",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_READ),
validateQuery(listRolesQuerySchema),
controller.findAll,
);
router.get(
"/:id",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_READ),
controller.findById,
);
router.post(
"/",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_CREATE),
validate(createRoleSchema),
controller.create,
);
router.patch(
"/:id",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_UPDATE),
validate(updateRoleSchema),
controller.update,
);
router.delete(
"/:id",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_DELETE),
controller.delete,
);
router.get(
"/:id/permissions",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_PERMISSIONS_READ),
controller.getRolePermissions,
);
router.put(
"/:id/permissions",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_PERMISSIONS_ASSIGN),
validate(assignRolePermissionsSchema),
controller.setRolePermissions,
);
router.get(
"/:id/users",
authMiddleware,
requirePermission(PERMISSIONS.ROLES_READ),
controller.getRoleUsers,
);
export default router;
import { RoleRepository } from "./role.repository";
import {
CreateRoleDto,
UpdateRoleDto,
RoleQueryDto,
RoleResponseDto,
} from "./role.dto";
import { AppError } from "../../common/errors/app-error";
import { ERROR_CODE } from "../../common/errors/error-code";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant";
import { AuditLogService } from "../audit-logs/audit-log.service";
import { authorizationCache } from "../../common/helpers/authorization-cache.helper";
interface AuditContext {
actorId?: string;
ipAddress?: string;
userAgent?: string;
}
interface RoleWithPermissions {
id: string;
name: string;
slug: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
rolePermissions?: Array<{
permission: {
id: string;
name: string;
slug: string;
resource: string;
action: string;
};
}>;
_count?: {
userRoles?: number;
};
}
export class RoleService {
private readonly repository = new RoleRepository();
private readonly auditLogService = new AuditLogService();
private formatRole(role: RoleWithPermissions): RoleResponseDto {
return {
id: role.id,
name: role.name,
slug: role.slug,
description: role.description ?? null,
isSystem: role.isSystem,
isActive: role.isActive,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
permissions: role.rolePermissions
? role.rolePermissions.map((rp) => ({
id: rp.permission.id,
name: rp.permission.name,
slug: rp.permission.slug,
resource: rp.permission.resource,
action: rp.permission.action,
}))
: undefined,
userCount: role._count?.userRoles ?? undefined,
};
}
async findAll(query: RoleQueryDto = {}) {
const { items, total, page, limit } = await this.repository.findAll(query);
return {
items: items.map((r) => this.formatRole(r)),
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
async findById(id: string): Promise<RoleResponseDto> {
const role = await this.repository.findById(id);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
return this.formatRole(role);
}
async create(
dto: CreateRoleDto,
context?: AuditContext,
): Promise<RoleResponseDto> {
const normalizedSlug = dto.slug.toLowerCase().trim();
// Check reserved system slug
const reservedSlugs = Object.values(SYSTEM_ROLE_SLUGS) as string[];
if (reservedSlugs.includes(normalizedSlug)) {
throw new AppError(
`Cannot create role with reserved system slug: ${normalizedSlug}`,
400,
ERROR_CODE.VALIDATION_ERROR,
);
}
// Check duplicate
const existing = await this.repository.findBySlug(normalizedSlug);
if (existing) {
throw new AppError(
`Role with slug '${normalizedSlug}' already exists`,
409,
ERROR_CODE.DUPLICATE_ENTRY,
);
}
const created = await this.repository.create({
name: dto.name,
slug: normalizedSlug,
description: dto.description,
isSystem: false,
permissionIds: dto.permissionIds,
});
if (context?.actorId) {
await this.auditLogService.log({
userId: context.actorId,
action: AUDIT_ACTIONS.ROLE_CREATED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
roleId: created.id,
name: created.name,
slug: created.slug,
permissionCount: dto.permissionIds?.length ?? 0,
},
});
}
authorizationCache.invalidateAll();
return this.formatRole(created);
}
async update(
id: string,
dto: UpdateRoleDto,
context?: AuditContext,
): Promise<RoleResponseDto> {
const existing = await this.repository.findById(id);
if (!existing) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
// Protect system roles from deactivation
if (existing.isSystem && dto.isActive === false) {
throw new AppError(
"System roles cannot be deactivated.",
400,
ERROR_CODE.SYSTEM_ROLE_PROTECTED,
);
}
const updated = await this.repository.update(id, {
name: dto.name,
description: dto.description,
isActive: dto.isActive,
});
if (context?.actorId) {
await this.auditLogService.log({
userId: context.actorId,
action: AUDIT_ACTIONS.ROLE_UPDATED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
roleId: updated.id,
slug: updated.slug,
updatedFields: Object.keys(dto),
},
});
}
authorizationCache.invalidateAll();
return this.formatRole(updated);
}
async delete(id: string, context?: AuditContext): Promise<void> {
const existing = await this.repository.findById(id);
if (!existing) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
// Protect system roles from deletion
if (existing.isSystem) {
throw new AppError(
"System roles cannot be deleted.",
400,
ERROR_CODE.SYSTEM_ROLE_PROTECTED,
);
}
await this.repository.delete(id);
if (context?.actorId) {
await this.auditLogService.log({
userId: context.actorId,
action: AUDIT_ACTIONS.ROLE_DELETED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
roleId: id,
slug: existing.slug,
name: existing.name,
},
});
}
authorizationCache.invalidateAll();
}
async getRolePermissions(roleId: string) {
const role = await this.repository.findById(roleId);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
const permissions = await this.repository.getRolePermissions(roleId);
return permissions.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
description: p.description,
resource: p.resource,
action: p.action,
}));
}
async setRolePermissions(
roleId: string,
permissionIds: string[],
actorRoles: string[] = [],
context?: AuditContext,
): Promise<RoleResponseDto> {
const role = await this.repository.findById(roleId);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
// Privilege escalation defense: Only super_admin can modify permissions of super_admin role
if (
role.slug === SYSTEM_ROLE_SLUGS.SUPER_ADMIN &&
!actorRoles.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
if (context?.actorId) {
await this.auditLogService.log({
userId: context.actorId,
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
reason:
"Non-super-admin attempted to modify super_admin permissions",
targetRoleId: roleId,
},
});
}
throw new AppError(
"Forbidden: Only Super Administrators can modify Super Admin permissions.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
const updated = await this.repository.setRolePermissions(
roleId,
permissionIds,
);
if (context?.actorId) {
await this.auditLogService.log({
userId: context.actorId,
action: AUDIT_ACTIONS.PERMISSION_ASSIGNED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
roleId,
roleSlug: role.slug,
assignedPermissionCount: permissionIds.length,
},
});
}
authorizationCache.invalidateAll();
return this.formatRole(updated);
}
async getRoleUsers(roleId: string) {
const role = await this.repository.findById(roleId);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
return this.repository.getUsersWithRole(roleId);
}
}
import { z } from "zod";
export const createRoleSchema = z.object({
name: z
.string({ required_error: "Role name is required" })
.min(2, "Role name must be at least 2 characters")
.max(100, "Role name must not exceed 100 characters")
.trim(),
slug: z
.string({ required_error: "Role slug is required" })
.min(2, "Role slug must be at least 2 characters")
.max(50, "Role slug must not exceed 50 characters")
.regex(
/^[a-z0-9_]+$/,
"Role slug must only contain lowercase alphanumeric characters and underscores",
)
.trim(),
description: z.string().max(500).optional(),
permissionIds: z
.array(z.string().uuid("Invalid permission ID format"))
.optional(),
});
export const updateRoleSchema = z.object({
name: z
.string()
.min(2, "Role name must be at least 2 characters")
.max(100, "Role name must not exceed 100 characters")
.trim()
.optional(),
description: z.string().max(500).optional(),
isActive: z.boolean().optional(),
});
export const assignRolePermissionsSchema = z.object({
permissionIds: z.array(z.string().uuid("Invalid permission ID format"), {
required_error: "permissionIds array is required",
}),
});
export const listRolesQuerySchema = z.object({
search: z.string().optional(),
isSystem: z
.preprocess((val) => {
if (val === "true") return true;
if (val === "false") return false;
return val;
}, z.boolean().optional())
.optional(),
isActive: z
.preprocess((val) => {
if (val === "true") return true;
if (val === "false") return false;
return val;
}, z.boolean().optional())
.optional(),
page: z
.preprocess(
(val) => (val ? Number(val) : 1),
z.number().int().min(1).default(1),
)
.optional(),
limit: z
.preprocess(
(val) => (val ? Number(val) : 20),
z.number().int().min(1).max(100).default(20),
)
.optional(),
});
...@@ -64,7 +64,12 @@ export class UserController { ...@@ -64,7 +64,12 @@ export class UserController {
update = async (req: Request, res: Response, next: NextFunction) => { update = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const updateUserDto: UpdateUserDto = req.body; const updateUserDto: UpdateUserDto = req.body;
const result = await this.service.update(req.params.id, updateUserDto); const result = await this.service.update(
req.params.id,
updateUserDto,
req.user.id,
req.user.roles || [],
);
await this.auditLogService.log({ await this.auditLogService.log({
userId: req.user.id, userId: req.user.id,
...@@ -88,7 +93,11 @@ export class UserController { ...@@ -88,7 +93,11 @@ export class UserController {
delete = async (req: Request, res: Response, next: NextFunction) => { delete = async (req: Request, res: Response, next: NextFunction) => {
try { try {
await this.service.delete(req.params.id, req.user.id); await this.service.delete(
req.params.id,
req.user.id,
req.user.roles || [],
);
await this.auditLogService.log({ await this.auditLogService.log({
userId: req.user.id, userId: req.user.id,
...@@ -106,4 +115,103 @@ export class UserController { ...@@ -106,4 +115,103 @@ export class UserController {
next(error); next(error);
} }
}; };
getUserRoles = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.getUserRoles(req.params.id);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
assignRoles = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const { roleIds } = req.body as { roleIds: string[] };
const result = await this.service.assignUserRoles(
req.user.id,
req.user.roles || [],
req.params.id,
roleIds,
{
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
},
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
assignRole = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.assignSingleRole(
req.user.id,
req.user.roles || [],
req.params.id,
req.params.roleId,
{
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
},
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
revokeRole = async (
req: Request,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const result = await this.service.revokeSingleRole(
req.user.id,
req.user.roles || [],
req.params.id,
req.params.roleId,
{
actorId: req.user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] as string,
},
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
} }
...@@ -3,6 +3,7 @@ import { UserRole, Prisma, User } from "@prisma/client"; ...@@ -3,6 +3,7 @@ import { UserRole, Prisma, User } from "@prisma/client";
import { UserQueryDto } from "./user.dto"; import { UserQueryDto } from "./user.dto";
import { envConfig } from "../../config/env.config"; import { envConfig } from "../../config/env.config";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant";
export class UserRepository { export class UserRepository {
async findAll(query: UserQueryDto = {}) { async findAll(query: UserQueryDto = {}) {
...@@ -46,6 +47,13 @@ export class UserRepository { ...@@ -46,6 +47,13 @@ export class UserRepository {
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
prisma.user.findMany({ prisma.user.findMany({
where, where,
include: {
userRoles: {
include: {
role: true,
},
},
},
orderBy, orderBy,
skip, skip,
take: limit, take: limit,
...@@ -67,6 +75,19 @@ export class UserRepository { ...@@ -67,6 +75,19 @@ export class UserRepository {
}); });
} }
findByIdWithRoles(id: string) {
return prisma.user.findFirst({
where: { id, deletedAt: null },
include: {
userRoles: {
include: {
role: true,
},
},
},
});
}
findByEmail(email: string): Promise<User | null> { findByEmail(email: string): Promise<User | null> {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { email, deletedAt: null }, where: { email, deletedAt: null },
...@@ -136,4 +157,152 @@ export class UserRepository { ...@@ -136,4 +157,152 @@ export class UserRepository {
return user; return user;
}); });
} }
async getUserRoles(userId: string) {
const assignments = await prisma.userRoleAssignment.findMany({
where: { userId },
include: {
role: true,
},
orderBy: {
assignedAt: "desc",
},
});
return assignments.map((a) => ({
id: a.role.id,
name: a.role.name,
slug: a.role.slug,
description: a.role.description,
isSystem: a.role.isSystem,
isActive: a.role.isActive,
assignedAt: a.assignedAt,
assignedBy: a.assignedBy,
}));
}
async assignUserRoles(
userId: string,
roleIds: string[],
assignedBy?: string,
) {
return prisma.$transaction(async (tx) => {
await tx.userRoleAssignment.deleteMany({
where: { userId },
});
if (roleIds.length > 0) {
await tx.userRoleAssignment.createMany({
data: roleIds.map((roleId) => ({
userId,
roleId,
assignedBy,
})),
skipDuplicates: true,
});
}
// Sync legacy role enum
const assignedRoles = await tx.role.findMany({
where: { id: { in: roleIds } },
});
const slugs = assignedRoles.map((r) => r.slug);
let legacyRole: UserRole = ROLES.VIEWER;
if (
slugs.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN) ||
slugs.includes(SYSTEM_ROLE_SLUGS.ADMIN)
) {
legacyRole = ROLES.ADMIN;
} else if (slugs.includes(SYSTEM_ROLE_SLUGS.CRAWLER_USER)) {
legacyRole = ROLES.CRAWLER_USER;
}
await tx.user.update({
where: { id: userId },
data: { role: legacyRole },
});
return tx.userRoleAssignment.findMany({
where: { userId },
include: { role: true },
});
});
}
async assignSingleRole(userId: string, roleId: string, assignedBy?: string) {
return prisma.$transaction(async (tx) => {
const assignment = await tx.userRoleAssignment.upsert({
where: {
userId_roleId: { userId, roleId },
},
update: { assignedBy },
create: { userId, roleId, assignedBy },
include: { role: true },
});
if (
assignment.role.slug === SYSTEM_ROLE_SLUGS.SUPER_ADMIN ||
assignment.role.slug === SYSTEM_ROLE_SLUGS.ADMIN
) {
await tx.user.update({
where: { id: userId },
data: { role: ROLES.ADMIN },
});
}
return assignment;
});
}
async revokeSingleRole(userId: string, roleId: string) {
return prisma.$transaction(async (tx) => {
await tx.userRoleAssignment.deleteMany({
where: { userId, roleId },
});
// Update legacy role enum based on remaining roles
const remainingAssignments = await tx.userRoleAssignment.findMany({
where: { userId },
include: { role: true },
});
const slugs = remainingAssignments.map((a) => a.role.slug);
let legacyRole: UserRole = ROLES.VIEWER;
if (
slugs.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN) ||
slugs.includes(SYSTEM_ROLE_SLUGS.ADMIN)
) {
legacyRole = ROLES.ADMIN;
} else if (slugs.includes(SYSTEM_ROLE_SLUGS.CRAWLER_USER)) {
legacyRole = ROLES.CRAWLER_USER;
}
await tx.user.update({
where: { id: userId },
data: { role: legacyRole },
});
return remainingAssignments;
});
}
async countActiveSuperAdmins(): Promise<number> {
return prisma.userRoleAssignment.count({
where: {
role: { slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN },
user: { isActive: true, deletedAt: null },
},
});
}
async isUserSuperAdmin(userId: string): Promise<boolean> {
const count = await prisma.userRoleAssignment.count({
where: {
userId,
role: { slug: SYSTEM_ROLE_SLUGS.SUPER_ADMIN },
},
});
return count > 0;
}
} }
import { Router } from "express"; import { Router } from "express";
import { UserController } from "./user.controller"; import { UserController } from "./user.controller";
import { authMiddleware } from "../../middlewares/auth.middleware"; import { authMiddleware } from "../../middlewares/auth.middleware";
import { requireRole } from "../../middlewares/role.middleware"; import { requirePermission } from "../../middlewares/permission.middleware";
import { validate, validateQuery } from "../../middlewares/validate.middleware"; import { validate, validateQuery } from "../../middlewares/validate.middleware";
import { import {
createUserSchema, createUserSchema,
updateUserSchema, updateUserSchema,
listUsersQuerySchema, listUsersQuerySchema,
assignUserRolesSchema,
} from "./user.validation"; } from "./user.validation";
import { ROLES } from "../../common/constants/role.constant"; import { PERMISSIONS } from "../../common/constants/permission.constant";
const router = Router(); const router = Router();
const controller = new UserController(); const controller = new UserController();
...@@ -16,41 +17,75 @@ const controller = new UserController(); ...@@ -16,41 +17,75 @@ const controller = new UserController();
router.get( router.get(
"/", "/",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.USERS_READ),
validateQuery(listUsersQuerySchema), validateQuery(listUsersQuerySchema),
controller.findAll, controller.findAll,
); );
router.get( router.get(
"/:id", "/:id",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.USERS_READ),
controller.findById, controller.findById,
); );
router.post( router.post(
"/", "/",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.USERS_CREATE),
validate(createUserSchema), validate(createUserSchema),
(req, res, next) => { (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/CreateUserRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/CreateUserRequest' } }
controller.create(req, res, next); controller.create(req, res, next);
}, },
); );
router.put( router.put(
"/:id", "/:id",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.USERS_UPDATE),
validate(updateUserSchema), validate(updateUserSchema),
(req, res, next) => { (req, res, next) => {
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/UpdateUserRequest' } } // #swagger.requestBody = { schema: { $ref: '#/components/schemas/UpdateUserRequest' } }
controller.update(req, res, next); controller.update(req, res, next);
}, },
); );
router.delete( router.delete(
"/:id", "/:id",
authMiddleware, authMiddleware,
requireRole(ROLES.ADMIN), requirePermission(PERMISSIONS.USERS_DELETE),
controller.delete, controller.delete,
); );
// User Roles Management
router.get(
"/:id/roles",
authMiddleware,
requirePermission(PERMISSIONS.USERS_ROLES_READ),
controller.getUserRoles,
);
router.put(
"/:id/roles",
authMiddleware,
requirePermission(PERMISSIONS.USERS_ROLES_ASSIGN),
validate(assignUserRolesSchema),
controller.assignRoles,
);
router.post(
"/:id/roles/:roleId",
authMiddleware,
requirePermission(PERMISSIONS.USERS_ROLES_ASSIGN),
controller.assignRole,
);
router.delete(
"/:id/roles/:roleId",
authMiddleware,
requirePermission(PERMISSIONS.USERS_ROLES_ASSIGN),
controller.revokeRole,
);
export default router; export default router;
This diff is collapsed.
...@@ -41,3 +41,9 @@ export const listUsersQuerySchema = z.object({ ...@@ -41,3 +41,9 @@ export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
}); });
export const assignUserRolesSchema = z.object({
roleIds: z.array(z.string().uuid("Invalid role ID format"), {
required_error: "roleIds array is required",
}),
});
...@@ -10,12 +10,16 @@ import extractionTemplateRoute from "../modules/extraction-templates/extraction- ...@@ -10,12 +10,16 @@ import extractionTemplateRoute from "../modules/extraction-templates/extraction-
import crawlScheduleRoute from "../modules/crawl-schedules/crawl-schedule.route"; import crawlScheduleRoute from "../modules/crawl-schedules/crawl-schedule.route";
import healthRoute from "../modules/health/health.route"; import healthRoute from "../modules/health/health.route";
import dashboardRoute from "../modules/dashboard/dashboard.route"; import dashboardRoute from "../modules/dashboard/dashboard.route";
import roleRoute from "../modules/roles/role.route";
import permissionRoute from "../modules/permissions/permission.route";
const router = Router(); const router = Router();
router.use("/health", healthRoute); router.use("/health", healthRoute);
router.use("/auth", authRoute); router.use("/auth", authRoute);
router.use("/users", userRoute); router.use("/users", userRoute);
router.use("/roles", roleRoute);
router.use("/permissions", permissionRoute);
router.use("/dashboard", dashboardRoute); router.use("/dashboard", dashboardRoute);
router.use("/crawl-jobs", crawlJobRoute); router.use("/crawl-jobs", crawlJobRoute);
router.use("/crawl-schedules", crawlScheduleRoute); router.use("/crawl-schedules", crawlScheduleRoute);
......
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