From 7745e4e4602edc7cf9679edb0bfb4cd33f73dc5e Mon Sep 17 00:00:00 2001 From: navaneeth Date: Wed, 21 Jul 2021 22:27:04 +0530 Subject: [PATCH] Set up CASL abilities --- frontend/src/_helpers/handle-response.js | 2 +- server/package-lock.json | 82 +++++++++++++++++++ server/package.json | 1 + server/src/app.module.ts | 4 +- .../organization_users.controller.ts | 10 ++- .../controllers/organizations.controller.ts | 3 +- server/src/entities/user.entity.ts | 9 +- .../modules/casl/casl-ability.factory.spec.ts | 7 ++ .../src/modules/casl/casl-ability.factory.ts | 37 +++++++++ server/src/modules/casl/casl.module.ts | 16 ++++ .../modules/casl/check_policies.decorator.ts | 6 ++ server/src/modules/casl/policies.guard.ts | 36 ++++++++ .../modules/casl/policyhandler.interface.ts | 9 ++ .../organizations/organizations.module.ts | 3 +- .../services/organization_users.service.ts | 4 + 15 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 server/src/modules/casl/casl-ability.factory.spec.ts create mode 100644 server/src/modules/casl/casl-ability.factory.ts create mode 100644 server/src/modules/casl/casl.module.ts create mode 100644 server/src/modules/casl/check_policies.decorator.ts create mode 100644 server/src/modules/casl/policies.guard.ts create mode 100644 server/src/modules/casl/policyhandler.interface.ts diff --git a/frontend/src/_helpers/handle-response.js b/frontend/src/_helpers/handle-response.js index 8d073e973e..a77ce5ca5c 100644 --- a/frontend/src/_helpers/handle-response.js +++ b/frontend/src/_helpers/handle-response.js @@ -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); diff --git a/server/package-lock.json b/server/package-lock.json index 16b315601e..ee74397a06 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 312e8f6eb6..e11d2a14b5 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 65cb19f570..2b093a8c48 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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], diff --git a/server/src/controllers/organization_users.controller.ts b/server/src/controllers/organization_users.controller.ts index 1b38b31b61..8c67640cab 100644 --- a/server/src/controllers/organization_users.controller.ts +++ b/server/src/controllers/organization_users.controller.ts @@ -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); diff --git a/server/src/controllers/organizations.controller.ts b/server/src/controllers/organizations.controller.ts index 1cb04c494e..81eb24b514 100644 --- a/server/src/controllers/organizations.controller.ts +++ b/server/src/controllers/organizations.controller.ts @@ -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) diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts index 6a21d6945b..4813e0cdb5 100644 --- a/server/src/entities/user.entity.ts +++ b/server/src/entities/user.entity.ts @@ -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'; + } + } diff --git a/server/src/modules/casl/casl-ability.factory.spec.ts b/server/src/modules/casl/casl-ability.factory.spec.ts new file mode 100644 index 0000000000..613b677bb6 --- /dev/null +++ b/server/src/modules/casl/casl-ability.factory.spec.ts @@ -0,0 +1,7 @@ +import { CaslAbilityFactory } from './casl-ability.factory'; + +describe('CaslAbilityFactory', () => { + it('should be defined', () => { + expect(new CaslAbilityFactory()).toBeDefined(); + }); +}); diff --git a/server/src/modules/casl/casl-ability.factory.ts b/server/src/modules/casl/casl-ability.factory.ts new file mode 100644 index 0000000000..48d2f141fa --- /dev/null +++ b/server/src/modules/casl/casl-ability.factory.ts @@ -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 | '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); + + 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 + }); + } +} diff --git a/server/src/modules/casl/casl.module.ts b/server/src/modules/casl/casl.module.ts new file mode 100644 index 0000000000..60e7e6dc21 --- /dev/null +++ b/server/src/modules/casl/casl.module.ts @@ -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 { } diff --git a/server/src/modules/casl/check_policies.decorator.ts b/server/src/modules/casl/check_policies.decorator.ts new file mode 100644 index 0000000000..bc2a9dc9e5 --- /dev/null +++ b/server/src/modules/casl/check_policies.decorator.ts @@ -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); diff --git a/server/src/modules/casl/policies.guard.ts b/server/src/modules/casl/policies.guard.ts new file mode 100644 index 0000000000..6c13296f8d --- /dev/null +++ b/server/src/modules/casl/policies.guard.ts @@ -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 { + const policyHandlers = + this.reflector.get( + 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); + } +} \ No newline at end of file diff --git a/server/src/modules/casl/policyhandler.interface.ts b/server/src/modules/casl/policyhandler.interface.ts new file mode 100644 index 0000000000..920a550cf6 --- /dev/null +++ b/server/src/modules/casl/policyhandler.interface.ts @@ -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; diff --git a/server/src/modules/organizations/organizations.module.ts b/server/src/modules/organizations/organizations.module.ts index 28c8b7f8d6..7c16e31142 100644 --- a/server/src/modules/organizations/organizations.module.ts +++ b/server/src/modules/organizations/organizations.module.ts @@ -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], }) diff --git a/server/src/services/organization_users.service.ts b/server/src/services/organization_users.service.ts index 6d578bdaba..dd1d16a041 100644 --- a/server/src/services/organization_users.service.ts +++ b/server/src/services/organization_users.service.ts @@ -16,6 +16,10 @@ export class OrganizationUsersService { private usersService: UsersService, ) { } + async findOne(id: string): Promise { + return await this.organizationUsersRepository.findOne(id); + } + async inviteNewUser(currentUser: User, params: any): Promise { const userParams = {