Skip to content

Commit

Permalink
feat: use casl role base (#37)
Browse files Browse the repository at this point in the history
* feat: implement casl role base

* fix: roles instead of role
  • Loading branch information
Notekunn authored Jan 20, 2023
1 parent 994e7a6 commit c212b62
Show file tree
Hide file tree
Showing 17 changed files with 233 additions and 14 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"class-validator": "^0.13.2",
"env-cmd": "^10.1.0",
"morgan": "^1.10.0",
"nest-casl": "^1.8.2",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
Expand Down
10 changes: 10 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { UserModule } from '@modules/users/user.module'
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { QueryBus } from '@nestjs/cqrs'
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'
import { AuthorizableUser, CaslModule } from 'nest-casl'

import { AppController } from './app.controller'
import { Roles } from './common/enum/role.enum'
import { appConfiguration } from './configurations/app.config'
import { jwtConfiguration } from './configurations/jwt.config'
import { typeormConfiguration } from './configurations/typeorm.config'
import { AuthModule } from './modules/auth/auth.module'
import { GetUserByIdQuery } from './modules/users/cqrs/queries/impl/get-user-by-id.query'

const appModules = [AuthModule, UserModule]

Expand All @@ -22,6 +26,12 @@ const appModules = [AuthModule, UserModule]
inject: [ConfigService],
useFactory: (configService: ConfigService) => configService.get<TypeOrmModuleOptions>('orm'),
}),
CaslModule.forRoot<Roles, AuthorizableUser<Roles, number>>({
superuserRole: Roles.Admin,
getUserFromRequest(request) {
return request.user
},
}),
...appModules,
],
controllers: [AppController],
Expand Down
4 changes: 4 additions & 0 deletions src/common/enum/role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum Roles {
Admin = 'admin',
User = 'user',
}
4 changes: 2 additions & 2 deletions src/configurations/typeorm.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export const typeormConfiguration = registerAs<TypeOrmModuleOptions>('orm', () =
database: process.env.DB_NAME,
namingStrategy: new SnakeNamingStrategy(),
migrationsTableName: '__migrations',
entities: ['**/modules/**/*.entity.js'],
migrations: ['**/{migrations,seeds}/*.js'],
entities: ['dist/modules/**/*.entity.js'],
migrations: ['dist/databases/{migrations,seeds}/*.js'],
migrationsRun: process.env.DB_AUTO_RUN_MIGRATIONS === 'true',
synchronize: process.env.DB_AUTO_SYNC === 'true',
logging: process.env.DB_LOGGING === 'true',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ export class CreateTokenCommandHandler implements ICommandHandler<CreateTokenCom
constructor(private readonly jwtService: JwtService) {}
async execute(command: CreateTokenCommand) {
const { user } = command
const { id, email } = user
const { id, email, role } = user

const token = this.jwtService.sign({
id,
email,
roles: [role],
})
const { exp } = this.jwtService.verify(token)

Expand Down
6 changes: 6 additions & 0 deletions src/modules/auth/dto/jwt-claims.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Roles } from '@common/enum/role.enum'
import { ApiProperty } from '@nestjs/swagger'

export class JwtClaimsDto {
Expand All @@ -8,4 +9,9 @@ export class JwtClaimsDto {
email: string

//TODO: add roles
@ApiProperty({
enum: Roles,
isArray: true,
})
roles: Roles[]
}
3 changes: 1 addition & 2 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {

async validate({ id: userId }): Promise<JwtClaimsDto> {
const user = await this.queryBus.execute(new GetUserByIdQuery(userId))

return user
return { ...user, roles: [user.role] }
}
}
15 changes: 15 additions & 0 deletions src/modules/users/casl/user.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common'
import { Request, SubjectBeforeFilterHook } from 'nest-casl'

import { UserEntity } from '../entities/user.entity'
import { UserRepository } from '../repositories/user.repository'

@Injectable()
export class UserHook implements SubjectBeforeFilterHook<UserEntity, Request> {
constructor(private readonly userRepository: UserRepository) {}
async run({ params }: Request) {
return this.userRepository.create({
id: params.id,
})
}
}
21 changes: 21 additions & 0 deletions src/modules/users/casl/user.permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Subject } from '@casl/ability'
import { Roles } from '@common/enum/role.enum'
import { Actions, Permissions } from 'nest-casl'

import { UserEntity } from '../entities/user.entity'

export const permissions: Permissions<Roles, Subject, Actions> = {
everyone({ can }) {
can(Actions.read, UserEntity)
can(Actions.create, UserEntity)
},
admin({ can }) {
can(Actions.manage, UserEntity)
},
user({ can, cannot, user }) {
can(Actions.update, UserEntity, {
id: user.id,
})
cannot(Actions.delete, UserEntity)
},
}
27 changes: 24 additions & 3 deletions src/modules/users/controllers/user-admin.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import { Controller } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { Body, Controller, Param, ParseIntPipe, Put, UseGuards } from '@nestjs/common'
import { CommandBus } from '@nestjs/cqrs'
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'
import { JwtAuthGuard } from '@root/guards/jwt-auth.guard'
import { AccessGuard, Actions, UseAbility } from 'nest-casl'

import { UserHook } from '../casl/user.hook'
import { UpdateUserCommand } from '../cqrs/commands/impl/update-user.command'
import { UpdateUserDto } from '../dto/update-user.dto'
import { UserEntity } from '../entities/user.entity'

@Controller('user')
@ApiTags('user')
export class UserAdminController {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, AccessGuard)
export class UserAdminController {
constructor(private readonly commandBus: CommandBus) {}

@UseAbility(Actions.update, UserEntity, UserHook)
@Put('/:id')
@ApiOperation({
summary: 'Update user profile (admin)',
})
updateProfile(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) {
return this.commandBus.execute(new UpdateUserCommand(id, dto))
}
}
25 changes: 21 additions & 4 deletions src/modules/users/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import { AuthUser } from '@decorators/auth-user.decorator'
import { JwtAuthGuard } from '@guards/jwt-auth.guard'
import { JwtClaimsDto } from '@modules/auth/dto/jwt-claims.dto'
import { Controller, Get, UseGuards } from '@nestjs/common'
import { QueryBus } from '@nestjs/cqrs'
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
import { CommandBus, QueryBus } from '@nestjs/cqrs'
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'
import { AccessGuard, Actions, UseAbility } from 'nest-casl'

import { UpdateUserCommand } from '../cqrs/commands/impl/update-user.command'
import { GetUserByIdQuery } from '../cqrs/queries/impl/get-user-by-id.query'
import { UpdateUserDto } from '../dto/update-user.dto'
import { UserEntity } from '../entities/user.entity'

@Controller('user')
@ApiTags('user')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UserController {
constructor(private readonly queryBus: QueryBus) {}
constructor(private readonly queryBus: QueryBus, private readonly commandBus: CommandBus) {}

@Get('profile')
@UseGuards(AccessGuard)
@UseAbility(Actions.read, UserEntity)
@ApiOperation({
summary: 'Get user profile',
})
getProfile(@AuthUser() user: JwtClaimsDto) {
return this.queryBus.execute(new GetUserByIdQuery(user.id))
}

@Post('profile')
@ApiOperation({
summary: 'Update user profile',
})
updateProfile(@AuthUser() user: JwtClaimsDto, @Body() dto: UpdateUserDto) {
return this.commandBus.execute(new UpdateUserCommand(user.id, dto))
}
}
29 changes: 29 additions & 0 deletions src/modules/users/cqrs/commands/handler/update-user.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { UserRepository } from '@modules/users/repositories/user.repository'
import { NotFoundException } from '@nestjs/common'
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { UtilService } from '@shared/utils.service'

import { UpdateUserCommand } from '../impl/update-user.command'

@CommandHandler(UpdateUserCommand)
export class UpdateUserCommandHandler implements ICommandHandler<UpdateUserCommand> {
constructor(private readonly userRepository: UserRepository) {}
async execute(command: UpdateUserCommand) {
const { userId, dto } = command
const _user = await this.userRepository.findOne({
where: {
id: userId,
},
})

if (!_user) throw new NotFoundException('error.userNotFound')

if (dto.password) {
dto.password = UtilService.generateHash(dto.password)
}

const user = this.userRepository.merge(_user, dto)
await this.userRepository.save(user)
return user
}
}
8 changes: 8 additions & 0 deletions src/modules/users/cqrs/commands/impl/update-user.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { UpdateUserDto } from '@modules/users/dto/update-user.dto'
import { UserEntity } from '@modules/users/entities/user.entity'
import { Command } from '@nestjs-architects/typed-cqrs'
export class UpdateUserCommand extends Command<UserEntity> {
constructor(public readonly userId: number, public readonly dto: UpdateUserDto) {
super()
}
}
14 changes: 14 additions & 0 deletions src/modules/users/dto/update-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsOptional, IsString } from 'class-validator'

export class UpdateUserDto {
@ApiProperty()
@IsString()
@IsOptional()
password?: string

@ApiProperty()
@IsString()
@IsOptional()
name?: string
}
11 changes: 11 additions & 0 deletions src/modules/users/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseEntity } from '@common/entities/base.entity'
import { Roles } from '@common/enum/role.enum'
import { Exclude } from 'class-transformer'
import { Column, Entity } from 'typeorm'

Expand All @@ -10,4 +11,14 @@ export class UserEntity extends BaseEntity {
@Column()
@Exclude()
password: string

@Column({ nullable: true })
name: string

@Column({
type: 'enum',
enum: Roles,
default: Roles.User,
})
role: Roles
}
13 changes: 11 additions & 2 deletions src/modules/users/user.module.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { TypeOrmExModule } from '@modules/typeorm-ex.module'
import { Module } from '@nestjs/common'
import { CqrsModule } from '@nestjs/cqrs'
import { CaslModule } from 'nest-casl'

import { permissions } from './casl/user.permission'
import { UserController } from './controllers/user.controller'
import { UserAdminController } from './controllers/user-admin.controller'
import { UpdateUserCommandHandler } from './cqrs/commands/handler/update-user.handler'
import { GetUserByEmailQueryHandler } from './cqrs/queries/handler/get-user-by-email.handler'
import { GetUserByIdQueryHandler } from './cqrs/queries/handler/get-user-by-id.handler'
import { UserRepository } from './repositories/user.repository'

const QueryHandlers = [GetUserByEmailQueryHandler, GetUserByIdQueryHandler]
const CommandHandlers = []
const CommandHandlers = [UpdateUserCommandHandler]

@Module({
providers: [...QueryHandlers, ...CommandHandlers],
controllers: [UserController, UserAdminController],
imports: [CqrsModule, TypeOrmExModule.forCustomRepository([UserRepository])],
imports: [
CqrsModule,
TypeOrmExModule.forCustomRepository([UserRepository]),
CaslModule.forFeature({
permissions,
}),
],
})
export class UserModule {}
53 changes: 53 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,13 @@
resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==

"@casl/ability@^6.0.0":
version "6.3.3"
resolved "https://registry.yarnpkg.com/@casl/ability/-/ability-6.3.3.tgz#219e958f191cd2163482abb6a5196593d319fc2a"
integrity sha512-UzbqsE9etu6QzZrRmqIyVun2kztAzJ46Tz7lC/2P2buCE6B6Ll7Vptz7JTQtGwapLbeKo2jS7dL966TVOQ7x4g==
dependencies:
"@ucast/mongo2js" "^1.3.0"

"@colors/[email protected]":
version "1.5.0"
resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz"
Expand Down Expand Up @@ -1934,6 +1941,41 @@
"@typescript-eslint/types" "5.48.2"
eslint-visitor-keys "^3.3.0"

"@ucast/core@^1.0.0", "@ucast/core@^1.10.1", "@ucast/core@^1.4.1", "@ucast/core@^1.6.1":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@ucast/core/-/core-1.10.1.tgz#03a77a7804bcb5002a5cad3681e86cd1897e2e1f"
integrity sha512-sXKbvQiagjFh2JCpaHUa64P4UdJbOxYeC5xiZFn8y6iYdb0WkismduE+RmiJrIjw/eLDYmIEXiQeIYYowmkcAw==

"@ucast/js@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@ucast/js/-/js-3.0.2.tgz#862838ee68112c6c262d4f4693cc592ba83157e0"
integrity sha512-zxNkdIPVvqJjHI7D/iK8Aai1+59yqU+N7bpHFodVmiTN7ukeNiGGpNmmSjQgsUw7eNcEBnPrZHNzp5UBxwmaPw==
dependencies:
"@ucast/core" "^1.0.0"

"@ucast/mongo2js@^1.3.0", "@ucast/mongo2js@^1.3.3":
version "1.3.3"
resolved "https://registry.yarnpkg.com/@ucast/mongo2js/-/mongo2js-1.3.3.tgz#a683a59cea22887a72e4302f3826e41ccf51dbbe"
integrity sha512-sBPtMUYg+hRnYeVYKL+ATm8FaRPdlU9PijMhGYKgsPGjV9J4Ks41ytIjGayvKUnBOEhiCaKUUnY4qPeifdqATw==
dependencies:
"@ucast/core" "^1.6.1"
"@ucast/js" "^3.0.0"
"@ucast/mongo" "^2.4.0"

"@ucast/mongo@^2.4.0", "@ucast/mongo@^2.4.2":
version "2.4.2"
resolved "https://registry.yarnpkg.com/@ucast/mongo/-/mongo-2.4.2.tgz#a8a1c32e65ccab623be023e6cedb11d136d50f19"
integrity sha512-/zH1TdBJlYGKKD+Wh0oyD+aBvDSWrwHcD8b4tUL9UgHLhzHtkEnMVFuxbw3SRIRsAa01wmy06+LWt+WoZdj1Bw==
dependencies:
"@ucast/core" "^1.4.1"

"@ucast/sql@^1.0.0-alpha.1":
version "1.0.0-alpha.1"
resolved "https://registry.yarnpkg.com/@ucast/sql/-/sql-1.0.0-alpha.1.tgz#575bf0e74252b05e87ec3c988e66533e6b7e245e"
integrity sha512-0XqQ5hskjO0FjCc259MxelpdAdKbx7rg3ZbS4NAudG6HB/ddnle1RtSlO/I+z/Y4viOmJgGDCC9NVIKm+5BG5g==
dependencies:
"@ucast/core" "^1.0.0"

"@webassemblyjs/[email protected]":
version "1.11.1"
resolved "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz"
Expand Down Expand Up @@ -6714,6 +6756,17 @@ nerf-dart@^1.0.0:
resolved "https://registry.npmjs.org/nerf-dart/-/nerf-dart-1.0.0.tgz"
integrity sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==

nest-casl@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/nest-casl/-/nest-casl-1.8.2.tgz#627606c8a327d950d762cc7207e8e750b84e562d"
integrity sha512-kuA5dPzCZLvYaxpTvkjB0SkY2Ize/mtPKSFembJt+FjTq8eKVoR2368Xz/C1v15kju3RonVm5x317oihAYdm3w==
dependencies:
"@casl/ability" "^6.0.0"
"@ucast/core" "^1.10.1"
"@ucast/mongo" "^2.4.2"
"@ucast/mongo2js" "^1.3.3"
"@ucast/sql" "^1.0.0-alpha.1"

node-abort-controller@^3.0.1:
version "3.0.1"
resolved "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz"
Expand Down

0 comments on commit c212b62

Please sign in to comment.