From f6d5180e5f29af271226de5a5c4aaf3bbe0957bf Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 6 Apr 2020 15:57:31 +0200 Subject: [PATCH] Adds medusa-plugin-permissions (#12) --- packages/medusa-plugin-permissions/.babelrc | 9 + packages/medusa-plugin-permissions/.gitignore | 8 + .../medusa-plugin-permissions/.prettierrc | 7 + .../medusa-plugin-permissions/package.json | 35 ++ .../src/api/middlewares.js | 14 + .../src/models/__mocks__/role.js | 36 ++ .../src/models/role.js | 13 + .../src/models/schemas/permission.js | 6 + .../src/services/__mocks__/permission.js | 15 + .../src/services/__tests__/permission.js | 354 ++++++++++++++++++ .../src/services/permission.js | 161 ++++++++ packages/medusa/.prettierrc | 3 +- packages/medusa/src/models/__mocks__/user.js | 8 + 13 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 packages/medusa-plugin-permissions/.babelrc create mode 100644 packages/medusa-plugin-permissions/.gitignore create mode 100644 packages/medusa-plugin-permissions/.prettierrc create mode 100644 packages/medusa-plugin-permissions/package.json create mode 100644 packages/medusa-plugin-permissions/src/api/middlewares.js create mode 100644 packages/medusa-plugin-permissions/src/models/__mocks__/role.js create mode 100644 packages/medusa-plugin-permissions/src/models/role.js create mode 100644 packages/medusa-plugin-permissions/src/models/schemas/permission.js create mode 100644 packages/medusa-plugin-permissions/src/services/__mocks__/permission.js create mode 100644 packages/medusa-plugin-permissions/src/services/__tests__/permission.js create mode 100644 packages/medusa-plugin-permissions/src/services/permission.js diff --git a/packages/medusa-plugin-permissions/.babelrc b/packages/medusa-plugin-permissions/.babelrc new file mode 100644 index 0000000000..b48db12268 --- /dev/null +++ b/packages/medusa-plugin-permissions/.babelrc @@ -0,0 +1,9 @@ +{ + "plugins": ["@babel/plugin-proposal-class-properties"], + "presets": ["@babel/preset-env"], + "env": { + "test": { + "plugins": ["@babel/plugin-transform-runtime"] + } + } +} diff --git a/packages/medusa-plugin-permissions/.gitignore b/packages/medusa-plugin-permissions/.gitignore new file mode 100644 index 0000000000..c93fa14dda --- /dev/null +++ b/packages/medusa-plugin-permissions/.gitignore @@ -0,0 +1,8 @@ +dist/ +node_modules/ +.DS_store +.env* +/*.js +!index.js +yarn.lock +yarn-error.log diff --git a/packages/medusa-plugin-permissions/.prettierrc b/packages/medusa-plugin-permissions/.prettierrc new file mode 100644 index 0000000000..70175ce150 --- /dev/null +++ b/packages/medusa-plugin-permissions/.prettierrc @@ -0,0 +1,7 @@ +{ + "endOfLine": "lf", + "semi": false, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/packages/medusa-plugin-permissions/package.json b/packages/medusa-plugin-permissions/package.json new file mode 100644 index 0000000000..bcd80dbf19 --- /dev/null +++ b/packages/medusa-plugin-permissions/package.json @@ -0,0 +1,35 @@ +{ + "name": "medusa-plugin-permissions", + "version": "1.0.0", + "description": "Role permission for Medusa core", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/medusa-plugin-permissions" + }, + "scripts": { + "build": "babel src --out-dir dist/ --ignore **/__tests__", + "prepare": "cross-env NODE_ENV=production npm run build", + "watch": "babel -w src --out-dir dist/ --ignore **/__tests__", + "test": "jest" + }, + "author": "Oliver Juhl", + "license": "AGPL-3.0-or-later", + "devDependencies": { + "@babel/cli": "^7.7.5", + "@babel/core": "^7.7.5", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-transform-runtime": "^7.7.6", + "@babel/preset-env": "^7.7.5", + "@babel/runtime": "^7.7.6", + "cross-env": "^5.2.1", + "jest": "^24.9.0" + }, + "dependencies": { + "mongoose": "^5.8.0", + "medusa-test-utils": "^1.0.0", + "medusa-core-utils": "^1.0.0", + "medusa-interfaces": "^1.0.0" + } +} \ No newline at end of file diff --git a/packages/medusa-plugin-permissions/src/api/middlewares.js b/packages/medusa-plugin-permissions/src/api/middlewares.js new file mode 100644 index 0000000000..1835186c67 --- /dev/null +++ b/packages/medusa-plugin-permissions/src/api/middlewares.js @@ -0,0 +1,14 @@ +// This middleware is injected to ensure authorization of requests +// Since this middleware uses the user object on the request, this should be +// injected after authentication in the core middleware, hence we name +// the middleware postAuth. +export default postAuth = () => { + return (err, req, res, next) => { + const permissionService = req.scope.resolve("permissionService") + if (permissionService.hasPermission(req.user, req.method, req.path)) { + next() + } else { + res.status(422) + } + } +} diff --git a/packages/medusa-plugin-permissions/src/models/__mocks__/role.js b/packages/medusa-plugin-permissions/src/models/__mocks__/role.js new file mode 100644 index 0000000000..d840f21080 --- /dev/null +++ b/packages/medusa-plugin-permissions/src/models/__mocks__/role.js @@ -0,0 +1,36 @@ +import { IdMap } from "medusa-test-utils" + +export const permissions = { + productEditorPermission: { + _id: IdMap.getId("product_editor"), + name: "product_editor", + permissions: [ + { + method: "POST", + endpoint: "/products", + }, + { + method: "GET", + endpoint: "/products", + }, + { + method: "PUT", + endpoint: "/products", + }, + ], + }, +} + +export const RoleModelMock = { + create: jest.fn().mockReturnValue(Promise.resolve()), + deleteOne: jest.fn().mockReturnValue(Promise.resolve()), + findOne: jest.fn().mockImplementation(query => { + if (query.name === "product_editor") { + return Promise.resolve(permissions.productEditorPermission) + } + return Promise.resolve(undefined) + }), + updateOne: jest.fn().mockImplementation((query, update) => { + return Promise.resolve() + }), +} diff --git a/packages/medusa-plugin-permissions/src/models/role.js b/packages/medusa-plugin-permissions/src/models/role.js new file mode 100644 index 0000000000..0ab1148fda --- /dev/null +++ b/packages/medusa-plugin-permissions/src/models/role.js @@ -0,0 +1,13 @@ +import { BaseModel } from "medusa-interfaces" + +import PermissionSchema from "./schemas/permission" + +class RoleModel extends BaseModel { + static modelName = "Role" + static schema = { + name: { type: String, required: true, unique: true }, + permissions: { type: [PermissionSchema], required: true, default: [] }, + } +} + +export default RoleModel diff --git a/packages/medusa-plugin-permissions/src/models/schemas/permission.js b/packages/medusa-plugin-permissions/src/models/schemas/permission.js new file mode 100644 index 0000000000..48da1d9ebc --- /dev/null +++ b/packages/medusa-plugin-permissions/src/models/schemas/permission.js @@ -0,0 +1,6 @@ +import mongoose from "mongoose" + +export default new mongoose.Schema({ + method: { type: String }, + endpoint: { type: String }, +}) diff --git a/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js b/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js new file mode 100644 index 0000000000..aff1a3e94b --- /dev/null +++ b/packages/medusa-plugin-permissions/src/services/__mocks__/permission.js @@ -0,0 +1,15 @@ +import { IdMap } from "medusa-test-utils" + +export const PermissionServiceMock = { + hasPermission: jest.fn().mockImplementation((user, method, endpoint) => { + if (user._id === IdMap.getId("test-user")) { + return Promise.resolve(true) + } + }), +} + +const mock = jest.fn().mockImplementation(() => { + return PermissionServiceMock +}) + +export default mock diff --git a/packages/medusa-plugin-permissions/src/services/__tests__/permission.js b/packages/medusa-plugin-permissions/src/services/__tests__/permission.js new file mode 100644 index 0000000000..b663d8c22f --- /dev/null +++ b/packages/medusa-plugin-permissions/src/services/__tests__/permission.js @@ -0,0 +1,354 @@ +import mongoose from "mongoose" +import { IdMap } from "medusa-test-utils" +import PermissionService from "../permission" +import { permissions, RoleModelMock } from "../../models/__mocks__/role" + +describe("PermissionService", () => { + describe("hasPermission", () => { + let result + let user = { + _id: IdMap.getId("test-user"), + email: "oliver@medusa.test", + passwordHash: "123456789", + metadata: { + roles: ["product_editor"], + }, + } + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + result = await permissionService.hasPermission(user, "POST", "/products") + }) + + it("calls permission model functions", () => { + expect(RoleModelMock.findOne).toHaveBeenCalledTimes( + user.metadata.roles.length + ) + }) + + it("successfully grants access to user", () => { + expect(result).toEqual(true) + }) + + it("succesfully denies access to user", async () => { + const accessDenied = await permissionService.hasPermission( + user, + "CREATE", + "/orders" + ) + expect(accessDenied).toEqual(false) + }) + }) + + describe("retrieveRole", () => { + let result + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + }) + beforeAll(async () => { + jest.clearAllMocks() + + result = await permissionService.retrieveRole("product_editor") + }) + + it("calls permission model functions", () => { + expect(RoleModelMock.findOne).toHaveBeenCalledTimes(1) + }) + + it("successfully fetches product editor permissions", () => { + expect(result).toEqual(permissions.productEditorPermission) + }) + + it("throws if role with name does not exist", async () => { + try { + await permissionService.retrieveRole("product_editor") + } catch (error) { + expect(error.message).toEqual( + "test_editor does not exist. Use method createRole to create it." + ) + } + }) + }) + + describe("createRole", () => { + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + + const contentEditorPermissions = [ + { + method: "POST", + endpoint: "/contents", + }, + { + method: "GET", + endpoint: "/contents", + }, + { + method: "PUT", + endpoint: "/contents", + }, + ] + + await permissionService.createRole( + "content_editor", + contentEditorPermissions + ) + }) + + it("calls permission model functions", () => { + expect(RoleModelMock.create).toHaveBeenCalledTimes(1) + expect(RoleModelMock.create).toHaveBeenCalledWith({ + name: "content_editor", + permissions: [ + { + method: "POST", + endpoint: "/contents", + }, + { + method: "GET", + endpoint: "/contents", + }, + { + method: "PUT", + endpoint: "/contents", + }, + ], + }) + }) + + it("throws if any permission is invalid", async () => { + try { + await permissionService.createRole("content_editor", [ + { + method: "POST", + endpoint: "/products", + }, + { + // Should fail since this is not a valid http request + method: "FETCH", + endpoint: "/products", + }, + ]) + } catch (err) { + expect(err.message).toEqual("Permission is not valid") + } + }) + + it("throws if role with name already exists", async () => { + try { + await permissionService.createRole("product_editor", [ + { + method: "POST", + endpoint: "/order", + }, + ]) + } catch (err) { + expect(err.message).toEqual("product_editor already exists") + } + }) + }) + + describe("grantRole", () => { + const setMetadataMock = jest.fn().mockReturnValue(Promise.resolve()) + const userRetrieveMock = jest.fn().mockImplementation((data) => { + if (data === IdMap.getId("permission-user")) { + return Promise.resolve({ + _id: IdMap.getId("permission-user"), + email: "oliver@test.dk", + metadata: { + roles: ["content_editor"], + }, + }) + } + if (data === IdMap.getId("user-without-roles")) { + return Promise.resolve({ + _id: IdMap.getId("user-without-roles"), + email: "oliver@test.dk", + metadata: {}, + }) + } + return Promise.resolve(undefined) + }) + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + userService: { + setMetadata: setMetadataMock, + retrieve: userRetrieveMock, + }, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully grants role to user", async () => { + await permissionService.grantRole( + IdMap.getId("permission-user"), + "product_editor" + ) + + expect(setMetadataMock).toHaveBeenCalledTimes(1) + expect(setMetadataMock).toHaveBeenCalledWith( + IdMap.getId("permission-user"), + "roles", + ["content_editor", "product_editor"] + ) + }) + + it("sets user metadata.roles to user that does not have metadata.roles", async () => { + await permissionService.grantRole( + IdMap.getId("user-without-roles"), + "product_editor" + ) + + expect(setMetadataMock).toHaveBeenCalledTimes(1) + expect(setMetadataMock).toHaveBeenCalledWith( + IdMap.getId("user-without-roles"), + "roles", + ["product_editor"] + ) + }) + + it("throws if user already has role", async () => { + try { + await permissionService.grantRole( + IdMap.getId("permission-user"), + "product_editor" + ) + } catch (err) { + expect(err.message).toEqual("User already has role: product_editor") + } + }) + }) + + describe("addPermission", () => { + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("successfully adds permission", async () => { + const toAdd = { + method: "POST", + endpoint: "/products", + } + await permissionService.addPermission("product_editor", toAdd) + + expect(RoleModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RoleModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("product_editor") }, + { + $push: { permissions: { method: "POST", endpoint: "/products" } }, + } + ) + }) + + it("throws if permission is not valid", async () => { + const toAdd = { + method: "POST", + endpoint: 1234, + } + + try { + await permissionService.addPermission("product_editor", toAdd) + } catch (err) { + expect(err.message).toEqual("Permission is not valid") + } + }) + }) + + describe("removePermission", () => { + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + }) + + beforeAll(async () => { + jest.clearAllMocks() + }) + + it("successfully removes permission", async () => { + const toRemove = { + method: "POST", + endpoint: "/products", + } + await permissionService.removePermission("product_editor", toRemove) + + expect(RoleModelMock.updateOne).toHaveBeenCalledTimes(1) + expect(RoleModelMock.updateOne).toHaveBeenCalledWith( + { _id: IdMap.getId("product_editor") }, + { + $pull: { permissions: { method: "POST", endpoint: "/products" } }, + } + ) + }) + + it("throws if permission is not valid", async () => { + const update = { + method: "FETCH", + endpoint: "/cart", + } + + try { + await permissionService.addPermission("product_editor", update) + } catch (err) { + expect(err.message).toEqual("Permission is not valid") + } + }) + }) + + describe("revokeRole", () => { + const setMetadataMock = jest.fn().mockReturnValue(Promise.resolve()) + const userRetrieveMock = jest.fn().mockReturnValue( + Promise.resolve({ + _id: IdMap.getId("product_editor"), + email: "oliver@test.dk", + metadata: { + roles: ["product_editor"], + }, + }) + ) + const permissionService = new PermissionService({ + roleModel: RoleModelMock, + userService: { + setMetadata: setMetadataMock, + retrieve: userRetrieveMock, + }, + }) + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully revokes a role from user", async () => { + await permissionService.revokeRole( + IdMap.getId("product_editor"), + "product_editor" + ) + + expect(setMetadataMock).toHaveBeenCalledTimes(1) + expect(setMetadataMock).toHaveBeenCalledWith( + IdMap.getId("product_editor"), + "roles", + [] + ) + }) + + it("succeeds idempotently if user does not have the role to delete", async () => { + await permissionService.revokeRole( + IdMap.getId("product_editor"), + "content_editor" + ) + + expect(setMetadataMock).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/packages/medusa-plugin-permissions/src/services/permission.js b/packages/medusa-plugin-permissions/src/services/permission.js new file mode 100644 index 0000000000..ca02e65b90 --- /dev/null +++ b/packages/medusa-plugin-permissions/src/services/permission.js @@ -0,0 +1,161 @@ +import { BaseService } from "medusa-interfaces" +import { Validator, MedusaError } from "medusa-core-utils" + +class PermissionService extends BaseService { + constructor({ userService, roleModel }) { + super() + + /** @private @const {UserService} */ + this.userService_ = userService + + /** @private @const {RoleModel} */ + this.roleModel_ = roleModel + } + + validatePermission_(permission) { + const schema = Validator.object({ + method: Validator.string().valid( + "POST", + "GET", + "PUT", + "PATCH", + "DELETE", + "CONNECT", + "OPTIONS", + "HEAD", + "TRACE" + ), + endpoint: Validator.string(), + }) + + const { value, error } = schema.validate(permission) + + if (error) { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Permission is not valid" + ) + } + + return value + } + + async retrieveRole(name) { + const role = await this.roleModel_.findOne({ name }).catch((err) => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + + if (!role) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `${name} does not exist. Use method createRole to create it.` + ) + } + return role + } + + async hasPermission(user, method, endpoint) { + for (let i = 0; i < user.metadata.roles.length; i++) { + const role = user.metadata.roles[i] + const permissions = await this.retrieveRole(role) + return permissions.permissions.some((action) => { + return action.method === method && action.endpoint === endpoint + }) + } + return false + } + + async createRole(roleName, permissions) { + const validatedPermissions = permissions.map((permission) => + this.validatePermission_(permission) + ) + + return this.retrieveRole(roleName) + .then((role) => { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + `${role.name} already exists` + ) + }) + .catch((error) => { + if (error.name === MedusaError.Types.NOT_FOUND) { + return this.roleModel_.create({ + name: roleName, + permissions: validatedPermissions, + }) + } else { + throw error + } + }) + } + + async deleteRole(roleName) { + const role = await this.retrieve(roleName) + // Delete is idempotent, but we return a promise to allow then-chaining + if (!role) { + return Promise.resolve() + } + + return this.roleModel_ + .deleteOne({ + _id: role._id, + }) + .catch((err) => { + throw new MedusaError(MedusaError.Types.DB_ERROR, err.message) + }) + } + + async addPermission(roleName, permission) { + const role = await this.retrieveRole(roleName) + const validatedPermission = this.validatePermission_(permission) + + return this.roleModel_.updateOne( + { _id: role._id }, + { $push: { permissions: validatedPermission } } + ) + } + + async removePermission(roleName, permission) { + const role = await this.retrieveRole(roleName) + const validatedPermission = this.validatePermission_(permission) + + return this.roleModel_.updateOne( + { _id: role._id }, + { $pull: { permissions: validatedPermission } } + ) + } + + async grantRole(userId, roleName) { + const role = await this.retrieveRole(roleName) + const user = await this.userService_.retrieve(userId) + + if (!user.metadata.roles) { + return this.userService_.setMetadata(userId, "roles", [roleName]) + } + + if (user.metadata.roles.includes(role.name)) { + throw new MedusaError( + MedusaError.Types.DB_ERROR, + `User already has role: ${role.name}` + ) + } + + user.metadata.roles.push(roleName) + return this.userService_.setMetadata(userId, "roles", user.metadata.roles) + } + + async revokeRole(userId, roleName) { + const user = await this.userService_.retrieve(userId) + + if (!user.metadata.roles || !user.metadata.roles.includes(roleName)) { + // revokeRole is idempotent, we return a promise to allow then-chaining + return Promise.resolve() + } + // remove role from metadata.roles + const newRoles = user.metadata.roles.filter((r) => r !== roleName) + + return this.userService_.setMetadata(userId, "roles", newRoles) + } +} + +export default PermissionService diff --git a/packages/medusa/.prettierrc b/packages/medusa/.prettierrc index 284e842b02..70175ce150 100644 --- a/packages/medusa/.prettierrc +++ b/packages/medusa/.prettierrc @@ -4,5 +4,4 @@ "singleQuote": false, "tabWidth": 2, "trailingComma": "es5" -} - +} \ No newline at end of file diff --git a/packages/medusa/src/models/__mocks__/user.js b/packages/medusa/src/models/__mocks__/user.js index 4704bc0ae9..826aa6e6d4 100644 --- a/packages/medusa/src/models/__mocks__/user.js +++ b/packages/medusa/src/models/__mocks__/user.js @@ -6,6 +6,11 @@ export const users = { email: "oliver@medusa.test", passwordHash: "123456789", }, + permissionUser: { + _id: IdMap.getId("permissions-user"), + email: "oliver@medusa.com", + passwordHash: "123456789", + }, } export const UserModelMock = { @@ -18,6 +23,9 @@ export const UserModelMock = { if (query._id === IdMap.getId("test-user")) { return Promise.resolve(users.testUser) } + if (query._id === IdMap.getId("permission-user")) { + return Promise.resolve(users.permissionUser) + } return Promise.resolve(undefined) }), }