diff --git a/package-lock.json b/package-lock.json index 70cafbf4..5799b0e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12975,6 +12975,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" + }, "node_modules/axios": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", @@ -13790,6 +13795,77 @@ "upper-case-first": "^2.0.2" } }, + "node_modules/casbin": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/casbin/-/casbin-5.28.0.tgz", + "integrity": "sha512-7R1zGDOWUKVowPTT/qTZjm5L5G0ZASQ6dmKIGHYM8KqmkTc28P/KUO9WeaGjLKELnpOCkPIz0EJHw1CaTtgucw==", + "dependencies": { + "await-lock": "^2.0.1", + "buffer": "^6.0.3", + "csv-parse": "^5.3.5", + "expression-eval": "^5.0.0", + "minimatch": "^7.4.2" + } + }, + "node_modules/casbin-mongoose-adapter": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/casbin-mongoose-adapter/-/casbin-mongoose-adapter-5.3.1.tgz", + "integrity": "sha512-bF7Ff7kTedBrHkqFoBpFe222VzlNHack+3Gy8P/qaShCSBZHoVW2eN3HKK2A5rH4mjyK+NSY0CO7dP0/FsIEfg==", + "dependencies": { + "mongoose": "^7.3.4" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "casbin": "^5.13.2" + } + }, + "node_modules/casbin/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/casbin/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/casbin/node_modules/minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -14377,6 +14453,11 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, + "node_modules/csv-parse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.3.tgz", + "integrity": "sha512-v0KW6C0qlZzoGjk6u5tLmVfyZxNgPGXZsWTXshpAgKVGmGXzaVWGdlCFxNx5iuzcXT/oJN1HHM9DZKwtAtYa+A==" + }, "node_modules/csv-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.0.0.tgz", @@ -15634,6 +15715,15 @@ "node": ">= 0.8" } }, + "node_modules/expression-eval": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/expression-eval/-/expression-eval-5.0.1.tgz", + "integrity": "sha512-7SL4miKp19lI834/F6y156xlNg+i9Q41tteuGNCq9C06S78f1bm3BXuvf0+QpQxv369Pv/P2R7Hb17hzxLpbDA==", + "deprecated": "The expression-eval npm package is no longer maintained. The package was originally published as part of a now-completed personal project, and I do not have incentives to continue maintenance.", + "dependencies": { + "jsep": "^0.3.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -17045,7 +17135,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -19543,6 +19632,14 @@ "signal-exit": "^3.0.2" } }, + "node_modules/jsep": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-0.3.5.tgz", + "integrity": "sha512-AoRLBDc6JNnKjNcmonituEABS5bcfqDhQAWWXNTFrqu6nVXBpBAGfcoTGZMFlIrh9FjmE1CQyX9CTNwZrXMMDA==", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -26279,6 +26376,8 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^9.0.0", "@types/passport-jwt": "^3.0.13", + "casbin": "^5.28.0", + "casbin-mongoose-adapter": "^5.3.1", "csv-parser": "^3.0.0", "graphql-type-json": "^0.3.2", "jsonschema": "^1.4.1", @@ -35524,6 +35623,11 @@ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" }, + "await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" + }, "axios": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", @@ -36151,6 +36255,53 @@ "upper-case-first": "^2.0.2" } }, + "casbin": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/casbin/-/casbin-5.28.0.tgz", + "integrity": "sha512-7R1zGDOWUKVowPTT/qTZjm5L5G0ZASQ6dmKIGHYM8KqmkTc28P/KUO9WeaGjLKELnpOCkPIz0EJHw1CaTtgucw==", + "requires": { + "await-lock": "^2.0.1", + "buffer": "^6.0.3", + "csv-parse": "^5.3.5", + "expression-eval": "^5.0.0", + "minimatch": "^7.4.2" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "casbin-mongoose-adapter": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/casbin-mongoose-adapter/-/casbin-mongoose-adapter-5.3.1.tgz", + "integrity": "sha512-bF7Ff7kTedBrHkqFoBpFe222VzlNHack+3Gy8P/qaShCSBZHoVW2eN3HKK2A5rH4mjyK+NSY0CO7dP0/FsIEfg==", + "requires": { + "mongoose": "^7.3.4" + } + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -36610,6 +36761,11 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, + "csv-parse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.3.tgz", + "integrity": "sha512-v0KW6C0qlZzoGjk6u5tLmVfyZxNgPGXZsWTXshpAgKVGmGXzaVWGdlCFxNx5iuzcXT/oJN1HHM9DZKwtAtYa+A==" + }, "csv-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.0.0.tgz", @@ -37568,6 +37724,14 @@ } } }, + "expression-eval": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/expression-eval/-/expression-eval-5.0.1.tgz", + "integrity": "sha512-7SL4miKp19lI834/F6y156xlNg+i9Q41tteuGNCq9C06S78f1bm3BXuvf0+QpQxv369Pv/P2R7Hb17hzxLpbDA==", + "requires": { + "jsep": "^0.3.0" + } + }, "extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -38636,8 +38800,7 @@ "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "ignore": { "version": "5.2.4", @@ -40516,6 +40679,11 @@ } } }, + "jsep": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-0.3.5.tgz", + "integrity": "sha512-AoRLBDc6JNnKjNcmonituEABS5bcfqDhQAWWXNTFrqu6nVXBpBAGfcoTGZMFlIrh9FjmE1CQyX9CTNwZrXMMDA==" + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -43000,10 +43168,12 @@ "@types/express": "^4.17.13", "@types/jest": "28.1.8", "@types/node": "^16.0.0", - "@types/passport-jwt": "*", + "@types/passport-jwt": "^3.0.13", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "casbin": "^5.28.0", + "casbin-mongoose-adapter": "^5.3.1", "csv-parser": "^3.0.0", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", diff --git a/packages/server/package.json b/packages/server/package.json index 304d0274..336ca500 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,6 +34,8 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^9.0.0", "@types/passport-jwt": "^3.0.13", + "casbin": "^5.28.0", + "casbin-mongoose-adapter": "^5.3.1", "csv-parser": "^3.0.0", "graphql-type-json": "^0.3.2", "jsonschema": "^1.4.1", diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 622582aa..0c4791c4 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -122,13 +122,13 @@ type Mutation { createDataset(dataset: DatasetCreate!): Dataset! changeDatasetName(dataset: ID!, newName: String!): Boolean! changeDatasetDescription(dataset: ID!, newDescription: String!): Boolean! + grantOwner(targetUser: ID!): Boolean! signLabCreateProject(project: ProjectCreate!): Project! deleteProject(project: ID!): Boolean! createStudy(study: StudyCreate!): Study! deleteStudy(study: ID!): Boolean! changeStudyName(study: ID!, newName: String!): Study! changeStudyDescription(study: ID!, newDescription: String!): Study! - createEntry(entry: EntryCreate!, dataset: ID!): Entry! createUploadSession(dataset: ID!): UploadSession! completeUploadSession(session: ID!): UploadResult! createTags(study: ID!, entries: [ID!]!): [Tag!]! @@ -162,11 +162,4 @@ input StudyCreate { input TagSchemaInput { dataSchema: JSON! uiSchema: JSON! -} - -input EntryCreate { - entryID: String! - contentType: String! - creator: ID! - meta: JSON! } \ No newline at end of file diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index 44a59682..8e0bdd7a 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -6,6 +6,8 @@ import { JwtStrategy } from './jwt.strategy'; import { JwtAuthGuard } from './jwt.guard'; import { OrganizationModule } from '../organization/organization.module'; import { HttpModule } from '@nestjs/axios'; +import { casbinProvider } from './casbin.provider'; +import { AuthResolver } from './auth.resolver'; @Module({ imports: [ @@ -24,11 +26,13 @@ import { HttpModule } from '@nestjs/axios'; }; return options; } - }) + }), ], providers: [ AuthService, JwtAuthGuard, + casbinProvider, + AuthResolver, { provide: JwtStrategy, inject: [AuthService], @@ -38,6 +42,6 @@ import { HttpModule } from '@nestjs/axios'; } } ], - exports: [AuthService] + exports: [AuthService, casbinProvider] }) export class AuthModule {} diff --git a/packages/server/src/auth/auth.resolver.ts b/packages/server/src/auth/auth.resolver.ts new file mode 100644 index 00000000..d989c0f9 --- /dev/null +++ b/packages/server/src/auth/auth.resolver.ts @@ -0,0 +1,22 @@ +import { Resolver, Mutation, Args, ID } from '@nestjs/graphql'; +import { JwtAuthGuard } from './jwt.guard'; +import { UseGuards } from '@nestjs/common'; +import { UserContext } from './user.decorator'; +import { TokenPayload } from './user.dto'; +import { AuthService } from './auth.service'; +import {OrganizationContext} from 'src/organization/organization.context'; +import {Organization} from 'src/organization/organization.model'; + +@UseGuards(JwtAuthGuard) +@Resolver() +export class AuthResolver { + constructor(private readonly authService: AuthService) {} + + @Mutation(() => Boolean) + async grantOwner(@Args('targetUser', { type: () => ID }) targetUser: string, + @UserContext() requestingUser: TokenPayload, + @OrganizationContext() organization: Organization): Promise { + await this.authService.grantOwner(targetUser, requestingUser.id, organization._id); + return true; + } +} diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 325c3dbb..a010f96e 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -1,13 +1,18 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject, UnauthorizedException } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; +import { CASBIN_PROVIDER } from './casbin.provider'; +import * as casbin from 'casbin'; +import { Roles } from './roles'; @Injectable() export class AuthService { private publicKey: string | null = null; - constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) {} + constructor(private readonly httpService: HttpService, + private readonly configService: ConfigService, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} // TODO: In the future this will be replaced by a library which handles // key rotation @@ -18,8 +23,18 @@ export class AuthService { return response.data[0]; } + /** requestingUser must be an owner themselves */ + async grantOwner(targetUser: string, requestingUser: string, organization: string): Promise { + // Make sure the requesting user is an owner + const isOwner = await this.enforcer.enforce(requestingUser, Roles.OWNER, organization); + if (!isOwner) { + throw new UnauthorizedException('Requesting user is not an owner'); + } + + await this.enforcer.addPolicy(targetUser, Roles.OWNER, organization); + } + async getPublicKey(): Promise { - // TODO: Replace with an actual call to the auth service if (this.publicKey === null) { this.publicKey = await this.queryForPublicKey(); } diff --git a/packages/server/src/auth/casbin.provider.ts b/packages/server/src/auth/casbin.provider.ts new file mode 100644 index 00000000..97c4c0a3 --- /dev/null +++ b/packages/server/src/auth/casbin.provider.ts @@ -0,0 +1,33 @@ +import { Provider } from '@nestjs/common'; +import {ConfigService} from '@nestjs/config'; +import * as casbin from 'casbin'; +import { MongooseAdapter } from 'casbin-mongoose-adapter'; +import { roleHierarchy } from './roles'; +import { roleToStudyPermissions } from './permissions/study'; +import { roleToProjectPermissions } from './permissions/project'; +import { roleToTagPermissions } from './permissions/tag'; +import { roleToDatasetPermissions } from './permissions/dataset'; + +export const CASBIN_PROVIDER = 'CASBIN_PROVIDER'; + +export const casbinProvider: Provider = { + provide: CASBIN_PROVIDER, + useFactory: async (configService: ConfigService) => { + const model = configService.getOrThrow('casbin.model'); + const policy = await MongooseAdapter.newAdapter(configService.getOrThrow('casbin.mongo.uri')); + const enforcer = await casbin.newEnforcer(model, policy); + + // Add all the role mappings + const groups = [ + ...roleHierarchy, + ...roleToStudyPermissions, + ...roleToProjectPermissions, + ...roleToTagPermissions, + ...roleToDatasetPermissions + ]; + await Promise.all(groups.map(group => enforcer.addNamedGroupingPolicy('g', ...group))); + + return enforcer; + }, + inject: [ConfigService] +}; diff --git a/packages/server/src/auth/permissions/dataset.ts b/packages/server/src/auth/permissions/dataset.ts new file mode 100644 index 00000000..81c0d549 --- /dev/null +++ b/packages/server/src/auth/permissions/dataset.ts @@ -0,0 +1,26 @@ +import { Roles } from '../roles'; + +export enum DatasetPermissions { + CREATE = 'dataset:create', + READ = 'dataset:read', + UPDATE = 'dataset:update', + DELETE = 'dataset:delete', + GRANT_ACCESS = 'dataset:grant_access' +} + +/** All role to dataset permissions */ +export const roleToDatasetPermissions: string[][] = [ + // OWNER permissions + [Roles.OWNER, DatasetPermissions.CREATE], + [Roles.OWNER, DatasetPermissions.READ], + [Roles.OWNER, DatasetPermissions.UPDATE], + [Roles.OWNER, DatasetPermissions.DELETE], + [Roles.OWNER, DatasetPermissions.GRANT_ACCESS], + + // PROJECT_ADMIN permissions + + // STUDY_ADMIN permissions + + // CONTRIBUTOR permissions + [Roles.CONTRIBUTOR, DatasetPermissions.READ] +]; diff --git a/packages/server/src/auth/permissions/project.ts b/packages/server/src/auth/permissions/project.ts new file mode 100644 index 00000000..327f4d6d --- /dev/null +++ b/packages/server/src/auth/permissions/project.ts @@ -0,0 +1,24 @@ +import { Roles } from '../roles'; + +/** Permissions associated with projects */ +export enum ProjectPermissions { + CREATE = 'project:create', + READ = 'project:read', + UPDATE = 'project:update', + DELETE = 'project:delete' +} + +/** All role to project permissions */ +export const roleToProjectPermissions: string[][] = [ + // OWNER permissions + [Roles.OWNER, ProjectPermissions.CREATE], + [Roles.OWNER, ProjectPermissions.DELETE], + + // PROJECT_ADMIN permissions + [Roles.PROJECT_ADMIN, ProjectPermissions.UPDATE], + + // STUDY_ADMIN permissions + + // CONTRIBUTOR permissions + [Roles.CONTRIBUTOR, ProjectPermissions.READ] +]; diff --git a/packages/server/src/auth/permissions/study.ts b/packages/server/src/auth/permissions/study.ts new file mode 100644 index 00000000..abc0d8bf --- /dev/null +++ b/packages/server/src/auth/permissions/study.ts @@ -0,0 +1,26 @@ +import { Roles } from '../roles'; + +/** Permissions associated with studies */ +export enum StudyPermissions { + CREATE = 'study:create', + READ = 'study:read', + UPDATE = 'study:update', + DELETE = 'study:delete', + GRANT_ACCESS = 'study:grant_access' +} + +/** All role to study permissions */ +export const roleToStudyPermissions: string[][] = [ + // OWNER permissions + + // PROJECT_ADMIN permissions + [Roles.PROJECT_ADMIN, StudyPermissions.CREATE], + [Roles.PROJECT_ADMIN, StudyPermissions.DELETE], + + // STUDY_ADMIN permissions + [Roles.STUDY_ADMIN, StudyPermissions.UPDATE], + [Roles.STUDY_ADMIN, StudyPermissions.GRANT_ACCESS], + + // CONTRIBUTOR permissions + [Roles.CONTRIBUTOR, StudyPermissions.READ] +]; diff --git a/packages/server/src/auth/permissions/tag.ts b/packages/server/src/auth/permissions/tag.ts new file mode 100644 index 00000000..c0a4e671 --- /dev/null +++ b/packages/server/src/auth/permissions/tag.ts @@ -0,0 +1,22 @@ +import { Roles } from '../roles'; + +export enum TagPermissions { + CREATE = 'tag:create', + READ = 'tag:read', + UPDATE = 'tag:update', + DELETE = 'tag:delete' +} + +export const roleToTagPermissions: string[][] = [ + // OWNER permissions + + // PROJECT_ADMIN permissions + + // STUDY_ADMIN permissions + [Roles.STUDY_ADMIN, TagPermissions.READ], + [Roles.STUDY_ADMIN, TagPermissions.DELETE], + [Roles.STUDY_ADMIN, TagPermissions.UPDATE], + + // CONTRIBUTOR permissions + [Roles.CONTRIBUTOR, TagPermissions.CREATE] +]; diff --git a/packages/server/src/auth/roles.ts b/packages/server/src/auth/roles.ts new file mode 100644 index 00000000..f228a43a --- /dev/null +++ b/packages/server/src/auth/roles.ts @@ -0,0 +1,16 @@ +export enum Roles { + OWNER = 'owner', + PROJECT_ADMIN = 'project_admin', + STUDY_ADMIN = 'study_admin', + CONTRIBUTOR = 'contributor' +} + +/** + * Capturing heirarchy of roles. First role in each sub-list is given + * the permissions of the roles that follow it. + */ +export const roleHierarchy: string[][] = [ + [Roles.OWNER, Roles.PROJECT_ADMIN], + [Roles.PROJECT_ADMIN, Roles.STUDY_ADMIN], + [Roles.STUDY_ADMIN, Roles.CONTRIBUTOR] +]; diff --git a/packages/server/src/config/casbin-model.conf b/packages/server/src/config/casbin-model.conf new file mode 100644 index 00000000..d14b9f99 --- /dev/null +++ b/packages/server/src/config/casbin-model.conf @@ -0,0 +1,15 @@ +[request_definition] +r = sub, act, obj + +[policy_definition] +p = sub, act, obj + +[role_definition] +g = _, _ +g2 = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && g(p.act, r.act) && g2(p.obj, r.obj) diff --git a/packages/server/src/config/configuration.ts b/packages/server/src/config/configuration.ts index e7d261fe..4f7947f6 100644 --- a/packages/server/src/config/configuration.ts +++ b/packages/server/src/config/configuration.ts @@ -21,5 +21,11 @@ export default () => ({ }, auth: { publicKeyUrl: process.env.AUTH_PUBLIC_KEY_URL || 'https://test-auth-service.sail.codes/public-key' + }, + casbin: { + model: process.env.CASBIN_MODEL || 'src/config/casbin-model.conf', + mongo: { + uri: process.env.CASBIN_MONGO_URI || 'mongodb://127.0.0.1:27017/casbin' + } } }); diff --git a/packages/server/src/dataset/dataset.module.ts b/packages/server/src/dataset/dataset.module.ts index cadc0288..387e4267 100644 --- a/packages/server/src/dataset/dataset.module.ts +++ b/packages/server/src/dataset/dataset.module.ts @@ -4,9 +4,10 @@ import { DatasetService } from './dataset.service'; import { MongooseModule } from '@nestjs/mongoose'; import { Dataset, DatasetSchema } from './dataset.model'; import { DatasetPipe } from './pipes/dataset.pipe'; +import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [MongooseModule.forFeature([{ name: Dataset.name, schema: DatasetSchema }])], + imports: [MongooseModule.forFeature([{ name: Dataset.name, schema: DatasetSchema }]), AuthModule], providers: [DatasetResolver, DatasetService, DatasetPipe], exports: [DatasetService, DatasetPipe] }) diff --git a/packages/server/src/dataset/dataset.resolver.ts b/packages/server/src/dataset/dataset.resolver.ts index cc9357ce..33623bb7 100644 --- a/packages/server/src/dataset/dataset.resolver.ts +++ b/packages/server/src/dataset/dataset.resolver.ts @@ -5,23 +5,35 @@ import { Organization } from '../organization/organization.model'; import { OrganizationContext } from '../organization/organization.context'; import { DatasetCreate } from './dtos/create.dto'; import { DatasetPipe } from './pipes/dataset.pipe'; -import { BadRequestException, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard } from 'src/auth/jwt.guard'; +import { BadRequestException, UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt.guard'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; +import { UserContext } from '../auth/user.decorator'; +import { TokenPayload } from '../auth/user.dto'; +import { DatasetPermissions } from '../auth/permissions/dataset'; // TODO: Add authentication @UseGuards(JwtAuthGuard) @Resolver(() => Dataset) export class DatasetResolver { - constructor(private readonly datasetService: DatasetService) {} + constructor(private readonly datasetService: DatasetService, @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} @Query(() => [Dataset]) async getDatasets(@OrganizationContext() organization: Organization): Promise { + // TODO: Get datasets based on access permissions + return this.datasetService.findAll(organization._id); } @Mutation(() => Dataset) async createDataset(@Args('dataset') dataset: DatasetCreate, - @OrganizationContext() organization: Organization): Promise { + @OrganizationContext() organization: Organization, + @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.CREATE, organization._id))) { + throw new UnauthorizedException('User does not have permission to create a dataset in this organization'); + } + const existingDataset = await this.datasetService.findByName(organization._id, dataset.name); if (existingDataset) { throw new BadRequestException(`Dataset with the name ${dataset.name} already exists`); @@ -36,6 +48,10 @@ export class DatasetResolver { @Args('newName') newName: string, @OrganizationContext() organization: Organization): Promise { + if (!(await this.enforcer.enforce(organization._id, DatasetPermissions.UPDATE, dataset._id))) { + throw new UnauthorizedException('User does not have permission to update this dataset'); + } + const existingDataset = await this.datasetService.findByName(organization._id, newName); if (existingDataset) { throw new BadRequestException(`Dataset with the name ${newName} already exists`); @@ -51,6 +67,10 @@ export class DatasetResolver { @Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset, @Args('newDescription') newDescription: string): Promise { + if (!(await this.enforcer.enforce(dataset._id, DatasetPermissions.UPDATE, dataset._id))) { + throw new UnauthorizedException('User does not have permission to update this dataset'); + } + await this.datasetService.changeDescription(dataset, newDescription); return true; diff --git a/packages/server/src/dataset/dataset.service.ts b/packages/server/src/dataset/dataset.service.ts index 827ebadb..938ebccd 100644 --- a/packages/server/src/dataset/dataset.service.ts +++ b/packages/server/src/dataset/dataset.service.ts @@ -1,16 +1,19 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { Model } from 'mongoose'; import { InjectModel } from '@nestjs/mongoose'; import { Dataset } from './dataset.model'; import { DatasetCreate } from './dtos/create.dto'; -import {ConfigService} from '@nestjs/config'; - +import { ConfigService } from '@nestjs/config'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; @Injectable() export class DatasetService { private readonly datasetPrefix = this.configService.getOrThrow('dataset.prefix'); - constructor(@InjectModel(Dataset.name) private readonly datasetModel: Model, private readonly configService: ConfigService) {} + constructor(@InjectModel(Dataset.name) private readonly datasetModel: Model, + private readonly configService: ConfigService, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} async findById(id: string): Promise { return this.datasetModel.findById(id); @@ -32,6 +35,9 @@ export class DatasetService { dataset.bucketPrefix = `${this.datasetPrefix}/${organization}/${dataset._id}`; await dataset.save(); + // Add organization - dataset mapping to casbin + await this.enforcer.addNamedGroupingPolicy('g2', organization, dataset._id.toString()); + // Return the created dataset return dataset; } diff --git a/packages/server/src/entry/entry.module.ts b/packages/server/src/entry/entry.module.ts index 947c24ab..a494f190 100644 --- a/packages/server/src/entry/entry.module.ts +++ b/packages/server/src/entry/entry.module.ts @@ -13,6 +13,7 @@ import { EntryUploadService } from './services/entry-upload.service'; import { UploadSessionPipe } from './pipes/upload-session.pipe'; import { GcpModule } from '../gcp/gcp.module'; import { CsvValidationService } from './services/csv-validation.service'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -22,7 +23,8 @@ import { CsvValidationService } from './services/csv-validation.service'; { name: EntryUpload.name, schema: EntryUploadSchema }, ]), DatasetModule, - GcpModule + GcpModule, + AuthModule ], providers: [ EntryResolver, diff --git a/packages/server/src/entry/resolvers/entry.resolver.ts b/packages/server/src/entry/resolvers/entry.resolver.ts index 23ef0bf5..b7599ac7 100644 --- a/packages/server/src/entry/resolvers/entry.resolver.ts +++ b/packages/server/src/entry/resolvers/entry.resolver.ts @@ -1,30 +1,36 @@ -import { Args, ID, Mutation, Resolver, Query, ResolveField, Parent } from '@nestjs/graphql'; +import { Args, ID, Resolver, Query, ResolveField, Parent } from '@nestjs/graphql'; import { Dataset } from '../../dataset/dataset.model'; import { Entry } from '../models/entry.model'; -import { EntryCreate } from '../dtos/create.dto'; import { EntryService } from '../services/entry.service'; import { DatasetPipe } from '../../dataset/pipes/dataset.pipe'; -import { UseGuards } from '@nestjs/common'; +import { UseGuards, Inject, UnauthorizedException} from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/jwt.guard'; +import { DatasetPermissions } from '../../auth/permissions/dataset'; +import { CASBIN_PROVIDER } from '../../auth/casbin.provider'; +import * as casbin from 'casbin'; +import { TokenPayload } from '../../auth/user.dto'; +import { UserContext } from '../../auth/user.decorator'; @UseGuards(JwtAuthGuard) @Resolver(() => Entry) export class EntryResolver { - constructor(private readonly entryService: EntryService) {} - - // TODO: Validate the entryID is unique - @Mutation(() => Entry) - async createEntry(@Args('entry') entry: EntryCreate, @Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset): Promise { - return this.entryService.create(entry, dataset); - } + constructor(private readonly entryService: EntryService, @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} @Query(() => [Entry]) - async entryForDataset(@Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset): Promise { + async entryForDataset(@Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset, @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.READ, dataset._id))) { + throw new UnauthorizedException('User cannot read entries on this dataset'); + } + return this.entryService.findForDataset(dataset); } @ResolveField(() => String) async signedUrl(@Parent() entry: Entry): Promise { + if (!(await this.enforcer.enforce(entry.dataset, DatasetPermissions.READ, entry.dataset))) { + throw new UnauthorizedException('User cannot read entries on this dataset'); + } + return this.entryService.getSignedUrl(entry); } @@ -32,6 +38,10 @@ export class EntryResolver { // if the request to `signedUrl` is made. @ResolveField(() => Number, { description: 'Get the number of milliseconds the signed URL is valid for.' }) async signedUrlExpiration(@Parent() entry: Entry): Promise { + if (!(await this.enforcer.enforce(entry.dataset, DatasetPermissions.READ, entry.dataset))) { + throw new UnauthorizedException('User cannot read entries on this dataset'); + } + return this.entryService.getSignedUrlExpiration(entry); } } diff --git a/packages/server/src/entry/resolvers/upload-session.resolver.ts b/packages/server/src/entry/resolvers/upload-session.resolver.ts index 3074e908..957e6166 100644 --- a/packages/server/src/entry/resolvers/upload-session.resolver.ts +++ b/packages/server/src/entry/resolvers/upload-session.resolver.ts @@ -1,4 +1,4 @@ -import { Injectable, UseGuards } from '@nestjs/common'; +import { Injectable, UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; import { UploadSession } from '../models/upload-session.model'; import { UploadSessionService } from '../services/upload-session.service'; import { Dataset } from '../../dataset/dataset.model'; @@ -7,32 +7,56 @@ import { UploadSessionPipe } from '../pipes/upload-session.pipe'; import { DatasetPipe } from '../../dataset/pipes/dataset.pipe'; import { UploadResult } from '../dtos/upload-result.dto'; import { JwtAuthGuard } from '../../auth/jwt.guard'; +import { DatasetPermissions } from '../../auth/permissions/dataset'; +import { CASBIN_PROVIDER } from '../../auth/casbin.provider'; +import * as casbin from 'casbin'; +import {UserContext} from 'src/auth/user.decorator'; +import {TokenPayload} from 'src/auth/user.dto'; @UseGuards(JwtAuthGuard) @Injectable() export class UploadSessionResolver { - constructor(private readonly uploadSessionService: UploadSessionService) {} + constructor(private readonly uploadSessionService: UploadSessionService, @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} // TODO: Grab the user from the request @Mutation(() => UploadSession) - async createUploadSession(@Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset): Promise { + async createUploadSession(@Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset, @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.UPDATE, dataset._id))) { + throw new UnauthorizedException('User cannot write entries on this dataset'); + } + return this.uploadSessionService.create(dataset); } // TODO: Add return for any cleanup @Mutation(() => UploadResult) - async completeUploadSession(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession): Promise { + async completeUploadSession(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession, + @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.UPDATE, uploadSession.dataset))) { + throw new UnauthorizedException('User cannot write entries on this dataset'); + } + return this.uploadSessionService.complete(uploadSession); } @Query(() => String, { description: 'Get the presigned URL for where to upload the CSV against' }) - async getCSVUploadURL(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession): Promise { + async getCSVUploadURL(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession, + @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.UPDATE, uploadSession.dataset))) { + throw new UnauthorizedException('User cannot write entries on this dataset'); + } + return this.uploadSessionService.getCSVUploadURL(uploadSession); } @Query(() => UploadResult) - async validateCSV(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession): Promise { + async validateCSV(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession, + @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.UPDATE, uploadSession.dataset))) { + throw new UnauthorizedException('User cannot write entries on this dataset'); + } + return await this.uploadSessionService.validateCSV(uploadSession); } @@ -40,7 +64,13 @@ export class UploadSessionResolver { @Query(() => String) async getEntryUploadURL(@Args('session', { type: () => ID }, UploadSessionPipe) uploadSession: UploadSession, @Args('filename') filename: string, - @Args('contentType') contentType: string): Promise { + @Args('contentType') contentType: string, + @UserContext() user: TokenPayload): Promise { + + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.UPDATE, uploadSession.dataset))) { + throw new UnauthorizedException('User cannot write entries on this dataset'); + } + return this.uploadSessionService.getEntryUploadURL(uploadSession, filename, contentType); } } diff --git a/packages/server/src/organization/organization.context.ts b/packages/server/src/organization/organization.context.ts index 74d2df27..8a9bc72a 100644 --- a/packages/server/src/organization/organization.context.ts +++ b/packages/server/src/organization/organization.context.ts @@ -1,4 +1,4 @@ -import {createParamDecorator, ExecutionContext} from '@nestjs/common'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; // TODO: After users are added in, grab organization from user export const OrganizationContext = createParamDecorator((_data: unknown, _ctx: ExecutionContext) => { diff --git a/packages/server/src/project/project.module.ts b/packages/server/src/project/project.module.ts index b0b06f04..79ad2d25 100644 --- a/packages/server/src/project/project.module.ts +++ b/packages/server/src/project/project.module.ts @@ -6,6 +6,7 @@ import { Project, ProjectSchema } from './project.model'; import { ProjectPipe } from './pipes/project.pipe'; import { MongooseMiddlewareService } from 'src/shared/service/mongoose-callback.service'; import { SharedModule } from 'src/shared/shared.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [ @@ -25,7 +26,8 @@ import { SharedModule } from 'src/shared/shared.module'; imports: [SharedModule], inject: [MongooseMiddlewareService], } - ]) + ]), + AuthModule ], providers: [ProjectResolver, ProjectService, ProjectPipe], exports: [ProjectPipe, ProjectService] diff --git a/packages/server/src/project/project.resolver.ts b/packages/server/src/project/project.resolver.ts index 9f74a1a3..c2d1c9a8 100644 --- a/packages/server/src/project/project.resolver.ts +++ b/packages/server/src/project/project.resolver.ts @@ -1,4 +1,4 @@ -import { BadRequestException, UseGuards } from '@nestjs/common'; +import { BadRequestException, UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; import { Resolver, Mutation, Query, Args, ID } from '@nestjs/graphql'; import { OrganizationContext } from 'src/organization/organization.context'; import { Organization } from 'src/organization/organization.model'; @@ -7,37 +7,55 @@ import { Project } from './project.model'; import { ProjectService } from './project.service'; import { ProjectPipe } from './pipes/project.pipe'; import { JwtAuthGuard } from '../auth/jwt.guard'; - +import { UserContext } from '../auth/user.decorator'; +import { TokenPayload } from '../auth/user.dto'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; +import { ProjectPermissions } from '../auth/permissions/project'; @UseGuards(JwtAuthGuard) @Resolver(() => Project) export class ProjectResolver { - constructor(private readonly projectService: ProjectService) {} + constructor(private readonly projectService: ProjectService, @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} @Mutation(() => Project) - async signLabCreateProject(@Args('project') project: ProjectCreate, @OrganizationContext() organization: Organization): Promise { + async signLabCreateProject(@Args('project') project: ProjectCreate, @OrganizationContext() organization: Organization, @UserContext() user: TokenPayload): Promise { + // Make sure the user is allowed to create projects + if(!(await this.enforcer.enforce(user.id, ProjectPermissions.CREATE, organization._id))) { + throw new UnauthorizedException('User does not have permission to create projects'); + } + // Make sure the project name is unique for the given organization - if (await this.projectExists(project.name, organization)) { + if (await this.projectExists(project.name, organization, user)) { throw new BadRequestException(`Project with name ${project.name} already exists`); } return this.projectService.create(project, organization._id); } @Query(() => Boolean) - async projectExists(@Args('name') name: string, @OrganizationContext() organization: Organization): Promise { + async projectExists(@Args('name') name: string, @OrganizationContext() organization: Organization, @UserContext() _user: TokenPayload): Promise { return this.projectService.exists(name, organization._id); } // TODO: Handle Project deletion @Mutation(() => Boolean) - async deleteProject(@Args('project', { type: () => ID }, ProjectPipe) project: Project): Promise { + async deleteProject(@Args('project', { type: () => ID }, ProjectPipe) project: Project, + @UserContext() user: TokenPayload, + @OrganizationContext() organization: Organization): Promise { + if(!(await this.enforcer.enforce(user.id, ProjectPermissions.DELETE, organization._id))) { + throw new UnauthorizedException('User does not have permission to delete projects'); + } + await this.projectService.delete(project); return true; } // TODO: Handle the ability to get project based on user access @Query(() => [Project]) - async getProjects(@OrganizationContext() organization: Organization): Promise { + async getProjects(@OrganizationContext() organization: Organization, @UserContext() user: TokenPayload): Promise { + if(!(await this.enforcer.enforce(user.id, ProjectPermissions.READ, organization._id))) { + throw new UnauthorizedException('User does not have permission to read projects'); + } return this.projectService.findAll(organization._id); } } diff --git a/packages/server/src/project/project.service.ts b/packages/server/src/project/project.service.ts index d7b7f2cb..5bfcf66f 100644 --- a/packages/server/src/project/project.service.ts +++ b/packages/server/src/project/project.service.ts @@ -1,19 +1,27 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model } from 'mongoose'; import { Project, ProjectDocument } from './project.model'; import { ProjectCreate } from './dtos/create.dto'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; @Injectable() export class ProjectService { - constructor(@InjectModel(Project.name) private projectModel: Model) {} + constructor(@InjectModel(Project.name) private projectModel: Model, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} async create(project: ProjectCreate, organization: string): Promise { - return this.projectModel.create({ + const newProject = await this.projectModel.create({ ...project, organization, created: new Date() }); + + // Make the project - organization relation in the enforcer model + await this.enforcer.addNamedGroupingPolicy('g2', organization, newProject._id.toString()); + + return newProject; } async findById(id: string): Promise { diff --git a/packages/server/src/study/study.module.ts b/packages/server/src/study/study.module.ts index be1b9b29..9dbbbaa3 100644 --- a/packages/server/src/study/study.module.ts +++ b/packages/server/src/study/study.module.ts @@ -8,6 +8,7 @@ import { StudyPipe } from './pipes/study.pipe'; import { StudyCreatePipe } from './pipes/create.pipe'; import { MongooseMiddlewareService } from '../shared/service/mongoose-callback.service'; import { SharedModule } from '../shared/shared.module'; +import { AuthModule } from '../auth/auth.module'; @Module({ imports: [MongooseModule.forFeatureAsync([ @@ -26,7 +27,7 @@ import { SharedModule } from '../shared/shared.module'; imports: [SharedModule], inject: [MongooseMiddlewareService], } - ]), ProjectModule, SharedModule], + ]), ProjectModule, SharedModule, AuthModule], providers: [StudyService, StudyResolver, StudyPipe, StudyCreatePipe], exports: [StudyService, StudyPipe] }) diff --git a/packages/server/src/study/study.resolver.ts b/packages/server/src/study/study.resolver.ts index 7ae0e935..3859df73 100644 --- a/packages/server/src/study/study.resolver.ts +++ b/packages/server/src/study/study.resolver.ts @@ -6,22 +6,34 @@ import { StudyPipe } from './pipes/study.pipe'; import { StudyCreate } from './dtos/create.dto'; import { StudyService } from './study.service'; import { StudyCreatePipe } from './pipes/create.pipe'; -import { UseGuards } from '@nestjs/common'; +import { UseGuards, Inject, UnauthorizedException } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt.guard'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; +import { StudyPermissions } from '../auth/permissions/study'; +import {UserContext} from 'src/auth/user.decorator'; +import {TokenPayload} from 'src/auth/user.dto'; @UseGuards(JwtAuthGuard) @Resolver(() => Study) export class StudyResolver { - constructor(private readonly studyService: StudyService) {} + constructor(private readonly studyService: StudyService, @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) {} @Mutation(() => Study) - async createStudy(@Args('study', { type: () => StudyCreate }, StudyCreatePipe) study: StudyCreate): Promise { - // TODO: Verify the user has access to the given project + async createStudy(@Args('study', { type: () => StudyCreate }, StudyCreatePipe) study: StudyCreate, @UserContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.CREATE, study.project))) { + throw new UnauthorizedException('User cannot create studies on this project'); + } + return this.studyService.create(study); } @Query(() => Boolean) async studyExists(@Args('name') name: string, @Args('project', { type: () => ID }, ProjectPipe) project: Project): Promise { + if (!(await this.enforcer.enforce(name, StudyPermissions.READ, project))) { + throw new UnauthorizedException('User cannot read studies on this project'); + } + return this.studyService.exists(name, project._id); } @@ -33,17 +45,29 @@ export class StudyResolver { @Mutation(() => Boolean) async deleteStudy(@Args('study', { type: () => ID }, StudyPipe) study: Study): Promise { + if (!(await this.enforcer.enforce(study.name, StudyPermissions.DELETE, study._id))) { + throw new UnauthorizedException('User cannot delete studies on this project'); + } + await this.studyService.delete(study); return true; } @Mutation(() => Study) async changeStudyName(@Args('study',{ type: () => ID }, StudyPipe) study: Study, @Args('newName') newName: string): Promise { + if (!(await this.enforcer.enforce(study.name, StudyPermissions.UPDATE, study._id))) { + throw new UnauthorizedException('User cannot update studies on this project'); + } + return this.studyService.changeName(study, newName); } @Mutation(() => Study) async changeStudyDescription(@Args('study', { type: () => ID }, StudyPipe) study: Study, @Args('newDescription') newDescription: string): Promise { + if (!(await this.enforcer.enforce(study.name, StudyPermissions.UPDATE, study._id))) { + throw new UnauthorizedException('User cannot update studies on this project'); + } + return this.studyService.changeDescription(study, newDescription); } } diff --git a/packages/server/src/study/study.service.ts b/packages/server/src/study/study.service.ts index b9f13e41..634d07ee 100644 --- a/packages/server/src/study/study.service.ts +++ b/packages/server/src/study/study.service.ts @@ -1,23 +1,32 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Inject } from '@nestjs/common'; import { Model } from 'mongoose'; import { InjectModel } from '@nestjs/mongoose'; import { Study } from './study.model'; import { StudyCreate } from './dtos/create.dto'; import { Validator } from 'jsonschema'; import { Project } from 'src/project/project.model'; -import { MongooseMiddlewareService } from 'src/shared/service/mongoose-callback.service'; +import { MongooseMiddlewareService } from '../shared/service/mongoose-callback.service'; +import { CASBIN_PROVIDER } from '../auth/casbin.provider'; +import * as casbin from 'casbin'; @Injectable() export class StudyService { - constructor(@InjectModel(Study.name) private readonly studyModel: Model, middlewareService: MongooseMiddlewareService) { + constructor(@InjectModel(Study.name) private readonly studyModel: Model, middlewareService: MongooseMiddlewareService, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer) { // Remove cooresponding studies when a project is deleted middlewareService.register(Project.name, 'deleteOne', async (project: Project) => { + // TODO: Update Casbin policies await this.removeForProject(project); }); } async create(study: StudyCreate): Promise { - return this.studyModel.create(study); + const newStudy = await this.studyModel.create(study); + + // Make the study - project relation in the enforcer model + await this.enforcer.addNamedGroupingPolicy('g2', study.project, newStudy._id.toString()); + + return newStudy; } async findAll(project: Project): Promise { diff --git a/packages/server/src/tag/tag.resolver.ts b/packages/server/src/tag/tag.resolver.ts index 147d459a..d310a9c3 100644 --- a/packages/server/src/tag/tag.resolver.ts +++ b/packages/server/src/tag/tag.resolver.ts @@ -10,7 +10,7 @@ import JSON from 'graphql-type-json'; import { UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt.guard'; - +// TODO: Add permissioning @UseGuards(JwtAuthGuard) @Resolver(() => Tag) export class TagResolver {