From a63636125d978e882b8135d4577d4fc6fb6a7671 Mon Sep 17 00:00:00 2001 From: unknown Date: Sat, 30 May 2026 23:20:04 +0100 Subject: [PATCH] enhance: granular RBAC AND DEPENDABOT management --- .github/dependabot.yml | 300 +++++++++++++++++- src/auth/auth.module.ts | 6 +- src/auth/decorators/permissions.decorator.ts | 3 + src/auth/decorators/roles.decorator.ts | 3 +- src/auth/guards/permissions.guard.ts | 37 +++ src/auth/guards/roles.guard.ts | 9 +- src/auth/jwt.strategy.ts | 32 +- src/courses/courses.service.ts | 8 +- src/rbac/entities/permission.entity.ts | 37 +++ src/rbac/entities/role.entity.ts | 40 +++ .../permissions/permissions.controller.ts | 42 +++ src/rbac/permissions/permissions.service.ts | 49 +++ src/rbac/rbac.module.ts | 16 + src/rbac/roles/roles.controller.ts | 58 ++++ src/rbac/roles/roles.service.ts | 102 ++++++ src/users/entities/user.entity.ts | 21 +- 16 files changed, 730 insertions(+), 33 deletions(-) create mode 100644 src/auth/decorators/permissions.decorator.ts create mode 100644 src/auth/guards/permissions.guard.ts create mode 100644 src/rbac/entities/permission.entity.ts create mode 100644 src/rbac/entities/role.entity.ts create mode 100644 src/rbac/permissions/permissions.controller.ts create mode 100644 src/rbac/permissions/permissions.service.ts create mode 100644 src/rbac/rbac.module.ts create mode 100644 src/rbac/roles/roles.controller.ts create mode 100644 src/rbac/roles/roles.service.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index db9ba417..a4d0250f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,300 @@ version: 2 updates: - - package-ecosystem: 'npm' - directory: '/' + # npm dependencies - regular updates + - package-ecosystem: "npm" + directory: "/" schedule: - interval: 'weekly' + interval: "weekly" + day: "monday" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 20 + labels: + - "dependencies" + - "npm" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: + # Ignore deprecated packages that we know about + - dependency-name: "lodash" + versions: [">=4.17.15 <5.0.0"] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge non-major version updates + automerge: + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # npm dependencies - security updates (more frequent) + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "security" + - "npm" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge security updates and minor/patch version updates + automerge: + - type: "security" + method: "squash" + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # Docker dependencies - regular updates + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "tuesday" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge non-major version updates + automerge: + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # Docker dependencies - security updates (more frequent) + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "security" + - "docker" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge security updates and minor/patch version updates + automerge: + - type: "security" + method: "squash" + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # GitHub Actions - regular updates + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "wednesday" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "github-actions" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + # Automatically merge non-major version updates + automerge: + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # GitHub Actions - security updates (more frequent) + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "security" + - "github-actions" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + # Automatically merge security updates and minor/patch version updates + automerge: + - type: "security" + method: "squash" + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # pip dependencies (Python SDK) - regular updates + - package-ecosystem: "pip" + directory: "/sdk/python" + schedule: + interval: "weekly" + day: "thursday" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "pip" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge non-major version updates + automerge: + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + + # pip dependencies (Python SDK) - security updates (more frequent) + - package-ecosystem: "pip" + directory: "/sdk/python" + schedule: + interval: "daily" + time: "02:00" + timezone: "UTC" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "security" + - "pip" + reviewers: + - "@teachlink/backend-maintainers" + assignees: + - "@teachlink/backend-maintainers" + commit-message: + prefix: "deps" + prefix-development: "deps" + include: "scope" + ignore: [] + allow: + - dependency-type: "direct" + - dependency-type: "indirect" + # Automatically merge security updates and minor/patch version updates + automerge: + - type: "security" + method: "squash" + - type: "version" + update-types: + - "minor" + - "patch" + method: "squash" + merge-conditions: + - required + - required + +# Options for handling updates +options: + # Allow Dependabot to create PRs for security updates even if they would normally be ignored + allow: + dependency-type: "direct" + dependency-type: "indirect" + # Don't auto-close old PRs when new ones are opened for the same dependency + # This helps prevent losing track of updates + pull-request-limit: 25 \ No newline at end of file diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 3a03d5a2..22716a64 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,6 +4,8 @@ import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '../users/entities/user.entity'; import { JwtStrategy } from './jwt.strategy'; +import { RolesGuard } from './guards/roles.guard'; +import { PermissionsGuard } from './guards/permissions.guard'; /** * Registers the authentication module with Passport and JWT support. @@ -17,7 +19,7 @@ import { JwtStrategy } from './jwt.strategy'; }), TypeOrmModule.forFeature([User]), ], - providers: [JwtStrategy], - exports: [PassportModule, JwtModule], + providers: [JwtStrategy, RolesGuard, PermissionsGuard], + exports: [PassportModule, JwtModule, RolesGuard, PermissionsGuard], }) export class AuthModule {} diff --git a/src/auth/decorators/permissions.decorator.ts b/src/auth/decorators/permissions.decorator.ts new file mode 100644 index 00000000..72c24f4c --- /dev/null +++ b/src/auth/decorators/permissions.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from '@nestjs/common'; +export const PERMISSIONS_KEY = 'permissions'; +export const Permissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions); \ No newline at end of file diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts index 850c8575..3e3861f2 100644 --- a/src/auth/decorators/roles.decorator.ts +++ b/src/auth/decorators/roles.decorator.ts @@ -1,4 +1,3 @@ import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '../../users/entities/user.entity'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/guards/permissions.guard.ts b/src/auth/guards/permissions.guard.ts new file mode 100644 index 00000000..a6753748 --- /dev/null +++ b/src/auth/guards/permissions.guard.ts @@ -0,0 +1,37 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; + +/** + * Protects permissions execution paths. + */ +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + /** + * Executes can Activate. + * @param context The context. + * @returns Whether the operation succeeded. + */ + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.getAllAndOverride(PERMISSIONS_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredPermissions) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + if (!user) { + // This should not happen if the JWT guard is applied, but just in case. + return false; + } + + // Assuming user.permissions is an array of permission strings in the format "resource:action" + return requiredPermissions.every(permission => user.permissions.includes(permission)); + } +} \ No newline at end of file diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts index 6359b583..c6a916d7 100644 --- a/src/auth/guards/roles.guard.ts +++ b/src/auth/guards/roles.guard.ts @@ -1,7 +1,6 @@ import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ROLES_KEY } from '../decorators/roles.decorator'; -import { UserRole } from '../../users/entities/user.entity'; /** * Protects roles execution paths. @@ -16,7 +15,7 @@ export class RolesGuard implements CanActivate { * @returns Whether the operation succeeded. */ canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); @@ -25,11 +24,13 @@ export class RolesGuard implements CanActivate { return true; } - const { user } = context.switchToHttp().getRequest(); + const request = context.switchToHttp().getRequest(); + const user = request.user; if (!user) { throw new UnauthorizedException(); } - return requiredRoles.includes(user.role); + // Assuming user.roles is an array of role names (strings) + return requiredRoles.some(role => user.roles.includes(role)); } } diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 8c7f891f..f645a0b8 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -8,7 +8,8 @@ import { User } from '../users/entities/user.entity'; export interface JwtPayload { sub: string; email: string; - role: string; + roles: string[]; + permissions: string[]; } /** @@ -30,13 +31,36 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { /** * Validates the decoded JWT payload and returns the user object. * @param payload The decoded JWT payload. - * @returns The authenticated user. + * @returns The authenticated user with roles and permissions. */ - async validate(payload: JwtPayload): Promise { + async validate(payload: JwtPayload): Promise { const user = await this.userRepository.findOneBy({ id: payload.sub }); if (!user) { throw new UnauthorizedException('User not found'); } - return user; + + // Fetch roles and permissions for the user + const userWithRolesAndPermissions = await this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.roles', 'role') + .leftJoinAndSelect('role.permissions', 'permission') + .where('user.id = :id', { id: user.id }) + .getOne(); + + if (!userWithRolesAndPermissions) { + throw new UnauthorizedException('User not found'); + } + + const roles = userWithRolesAndPermissions.roles.map(role => role.name); + const permissions = userWithRolesAndPermissions.roles + .reduce((acc, role) => { + return acc.concat(role.permissions.map(p => `${p.resource}:${p.action}`)); + }, [] as string[]); + + return { + ...payload, + roles, + permissions, + }; } } diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 9253a3a7..0f5f0e59 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -10,7 +10,7 @@ import { Repository } from 'typeorm'; import { CACHE_EVENTS } from '../caching/caching.constants'; import { Course, CourseStatus } from './entities/course.entity'; import { CourseReview, ReviewDecision } from './entities/course-review.entity'; -import { User, UserRole } from '../users/entities/user.entity'; +import { User } from '../users/entities/user.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { SubmitForReviewDto } from './dto/submit-for-review.dto'; @@ -74,7 +74,7 @@ export class CoursesService { async findAll(requestingUser?: User): Promise { const isPrivileged = requestingUser && - [UserRole.ADMIN, UserRole.MODERATOR].includes(requestingUser.role); + requestingUser.roles.some(role => ['admin', 'moderator'].includes(role)); if (isPrivileged) { return this.courseRepo.find({ order: { createdAt: 'DESC' } }); @@ -223,14 +223,14 @@ export class CoursesService { } private assertPrivileged(user: User): void { - if (![UserRole.ADMIN, UserRole.MODERATOR].includes(user.role)) { + if (!user.roles.some(role => ['admin', 'moderator'].includes(role))) { throw new ForbiddenException('Only admins or moderators may perform this action.'); } } private assertOwnerOrPrivileged(course: Course, user: User): void { const isOwner = course.instructorId === user.id; - const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role); + const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role)); if (!isOwner && !isPrivileged) { throw new ForbiddenException('Insufficient permissions.'); } diff --git a/src/rbac/entities/permission.entity.ts b/src/rbac/entities/permission.entity.ts new file mode 100644 index 00000000..7b46f8aa --- /dev/null +++ b/src/rbac/entities/permission.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, +} from 'typeorm'; +import { Role } from './role.entity'; + +/** + * Represents a permission in the system. + * A permission is defined by a resource and an action. + */ +@Entity('permissions') +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + resource: string; + + @Column() + action: string; + + @Column({ nullable: true }) + description?: string; + + @ManyToMany(() => Role, (role) => role.permissions) + roles: Role[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/rbac/entities/role.entity.ts b/src/rbac/entities/role.entity.ts new file mode 100644 index 00000000..f406409b --- /dev/null +++ b/src/rbac/entities/role.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { Permission } from './permission.entity'; +import { User } from '../../users/entities/user.entity'; + +/** + * Represents a role in the system. + * A role can have multiple permissions and can be assigned to multiple users. + */ +@Entity('roles') +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description?: string; + + @ManyToMany(() => Permission, (permission) => permission.roles, { eager: true }) + @JoinTable() + permissions: Permission[]; + + @ManyToMany(() => User, (user) => user.roles) + users: User[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/rbac/permissions/permissions.controller.ts b/src/rbac/permissions/permissions.controller.ts new file mode 100644 index 00000000..b2e16f69 --- /dev/null +++ b/src/rbac/permissions/permissions.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Post, Body, Param, Put, Delete } from '@nestjs/common'; +import { PermissionsService } from './permissions.service'; +import { Permission } from '../entities/permission.entity'; + +@Controller('permissions') +export class PermissionsController { + constructor(private readonly permissionsService: PermissionsService) {} + + @Post() + async create( + @Body('resource') resource: string, + @Body('action') action: string, + @Body('description') description?: string, + ): Promise { + return this.permissionsService.createPermission(resource, action, description); + } + + @Get() + async findAll(): Promise { + return this.permissionsService.findAllPermissions(); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.permissionsService.findPermissionById(id); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body('resource') resource: string, + @Body('action') action: string, + @Body('description') description?: string, + ): Promise { + return this.permissionsService.updatePermission(id, resource, action, description); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise { + return this.permissionsService.deletePermission(id); + } +} \ No newline at end of file diff --git a/src/rbac/permissions/permissions.service.ts b/src/rbac/permissions/permissions.service.ts new file mode 100644 index 00000000..d65d1e89 --- /dev/null +++ b/src/rbac/permissions/permissions.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Permission } from '../entities/permission.entity'; + +@Injectable() +export class PermissionsService { + constructor( + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + ) {} + + async createPermission(resource: string, action: string, description?: string): Promise { + const permission = this.permissionRepository.create({ + resource, + action, + description, + }); + return this.permissionRepository.save(permission); + } + + async findAllPermissions(): Promise { + return this.permissionRepository.find(); + } + + async findPermissionById(id: string): Promise { + const permission = await this.permissionRepository.findOneBy({ id }); + if (!permission) { + throw new NotFoundException(`Permission with ID ${id} not found`); + } + return permission; + } + + async updatePermission(id: string, resource: string, action: string, description?: string): Promise { + await this.permissionRepository.update(id, { resource, action, description }); + const updated = await this.permissionRepository.findOneBy({ id }); + if (!updated) { + throw new NotFoundException(`Permission with ID ${id} not found`); + } + return updated; + } + + async deletePermission(id: string): Promise { + const result = await this.permissionRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Permission with ID ${id} not found`); + } + } +} \ No newline at end of file diff --git a/src/rbac/rbac.module.ts b/src/rbac/rbac.module.ts new file mode 100644 index 00000000..3f795d63 --- /dev/null +++ b/src/rbac/rbac.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Permission } from './entities/permission.entity'; +import { Role } from './entities/role.entity'; +import { PermissionsController } from './permissions/permissions.controller'; +import { PermissionsService } from './permissions/permissions.service'; +import { RolesController } from './roles/roles.controller'; +import { RolesService } from './roles/roles.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Permission, Role])], + controllers: [PermissionsController, RolesController], + providers: [PermissionsService, RolesService], + exports: [TypeOrmModule], +}) +export class RbacModule {} \ No newline at end of file diff --git a/src/rbac/roles/roles.controller.ts b/src/rbac/roles/roles.controller.ts new file mode 100644 index 00000000..a18daf03 --- /dev/null +++ b/src/rbac/roles/roles.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Post, Body, Param, Put, Delete } from '@nestjs/common'; +import { RolesService } from './roles.service'; +import { Role } from '../entities/role.entity'; + +@Controller('roles') +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Post() + async create( + @Body('name') name: string, + @Body('description') description?: string, + @Body('permissionIds') permissionIds?: string[], + ): Promise { + return this.rolesService.createRole(name, description, permissionIds); + } + + @Get() + async findAll(): Promise { + return this.rolesService.findAllRoles(); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.rolesService.findRoleById(id); + } + + @Put(':id') + async update( + @Param('id') id: string, + @Body('name') name: string, + @Body('description') description?: string, + @Body('permissionIds') permissionIds?: string[], + ): Promise { + return this.rolesService.updateRole(id, name, description, permissionIds); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise { + return this.rolesService.deleteRole(id); + } + + @Post(':roleId/permissions/:permissionId') + async addPermission( + @Param('roleId') roleId: string, + @Param('permissionId') permissionId: string, + ): Promise { + return this.rolesService.addPermissionToRole(roleId, permissionId); + } + + @Delete(':roleId/permissions/:permissionId') + async removePermission( + @Param('roleId') roleId: string, + @Param('permissionId') permissionId: string, + ): Promise { + return this.rolesService.removePermissionFromRole(roleId, permissionId); + } +} \ No newline at end of file diff --git a/src/rbac/roles/roles.service.ts b/src/rbac/roles/roles.service.ts new file mode 100644 index 00000000..62f2337c --- /dev/null +++ b/src/rbac/roles/roles.service.ts @@ -0,0 +1,102 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Role } from '../entities/role.entity'; +import { Permission } from '../entities/permission.entity'; + +@Injectable() +export class RolesService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + ) {} + + async createRole(name: string, description?: string, permissionIds?: string[]): Promise { + const role = this.roleRepository.create({ + name, + description, + }); + + if (permissionIds && permissionIds.length > 0) { + const permissions = await this.permissionRepository.findByIds(permissionIds); + role.permissions = permissions; + } + + return this.roleRepository.save(role); + } + + async findAllRoles(): Promise { + return this.roleRepository.find({ relations: ['permissions'] }); + } + + async findRoleById(id: string): Promise { + const role = await this.roleRepository.findOneBy({ id }, { relations: ['permissions'] }); + if (!role) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + return role; + } + + async updateRole( + id: string, + name: string, + description?: string, + permissionIds?: string[], + ): Promise { + await this.roleRepository.update(id, { name, description }); + + if (permissionIds !== undefined) { + const permissions = await this.permissionRepository.findByIds(permissionIds); + await this.roleRepository.createQueryBuilder() + .relation(Role, 'permissions') + .of(id) + .set(permissions); + } + + const updated = await this.roleRepository.findOneBy({ id }, { relations: ['permissions'] }); + if (!updated) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + return updated; + } + + async deleteRole(id: string): Promise { + const result = await this.roleRepository.delete(id); + if (result.affected === 0) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + } + + async addPermissionToRole(roleId: string, permissionId: string): Promise { + const role = await this.roleRepository.findOneBy({ roleId }, { relations: ['permissions'] }); + if (!role) { + throw new NotFoundException(`Role with ID ${roleId} not found`); + } + + const permission = await this.permissionRepository.findOneBy({ permissionId }); + if (!permission) { + throw new NotFoundException(`Permission with ID ${permissionId} not found`); + } + + if (!role.permissions.some(p => p.id === permission.id)) { + role.permissions.push(permission); + await this.roleRepository.save(role); + } + + return role; + } + + async removePermissionFromRole(roleId: string, permissionId: string): Promise { + const role = await this.roleRepository.findOneBy({ roleId }, { relations: ['permissions'] }); + if (!role) { + throw new NotFoundException(`Role with ID ${roleId} not found`); + } + + role.permissions = role.permissions.filter(p => p.id !== permissionId); + await this.roleRepository.save(role); + + return role; + } +} \ No newline at end of file diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 3427a64d..d3c92bd6 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -8,16 +8,12 @@ import { Index, OneToMany, VersionColumn, + ManyToMany, + JoinTable, } from 'typeorm'; import { Course } from '../../courses/entities/course.entity'; import { Enrollment } from '../../courses/entities/enrollment.entity'; -export enum UserRole { - STUDENT = 'student', - TEACHER = 'teacher', - INSTRUCTOR = 'instructor', - MODERATOR = 'moderator', - ADMIN = 'admin', -} +import { Role } from '../../rbac/entities/role.entity'; export enum UserStatus { ACTIVE = 'active', INACTIVE = 'inactive', @@ -52,13 +48,6 @@ export class User { @Column() lastName: string; - @Column({ - type: 'enum', - enum: UserRole, - default: UserRole.STUDENT, - }) - role: UserRole; - @Column({ type: 'enum', enum: UserStatus, @@ -97,6 +86,10 @@ export class User { @Column({ type: 'timestamp', nullable: true }) lastLoginAt?: Date; + @ManyToMany(() => Role, (role) => role.users) + @JoinTable() + roles: Role[]; + @OneToMany(() => Course, (course) => course.instructor) courses: Course[];