diff --git a/package-lock.json b/package-lock.json index 4a4d2bb..8d310d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.2.0", "license": "MIT", "dependencies": { + "@casl/ability": "^6.7.1", "@nestjs/common": "^10.3.8", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.3.8", @@ -827,6 +828,17 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.1.tgz", + "integrity": "sha512-e+Vgrehd1/lzOSwSqKHtmJ6kmIuZbGBlM2LBS5IuYGGKmVHuhUuyh3XgTn1VIw9+TO4gqU+uptvxfIRBUEdJuw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -2739,6 +2751,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.4.tgz", + "integrity": "sha512-TgG1aIaCMdcaEyckOZKQozn1hazE0w90SVdlpIJ/er8xVumE11gYAtSbw/LBeUnA4fFnFWTcw3t6reqseeH/4Q==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", diff --git a/package.json b/package.json index 5ab9800..8d3f455 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prepare": "husky install" }, "dependencies": { + "@casl/ability": "^6.7.1", "@nestjs/common": "^10.3.8", "@nestjs/config": "^3.2.2", "@nestjs/core": "^10.3.8", diff --git a/src/modules/infrastructure/casl/action.enum.ts b/src/modules/infrastructure/casl/action.enum.ts new file mode 100644 index 0000000..b398b21 --- /dev/null +++ b/src/modules/infrastructure/casl/action.enum.ts @@ -0,0 +1,7 @@ +export enum Action { + Manage = 'manage', + Create = 'create', + Read = 'read', + Update = 'update', + Delete = 'delete', +} diff --git a/src/modules/infrastructure/casl/casl-ability.factory.ts b/src/modules/infrastructure/casl/casl-ability.factory.ts new file mode 100644 index 0000000..c02e0d2 --- /dev/null +++ b/src/modules/infrastructure/casl/casl-ability.factory.ts @@ -0,0 +1,30 @@ +import { + AbilityBuilder, + ExtractSubjectType, + InferSubjects, + PureAbility, + AbilityClass, +} from '@casl/ability'; +import { Injectable } from '@nestjs/common'; +import { Action } from './action.enum'; +import { User } from '@app/modules/user/user.entity'; + +type Subjects = InferSubjects | 'all'; + +export type AppAbility = PureAbility<[Action, Subjects]>; + +@Injectable() +export class CaslAbilityFactory { + createForUser(user: User) { + const { can, cannot, build } = new AbilityBuilder( + PureAbility as AbilityClass, + ); + + // TODO: Build abilities + + return build({ + detectSubjectType: (subject) => + subject.constructor as ExtractSubjectType, + }); + } +} diff --git a/src/modules/infrastructure/casl/casl.module.ts b/src/modules/infrastructure/casl/casl.module.ts new file mode 100644 index 0000000..d355e7d --- /dev/null +++ b/src/modules/infrastructure/casl/casl.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { CaslAbilityFactory } from './casl-ability.factory'; + +@Global() +@Module({ + providers: [CaslAbilityFactory], + exports: [CaslAbilityFactory], +}) +export class CaslModule {} diff --git a/src/modules/infrastructure/casl/check-policies.decorator.ts b/src/modules/infrastructure/casl/check-policies.decorator.ts new file mode 100644 index 0000000..b65a13e --- /dev/null +++ b/src/modules/infrastructure/casl/check-policies.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { PolicyHandler } from './policies.guard'; + +export const CHECK_POLICIES_KEY = 'check_policy'; +export const CheckPolicies = (...handlers: PolicyHandler[]) => + SetMetadata(CHECK_POLICIES_KEY, handlers); diff --git a/src/modules/infrastructure/casl/policies.guard.ts b/src/modules/infrastructure/casl/policies.guard.ts new file mode 100644 index 0000000..2097a4a --- /dev/null +++ b/src/modules/infrastructure/casl/policies.guard.ts @@ -0,0 +1,42 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AppAbility, CaslAbilityFactory } from './casl-ability.factory'; +import { CHECK_POLICIES_KEY } from './check-policies.decorator'; + +interface IPolicyHandler { + handle(ability: AppAbility): boolean; +} + +type PolicyHandlerCallback = (ability: AppAbility) => boolean; + +export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback; + +@Injectable() +export class PoliciesGuard implements CanActivate { + constructor( + private reflector: Reflector, + private caslAbilityFactory: CaslAbilityFactory, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policyHandlers = + this.reflector.get( + CHECK_POLICIES_KEY, + context.getHandler(), + ) || []; + + const { user } = context.switchToHttp().getRequest(); + const ability = this.caslAbilityFactory.createForUser(user); + + return policyHandlers.every((handler) => + this.execPolicyHandler(handler, ability), + ); + } + + private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) { + if (typeof handler === 'function') { + return handler(ability); + } + return handler.handle(ability); + } +} diff --git a/src/modules/user/role.entity.ts b/src/modules/user/role.entity.ts new file mode 100644 index 0000000..29d9de9 --- /dev/null +++ b/src/modules/user/role.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity({ + name: 'roles', +}) +export class Role { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 50 }) + name: string; +} + +// We treat roles as schema +export enum RoleEnum { + Admin = 'admin', + User = 'user', +} diff --git a/src/modules/user/user.entity.ts b/src/modules/user/user.entity.ts index 04a85d0..9586a54 100644 --- a/src/modules/user/user.entity.ts +++ b/src/modules/user/user.entity.ts @@ -4,15 +4,21 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, + ManyToMany, + JoinTable, } from 'typeorm'; import { Exclude } from 'class-transformer'; import { PasswordTransformer } from '../common/transformer/password.transformer'; +import { Role } from './role.entity'; @Entity({ name: 'users', }) export class User { + @PrimaryGeneratedColumn() + id: number; + @CreateDateColumn() @Exclude() created_at: Date; @@ -21,9 +27,6 @@ export class User { @Exclude() updated_at: Date; - @PrimaryGeneratedColumn() - id: number; - @Column({ name: 'first_name', length: 255 }) firstName: string; @@ -40,4 +43,16 @@ export class User { }) @Exclude() password: string; + + @ManyToMany(() => Role, { eager: true }) + @JoinTable({ + name: 'users_roles', + joinColumn: { + name: 'user_id', + }, + inverseJoinColumn: { + name: 'role_id', + }, + }) + roles: Role[]; } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 9fddcaa..f227e50 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -3,9 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.entity'; import { UserController } from './user.controller'; import { UserService } from './user.service'; +import { Role } from './role.entity'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [TypeOrmModule.forFeature([User, Role])], controllers: [UserController], providers: [UserService], exports: [UserService],