From 68a494851ce90a4433d27ee22565ec329e4d2a47 Mon Sep 17 00:00:00 2001 From: alexeh Date: Mon, 9 Sep 2024 11:01:02 +0200 Subject: [PATCH] basic RBAC system --- api/src/app.module.ts | 7 +++- .../modules/auth/authorisation/roles.enum.ts | 11 +++++ .../auth/decorators/roles.decorator.ts | 6 +++ api/src/modules/auth/guards/roles.guard.ts | 33 +++++++++++++++ api/src/modules/users/users.controller.ts | 26 ++++++++++++ api/src/modules/users/users.module.ts | 2 + .../{auth.spec.ts => authentication.spec.ts} | 0 api/test/auth/authorization.spec.ts | 42 +++++++++++++++++++ api/test/utils/test-manager.ts | 2 +- shared/entities/users/user.entity.ts | 9 ++++ 10 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 api/src/modules/auth/authorisation/roles.enum.ts create mode 100644 api/src/modules/auth/decorators/roles.decorator.ts create mode 100644 api/src/modules/auth/guards/roles.guard.ts create mode 100644 api/src/modules/users/users.controller.ts rename api/test/auth/{auth.spec.ts => authentication.spec.ts} (100%) create mode 100644 api/test/auth/authorization.spec.ts diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 38ceba9..a472ae7 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -5,10 +5,15 @@ import { ApiConfigModule } from '@api/modules/config/app-config.module'; import { APP_GUARD } from '@nestjs/core'; import { AuthModule } from '@api/modules/auth/auth.module'; import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; +import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; @Module({ imports: [ApiConfigModule, AuthModule], controllers: [AppController], - providers: [AppService, { provide: APP_GUARD, useClass: JwtAuthGuard }], + providers: [ + AppService, + { provide: APP_GUARD, useClass: JwtAuthGuard }, + { provide: APP_GUARD, useClass: RolesGuard }, + ], }) export class AppModule {} diff --git a/api/src/modules/auth/authorisation/roles.enum.ts b/api/src/modules/auth/authorisation/roles.enum.ts new file mode 100644 index 0000000..6f3f7d7 --- /dev/null +++ b/api/src/modules/auth/authorisation/roles.enum.ts @@ -0,0 +1,11 @@ +export enum ROLES { + ADMIN = 'admin', + PARTNER = 'partner', + GENERAL_USER = 'general_user', +} + +export const ROLES_HIERARCHY = { + [ROLES.ADMIN]: [ROLES.PARTNER, ROLES.GENERAL_USER], + [ROLES.PARTNER]: [ROLES.GENERAL_USER], + [ROLES.GENERAL_USER]: [], +}; diff --git a/api/src/modules/auth/decorators/roles.decorator.ts b/api/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..c3c2900 --- /dev/null +++ b/api/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; + +export const ROLES_KEY = 'roles'; +export const RequiredRoles = (...roles: ROLES[]) => + SetMetadata(ROLES_KEY, roles); diff --git a/api/src/modules/auth/guards/roles.guard.ts b/api/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..5fc0aa8 --- /dev/null +++ b/api/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,33 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { + ROLES, + ROLES_HIERARCHY, +} from '@api/modules/auth/authorisation/roles.enum'; +import { ROLES_KEY } from '@api/modules/auth/decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles: ROLES[] = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + + return this.hasRequiredRole(user.role, requiredRoles); + } + + private hasRequiredRole(userRole: ROLES, requiredRoles: ROLES[]): boolean { + return requiredRoles.some( + (requiredRole) => + userRole === requiredRole || + ROLES_HIERARCHY[userRole]?.includes(requiredRole), + ); + } +} diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts new file mode 100644 index 0000000..9956253 --- /dev/null +++ b/api/src/modules/users/users.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get } from '@nestjs/common'; +import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; + +@Controller('users') +export class UsersController { + // TODO: All of these endpoints are fake, only to test the role guard + + @RequiredRoles(ROLES.ADMIN) + @Get('admin') + async createUserAsAdmin() { + return [ROLES.ADMIN]; + } + + @RequiredRoles(ROLES.PARTNER) + @Get('partner') + async createUserAsPartner() { + return [ROLES.PARTNER, ROLES.ADMIN]; + } + + @RequiredRoles(ROLES.GENERAL_USER) + @Get('user') + async createUserAsUser() { + return [ROLES.GENERAL_USER, ROLES.PARTNER, ROLES.ADMIN]; + } +} diff --git a/api/src/modules/users/users.module.ts b/api/src/modules/users/users.module.ts index 2399f95..35aadf3 100644 --- a/api/src/modules/users/users.module.ts +++ b/api/src/modules/users/users.module.ts @@ -2,10 +2,12 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from '@shared/entities/users/user.entity'; +import { UsersController } from '@api/modules/users/users.controller'; @Module({ imports: [TypeOrmModule.forFeature([User])], providers: [UsersService], exports: [UsersService], + controllers: [UsersController], }) export class UsersModule {} diff --git a/api/test/auth/auth.spec.ts b/api/test/auth/authentication.spec.ts similarity index 100% rename from api/test/auth/auth.spec.ts rename to api/test/auth/authentication.spec.ts diff --git a/api/test/auth/authorization.spec.ts b/api/test/auth/authorization.spec.ts new file mode 100644 index 0000000..d33cfa7 --- /dev/null +++ b/api/test/auth/authorization.spec.ts @@ -0,0 +1,42 @@ +import { TestManager } from '../utils/test-manager'; + +import { User } from '@shared/entities/users/user.entity'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; + +describe('Authorization', () => { + let testManager: TestManager; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + test('a user should have a default general user role when signing up', async () => { + await testManager + .request() + .post('/authentication/signup') + .send({ email: 'test@test.com', password: '123456' }); + + const user = await testManager + .getDataSource() + .getRepository(User) + .findOne({ where: { email: 'test@test.com' } }); + + expect(user.role).toEqual(ROLES.GENERAL_USER); + + describe('ROLE TEST ENDPOINTS, REMOVE!', () => { + test('when role required is general user, the general user and above roles should have access', async () => { + const user = await testManager + .mocks() + .createUser({ role: ROLES.GENERAL_USER }); + const { jwtToken } = await testManager.logUserIn(user); + }); + }); + }); +}); diff --git a/api/test/utils/test-manager.ts b/api/test/utils/test-manager.ts index e4ab132..95ed2db 100644 --- a/api/test/utils/test-manager.ts +++ b/api/test/utils/test-manager.ts @@ -80,7 +80,7 @@ export class TestManager { mocks() { return { - createUser: (additionalData: Partial) => + createUser: (additionalData?: Partial) => createUser(this.getDataSource(), additionalData), }; } diff --git a/shared/entities/users/user.entity.ts b/shared/entities/users/user.entity.ts index b62a9ee..89193d5 100644 --- a/shared/entities/users/user.entity.ts +++ b/shared/entities/users/user.entity.ts @@ -6,6 +6,7 @@ import { PrimaryGeneratedColumn, } from "typeorm"; import { Exclude } from "class-transformer"; +import { ROLES } from "@api/modules/auth/authorisation/roles.enum"; @Entity({ name: "users" }) export class User { @@ -19,6 +20,14 @@ export class User { @Exclude() password: string; + @Column({ + type: "enum", + default: ROLES.GENERAL_USER, + enum: ROLES, + enumName: "user_roles", + }) + role: ROLES; + @CreateDateColumn({ name: "created_at" }) createdAt: Date; }