Can't use CASL, using custom perms

This commit is contained in:
Bucaille Thommy 2022-01-14 19:42:14 +01:00
parent c66e93df96
commit 8e3383fd1c
13 changed files with 157 additions and 66 deletions

View File

@ -1,15 +1,6 @@
import { Controller, Get, Redirect } from '@nestjs/common';
import { ApiMovedPermanentlyResponse, ApiProperty } from '@nestjs/swagger';
import { AppService } from './app.service';
import { Guest } from './auth/guest.decorator';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Guest()
@Get()
@ApiMovedPermanentlyResponse({ description: "Redirect to /api" })
@Redirect("/api")
home(): void { }
constructor() {}
}

View File

@ -27,6 +27,8 @@ import { CaslModule } from './casl/casl.module';
password: configService.get("DB_PASS", ""),
database: configService.get("DB_NAME", "test"),
autoLoadModels: true,
logging: configService.get("APP_ENV", "development") === "development" ? console.log : false,
synchronize: configService.get("APP_ENV", "development") === "development" ? true : false
}),
inject: [ConfigService],
}),
@ -36,7 +38,7 @@ import { CaslModule } from './casl/casl.module';
CaslModule
],
controllers: [AppController],
providers: [AppService, {
providers: [{
provide: APP_GUARD,
useClass: JwtAuthGuard,
}],

View File

@ -24,7 +24,7 @@ import { LocalStrategy } from './local.strategy';
inject: [ConfigService]
}),
],
providers: [AuthService, UsersService, ConfigService, LocalStrategy, JwtStrategy],
providers: [AuthService, UsersService, ConfigService, LocalStrategy, JwtStrategy, UsersService],
exports: [AuthService]
})

View File

@ -2,10 +2,11 @@ import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(protected configService: ConfigService) {
constructor(protected configService: ConfigService, private usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
@ -14,6 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
const user = await this.usersService.findOne(payload.sub);
return { userId: payload.sub, username: payload.username, data: user };
}
}

View File

@ -1,3 +1,4 @@
import { subject } from "@casl/ability";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { User } from "src/users/models/user.model";
@ -20,7 +21,7 @@ export class PoliciesGuard implements CanActivate {
) || [];
const { user } = context.switchToHttp().getRequest();
let userDb = await User.findByPk(user.userId);
const userDb = await User.findByPk(user.userId);
const ability = this.caslAbilityFactory.createForUser(userDb);
return policyHandlers.every((handler) =>

View File

@ -1,7 +1,71 @@
import { CaslAbilityFactory } from './casl-ability.factory';
import { Sequelize } from "sequelize-typescript";
import { Action } from "../auth/enums";
import { Company } from "../companies/models/company.model";
import { CompanyEarn } from "../companies/models/companyEarn.model";
import { User } from "../users/models/user.model";
import { CaslAbilityFactory } from "./casl-ability.factory";
describe('CaslAbilityFactory', () => {
it('should be defined', () => {
expect(new CaslAbilityFactory()).toBeDefined();
});
});
describe("Permissions", () => {
new Sequelize({
validateOnly: true,
models: [User, Company, CompanyEarn] // don't forget to add your models like this... or [User, ...]
});
let user: User;
let user2: User;
let ability;
const caslAbilityFactoryInstance = new CaslAbilityFactory();
describe("when user is an admin", () => {
beforeEach(() => {
user = new User();
user.firstName = "John";
user.lastName = "Doe";
user.email = "email@domain.tld";
user.isAdmin = true;
ability = caslAbilityFactoryInstance.createForUser(user);
});
it("can do anything", () => {
expect(ability.can(Action.Manage, "all")).toBe(true);
});
});
describe("when user is a not admin", () => {
beforeEach(() => {
user = new User();
user.firstName = "John";
user.lastName = "Doe";
user.email = "email@domain.tld";
user.isAdmin = false;
user2 = new User();
user2.firstName = "Maria";
user2.lastName = "Doe";
user2.email = "email@domain.tld";
user2.isAdmin = false;
ability = caslAbilityFactoryInstance.createForUser(user);
});
it("should return user model name", () => {
expect(user.modelName).toBe("User");
})
it("can read self data", () => {
expect(ability.can(Action.Read, user)).toBe(true);
});
it("cannot read other user data", () => {
expect(ability.can(Action.Read, user2)).toBe(false);
});
});
});

View File

@ -1,11 +1,10 @@
import { Ability, AbilityBuilder, AbilityClass, ExtractSubjectType, InferSubjects } from "@casl/ability";
import { Ability, AbilityBuilder, AbilityClass } from "@casl/ability";
import { Injectable } from "@nestjs/common";
import { Action } from "src/auth/enums";
import { Company } from "src/companies/models/company.model";
import { User } from "src/users/models/user.model";
import { Action } from "../auth/enums";
import { User } from "../users/models/user.model";
type Subjects = InferSubjects<typeof Company | typeof User> | 'all';
type Subjects = 'Company' | 'User' | 'all';
export type AppAbility = Ability<[Action, Subjects]>;
@Injectable()
@ -17,24 +16,21 @@ export class CaslAbilityFactory {
if (user.isAdmin) {
can(Action.Manage, 'all'); // read-write access to everything
}else{
// Everything rules
can(Action.Create, 'all');
// User rules
can(Action.Read, 'User', { id: user.id }).because("User can only read its own data");
can(Action.Update, 'User', { id: user.id });
can(Action.Delete, 'User', { id: user.id });
// Company rules
can(Action.Read, 'Company', { user: user.id });
can(Action.Update, 'Company', { user: user.id });
can(Action.Delete, 'Company', { user: user.id });
}
// Everything rules
can(Action.Create, 'all');
// User rules
can(Action.Read, User, { id: user.id });
can(Action.Update, User, { id: user.id });
can(Action.Delete, User, { id: user.id });
// Company rules
can(Action.Read, Company, { user: user.id });
can(Action.Update, Company, { user: user.id });
can(Action.Delete, Company, { user: user.id });
return build({
// Read https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types for details
detectSubjectType: item => item.constructor as ExtractSubjectType<Subjects>
});
return build();
}
}

View File

@ -31,7 +31,7 @@ export class CompaniesController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Company))
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Company'))
findAll() {
return this.companiesService.findAll();
}
@ -42,7 +42,7 @@ export class CompaniesController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Get(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Company))
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Company'))
findOne(@Param('id') id: string) {
return this.companiesService.findOne(+id);
}
@ -53,7 +53,7 @@ export class CompaniesController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Patch(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Update, Company))
@CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'Company'))
update(@Param('id') id: string, @Body() updateCompanyDto: UpdateCompanyDto) {
return this.companiesService.update(+id, updateCompanyDto);
}
@ -64,7 +64,7 @@ export class CompaniesController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Delete(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Delete, Company))
@CheckPolicies((ability: AppAbility) => ability.can(Action.Delete, 'Company'))
remove(@Param('id') id: string) {
return this.companiesService.remove(+id);
}

View File

@ -1,6 +1,6 @@
import { AllowNull, BelongsTo, Column, CreatedAt, HasMany, Length, Model, NotEmpty, Table, UpdatedAt } from 'sequelize-typescript';
import { User } from 'src/users/models/user.model';
import { CompanyEarn } from './companyEarn.model';
import { Table, Model, AllowNull, NotEmpty, Length, Column, BelongsTo, HasMany, CreatedAt, UpdatedAt } from "sequelize-typescript";
import { User } from "../../users/models/user.model";
import { CompanyEarn } from "./companyEarn.model";
@Table
export class Company extends Model {
@ -25,4 +25,8 @@ export class Company extends Model {
@UpdatedAt
updatedAt: Date;
static get modelName() {
return "Company";
}
}

View File

@ -1,6 +1,6 @@
import sequelize from 'sequelize';
import { AllowNull, Column, CreatedAt, DefaultScope, HasMany, IsDate, IsEmail, Length, Model, NotEmpty, Scopes, Table, Unique, UpdatedAt } from 'sequelize-typescript';
import { Company } from '../../companies/models/company.model';
import sequelize from "sequelize";
import { DefaultScope, Scopes, Table, Model, AllowNull, NotEmpty, Length, Column, IsEmail, Unique, IsDate, HasMany, CreatedAt, UpdatedAt } from "sequelize-typescript";
import { Company } from "../../companies/models/company.model";
import * as bcrypt from 'bcrypt';
@DefaultScope(() => ({
@ -77,4 +77,8 @@ export class User extends Model {
@UpdatedAt
updatedAt: Date;
static get modelName() {
return "User";
}
}

View File

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const getUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user.data;
},
);

View File

@ -1,4 +1,4 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Request, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, Request, UseGuards, NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@ -14,6 +14,7 @@ import { PoliciesGuard } from 'src/casl/PoliciesGuard';
import { CheckPolicies } from 'src/casl/check-policy.decorator';
import { AppAbility } from 'src/casl/casl-ability.factory';
import { Action } from 'src/auth/enums';
import { getUser } from './user.decorator';
@ApiTags("Users")
@ApiBearerAuth()
@ -56,10 +57,13 @@ export class UsersController {
@ApiOkResponse({ description: "Success, return users data array", type: [PublicUserDto] })
@ApiUnauthorizedResponse({ description: "Unauthorized" })
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, User))
findAll() {
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'User'))
@Get()
findAll(@getUser() auth: User) {
if(!auth.isAdmin)
return new ForbiddenException("Access denied.");
return this.usersService.findAll();
}
@ -69,9 +73,18 @@ export class UsersController {
@ApiOperation({summary: "Get a user data", description: "Return all data from a specific user"})
@Get(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, User))
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'User'))
async findOne(@Param('id') id: string, @getUser() auth: User) {
const isAdmin = auth.isAdmin;
const user = await this.usersService.findOne(+id, isAdmin);
if(!user)
throw new NotFoundException("User not found");
if(auth.id != user.id && !auth.isAdmin)
throw new ForbiddenException("You can't read other user data");
return user;
}
@ApiOperation({summary: "Update a user", description: "Update user with send datas"})
@ -80,8 +93,11 @@ export class UsersController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Patch(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Update, User))
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
@CheckPolicies((ability: AppAbility) => ability.can(Action.Update, 'User'))
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto, @getUser() auth: User) {
if(!auth.isAdmin && auth.id != id)
throw new ForbiddenException("You can't update this user");
return this.usersService.update(+id, updateUserDto);
}
@ -91,8 +107,11 @@ export class UsersController {
@ApiInternalServerErrorResponse({ description: "Internal server error" })
@Delete(':id')
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Delete, User))
remove(@Param('id') id: string) {
@CheckPolicies((ability: AppAbility) => ability.can(Action.Delete, 'User'))
remove(@Param('id') id: string, @getUser() auth: User) {
if(!auth.isAdmin && auth.id != id)
throw new ForbiddenException("You can't update this user");
return this.usersService.remove(+id);
}
}

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
// import * as request from 'supertest';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
@ -15,10 +15,10 @@ describe('AppController (e2e)', () => {
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
// it('/ (GET)', () => {
// return request(app.getHttpServer())
// .get('/')
// .expect(200)
// .expect('Hello World!');
// });
});