Commit 3c94df00 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/dynamic-rbac-privilege-escalation-defense' into 'develop'

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

See merge request !7
parents 512dd5de 513a30d8
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
**Date**: 2026-09-03 **Date**: 2026-09-03
**Repository**: `data-crawler-be` **Repository**: `data-crawler-be`
**Status**: Clean & All P0/P1 Resolved **Status**: Clean & All P0/P1 Resolved
--- ---
...@@ -13,6 +13,7 @@ An autonomous, production-grade audit and remediation cycle was executed on the ...@@ -13,6 +13,7 @@ An autonomous, production-grade audit and remediation cycle was executed on the
All findings across authentication security, layered architecture boundaries, race conditions, N+1 queries, IDOR/ownership authorization, pagination bounds, zero-hardcode compliance, and response envelopes were triaged, verified against active code, repaired, and validated through the automated test suite. All findings across authentication security, layered architecture boundaries, race conditions, N+1 queries, IDOR/ownership authorization, pagination bounds, zero-hardcode compliance, and response envelopes were triaged, verified against active code, repaired, and validated through the automated test suite.
### Key Validation Outcomes: ### Key Validation Outcomes:
- **Typecheck (`pnpm build`)**: ✅ 0 errors (OpenAPI Swagger autogen clean) - **Typecheck (`pnpm build`)**: ✅ 0 errors (OpenAPI Swagger autogen clean)
- **Linter (`pnpm lint`)**: ✅ 0 errors, with strict ESLint `no-restricted-imports` rule active preventing non-repository `@prisma/client` imports - **Linter (`pnpm lint`)**: ✅ 0 errors, with strict ESLint `no-restricted-imports` rule active preventing non-repository `@prisma/client` imports
- **Automated Test Suite (`pnpm jest --runInBand`)**: ✅ **31/31 Test Suites Passed**, **349/349 Tests Passed** (100% Green) - **Automated Test Suite (`pnpm jest --runInBand`)**: ✅ **31/31 Test Suites Passed**, **349/349 Tests Passed** (100% Green)
...@@ -22,32 +23,33 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -22,32 +23,33 @@ All findings across authentication security, layered architecture boundaries, ra
## Findings Backlog & Resolution Summary ## Findings Backlog & Resolution Summary
| ID | Severity | Module | Summary of Issue | Verification | Resolution Status | | ID | Severity | Module | Summary of Issue | Verification | Resolution Status |
|---|---|---|---|---|---| | ---------- | -------- | -------------- | ------------------------------------------------------------------------ | ------------ | ------------------------------ |
| **BUG-01** | 🔴 P0 | Auth | `forgotPassword` leaked `resetToken` & `userId` in service return object | CONFIRMED | **FIXED & TESTED** | | **BUG-01** | 🔴 P0 | Auth | `forgotPassword` leaked `resetToken` & `userId` in service return object | CONFIRMED | **FIXED & TESTED** |
| **BUG-02** | 🔴 P0 | Architecture | Prisma enums/models imported directly outside repository layer | CONFIRMED | **FIXED & LINT-ENFORCED** | | **BUG-02** | 🔴 P0 | Architecture | Prisma enums/models imported directly outside repository layer | CONFIRMED | **FIXED & LINT-ENFORCED** |
| **BUG-03** | 🟠 P1 | CrawlSchedules | `limit`/`page` query params lacked upper bound validation (DoS risk) | CONFIRMED | **FIXED & BOUNDED** | | **BUG-03** | 🟠 P1 | CrawlSchedules | `limit`/`page` query params lacked upper bound validation (DoS risk) | CONFIRMED | **FIXED & BOUNDED** |
| **BUG-04** | 🟠 P1 | CrawlJobs | `updateStatus` TOCTOU race condition overriding `CANCELED` state | CONFIRMED | **FIXED (Atomic updateMany)** | | **BUG-04** | 🟠 P1 | CrawlJobs | `updateStatus` TOCTOU race condition overriding `CANCELED` state | CONFIRMED | **FIXED (Atomic updateMany)** |
| **BUG-05** | 🟠 P1 | Worker | Sequential DB round-trips for sensitive data scanning during crawl | CONFIRMED | **OPTIMIZED** | | **BUG-05** | 🟠 P1 | Worker | Sequential DB round-trips for sensitive data scanning during crawl | CONFIRMED | **OPTIMIZED** |
| **BUG-06** | 🟠 P1 | Worker | Duplicate `updateStatus(RUNNING)` call overwriting `startedAt` | CONFIRMED | **FIXED (Removed duplicate)** | | **BUG-06** | 🟠 P1 | Worker | Duplicate `updateStatus(RUNNING)` call overwriting `startedAt` | CONFIRMED | **FIXED (Removed duplicate)** |
| **BUG-07** | 🟠 P1 | CrawlJobs | `scheduleId` lacked user ownership authorization check (IDOR risk) | CONFIRMED | **FIXED & TESTED** | | **BUG-07** | 🟠 P1 | CrawlJobs | `scheduleId` lacked user ownership authorization check (IDOR risk) | CONFIRMED | **FIXED & TESTED** |
| **BUG-08** | 🟠 P1 | Auth | `authMiddleware` un-cached DB lookup per request | CONFIRMED | **DOCUMENTED (Redis cluster)** | | **BUG-08** | 🟠 P1 | Auth | `authMiddleware` un-cached DB lookup per request | CONFIRMED | **DOCUMENTED (Redis cluster)** |
| **BUG-09** | 🟠 P1 | Webhooks | Hardcoded string literals in webhook validation schemas | CONFIRMED | **FIXED (Constant enums)** | | **BUG-09** | 🟠 P1 | Webhooks | Hardcoded string literals in webhook validation schemas | CONFIRMED | **FIXED (Constant enums)** |
| **BUG-10** | 🟡 P2 | CrawlJobs | `getAssets` query parameters validated imperatively in controller | CONFIRMED | **FIXED (Zod Schema)** | | **BUG-10** | 🟡 P2 | CrawlJobs | `getAssets` query parameters validated imperatively in controller | CONFIRMED | **FIXED (Zod Schema)** |
| **BUG-11** | 🟡 P2 | CrawlPages | Search on large text columns without trigram index | CONFIRMED | **MAINTAINED (jobId scoped)** | | **BUG-11** | 🟡 P2 | CrawlPages | Search on large text columns without trigram index | CONFIRMED | **MAINTAINED (jobId scoped)** |
| **BUG-12** | 🟡 P2 | CrawlSchedules | `superRefine` direct data mutation (Zod anti-pattern) | CONFIRMED | **FIXED (Clean validation)** | | **BUG-12** | 🟡 P2 | CrawlSchedules | `superRefine` direct data mutation (Zod anti-pattern) | CONFIRMED | **FIXED (Clean validation)** |
| **BUG-13** | 🟡 P2 | Infrastructure | `express-rate-limit` in-memory store in multi-instance clusters | CONFIRMED | **DOCUMENTED (Redis store)** | | **BUG-13** | 🟡 P2 | Infrastructure | `express-rate-limit` in-memory store in multi-instance clusters | CONFIRMED | **DOCUMENTED (Redis store)** |
| **BUG-14** | 🟡 P2 | CrawlSchedules | `getScheduleHistory` tuple return format | CONFIRMED | **VERIFIED CLEAN** | | **BUG-14** | 🟡 P2 | CrawlSchedules | `getScheduleHistory` tuple return format | CONFIRMED | **VERIFIED CLEAN** |
| **BUG-15** | 🟡 P2 | CrawlJobs | Missing `total` and `totalPages` in `getAssets` and `getLogs` meta | CONFIRMED | **FIXED & STANDARDIZED** | | **BUG-15** | 🟡 P2 | CrawlJobs | Missing `total` and `totalPages` in `getAssets` and `getLogs` meta | CONFIRMED | **FIXED & STANDARDIZED** |
| **BUG-16** | 🟡 P2 | Users | User quota fields without upper bound limits | CONFIRMED | **FIXED (Upper bounds added)** | | **BUG-16** | 🟡 P2 | Users | User quota fields without upper bound limits | CONFIRMED | **FIXED (Upper bounds added)** |
| **BUG-17** | 🟢 P3 | Users | Hardcoded string `"CRAWLER_USER"` in `user.repository.ts` | CONFIRMED | **FIXED (ROLES.CRAWLER_USER)** | | **BUG-17** | 🟢 P3 | Users | Hardcoded string `"CRAWLER_USER"` in `user.repository.ts` | CONFIRMED | **FIXED (ROLES.CRAWLER_USER)** |
| **BUG-18** | 🟢 P3 | App | Morgan logger hardcoded to `"dev"` in production | CONFIRMED | **FIXED (Environment-aware)** | | **BUG-18** | 🟢 P3 | App | Morgan logger hardcoded to `"dev"` in production | CONFIRMED | **FIXED (Environment-aware)** |
--- ---
## Fixed Issues Detail ## Fixed Issues Detail
### [BUG-01] Auth: Reset Token Leakage Across Service Boundary ### [BUG-01] Auth: Reset Token Leakage Across Service Boundary
- **Severity**: 🔴 P0 - **Severity**: 🔴 P0
- **Module**: `auth` - **Module**: `auth`
- **Root Cause**: `AuthService.forgotPassword()` returned `{ success: true, resetToken, userId }` so that the controller could invoke `MailService`. This exposed sensitive reset tokens across architectural boundaries and to potential loggers/interceptors. - **Root Cause**: `AuthService.forgotPassword()` returned `{ success: true, resetToken, userId }` so that the controller could invoke `MailService`. This exposed sensitive reset tokens across architectural boundaries and to potential loggers/interceptors.
...@@ -63,6 +65,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -63,6 +65,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-02] Architecture: Direct `@prisma/client` Import Isolation ### [BUG-02] Architecture: Direct `@prisma/client` Import Isolation
- **Severity**: 🔴 P0 - **Severity**: 🔴 P0
- **Module**: `cross-cutting` - **Module**: `cross-cutting`
- **Root Cause**: Non-repository modules (`crawl-pages`, `webhooks`, `exports`, `users`, `change-detection`, `api-keys`) were importing enums and types directly from `@prisma/client`, violating `AGENTS.md` Rule 1. - **Root Cause**: Non-repository modules (`crawl-pages`, `webhooks`, `exports`, `users`, `change-detection`, `api-keys`) were importing enums and types directly from `@prisma/client`, violating `AGENTS.md` Rule 1.
...@@ -105,6 +108,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -105,6 +108,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-03 & BUG-12] CrawlSchedules: Pagination Bounds & Validation Cleanliness ### [BUG-03 & BUG-12] CrawlSchedules: Pagination Bounds & Validation Cleanliness
- **Severity**: 🟠 P1 / 🟡 P2 - **Severity**: 🟠 P1 / 🟡 P2
- **Module**: `crawl-schedules` - **Module**: `crawl-schedules`
- **Root Cause**: `crawlScheduleQuerySchema` parsed string values without `.max(100)` or integer validation, creating DoS and NaN risks. In addition, `createCrawlScheduleSchema` mutated data within `superRefine`. - **Root Cause**: `crawlScheduleQuerySchema` parsed string values without `.max(100)` or integer validation, creating DoS and NaN risks. In addition, `createCrawlScheduleSchema` mutated data within `superRefine`.
...@@ -118,6 +122,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -118,6 +122,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-04] CrawlJobs: Atomic `updateStatus` Concurrency Guard ### [BUG-04] CrawlJobs: Atomic `updateStatus` Concurrency Guard
- **Severity**: 🟠 P1 - **Severity**: 🟠 P1
- **Module**: `crawl-jobs` - **Module**: `crawl-jobs`
- **Root Cause**: Non-atomic read-then-write check allowed race conditions where a worker could overwrite a `CANCELED` job back to `RUNNING` or `COMPLETED`. - **Root Cause**: Non-atomic read-then-write check allowed race conditions where a worker could overwrite a `CANCELED` job back to `RUNNING` or `COMPLETED`.
...@@ -130,6 +135,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -130,6 +135,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-06] Worker: Redundant Status Transition Cleanup ### [BUG-06] Worker: Redundant Status Transition Cleanup
- **Severity**: 🟠 P1 - **Severity**: 🟠 P1
- **Module**: `worker` - **Module**: `worker`
- **Root Cause**: `processCrawlJob` called `updateStatus(RUNNING)` twice (before and after pre-crawl URL validation), overwriting `startedAt`. - **Root Cause**: `processCrawlJob` called `updateStatus(RUNNING)` twice (before and after pre-crawl URL validation), overwriting `startedAt`.
...@@ -141,6 +147,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -141,6 +147,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-07] CrawlJobs: Schedule Ownership Authorization (IDOR Prevention) ### [BUG-07] CrawlJobs: Schedule Ownership Authorization (IDOR Prevention)
- **Severity**: 🟠 P1 - **Severity**: 🟠 P1
- **Module**: `crawl-jobs` - **Module**: `crawl-jobs`
- **Root Cause**: `CrawlJobService.create()` accepted `scheduleId` without verifying that the referenced schedule belonged to the authenticated user. - **Root Cause**: `CrawlJobService.create()` accepted `scheduleId` without verifying that the referenced schedule belonged to the authenticated user.
...@@ -155,6 +162,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -155,6 +162,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-09] Webhooks: Zero-Hardcode Enum Validation ### [BUG-09] Webhooks: Zero-Hardcode Enum Validation
- **Severity**: 🟠 P1 - **Severity**: 🟠 P1
- **Module**: `webhooks` - **Module**: `webhooks`
- **Root Cause**: `webhook.validation.ts` used string literal arrays `z.enum([...])` instead of shared constants `z.nativeEnum()`. - **Root Cause**: `webhook.validation.ts` used string literal arrays `z.enum([...])` instead of shared constants `z.nativeEnum()`.
...@@ -166,6 +174,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -166,6 +174,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-10 & BUG-15] CrawlJobs: Validated Asset Query & Standardized Meta ### [BUG-10 & BUG-15] CrawlJobs: Validated Asset Query & Standardized Meta
- **Severity**: 🟡 P2 - **Severity**: 🟡 P2
- **Module**: `crawl-jobs` - **Module**: `crawl-jobs`
- **Root Cause**: `getAssets` performed manual parsing without Zod and response metadata omitted `total` and `totalPages`. `getLogs` returned `{ pagination }` instead of `{ meta }`. - **Root Cause**: `getAssets` performed manual parsing without Zod and response metadata omitted `total` and `totalPages`. `getLogs` returned `{ pagination }` instead of `{ meta }`.
...@@ -184,6 +193,7 @@ All findings across authentication security, layered architecture boundaries, ra ...@@ -184,6 +193,7 @@ All findings across authentication security, layered architecture boundaries, ra
--- ---
### [BUG-16, BUG-17, BUG-18] Users & App Configuration Standardization ### [BUG-16, BUG-17, BUG-18] Users & App Configuration Standardization
- **Severity**: 🟡 P2 / 🟢 P3 - **Severity**: 🟡 P2 / 🟢 P3
- **Module**: `users` / `app` - **Module**: `users` / `app`
- **Fixes Applied**: - **Fixes Applied**:
......
-- 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";
import { SYSTEM_ROLE_SLUGS, SystemRoleSlug } from "./system-role.constant";
export const PERMISSIONS = {
// Users
USERS_READ: "users.read",
USERS_CREATE: "users.create",
USERS_UPDATE: "users.update",
USERS_DELETE: "users.delete",
USERS_ROLES_READ: "users.roles.read",
USERS_ROLES_ASSIGN: "users.roles.assign",
// Roles
ROLES_READ: "roles.read",
ROLES_CREATE: "roles.create",
ROLES_UPDATE: "roles.update",
ROLES_DELETE: "roles.delete",
ROLES_PERMISSIONS_READ: "roles.permissions.read",
ROLES_PERMISSIONS_ASSIGN: "roles.permissions.assign",
// Permissions
PERMISSIONS_READ: "permissions.read",
// Crawl Jobs
CRAWL_JOBS_CREATE: "crawl_jobs.create",
CRAWL_JOBS_READ: "crawl_jobs.read",
CRAWL_JOBS_READ_ALL: "crawl_jobs.read_all",
CRAWL_JOBS_CANCEL: "crawl_jobs.cancel",
CRAWL_JOBS_RETRY: "crawl_jobs.retry",
CRAWL_JOBS_DELETE: "crawl_jobs.delete",
// Crawl Schedules
CRAWL_SCHEDULES_CREATE: "crawl_schedules.create",
CRAWL_SCHEDULES_READ: "crawl_schedules.read",
CRAWL_SCHEDULES_READ_ALL: "crawl_schedules.read_all",
CRAWL_SCHEDULES_UPDATE: "crawl_schedules.update",
CRAWL_SCHEDULES_DELETE: "crawl_schedules.delete",
CRAWL_SCHEDULES_RUN: "crawl_schedules.run",
// Exports
EXPORTS_READ: "exports.read",
EXPORTS_READ_ALL: "exports.read_all",
EXPORTS_CREATE: "exports.create",
EXPORTS_DOWNLOAD: "exports.download",
// Audit Logs
AUDIT_LOGS_READ: "audit_logs.read",
// Dashboard
DASHBOARD_READ: "dashboard.read",
DASHBOARD_READ_ALL: "dashboard.read_all",
} as const;
export type PermissionSlug = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export interface PermissionDefinition {
name: string;
slug: PermissionSlug;
description: string;
resource: string;
action: string;
isSystem: boolean;
}
export const SYSTEM_PERMISSIONS_CATALOG: PermissionDefinition[] = [
// Users
{
name: "View Users",
slug: PERMISSIONS.USERS_READ,
description: "Xem danh sách và thông tin người dùng",
resource: "users",
action: "read",
isSystem: true,
},
{
name: "Create User",
slug: PERMISSIONS.USERS_CREATE,
description: "Tạo tài khoản người dùng mới",
resource: "users",
action: "create",
isSystem: true,
},
{
name: "Update User",
slug: PERMISSIONS.USERS_UPDATE,
description: "Cập nhật thông tin và định mức tài nguyên của người dùng",
resource: "users",
action: "update",
isSystem: true,
},
{
name: "Delete User",
slug: PERMISSIONS.USERS_DELETE,
description: "Xóa hoặc vô hiệu hóa tài khoản người dùng",
resource: "users",
action: "delete",
isSystem: true,
},
{
name: "View User Roles",
slug: PERMISSIONS.USERS_ROLES_READ,
description: "Xem danh sách vai trò được gán cho người dùng",
resource: "users",
action: "roles.read",
isSystem: true,
},
{
name: "Assign User Roles",
slug: PERMISSIONS.USERS_ROLES_ASSIGN,
description: "Gán hoặc thu hồi vai trò của người dùng",
resource: "users",
action: "roles.assign",
isSystem: true,
},
// Roles
{
name: "View Roles",
slug: PERMISSIONS.ROLES_READ,
description: "Xem danh sách và chi tiết vai trò",
resource: "roles",
action: "read",
isSystem: true,
},
{
name: "Create Role",
slug: PERMISSIONS.ROLES_CREATE,
description: "Tạo vai trò tùy chỉnh mới",
resource: "roles",
action: "create",
isSystem: true,
},
{
name: "Update Role",
slug: PERMISSIONS.ROLES_UPDATE,
description: "Cập nhật thông tin vai trò tùy chỉnh",
resource: "roles",
action: "update",
isSystem: true,
},
{
name: "Delete Role",
slug: PERMISSIONS.ROLES_DELETE,
description: "Xóa vai trò tùy chỉnh",
resource: "roles",
action: "delete",
isSystem: true,
},
{
name: "View Role Permissions",
slug: PERMISSIONS.ROLES_PERMISSIONS_READ,
description: "Xem danh sách quyền hạn được gán cho vai trò",
resource: "roles",
action: "permissions.read",
isSystem: true,
},
{
name: "Assign Role Permissions",
slug: PERMISSIONS.ROLES_PERMISSIONS_ASSIGN,
description: "Gán hoặc thu hồi quyền hạn của vai trò",
resource: "roles",
action: "permissions.assign",
isSystem: true,
},
// Permissions
{
name: "View Permissions Registry",
slug: PERMISSIONS.PERMISSIONS_READ,
description: "Xem danh mục quyền hạn có trong hệ thống",
resource: "permissions",
action: "read",
isSystem: true,
},
// Crawl Jobs
{
name: "Create Crawl Job",
slug: PERMISSIONS.CRAWL_JOBS_CREATE,
description: "Khởi tạo tác vụ cào dữ liệu",
resource: "crawl_jobs",
action: "create",
isSystem: true,
},
{
name: "View Own Crawl Jobs",
slug: PERMISSIONS.CRAWL_JOBS_READ,
description: "Xem các tác vụ cào dữ liệu của chính mình",
resource: "crawl_jobs",
action: "read",
isSystem: true,
},
{
name: "View All Crawl Jobs",
slug: PERMISSIONS.CRAWL_JOBS_READ_ALL,
description: "Xem tất cả tác vụ cào dữ liệu trong hệ thống (Admin)",
resource: "crawl_jobs",
action: "read_all",
isSystem: true,
},
{
name: "Cancel Crawl Job",
slug: PERMISSIONS.CRAWL_JOBS_CANCEL,
description: "Hủy tác vụ cào dữ liệu đang thực thi",
resource: "crawl_jobs",
action: "cancel",
isSystem: true,
},
{
name: "Retry Crawl Job",
slug: PERMISSIONS.CRAWL_JOBS_RETRY,
description: "Chạy lại tác vụ cào dữ liệu bị lỗi",
resource: "crawl_jobs",
action: "retry",
isSystem: true,
},
{
name: "Delete Crawl Job",
slug: PERMISSIONS.CRAWL_JOBS_DELETE,
description: "Xóa bản ghi tác vụ cào dữ liệu",
resource: "crawl_jobs",
action: "delete",
isSystem: true,
},
// Crawl Schedules
{
name: "Create Crawl Schedule",
slug: PERMISSIONS.CRAWL_SCHEDULES_CREATE,
description: "Thiết lập lịch cào dữ liệu định kỳ",
resource: "crawl_schedules",
action: "create",
isSystem: true,
},
{
name: "View Own Crawl Schedules",
slug: PERMISSIONS.CRAWL_SCHEDULES_READ,
description: "Xem lịch cào dữ liệu của chính mình",
resource: "crawl_schedules",
action: "read",
isSystem: true,
},
{
name: "View All Crawl Schedules",
slug: PERMISSIONS.CRAWL_SCHEDULES_READ_ALL,
description: "Xem toàn bộ lịch cào dữ liệu trong hệ thống (Admin)",
resource: "crawl_schedules",
action: "read_all",
isSystem: true,
},
{
name: "Update Crawl Schedule",
slug: PERMISSIONS.CRAWL_SCHEDULES_UPDATE,
description: "Chỉnh sửa cấu hình lịch cào dữ liệu",
resource: "crawl_schedules",
action: "update",
isSystem: true,
},
{
name: "Delete Crawl Schedule",
slug: PERMISSIONS.CRAWL_SCHEDULES_DELETE,
description: "Xóa lịch cào dữ liệu định kỳ",
resource: "crawl_schedules",
action: "delete",
isSystem: true,
},
{
name: "Run Crawl Schedule Manually",
slug: PERMISSIONS.CRAWL_SCHEDULES_RUN,
description: "Kích hoạt chạy ngay một lịch cào dữ liệu",
resource: "crawl_schedules",
action: "run",
isSystem: true,
},
// Exports
{
name: "View Own Exports",
slug: PERMISSIONS.EXPORTS_READ,
description: "Xem danh sách file trích xuất của chính mình",
resource: "exports",
action: "read",
isSystem: true,
},
{
name: "View All Exports",
slug: PERMISSIONS.EXPORTS_READ_ALL,
description: "Xem danh sách file trích xuất toàn hệ thống",
resource: "exports",
action: "read_all",
isSystem: true,
},
{
name: "Create Export",
slug: PERMISSIONS.EXPORTS_CREATE,
description: "Tạo file trích xuất dữ liệu",
resource: "exports",
action: "create",
isSystem: true,
},
{
name: "Download Export",
slug: PERMISSIONS.EXPORTS_DOWNLOAD,
description: "Tải file trích xuất về máy",
resource: "exports",
action: "download",
isSystem: true,
},
// Audit Logs
{
name: "View Audit Logs",
slug: PERMISSIONS.AUDIT_LOGS_READ,
description: "Tra cứu nhật ký kiểm tra an toàn hệ thống",
resource: "audit_logs",
action: "read",
isSystem: true,
},
// Dashboard
{
name: "View Dashboard",
slug: PERMISSIONS.DASHBOARD_READ,
description: "Xem bảng thông số tổng quan cá nhân",
resource: "dashboard",
action: "read",
isSystem: true,
},
{
name: "View Global Dashboard",
slug: PERMISSIONS.DASHBOARD_READ_ALL,
description: "Xem bảng thông số toàn diện của toàn hệ thống",
resource: "dashboard",
action: "read_all",
isSystem: true,
},
];
export const SYSTEM_ROLE_DEFAULT_PERMISSIONS: Record<
SystemRoleSlug,
PermissionSlug[]
> = {
[SYSTEM_ROLE_SLUGS.SUPER_ADMIN]: Object.values(PERMISSIONS),
[SYSTEM_ROLE_SLUGS.ADMIN]: [
PERMISSIONS.USERS_READ,
PERMISSIONS.USERS_CREATE,
PERMISSIONS.USERS_UPDATE,
PERMISSIONS.USERS_DELETE,
PERMISSIONS.USERS_ROLES_READ,
PERMISSIONS.USERS_ROLES_ASSIGN,
PERMISSIONS.ROLES_READ,
PERMISSIONS.ROLES_CREATE,
PERMISSIONS.ROLES_UPDATE,
PERMISSIONS.ROLES_DELETE,
PERMISSIONS.ROLES_PERMISSIONS_READ,
PERMISSIONS.ROLES_PERMISSIONS_ASSIGN,
PERMISSIONS.PERMISSIONS_READ,
PERMISSIONS.CRAWL_JOBS_CREATE,
PERMISSIONS.CRAWL_JOBS_READ,
PERMISSIONS.CRAWL_JOBS_READ_ALL,
PERMISSIONS.CRAWL_JOBS_CANCEL,
PERMISSIONS.CRAWL_JOBS_RETRY,
PERMISSIONS.CRAWL_JOBS_DELETE,
PERMISSIONS.CRAWL_SCHEDULES_CREATE,
PERMISSIONS.CRAWL_SCHEDULES_READ,
PERMISSIONS.CRAWL_SCHEDULES_READ_ALL,
PERMISSIONS.CRAWL_SCHEDULES_UPDATE,
PERMISSIONS.CRAWL_SCHEDULES_DELETE,
PERMISSIONS.CRAWL_SCHEDULES_RUN,
PERMISSIONS.EXPORTS_READ,
PERMISSIONS.EXPORTS_READ_ALL,
PERMISSIONS.EXPORTS_CREATE,
PERMISSIONS.EXPORTS_DOWNLOAD,
PERMISSIONS.AUDIT_LOGS_READ,
PERMISSIONS.DASHBOARD_READ,
PERMISSIONS.DASHBOARD_READ_ALL,
],
[SYSTEM_ROLE_SLUGS.CRAWLER_USER]: [
PERMISSIONS.CRAWL_JOBS_CREATE,
PERMISSIONS.CRAWL_JOBS_READ,
PERMISSIONS.CRAWL_JOBS_CANCEL,
PERMISSIONS.CRAWL_JOBS_RETRY,
PERMISSIONS.CRAWL_SCHEDULES_CREATE,
PERMISSIONS.CRAWL_SCHEDULES_READ,
PERMISSIONS.CRAWL_SCHEDULES_UPDATE,
PERMISSIONS.CRAWL_SCHEDULES_DELETE,
PERMISSIONS.CRAWL_SCHEDULES_RUN,
PERMISSIONS.EXPORTS_READ,
PERMISSIONS.EXPORTS_CREATE,
PERMISSIONS.EXPORTS_DOWNLOAD,
PERMISSIONS.DASHBOARD_READ,
],
[SYSTEM_ROLE_SLUGS.VIEWER]: [
PERMISSIONS.CRAWL_JOBS_READ,
PERMISSIONS.CRAWL_SCHEDULES_READ,
PERMISSIONS.EXPORTS_READ,
PERMISSIONS.DASHBOARD_READ,
],
};
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" },
},
},
},
}; };
...@@ -1020,6 +1020,553 @@ ...@@ -1020,6 +1020,553 @@
"summary": "Xóa người dùng" "summary": "Xóa người dùng"
} }
}, },
"/users/{id}/roles": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Users"
],
"summary": "Xem Roles của User"
},
"put": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"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"
}
},
"tags": [
"Users"
],
"summary": "Cập nhật toàn bộ Roles của User",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignUserRolesRequest"
}
}
}
}
}
},
"/users/{id}/roles/{roleId}": {
"post": {
"description": "",
"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"
}
},
"tags": [
"Users"
],
"summary": "Gán thêm một Role cho User"
},
"delete": {
"description": "",
"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"
}
},
"tags": [
"Users"
],
"summary": "Gỡ một Role khỏi User"
}
},
"/roles": {
"get": {
"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"
}
},
"tags": [
"Roles"
],
"summary": "Danh sách Roles"
},
"post": {
"description": "Tạo mới một vai trò tùy chỉnh (Custom Role) trong hệ thống.",
"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"
}
},
"tags": [
"Roles"
],
"summary": "Tạo Role tùy chỉnh",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateRoleRequest"
}
}
}
}
}
},
"/roles/{id}": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Roles"
],
"summary": "Chi tiết Role"
},
"patch": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"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"
}
},
"tags": [
"Roles"
],
"summary": "Cập nhật Role",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateRoleRequest"
}
}
}
}
},
"delete": {
"description": "",
"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"
}
},
"tags": [
"Roles"
],
"summary": "Xóa Role"
}
},
"/roles/{id}/permissions": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Roles"
],
"summary": "Xem danh sách Permissions của Role"
},
"put": {
"description": "",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"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"
}
},
"tags": [
"Roles"
],
"summary": "Gán danh sách Permissions cho Role",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssignRolePermissionsRequest"
}
}
}
}
}
},
"/roles/{id}/users": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Roles"
],
"summary": "Danh sách Users thuộc Role"
}
},
"/permissions": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Permissions"
],
"summary": "Danh mục Permissions hệ thống"
}
},
"/permissions/{id}": {
"get": {
"description": "",
"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"
}
},
"tags": [
"Permissions"
],
"summary": "Chi tiết Permission"
}
},
"/dashboard/stats": { "/dashboard/stats": {
"get": { "get": {
"description": "", "description": "",
...@@ -4732,6 +5279,147 @@ ...@@ -4732,6 +5279,147 @@
} }
} }
} }
},
"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"
}
}
}
} }
} }
}, },
......
...@@ -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;
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { UserRepository } from "./user.repository"; import { UserRepository } from "./user.repository";
import { RoleRepository } from "../roles/role.repository";
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 { User } from "../../common/types/database.types"; import { User } from "../../common/types/database.types";
import { ROLES } from "../../common/constants/role.constant"; import { ROLES } from "../../common/constants/role.constant";
import { SYSTEM_ROLE_SLUGS } from "../../common/constants/system-role.constant";
import { AUDIT_ACTIONS } from "../../common/constants/audit-action.constant";
import { AuditLogService } from "../audit-logs/audit-log.service";
import { authorizationCache } from "../../common/helpers/authorization-cache.helper";
import { import {
CreateUserDto, CreateUserDto,
UpdateUserDto, UpdateUserDto,
...@@ -11,8 +16,16 @@ import { ...@@ -11,8 +16,16 @@ import {
UserQueryDto, UserQueryDto,
} from "./user.dto"; } from "./user.dto";
interface AuditContext {
actorId?: string;
ipAddress?: string;
userAgent?: string;
}
export class UserService { export class UserService {
private readonly repository = new UserRepository(); private readonly repository = new UserRepository();
private readonly roleRepository = new RoleRepository();
private readonly auditLogService = new AuditLogService();
private formatUser(user: User): UserResponseDto { private formatUser(user: User): UserResponseDto {
return { return {
...@@ -76,11 +89,61 @@ export class UserService { ...@@ -76,11 +89,61 @@ export class UserService {
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit, maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
}); });
// Auto assign matching default system role
const targetSlug =
data.role === ROLES.ADMIN
? SYSTEM_ROLE_SLUGS.ADMIN
: data.role === ROLES.VIEWER
? SYSTEM_ROLE_SLUGS.VIEWER
: SYSTEM_ROLE_SLUGS.CRAWLER_USER;
const role = await this.roleRepository.findBySlug(targetSlug);
if (role) {
await this.repository.assignSingleRole(user.id, role.id);
}
return this.formatUser(user); return this.formatUser(user);
} }
async update(id: string, data: UpdateUserDto): Promise<UserResponseDto> { async update(
id: string,
data: UpdateUserDto,
actorId?: string,
actorRoles: string[] = [],
): Promise<UserResponseDto> {
const existingUser = await this.findById(id); const existingUser = await this.findById(id);
const isTargetSuperAdmin =
typeof this.repository.isUserSuperAdmin === "function"
? await this.repository.isUserSuperAdmin(id)
: false;
// Defense: Non-super-admin cannot modify or deactivate a Super Admin
if (
isTargetSuperAdmin &&
actorRoles.length > 0 &&
!actorRoles.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
throw new AppError(
"Forbidden: Only Super Administrators can modify a Super Administrator account.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
// Defense: If deactivating user, check last active Super Admin
if (data.isActive === false && isTargetSuperAdmin) {
const activeSuperAdmins =
typeof this.repository.countActiveSuperAdmins === "function"
? await this.repository.countActiveSuperAdmins()
: 0;
if (activeSuperAdmins <= 1) {
throw new AppError(
"Cannot deactivate the last active Super Admin account.",
400,
ERROR_CODE.CANNOT_REMOVE_LAST_SUPER_ADMIN,
);
}
}
if ( if (
existingUser.role === ROLES.ADMIN && existingUser.role === ROLES.ADMIN &&
...@@ -103,10 +166,15 @@ export class UserService { ...@@ -103,10 +166,15 @@ export class UserService {
maxConcurrentJobsLimit: data.maxConcurrentJobsLimit, maxConcurrentJobsLimit: data.maxConcurrentJobsLimit,
}); });
authorizationCache.invalidateUser(id);
return this.formatUser(user); return this.formatUser(user);
} }
async delete(id: string, currentUserId: string): Promise<void> { async delete(
id: string,
currentUserId: string,
actorRoles: string[] = [],
): Promise<void> {
if (id === currentUserId) { if (id === currentUserId) {
throw new AppError( throw new AppError(
"Cannot delete your own account", "Cannot delete your own account",
...@@ -116,6 +184,343 @@ export class UserService { ...@@ -116,6 +184,343 @@ export class UserService {
} }
await this.findById(id); await this.findById(id);
const isTargetSuperAdmin =
typeof this.repository.isUserSuperAdmin === "function"
? await this.repository.isUserSuperAdmin(id)
: false;
// Defense: Non-super-admin cannot delete a Super Admin
if (
isTargetSuperAdmin &&
actorRoles.length > 0 &&
!actorRoles.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
throw new AppError(
"Forbidden: Only Super Administrators can delete a Super Administrator account.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
// Defense: Cannot delete the last active Super Admin
if (isTargetSuperAdmin) {
const activeSuperAdmins =
typeof this.repository.countActiveSuperAdmins === "function"
? await this.repository.countActiveSuperAdmins()
: 0;
if (activeSuperAdmins <= 1) {
throw new AppError(
"Cannot delete the last active Super Admin account.",
400,
ERROR_CODE.CANNOT_REMOVE_LAST_SUPER_ADMIN,
);
}
}
await this.repository.delete(id, currentUserId); await this.repository.delete(id, currentUserId);
authorizationCache.invalidateUser(id);
}
// --- Dynamic User Roles Management & Privilege Escalation Defense ---
async getUserRoles(userId: string) {
await this.findById(userId);
return this.repository.getUserRoles(userId);
}
async assignUserRoles(
actorId: string,
actorRoles: string[],
targetUserId: string,
roleIds: string[],
context?: AuditContext,
) {
// 1. Self-escalation barrier: Cannot modify own roles
if (actorId === targetUserId) {
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
reason:
"User attempted to modify own roles (self-escalation blocked)",
targetUserId,
},
});
}
throw new AppError(
"Forbidden: You cannot modify your own roles.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
await this.findById(targetUserId);
// 2. Fetch target roles to validate
const targetRoles = await Promise.all(
roleIds.map((id) => this.roleRepository.findById(id)),
);
const missingRole = targetRoles.find((r) => !r);
if (missingRole || targetRoles.length !== roleIds.length) {
throw new AppError(
"One or more roles not found",
404,
ERROR_CODE.ROLE_NOT_FOUND,
);
}
const targetSlugs = targetRoles.map((r) => r!.slug);
const isAssigningSuperAdmin = targetSlugs.includes(
SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
);
const isActorSuperAdmin = actorRoles.includes(
SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
);
const isTargetCurrentlySuperAdmin =
await this.repository.isUserSuperAdmin(targetUserId);
// 3. Super Admin isolation: Non-super-admin cannot assign Super Admin
if (isAssigningSuperAdmin && !isActorSuperAdmin) {
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.SUPER_ADMIN_ASSIGN_ATTEMPT,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
reason: "Non-super-admin attempted to assign Super Admin role",
targetUserId,
},
});
}
throw new AppError(
"Forbidden: Only Super Administrators can assign the Super Admin role.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
// 4. Target protection: Non-super-admin cannot modify roles of an existing Super Admin
if (isTargetCurrentlySuperAdmin && !isActorSuperAdmin) {
throw new AppError(
"Forbidden: Only Super Administrators can modify roles of a Super Administrator.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
// 5. Last active Super Admin protection: Cannot revoke Super Admin if it's the last one
if (isTargetCurrentlySuperAdmin && !isAssigningSuperAdmin) {
const activeSuperAdmins = await this.repository.countActiveSuperAdmins();
if (activeSuperAdmins <= 1) {
throw new AppError(
"Cannot revoke the last active Super Admin role from the system.",
400,
ERROR_CODE.CANNOT_REMOVE_LAST_SUPER_ADMIN,
);
}
}
const result = await this.repository.assignUserRoles(
targetUserId,
roleIds,
actorId,
);
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.ROLE_ASSIGNED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
targetUserId,
assignedRoleSlugs: targetSlugs,
roleCount: roleIds.length,
},
});
}
authorizationCache.invalidateUser(targetUserId);
return result;
}
async assignSingleRole(
actorId: string,
actorRoles: string[],
targetUserId: string,
roleId: string,
context?: AuditContext,
) {
if (actorId === targetUserId) {
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.PRIVILEGE_ESCALATION_BLOCKED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
reason: "Self-assignment blocked",
targetUserId,
roleId,
},
});
}
throw new AppError(
"Forbidden: You cannot assign roles to yourself.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
await this.findById(targetUserId);
const role = await this.roleRepository.findById(roleId);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
if (
role.slug === SYSTEM_ROLE_SLUGS.SUPER_ADMIN &&
!actorRoles.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.SUPER_ADMIN_ASSIGN_ATTEMPT,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
reason: "Non-super-admin attempted to assign Super Admin role",
targetUserId,
roleId,
},
});
}
throw new AppError(
"Forbidden: Only Super Administrators can assign the Super Admin role.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
const isTargetCurrentlySuperAdmin =
await this.repository.isUserSuperAdmin(targetUserId);
if (
isTargetCurrentlySuperAdmin &&
!actorRoles.includes(SYSTEM_ROLE_SLUGS.SUPER_ADMIN)
) {
throw new AppError(
"Forbidden: Only Super Administrators can modify roles of a Super Administrator.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
const assignment = await this.repository.assignSingleRole(
targetUserId,
roleId,
actorId,
);
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.ROLE_ASSIGNED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
targetUserId,
roleId,
roleSlug: role.slug,
},
});
}
authorizationCache.invalidateUser(targetUserId);
return assignment;
}
async revokeSingleRole(
actorId: string,
actorRoles: string[],
targetUserId: string,
roleId: string,
context?: AuditContext,
) {
if (actorId === targetUserId) {
throw new AppError(
"Forbidden: You cannot revoke roles from yourself.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
await this.findById(targetUserId);
const role = await this.roleRepository.findById(roleId);
if (!role) {
throw new AppError("Role not found", 404, ERROR_CODE.ROLE_NOT_FOUND);
}
const isActorSuperAdmin = actorRoles.includes(
SYSTEM_ROLE_SLUGS.SUPER_ADMIN,
);
if (role.slug === SYSTEM_ROLE_SLUGS.SUPER_ADMIN) {
if (!isActorSuperAdmin) {
throw new AppError(
"Forbidden: Only Super Administrators can revoke the Super Admin role.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
const activeSuperAdmins = await this.repository.countActiveSuperAdmins();
if (activeSuperAdmins <= 1) {
throw new AppError(
"Cannot revoke the last active Super Admin role from the system.",
400,
ERROR_CODE.CANNOT_REMOVE_LAST_SUPER_ADMIN,
);
}
}
const isTargetCurrentlySuperAdmin =
await this.repository.isUserSuperAdmin(targetUserId);
if (isTargetCurrentlySuperAdmin && !isActorSuperAdmin) {
throw new AppError(
"Forbidden: Only Super Administrators can modify roles of a Super Administrator.",
403,
ERROR_CODE.PRIVILEGE_ESCALATION_DENIED,
);
}
const remaining = await this.repository.revokeSingleRole(
targetUserId,
roleId,
);
if (context?.actorId) {
await this.auditLogService.log({
userId: actorId,
action: AUDIT_ACTIONS.ROLE_REVOKED,
ipAddress: context.ipAddress,
userAgent: context.userAgent,
details: {
targetUserId,
revokedRoleId: roleId,
revokedRoleSlug: role.slug,
},
});
}
authorizationCache.invalidateUser(targetUserId);
return remaining;
} }
} }
...@@ -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