diff --git a/src/core/app.module.ts b/src/core/app.module.ts index 8039f3d..22b09be 100644 --- a/src/core/app.module.ts +++ b/src/core/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { PassportModule } from '@teacinema/passport' +import { AccountModule } from '../modules/account/account.module' import { AuthModule } from '../modules/auth/auth.module' import { AppController } from './app.controller' @@ -12,6 +13,7 @@ import { getPassportConfig } from './config' imports: [ ConfigModule.forRoot({ isGlobal: true }), AuthModule, + AccountModule, PassportModule.registerAsync({ useFactory: getPassportConfig, inject: [ConfigService] diff --git a/src/modules/account/account.grpc.ts b/src/modules/account/account.grpc.ts new file mode 100644 index 0000000..a0b2437 --- /dev/null +++ b/src/modules/account/account.grpc.ts @@ -0,0 +1,22 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common' +import type { ClientGrpc } from '@nestjs/microservices' +import { + AccountServiceClient, + GetAccountRequest +} from '@teacinema/contracts/gen/account' + +@Injectable() +export class AccountClientGrpc implements OnModuleInit { + private accountService: AccountServiceClient + + constructor(@Inject('ACCOUNT_PACKAGE') private readonly client: ClientGrpc) {} + + onModuleInit() { + this.accountService = + this.client.getService('AccountService') + } + + public getAccount(request: GetAccountRequest) { + return this.accountService.getAccount(request) + } +} diff --git a/src/modules/account/account.module.ts b/src/modules/account/account.module.ts new file mode 100644 index 0000000..5fd3054 --- /dev/null +++ b/src/modules/account/account.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { ClientsModule, Transport } from '@nestjs/microservices' +import { PROTO_PATHS } from '@teacinema/contracts' + +import { AccountClientGrpc } from './account.grpc' + +@Module({ + imports: [ + ClientsModule.registerAsync([ + { + name: 'ACCOUNT_PACKAGE', + useFactory: (configService: ConfigService) => ({ + transport: Transport.GRPC, + options: { + package: 'account.v1', + protoPath: PROTO_PATHS.ACCOUNT, + url: configService.getOrThrow('AUTH_GRPC_URL') + } + }), + inject: [ConfigService] + } + ]) + ], + providers: [AccountClientGrpc], + exports: [AccountClientGrpc] +}) +export class AccountModule {} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index ac110f6..e9acb60 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config' import { ClientsModule, Transport } from '@nestjs/microservices' import { PROTO_PATHS } from '@teacinema/contracts' +import { AccountModule } from '../account/account.module' + import { AuthController } from './auth.controller' import { AuthClientGrpc } from './auth.grpc' @@ -21,7 +23,8 @@ import { AuthClientGrpc } from './auth.grpc' }), inject: [ConfigService] } - ]) + ]), + AccountModule ], controllers: [AuthController], providers: [AuthClientGrpc] diff --git a/src/shared/decorators/index.ts b/src/shared/decorators/index.ts index 75670ec..106e890 100644 --- a/src/shared/decorators/index.ts +++ b/src/shared/decorators/index.ts @@ -1,2 +1,3 @@ export * from './protected.decorator' export * from './current-user.decorator' +export * from './roles.decorator' diff --git a/src/shared/decorators/protected.decorator.ts b/src/shared/decorators/protected.decorator.ts index 1b67175..0406041 100644 --- a/src/shared/decorators/protected.decorator.ts +++ b/src/shared/decorators/protected.decorator.ts @@ -1,5 +1,14 @@ import { applyDecorators, UseGuards } from '@nestjs/common' +import { Role } from '@teacinema/contracts/gen/account' -import { AuthGuard } from '../guards' +import { AuthGuard, RolesGuard } from '../guards' -export const Protected = () => applyDecorators(UseGuards(AuthGuard)) +import { Roles } from './roles.decorator' + +export const Protected = (...roles: Role[]) => { + if (roles.length === 0) { + return applyDecorators(UseGuards(AuthGuard)) + } + + return applyDecorators(Roles(...roles), UseGuards(AuthGuard, RolesGuard)) +} diff --git a/src/shared/decorators/roles.decorator.ts b/src/shared/decorators/roles.decorator.ts new file mode 100644 index 0000000..5397e8f --- /dev/null +++ b/src/shared/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common' +import { Role } from '@teacinema/contracts/gen/account' + +export const ROLES_KEY = 'required_roles' + +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles) diff --git a/src/shared/guards/index.ts b/src/shared/guards/index.ts index 524c9d8..e8ec3c6 100644 --- a/src/shared/guards/index.ts +++ b/src/shared/guards/index.ts @@ -1 +1,2 @@ export * from './auth.guard' +export * from './roles.guard' diff --git a/src/shared/guards/roles.guard.ts b/src/shared/guards/roles.guard.ts new file mode 100644 index 0000000..93c0c6a --- /dev/null +++ b/src/shared/guards/roles.guard.ts @@ -0,0 +1,54 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { Role } from '@teacinema/contracts/gen/account' +import { Request } from 'express' +import { lastValueFrom } from 'rxjs' + +import { AccountClientGrpc } from '../../modules/account/account.grpc' +import { ROLES_KEY } from '../decorators' + +@Injectable() +export class RolesGuard implements CanActivate { + public constructor( + private readonly reflector: Reflector, + private readonly accountClient: AccountClientGrpc + ) {} + + public async canActivate(context: ExecutionContext) { + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass() + ]) + + if (!required || required.length === 0) { + return true + } + + const request = context.switchToHttp().getRequest() + const user = request.user + + if (!user) { + throw new ForbiddenException('User context missing') + } + + const account = await lastValueFrom( + this.accountClient.getAccount({ id: user.id }) + ) + + if (!account) { + throw new NotFoundException('Account not found') + } + + if (!required.includes(account.role)) { + throw new ForbiddenException('Forbidden') + } + + return true + } +} diff --git a/yarn.lock b/yarn.lock index 4c31bd7..560f0a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1221,9 +1221,9 @@ integrity sha512-L006mySvhRpUlRHf3ONbXA0RG42gbQ9QAnmQuZx4zejHEsWB9702xe18MSYcBilv+lmYkQpiX/g3p8P7suLfqQ== "@teacinema/contracts@^1.0.0": - version "1.0.5" - resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.5/contracts-1.0.5.tgz#f6db65f58ba3621e81e886f54d6ec9e674c4785b" - integrity sha512-paFa/zVWKzDNuH04EyGPlG981YEX7Xt42gRwpfZwACGa5CCM5lehT7xKU4h9p/l9IebrOtTPDwJdQn/D41o3Gw== + version "1.0.6" + resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.6/contracts-1.0.6.tgz#7aa97d4c3081b791908c90d2a12d771b5fb79bd5" + integrity sha512-2IU2S9hWtKeMpn4zRS9pJAaSryIODrRjS1q9QezQOXF6554vtQlMVr0fLVTGhNmXlbRfuje0f6HggApqPDFMaA== dependencies: "@nestjs/microservices" "^11.1.12" protoc "33.4.0"