Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
U
upgrade-data-crawler-be
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
upgrade-data-crawler-be
Commits
513a30d8
Commit
513a30d8
authored
Sep 03, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(auth): implement dynamic RBAC and privilege escalation defense
parent
d8221337
Changes
39
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
39 changed files
with
4827 additions
and
41 deletions
+4827
-41
latest-audit.md
docs/audits/latest-audit.md
+31
-21
migration.sql
.../migrations/20260903120000_add_dynamic_rbac/migration.sql
+97
-0
schema.prisma
prisma/schema.prisma
+68
-0
seed.ts
prisma/seed.ts
+178
-7
audit-action.constant.ts
src/common/constants/audit-action.constant.ts
+9
-0
index.ts
src/common/constants/index.ts
+2
-0
permission.constant.ts
src/common/constants/permission.constant.ts
+398
-0
system-role.constant.ts
src/common/constants/system-role.constant.ts
+50
-0
error-code.ts
src/common/errors/error-code.ts
+5
-0
authorization-cache.helper.ts
src/common/helpers/authorization-cache.helper.ts
+64
-0
database.types.ts
src/common/types/database.types.ts
+4
-0
express.d.ts
src/common/types/express.d.ts
+2
-0
swagger-paths.ts
src/docs/swagger-paths.ts
+312
-0
swagger.json
src/docs/swagger.json
+688
-0
swagger.ts
src/docs/swagger.ts
+71
-0
permission.middleware.test.ts
src/middlewares/__tests__/permission.middleware.test.ts
+118
-0
auth.middleware.ts
src/middlewares/auth.middleware.ts
+9
-0
permission.middleware.ts
src/middlewares/permission.middleware.ts
+133
-0
role.middleware.ts
src/middlewares/role.middleware.ts
+10
-2
permission.service.test.ts
src/modules/permissions/__tests__/permission.service.test.ts
+113
-0
permission.controller.ts
src/modules/permissions/permission.controller.ts
+46
-0
permission.dto.ts
src/modules/permissions/permission.dto.ts
+16
-0
permission.repository.ts
src/modules/permissions/permission.repository.ts
+93
-0
permission.route.ts
src/modules/permissions/permission.route.ts
+24
-0
permission.service.ts
src/modules/permissions/permission.service.ts
+69
-0
privilege-escalation.test.ts
src/modules/roles/__tests__/privilege-escalation.test.ts
+302
-0
role.service.test.ts
src/modules/roles/__tests__/role.service.test.ts
+282
-0
role.controller.ts
src/modules/roles/role.controller.ts
+173
-0
role.dto.ts
src/modules/roles/role.dto.ts
+45
-0
role.repository.ts
src/modules/roles/role.repository.ts
+226
-0
role.route.ts
src/modules/roles/role.route.ts
+77
-0
role.service.ts
src/modules/roles/role.service.ts
+305
-0
role.validation.ts
src/modules/roles/role.validation.ts
+69
-0
user.controller.ts
src/modules/users/user.controller.ts
+110
-2
user.repository.ts
src/modules/users/user.repository.ts
+169
-0
user.route.ts
src/modules/users/user.route.ts
+42
-7
user.service.ts
src/modules/users/user.service.ts
+407
-2
user.validation.ts
src/modules/users/user.validation.ts
+6
-0
index.ts
src/routes/index.ts
+4
-0
No files found.
docs/audits/latest-audit.md
View file @
513a30d8
This diff is collapsed.
Click to expand it.
prisma/migrations/20260903120000_add_dynamic_rbac/migration.sql
0 → 100644
View file @
513a30d8
-- 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
;
prisma/schema.prisma
View file @
513a30d8
...
...
@@ -105,6 +105,7 @@ model User {
auditLogs
AuditLog
[]
apiKeys
ApiKey
[]
webhookConfigs
WebhookConfig
[]
userRoles
UserRoleAssignment
[]
@@
map
(
"users"
)
}
...
...
@@ -394,3 +395,70 @@ model CrawlSchedule {
@@
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"
)
}
prisma/seed.ts
View file @
513a30d8
import
{
PrismaClient
,
UserRole
}
from
"@prisma/client"
;
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
();
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
crawlerPasswordHash
=
await
bcrypt
.
hash
(
"Crawler@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"
},
update
:
{},
create
:
{
email
:
"admin@crawl.local"
,
passwordHash
:
adminPasswordHash
,
fullName
:
"System Admin"
,
fullName
:
"System
Super
Admin"
,
role
:
UserRole
.
ADMIN
,
isActive
:
true
,
},
});
await
prisma
.
user
.
upsert
({
const
crawlerUser
=
await
prisma
.
user
.
upsert
({
where
:
{
email
:
"crawl@crawl.local"
},
update
:
{},
create
:
{
...
...
@@ -35,7 +126,7 @@ async function main() {
},
});
await
prisma
.
user
.
upsert
({
const
viewerUser
=
await
prisma
.
user
.
upsert
({
where
:
{
email
:
"viewer@crawl.local"
},
update
:
{},
create
:
{
...
...
@@ -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
()
.
catch
((
error
)
=>
{
console
.
error
(
error
);
console
.
error
(
"Seed error:"
,
error
);
process
.
exit
(
1
);
})
.
finally
(
async
()
=>
{
...
...
src/common/constants/audit-action.constant.ts
View file @
513a30d8
...
...
@@ -22,6 +22,15 @@ export const AUDIT_ACTIONS = {
REDELIVER_WEBHOOK
:
"REDELIVER_WEBHOOK"
,
REQUEST_DEACTIVATE_ACCOUNT
:
"REQUEST_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
;
export
type
AuditAction
=
(
typeof
AUDIT_ACTIONS
)[
keyof
typeof
AUDIT_ACTIONS
];
src/common/constants/index.ts
View file @
513a30d8
...
...
@@ -10,3 +10,5 @@ export * from "./schedule-frequency.constant";
export
*
from
"./asset-type.constant"
;
export
*
from
"./crawl-page-status.constant"
;
export
*
from
"./webhook.constant"
;
export
*
from
"./system-role.constant"
;
export
*
from
"./permission.constant"
;
src/common/constants/permission.constant.ts
0 → 100644
View file @
513a30d8
This diff is collapsed.
Click to expand it.
src/common/constants/system-role.constant.ts
0 → 100644
View file @
513a30d8
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
,
},
};
src/common/errors/error-code.ts
View file @
513a30d8
...
...
@@ -26,6 +26,11 @@ export const ERROR_CODE = {
WEBHOOK_CONFIG_NOT_FOUND
:
"WEBHOOK_CONFIG_NOT_FOUND"
,
CRAWL_SCHEDULE_NOT_FOUND
:
"CRAWL_SCHEDULE_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
;
export
type
ErrorCode
=
keyof
typeof
ERROR_CODE
;
src/common/helpers/authorization-cache.helper.ts
0 → 100644
View file @
513a30d8
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
();
src/common/types/database.types.ts
View file @
513a30d8
...
...
@@ -10,5 +10,9 @@ export type {
WebhookDelivery
,
AuditLog
,
RefreshToken
,
Role
,
Permission
,
UserRoleAssignment
,
RolePermission
,
Prisma
,
}
from
"@prisma/client"
;
src/common/types/express.d.ts
View file @
513a30d8
...
...
@@ -7,6 +7,8 @@ declare global {
id
:
string
;
email
:
string
;
role
:
UserRole
;
roles
?:
string
[];
permissions
?:
string
[];
};
}
}
...
...
src/docs/swagger-paths.ts
View file @
513a30d8
...
...
@@ -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"
},
},
},
},
};
src/docs/swagger.json
View file @
513a30d8
This diff is collapsed.
Click to expand it.
src/docs/swagger.ts
View file @
513a30d8
...
...
@@ -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"
;
...
...
src/middlewares/__tests__/permission.middleware.test.ts
0 → 100644
View file @
513a30d8
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"
,
}),
);
});
});
});
src/middlewares/auth.middleware.ts
View file @
513a30d8
...
...
@@ -4,8 +4,10 @@ import { jwtConfig } from "../config/jwt.config";
import
{
AppError
}
from
"../common/errors/app-error"
;
import
{
ERROR_CODE
}
from
"../common/errors/error-code"
;
import
{
UserRepository
}
from
"../modules/users/user.repository"
;
import
{
PermissionService
}
from
"../modules/permissions/permission.service"
;
const
userRepository
=
new
UserRepository
();
const
permissionService
=
new
PermissionService
();
export
async
function
authMiddleware
(
req
:
Request
,
...
...
@@ -45,10 +47,17 @@ export async function authMiddleware(
return
;
}
const
[
roles
,
permissions
]
=
await
Promise
.
all
([
permissionService
.
getUserRoles
(
user
.
id
),
permissionService
.
getUserPermissions
(
user
.
id
),
]);
req
.
user
=
{
id
:
user
.
id
,
email
:
user
.
email
,
role
:
user
.
role
,
roles
,
permissions
,
};
next
();
...
...
src/middlewares/permission.middleware.ts
0 → 100644
View file @
513a30d8
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
);
}
};
}
src/middlewares/role.middleware.ts
View file @
513a30d8
...
...
@@ -3,14 +3,22 @@ import { Role } from "../common/constants/role.constant";
import
{
AppError
}
from
"../common/errors/app-error"
;
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
=>
{
if
(
!
req
.
user
)
{
next
(
new
AppError
(
"Unauthorized"
,
401
,
ERROR_CODE
.
UNAUTHORIZED
));
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
));
return
;
}
...
...
src/modules/permissions/__tests__/permission.service.test.ts
0 → 100644
View file @
513a30d8
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
);
});
});
});
src/modules/permissions/permission.controller.ts
0 → 100644
View file @
513a30d8
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
);
}
};
}
src/modules/permissions/permission.dto.ts
0 → 100644
View file @
513a30d8
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
;
}
src/modules/permissions/permission.repository.ts
0 → 100644
View file @
513a30d8
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
);
}
}
src/modules/permissions/permission.route.ts
0 → 100644
View file @
513a30d8
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
;
src/modules/permissions/permission.service.ts
0 → 100644
View file @
513a30d8
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
;
}
}
src/modules/roles/__tests__/privilege-escalation.test.ts
0 → 100644
View file @
513a30d8
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"
,
);
});
});
});
src/modules/roles/__tests__/role.service.test.ts
0 → 100644
View file @
513a30d8
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
,
}),
);
});
});
});
src/modules/roles/role.controller.ts
0 → 100644
View file @
513a30d8
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
);
}
};
}
src/modules/roles/role.dto.ts
0 → 100644
View file @
513a30d8
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
;
}
src/modules/roles/role.repository.ts
0 → 100644
View file @
513a30d8
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
);
}
}
src/modules/roles/role.route.ts
0 → 100644
View file @
513a30d8
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
;
src/modules/roles/role.service.ts
0 → 100644
View file @
513a30d8
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
);
}
}
src/modules/roles/role.validation.ts
0 → 100644
View file @
513a30d8
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
(),
});
src/modules/users/user.controller.ts
View file @
513a30d8
...
...
@@ -64,7 +64,12 @@ export class UserController {
update
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
try
{
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
({
userId
:
req
.
user
.
id
,
...
...
@@ -88,7 +93,11 @@ export class UserController {
delete
=
async
(
req
:
Request
,
res
:
Response
,
next
:
NextFunction
)
=>
{
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
({
userId
:
req
.
user
.
id
,
...
...
@@ -106,4 +115,103 @@ export class UserController {
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
);
}
};
}
src/modules/users/user.repository.ts
View file @
513a30d8
...
...
@@ -3,6 +3,7 @@ import { UserRole, Prisma, User } from "@prisma/client";
import
{
UserQueryDto
}
from
"./user.dto"
;
import
{
envConfig
}
from
"../../config/env.config"
;
import
{
ROLES
}
from
"../../common/constants/role.constant"
;
import
{
SYSTEM_ROLE_SLUGS
}
from
"../../common/constants/system-role.constant"
;
export
class
UserRepository
{
async
findAll
(
query
:
UserQueryDto
=
{})
{
...
...
@@ -46,6 +47,13 @@ export class UserRepository {
const
[
items
,
total
]
=
await
Promise
.
all
([
prisma
.
user
.
findMany
({
where
,
include
:
{
userRoles
:
{
include
:
{
role
:
true
,
},
},
},
orderBy
,
skip
,
take
:
limit
,
...
...
@@ -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
>
{
return
prisma
.
user
.
findFirst
({
where
:
{
email
,
deletedAt
:
null
},
...
...
@@ -136,4 +157,152 @@ export class UserRepository {
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
;
}
}
src/modules/users/user.route.ts
View file @
513a30d8
import
{
Router
}
from
"express"
;
import
{
UserController
}
from
"./user.controller"
;
import
{
authMiddleware
}
from
"../../middlewares/auth.middleware"
;
import
{
require
Role
}
from
"../../middlewares/role
.middleware"
;
import
{
require
Permission
}
from
"../../middlewares/permission
.middleware"
;
import
{
validate
,
validateQuery
}
from
"../../middlewares/validate.middleware"
;
import
{
createUserSchema
,
updateUserSchema
,
listUsersQuerySchema
,
assignUserRolesSchema
,
}
from
"./user.validation"
;
import
{
ROLES
}
from
"../../common/constants/role
.constant"
;
import
{
PERMISSIONS
}
from
"../../common/constants/permission
.constant"
;
const
router
=
Router
();
const
controller
=
new
UserController
();
...
...
@@ -16,41 +17,75 @@ const controller = new UserController();
router
.
get
(
"/"
,
authMiddleware
,
require
Role
(
ROLES
.
ADMIN
),
require
Permission
(
PERMISSIONS
.
USERS_READ
),
validateQuery
(
listUsersQuerySchema
),
controller
.
findAll
,
);
router
.
get
(
"/:id"
,
authMiddleware
,
require
Role
(
ROLES
.
ADMIN
),
require
Permission
(
PERMISSIONS
.
USERS_READ
),
controller
.
findById
,
);
router
.
post
(
"/"
,
authMiddleware
,
require
Role
(
ROLES
.
ADMIN
),
require
Permission
(
PERMISSIONS
.
USERS_CREATE
),
validate
(
createUserSchema
),
(
req
,
res
,
next
)
=>
{
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/CreateUserRequest' } }
controller
.
create
(
req
,
res
,
next
);
},
);
router
.
put
(
"/:id"
,
authMiddleware
,
require
Role
(
ROLES
.
ADMIN
),
require
Permission
(
PERMISSIONS
.
USERS_UPDATE
),
validate
(
updateUserSchema
),
(
req
,
res
,
next
)
=>
{
// #swagger.requestBody = { schema: { $ref: '#/components/schemas/UpdateUserRequest' } }
controller
.
update
(
req
,
res
,
next
);
},
);
router
.
delete
(
"/:id"
,
authMiddleware
,
require
Role
(
ROLES
.
ADMIN
),
require
Permission
(
PERMISSIONS
.
USERS_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
;
src/modules/users/user.service.ts
View file @
513a30d8
This diff is collapsed.
Click to expand it.
src/modules/users/user.validation.ts
View file @
513a30d8
...
...
@@ -41,3 +41,9 @@ export const listUsersQuerySchema = z.object({
page
:
z
.
coerce
.
number
().
int
().
min
(
1
).
default
(
1
),
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"
,
}),
});
src/routes/index.ts
View file @
513a30d8
...
...
@@ -10,12 +10,16 @@ import extractionTemplateRoute from "../modules/extraction-templates/extraction-
import
crawlScheduleRoute
from
"../modules/crawl-schedules/crawl-schedule.route"
;
import
healthRoute
from
"../modules/health/health.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
();
router
.
use
(
"/health"
,
healthRoute
);
router
.
use
(
"/auth"
,
authRoute
);
router
.
use
(
"/users"
,
userRoute
);
router
.
use
(
"/roles"
,
roleRoute
);
router
.
use
(
"/permissions"
,
permissionRoute
);
router
.
use
(
"/dashboard"
,
dashboardRoute
);
router
.
use
(
"/crawl-jobs"
,
crawlJobRoute
);
router
.
use
(
"/crawl-schedules"
,
crawlScheduleRoute
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment