Set up CASL abilities

This commit is contained in:
navaneeth 2021-07-21 22:27:04 +05:30
parent b5527305cb
commit 7745e4e460
15 changed files with 222 additions and 7 deletions

View file

@ -4,7 +4,7 @@ export function handleResponse(response) {
return response.text().then(text => {
const data = text && JSON.parse(text);
if (!response.ok) {
if ([401, 403].indexOf(response.status) !== -1) {
if ([401].indexOf(response.status) !== -1) {
// auto logout if 401 Unauthorized or 403 Forbidden response returned from api
authenticationService.logout();
// location.reload(true);

View file

@ -8,6 +8,7 @@
"version": "0.0.1",
"license": "UNLICENSED",
"dependencies": {
"@casl/ability": "^5.3.1",
"@elastic/elasticsearch": "^7.13.0",
"@google-cloud/firestore": "^4.13.1",
"@nestjs/common": "^8.0.0",
@ -1214,6 +1215,17 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@casl/ability": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-5.3.1.tgz",
"integrity": "sha512-wke2zAJDBKs1VtYPNDz06KyvqOgU0PBLd9kmHpGC9YLxDt9J749riX/8Vc42hxod92m7BENhOW5Yp73ICYVUbg==",
"dependencies": {
"@ucast/mongo2js": "^1.3.0"
},
"funding": {
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
}
},
"node_modules/@elastic/elasticsearch": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.13.0.tgz",
@ -3099,6 +3111,37 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@ucast/core": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.1.tgz",
"integrity": "sha512-sXKbvQiagjFh2JCpaHUa64P4UdJbOxYeC5xiZFn8y6iYdb0WkismduE+RmiJrIjw/eLDYmIEXiQeIYYowmkcAw=="
},
"node_modules/@ucast/js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.2.tgz",
"integrity": "sha512-zxNkdIPVvqJjHI7D/iK8Aai1+59yqU+N7bpHFodVmiTN7ukeNiGGpNmmSjQgsUw7eNcEBnPrZHNzp5UBxwmaPw==",
"dependencies": {
"@ucast/core": "^1.0.0"
}
},
"node_modules/@ucast/mongo": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.2.tgz",
"integrity": "sha512-/zH1TdBJlYGKKD+Wh0oyD+aBvDSWrwHcD8b4tUL9UgHLhzHtkEnMVFuxbw3SRIRsAa01wmy06+LWt+WoZdj1Bw==",
"dependencies": {
"@ucast/core": "^1.4.1"
}
},
"node_modules/@ucast/mongo2js": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.3.tgz",
"integrity": "sha512-sBPtMUYg+hRnYeVYKL+ATm8FaRPdlU9PijMhGYKgsPGjV9J4Ks41ytIjGayvKUnBOEhiCaKUUnY4qPeifdqATw==",
"dependencies": {
"@ucast/core": "^1.6.1",
"@ucast/js": "^3.0.0",
"@ucast/mongo": "^2.4.0"
}
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
@ -13905,6 +13948,14 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@casl/ability": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@casl/ability/-/ability-5.3.1.tgz",
"integrity": "sha512-wke2zAJDBKs1VtYPNDz06KyvqOgU0PBLd9kmHpGC9YLxDt9J749riX/8Vc42hxod92m7BENhOW5Yp73ICYVUbg==",
"requires": {
"@ucast/mongo2js": "^1.3.0"
}
},
"@elastic/elasticsearch": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-7.13.0.tgz",
@ -15415,6 +15466,37 @@
"eslint-visitor-keys": "^2.0.0"
}
},
"@ucast/core": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.1.tgz",
"integrity": "sha512-sXKbvQiagjFh2JCpaHUa64P4UdJbOxYeC5xiZFn8y6iYdb0WkismduE+RmiJrIjw/eLDYmIEXiQeIYYowmkcAw=="
},
"@ucast/js": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.2.tgz",
"integrity": "sha512-zxNkdIPVvqJjHI7D/iK8Aai1+59yqU+N7bpHFodVmiTN7ukeNiGGpNmmSjQgsUw7eNcEBnPrZHNzp5UBxwmaPw==",
"requires": {
"@ucast/core": "^1.0.0"
}
},
"@ucast/mongo": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.2.tgz",
"integrity": "sha512-/zH1TdBJlYGKKD+Wh0oyD+aBvDSWrwHcD8b4tUL9UgHLhzHtkEnMVFuxbw3SRIRsAa01wmy06+LWt+WoZdj1Bw==",
"requires": {
"@ucast/core": "^1.4.1"
}
},
"@ucast/mongo2js": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.3.tgz",
"integrity": "sha512-sBPtMUYg+hRnYeVYKL+ATm8FaRPdlU9PijMhGYKgsPGjV9J4Ks41ytIjGayvKUnBOEhiCaKUUnY4qPeifdqATw==",
"requires": {
"@ucast/core": "^1.6.1",
"@ucast/js": "^3.0.0",
"@ucast/mongo": "^2.4.0"
}
},
"@webassemblyjs/ast": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",

View file

@ -26,6 +26,7 @@
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js --config ormconfig.ts"
},
"dependencies": {
"@casl/ability": "^5.3.1",
"@elastic/elasticsearch": "^7.13.0",
"@google-cloud/firestore": "^4.13.1",
"@nestjs/common": "^8.0.0",

View file

@ -13,12 +13,13 @@ import { DataSourcesModule } from './modules/data_sources/data_sources.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
import { ConfigModule } from '@nestjs/config';
import ormconfig from '../ormconfig';
import { CaslModule } from './modules/casl/casl.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: [`../.env.${process.env.NODE_ENV}`, '../.env'],
envFilePath: [`../.env.${process.env.NODE_ENV}`, '../.env']
}),
TypeOrmModule.forRoot(ormconfig),
AuthModule,
@ -29,6 +30,7 @@ import ormconfig from '../ormconfig';
DataQueriesModule,
DataSourcesModule,
OrganizationsModule,
CaslModule
],
controllers: [AppController],
providers: [AppService],

View file

@ -2,11 +2,16 @@ import { Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs
import { OrganizationUsersService } from 'src/services/organization_users.service';
import { decamelizeKeys } from 'humps';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
import { AppAbility, CaslAbilityFactory } from 'src/modules/casl/casl-ability.factory';
import { PoliciesGuard } from 'src/modules/casl/policies.guard';
import { CheckPolicies } from 'src/modules/casl/check_policies.decorator';
import { User } from 'src/entities/user.entity';
@Controller('organization_users')
export class OrganizationUsersController {
constructor(
private organizationUsersService: OrganizationUsersService
private organizationUsersService: OrganizationUsersService,
private caslAbilityFactory: CaslAbilityFactory
) { }
// Endpoint for inviting new organization users
@ -24,7 +29,8 @@ export class OrganizationUsersController {
return decamelizeKeys({ result });
}
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can('changeRole', User))
@Post(':id/change_role')
async changeRole(@Request() req, @Param() params) {
const result = await this.organizationUsersService.changeRole(req.user, params.id, req.body.role);

View file

@ -1,12 +1,13 @@
import { Controller, Get, Post, Query, Request, UseGuards } from '@nestjs/common';
import { OrganizationsService } from '@services/organizations.service';
import { decamelizeKeys } from 'humps';
import { CaslAbilityFactory } from 'src/modules/casl/casl-ability.factory';
import { JwtAuthGuard } from '../../src/modules/auth/jwt-auth.guard';
@Controller('organizations')
export class OrganizationsController {
constructor(
private organizationsService: OrganizationsService
private organizationsService: OrganizationsService,
) { }
@UseGuards(JwtAuthGuard)

View file

@ -1,4 +1,4 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, BeforeInsert, BeforeUpdate, OneToMany, ManyToOne, JoinColumn } from 'typeorm';
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, BeforeInsert, BeforeUpdate, OneToMany, ManyToOne, JoinColumn, AfterLoad } from 'typeorm';
import { Organization } from './organization.entity';
const bcrypt = require('bcrypt');
import { OrganizationUser } from './organization_user.entity';
@ -48,4 +48,11 @@ export class User {
@JoinColumn({ name: "organization_id" })
organization: Organization;
public isAdmin;
@AfterLoad()
generateCount(): void {
this.isAdmin = this.organizationUsers[0].role === 'admin';
}
}

View file

@ -0,0 +1,7 @@
import { CaslAbilityFactory } from './casl-ability.factory';
describe('CaslAbilityFactory', () => {
it('should be defined', () => {
expect(new CaslAbilityFactory()).toBeDefined();
});
});

View file

@ -0,0 +1,37 @@
import { User } from 'src/entities/user.entity';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { InferSubjects, AbilityBuilder, Ability, AbilityClass, ExtractSubjectType } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { OrganizationUsersService } from '@services/organization_users.service';
type Actions = 'changeRole' | 'archiveUser';
type Subjects = InferSubjects<typeof OrganizationUser| typeof User> | 'all';
export type AppAbility = Ability<[Actions, Subjects]>;
@Injectable()
export class CaslAbilityFactory {
constructor(
private organizationUsersService: OrganizationUsersService,
) { }
async archiveOrganizationUser(user: User, params: any) {
const { can, cannot, build } = new AbilityBuilder<
Ability<[Actions, Subjects]>
>(Ability as AbilityClass<AppAbility>);
const organizationUser = await this.organizationUsersService.findOne(params.id);
const currentUserBelongsToSameOrg = organizationUser.organizationId === user.organizationId;
if(user.isAdmin && currentUserBelongsToSameOrg) {
can('changeRole', User);
can('archiveUser', User);
}
return build({
detectSubjectType: item => item.constructor as ExtractSubjectType<Subjects>
});
}
}

View file

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrganizationUsersService } from '@services/organization_users.service';
import { UsersService } from '@services/users.service';
import { Organization } from 'src/entities/organization.entity';
import { OrganizationUser } from 'src/entities/organization_user.entity';
import { User } from 'src/entities/user.entity';
import { CaslAbilityFactory } from './casl-ability.factory';
@Module({
imports: [TypeOrmModule.forFeature([User, Organization, OrganizationUser])],
providers: [CaslAbilityFactory, OrganizationUsersService, UsersService],
exports: [CaslAbilityFactory]
})
export class CaslModule { }

View file

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
import { PolicyHandler } from './policyhandler.interface';
export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);

View file

@ -0,0 +1,36 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppAbility, CaslAbilityFactory } from './casl-ability.factory';
import { CHECK_POLICIES_KEY } from './check_policies.decorator';
import { PolicyHandler } from './policyhandler.interface';
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private caslAbilityFactory: CaslAbilityFactory,
) { }
async canActivate(context: ExecutionContext): Promise<boolean> {
const policyHandlers =
this.reflector.get<PolicyHandler[]>(
CHECK_POLICIES_KEY,
context.getHandler(),
) || [];
const { user, params } = context.switchToHttp().getRequest();
const ability = await this.caslAbilityFactory.archiveOrganizationUser(user, params);
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);
}
}

View file

@ -0,0 +1,9 @@
import { AppAbility } from '../casl/casl-ability.factory';
interface IPolicyHandler {
handle(ability: AppAbility): boolean;
}
type PolicyHandlerCallback = (ability: AppAbility) => boolean;
export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

View file

@ -8,9 +8,10 @@ import { OrganizationUsersService } from '@services/organization_users.service';
import { OrganizationsController } from '@controllers/organizations.controller';
import { OrganizationUsersController } from '@controllers/organization_users.controller';
import { UsersService } from 'src/services/users.service';
import { CaslModule } from '../casl/casl.module';
@Module({
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User])],
imports: [TypeOrmModule.forFeature([Organization, OrganizationUser, User]), CaslModule],
providers: [OrganizationsService, OrganizationUsersService, UsersService],
controllers: [OrganizationsController, OrganizationUsersController],
})

View file

@ -16,6 +16,10 @@ export class OrganizationUsersService {
private usersService: UsersService,
) { }
async findOne(id: string): Promise<OrganizationUser> {
return await this.organizationUsersRepository.findOne(id);
}
async inviteNewUser(currentUser: User, params: any): Promise<OrganizationUser> {
const userParams = <User> {