From e0cb9c0ee32dfe22f36c648d2d85e505a89d5fdf Mon Sep 17 00:00:00 2001 From: cbolles Date: Wed, 20 Dec 2023 15:59:14 -0500 Subject: [PATCH 1/9] Add in casbin module --- package-lock.json | 205 ++++++++++++++++++- packages/server/package.json | 3 + packages/server/src/auth/auth.module.ts | 25 +++ packages/server/src/config/casbin-model.conf | 15 ++ packages/server/src/config/configuration.ts | 3 + 5 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 packages/server/src/config/casbin-model.conf diff --git a/package-lock.json b/package-lock.json index 70cafbf4..9445f4fd 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", @@ -20689,6 +20786,23 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nest-authz": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/nest-authz/-/nest-authz-2.6.0.tgz", + "integrity": "sha512-lVPbSoR83XkvrJ2jxLQ2TuKZU8ooSKmO6jzHeph2lZFrPUw44efDvq6b/d3kgqt3Ci9hI4xvuM4zeGErxb4aEw==", + "dependencies": { + "casbin": "^5.11.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "peerDependencies": { + "@nestjs/common": "^9.0.3 || ^10.0.0", + "@nestjs/core": "^9.0.3 || ^10.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.5.6" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -26279,10 +26393,13 @@ "@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", "mongoose": "^7.4.3", + "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", @@ -35524,6 +35641,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 +36273,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 +36779,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 +37742,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 +38818,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 +40697,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", @@ -41376,6 +41562,14 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nest-authz": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/nest-authz/-/nest-authz-2.6.0.tgz", + "integrity": "sha512-lVPbSoR83XkvrJ2jxLQ2TuKZU8ooSKmO6jzHeph2lZFrPUw44efDvq6b/d3kgqt3Ci9hI4xvuM4zeGErxb4aEw==", + "requires": { + "casbin": "^5.11.1" + } + }, "no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -43000,10 +43194,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": "*", + "casbin-mongoose-adapter": "*", "csv-parser": "^3.0.0", "eslint": "^8.0.1", "eslint-config-prettier": "^8.3.0", @@ -43012,6 +43208,7 @@ "jest": "28.1.3", "jsonschema": "^1.4.1", "mongoose": "^7.4.3", + "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "prettier": "^2.3.2", "reflect-metadata": "^0.1.13", diff --git a/packages/server/package.json b/packages/server/package.json index 304d0274..10665259 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -34,10 +34,13 @@ "@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", "mongoose": "^7.4.3", + "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index 44a59682..2e21546f 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -6,6 +6,11 @@ import { JwtStrategy } from './jwt.strategy'; import { JwtAuthGuard } from './jwt.guard'; import { OrganizationModule } from '../organization/organization.module'; import { HttpModule } from '@nestjs/axios'; +import { AuthZModule, AUTHZ_ENFORCER } from 'nest-authz'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { ConfigService } from '@nestjs/config'; +import * as casbin from 'casbin'; +import { MongooseAdapter } from 'casbin-mongoose-adapter'; @Module({ imports: [ @@ -24,6 +29,26 @@ import { HttpModule } from '@nestjs/axios'; }; return options; } + }), + AuthZModule.register({ + enforcerProvider: { + provide: AUTHZ_ENFORCER, + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const model = configService.getOrThrow('casbin.model'); + const policy = await MongooseAdapter.newAdapter(configService.getOrThrow('mongo.uri')); + return casbin.newEnforcer(model, policy); + } + }, + usernameFromContext: (context) => { + // Return HTTP context + if (context.getType() === 'http') { + return context.switchToHttp().getRequest(); + } + // Return GraphQL context + const ctx = GqlExecutionContext.create(context); + return ctx.getContext().req; + } }) ], providers: [ 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..10e5b65d 100644 --- a/packages/server/src/config/configuration.ts +++ b/packages/server/src/config/configuration.ts @@ -21,5 +21,8 @@ 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' } }); From e53dec819b3e751f2321586607d254d7392270d2 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 11:26:53 -0500 Subject: [PATCH 2/9] Add custom casbin provider --- packages/server/src/auth/auth.module.ts | 27 ++----------------- packages/server/src/auth/casbin.provider.ts | 17 ++++++++++++ .../server/src/auth/permissions/dataset.ts | 7 +++++ .../server/src/auth/permissions/project.ts | 6 +++++ packages/server/src/auth/permissions/study.ts | 0 packages/server/src/auth/permissions/tag.ts | 6 +++++ 6 files changed, 38 insertions(+), 25 deletions(-) create mode 100644 packages/server/src/auth/casbin.provider.ts create mode 100644 packages/server/src/auth/permissions/dataset.ts create mode 100644 packages/server/src/auth/permissions/project.ts create mode 100644 packages/server/src/auth/permissions/study.ts create mode 100644 packages/server/src/auth/permissions/tag.ts diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index 2e21546f..b954e091 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -6,11 +6,7 @@ import { JwtStrategy } from './jwt.strategy'; import { JwtAuthGuard } from './jwt.guard'; import { OrganizationModule } from '../organization/organization.module'; import { HttpModule } from '@nestjs/axios'; -import { AuthZModule, AUTHZ_ENFORCER } from 'nest-authz'; -import { GqlExecutionContext } from '@nestjs/graphql'; -import { ConfigService } from '@nestjs/config'; -import * as casbin from 'casbin'; -import { MongooseAdapter } from 'casbin-mongoose-adapter'; +import { casbinProvider } from './casbin.provider'; @Module({ imports: [ @@ -30,30 +26,11 @@ import { MongooseAdapter } from 'casbin-mongoose-adapter'; return options; } }), - AuthZModule.register({ - enforcerProvider: { - provide: AUTHZ_ENFORCER, - inject: [ConfigService], - useFactory: async (configService: ConfigService) => { - const model = configService.getOrThrow('casbin.model'); - const policy = await MongooseAdapter.newAdapter(configService.getOrThrow('mongo.uri')); - return casbin.newEnforcer(model, policy); - } - }, - usernameFromContext: (context) => { - // Return HTTP context - if (context.getType() === 'http') { - return context.switchToHttp().getRequest(); - } - // Return GraphQL context - const ctx = GqlExecutionContext.create(context); - return ctx.getContext().req; - } - }) ], providers: [ AuthService, JwtAuthGuard, + casbinProvider, { provide: JwtStrategy, inject: [AuthService], diff --git a/packages/server/src/auth/casbin.provider.ts b/packages/server/src/auth/casbin.provider.ts new file mode 100644 index 00000000..1fb0bf48 --- /dev/null +++ b/packages/server/src/auth/casbin.provider.ts @@ -0,0 +1,17 @@ +import { Provider } from '@nestjs/common'; +import {ConfigService} from '@nestjs/config'; +import * as casbin from 'casbin'; +import { MongooseAdapter } from 'casbin-mongoose-adapter'; + +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')); + + return await casbin.newEnforcer(model, policy); + }, + 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..25587a7e --- /dev/null +++ b/packages/server/src/auth/permissions/dataset.ts @@ -0,0 +1,7 @@ +export enum DatasetPermissions { + CREATE = 'dataset:create', + READ = 'dataset:read', + UPDATE = 'dataset:update', + DELETE = 'dataset:delete', + GRANT_ACCESS = 'dataset:grant_access' +} diff --git a/packages/server/src/auth/permissions/project.ts b/packages/server/src/auth/permissions/project.ts new file mode 100644 index 00000000..45b767e2 --- /dev/null +++ b/packages/server/src/auth/permissions/project.ts @@ -0,0 +1,6 @@ +export enum ProjectPermissions { + CREATE = 'project:create', + READ = 'project:read', + UPDATE = 'project:update', + DELETE = 'project:delete' +} diff --git a/packages/server/src/auth/permissions/study.ts b/packages/server/src/auth/permissions/study.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/src/auth/permissions/tag.ts b/packages/server/src/auth/permissions/tag.ts new file mode 100644 index 00000000..fd2a8f28 --- /dev/null +++ b/packages/server/src/auth/permissions/tag.ts @@ -0,0 +1,6 @@ +export enum TagPermissions { + CREATE = 'tag:create', + READ = 'tag:read', + UPDATE = 'tag:update', + DELETE = 'tag:delete' +} From bb3a2d78dc9d7c914f61b93e38792aa7b842fe4c Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 12:49:52 -0500 Subject: [PATCH 3/9] Add in initial setup of basic group definitions --- package-lock.json | 31 ++----------------- packages/server/package.json | 1 - packages/server/src/auth/auth.service.ts | 1 - packages/server/src/auth/casbin.provider.ts | 16 +++++++++- .../server/src/auth/permissions/project.ts | 18 +++++++++++ packages/server/src/auth/permissions/study.ts | 26 ++++++++++++++++ packages/server/src/auth/permissions/tag.ts | 16 ++++++++++ packages/server/src/auth/roles.ts | 16 ++++++++++ packages/server/src/config/configuration.ts | 5 ++- .../server/src/project/project.resolver.ts | 10 +++--- 10 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/auth/roles.ts diff --git a/package-lock.json b/package-lock.json index 9445f4fd..5799b0e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20786,23 +20786,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "node_modules/nest-authz": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/nest-authz/-/nest-authz-2.6.0.tgz", - "integrity": "sha512-lVPbSoR83XkvrJ2jxLQ2TuKZU8ooSKmO6jzHeph2lZFrPUw44efDvq6b/d3kgqt3Ci9hI4xvuM4zeGErxb4aEw==", - "dependencies": { - "casbin": "^5.11.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "peerDependencies": { - "@nestjs/common": "^9.0.3 || ^10.0.0", - "@nestjs/core": "^9.0.3 || ^10.0.0", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.5.6" - } - }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -26399,7 +26382,6 @@ "graphql-type-json": "^0.3.2", "jsonschema": "^1.4.1", "mongoose": "^7.4.3", - "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", @@ -41562,14 +41544,6 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, - "nest-authz": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/nest-authz/-/nest-authz-2.6.0.tgz", - "integrity": "sha512-lVPbSoR83XkvrJ2jxLQ2TuKZU8ooSKmO6jzHeph2lZFrPUw44efDvq6b/d3kgqt3Ci9hI4xvuM4zeGErxb4aEw==", - "requires": { - "casbin": "^5.11.1" - } - }, "no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -43198,8 +43172,8 @@ "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", - "casbin": "*", - "casbin-mongoose-adapter": "*", + "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", @@ -43208,7 +43182,6 @@ "jest": "28.1.3", "jsonschema": "^1.4.1", "mongoose": "^7.4.3", - "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "prettier": "^2.3.2", "reflect-metadata": "^0.1.13", diff --git a/packages/server/package.json b/packages/server/package.json index 10665259..336ca500 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -40,7 +40,6 @@ "graphql-type-json": "^0.3.2", "jsonschema": "^1.4.1", "mongoose": "^7.4.3", - "nest-authz": "^2.6.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/packages/server/src/auth/auth.service.ts b/packages/server/src/auth/auth.service.ts index 325c3dbb..ee6805ed 100644 --- a/packages/server/src/auth/auth.service.ts +++ b/packages/server/src/auth/auth.service.ts @@ -19,7 +19,6 @@ export class AuthService { } 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 index 1fb0bf48..2cae1b5b 100644 --- a/packages/server/src/auth/casbin.provider.ts +++ b/packages/server/src/auth/casbin.provider.ts @@ -2,6 +2,10 @@ 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'; export const CASBIN_PROVIDER = 'CASBIN_PROVIDER'; @@ -10,8 +14,18 @@ export const casbinProvider: 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); - return await casbin.newEnforcer(model, policy); + // Add all the role mappings + const groups = [ + ...roleHierarchy, + ...roleToStudyPermissions, + ...roleToProjectPermissions, + ...roleToTagPermissions + ]; + await Promise.all(groups.map(group => enforcer.addNamedGroupingPolicy('g', ...group))); + + return enforcer; }, inject: [ConfigService] }; diff --git a/packages/server/src/auth/permissions/project.ts b/packages/server/src/auth/permissions/project.ts index 45b767e2..327f4d6d 100644 --- a/packages/server/src/auth/permissions/project.ts +++ b/packages/server/src/auth/permissions/project.ts @@ -1,6 +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 index e69de29b..abc0d8bf 100644 --- a/packages/server/src/auth/permissions/study.ts +++ 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 index fd2a8f28..c0a4e671 100644 --- a/packages/server/src/auth/permissions/tag.ts +++ b/packages/server/src/auth/permissions/tag.ts @@ -1,6 +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/configuration.ts b/packages/server/src/config/configuration.ts index 10e5b65d..4f7947f6 100644 --- a/packages/server/src/config/configuration.ts +++ b/packages/server/src/config/configuration.ts @@ -23,6 +23,9 @@ export default () => ({ 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' + 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/project/project.resolver.ts b/packages/server/src/project/project.resolver.ts index 9f74a1a3..dc27d242 100644 --- a/packages/server/src/project/project.resolver.ts +++ b/packages/server/src/project/project.resolver.ts @@ -7,6 +7,8 @@ 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'; @UseGuards(JwtAuthGuard) @@ -15,16 +17,16 @@ export class ProjectResolver { constructor(private readonly projectService: ProjectService) {} @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 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); } @@ -37,7 +39,7 @@ export class ProjectResolver { // 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 { return this.projectService.findAll(organization._id); } } From 9e3319acfdd456fb52941b5554ecb475f5eba1c5 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 13:22:41 -0500 Subject: [PATCH 4/9] Add ability to grant owner access --- packages/server/schema.gql | 1 + packages/server/src/auth/auth.module.ts | 4 +++- packages/server/src/auth/auth.resolver.ts | 22 +++++++++++++++++++ packages/server/src/auth/auth.service.ts | 20 +++++++++++++++-- .../src/organization/organization.context.ts | 2 +- packages/server/src/project/project.module.ts | 4 +++- .../server/src/project/project.service.ts | 14 +++++++++--- packages/server/src/study/study.module.ts | 3 ++- packages/server/src/study/study.service.ts | 17 ++++++++++---- 9 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/auth/auth.resolver.ts diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 622582aa..990c0cf6 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -124,6 +124,7 @@ type Mutation { changeDatasetDescription(dataset: ID!, newDescription: String!): Boolean! signLabCreateProject(project: ProjectCreate!): Project! deleteProject(project: ID!): Boolean! + grantOwner(targetUser: ID!): Boolean! createStudy(study: StudyCreate!): Study! deleteStudy(study: ID!): Boolean! changeStudyName(study: ID!, newName: String!): Study! diff --git a/packages/server/src/auth/auth.module.ts b/packages/server/src/auth/auth.module.ts index b954e091..8e0bdd7a 100644 --- a/packages/server/src/auth/auth.module.ts +++ b/packages/server/src/auth/auth.module.ts @@ -7,6 +7,7 @@ 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: [ @@ -31,6 +32,7 @@ import { casbinProvider } from './casbin.provider'; AuthService, JwtAuthGuard, casbinProvider, + AuthResolver, { provide: JwtStrategy, inject: [AuthService], @@ -40,6 +42,6 @@ import { casbinProvider } from './casbin.provider'; } } ], - 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 ee6805ed..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,6 +23,17 @@ 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 { if (this.publicKey === null) { this.publicKey = await this.queryForPublicKey(); 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.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.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 { From 4626bd456add854abbc1818e2637da8385fda339 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 16:43:07 -0500 Subject: [PATCH 5/9] Add authorization checks to project resolver --- .../server/src/project/project.resolver.ts | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/server/src/project/project.resolver.ts b/packages/server/src/project/project.resolver.ts index dc27d242..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'; @@ -9,15 +9,22 @@ 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, @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, user)) { throw new BadRequestException(`Project with name ${project.name} already exists`); @@ -32,14 +39,23 @@ export class ProjectResolver { // 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, @UserContext() _user: TokenPayload): 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); } } From d8b8ab15b2e064ef0446abac8a3820cddbce7a18 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 16:57:46 -0500 Subject: [PATCH 6/9] Add permission checking to dataset and study --- packages/server/schema.gql | 2 +- packages/server/src/auth/casbin.provider.ts | 4 ++- .../server/src/auth/permissions/dataset.ts | 19 +++++++++++ packages/server/src/dataset/dataset.module.ts | 3 +- .../server/src/dataset/dataset.resolver.ts | 28 +++++++++++++--- packages/server/src/study/study.resolver.ts | 32 ++++++++++++++++--- 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 990c0cf6..19e12f9a 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -122,9 +122,9 @@ 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! - grantOwner(targetUser: ID!): Boolean! createStudy(study: StudyCreate!): Study! deleteStudy(study: ID!): Boolean! changeStudyName(study: ID!, newName: String!): Study! diff --git a/packages/server/src/auth/casbin.provider.ts b/packages/server/src/auth/casbin.provider.ts index 2cae1b5b..97c4c0a3 100644 --- a/packages/server/src/auth/casbin.provider.ts +++ b/packages/server/src/auth/casbin.provider.ts @@ -6,6 +6,7 @@ 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'; @@ -21,7 +22,8 @@ export const casbinProvider: Provider = { ...roleHierarchy, ...roleToStudyPermissions, ...roleToProjectPermissions, - ...roleToTagPermissions + ...roleToTagPermissions, + ...roleToDatasetPermissions ]; await Promise.all(groups.map(group => enforcer.addNamedGroupingPolicy('g', ...group))); diff --git a/packages/server/src/auth/permissions/dataset.ts b/packages/server/src/auth/permissions/dataset.ts index 25587a7e..81c0d549 100644 --- a/packages/server/src/auth/permissions/dataset.ts +++ b/packages/server/src/auth/permissions/dataset.ts @@ -1,3 +1,5 @@ +import { Roles } from '../roles'; + export enum DatasetPermissions { CREATE = 'dataset:create', READ = 'dataset:read', @@ -5,3 +7,20 @@ export enum DatasetPermissions { 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/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/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); } } From bd8e99441beefdd0d2b64c88cfcc34fcd6082e09 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 17:03:09 -0500 Subject: [PATCH 7/9] Add enforcer to entry --- packages/server/schema.gql | 8 ----- .../src/entry/resolvers/entry.resolver.ts | 32 ++++++++++++------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/server/schema.gql b/packages/server/schema.gql index 19e12f9a..0c4791c4 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -129,7 +129,6 @@ type Mutation { 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!]! @@ -163,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/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); } } From bb322fa19b3a6f99307e4e0004a56f009a631661 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 17:18:31 -0500 Subject: [PATCH 8/9] Fix dataset permissions --- .../server/src/dataset/dataset.service.ts | 14 ++++-- packages/server/src/entry/entry.module.ts | 4 +- .../resolvers/upload-session.resolver.ts | 44 ++++++++++++++++--- 3 files changed, 50 insertions(+), 12 deletions(-) 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/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); } } From 4489bd36ae10d6137c2c428083a9a6ca1fc15172 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 2 Jan 2024 17:19:13 -0500 Subject: [PATCH 9/9] Identify need for permissioning on tag --- packages/server/src/tag/tag.resolver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 {