diff --git a/packages/medusa-interfaces/.eslintrc b/packages/medusa-interfaces/.eslintrc deleted file mode 100644 index 2a889697f0..0000000000 --- a/packages/medusa-interfaces/.eslintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": ["prettier"], - "extends": ["prettier"], - "rules": { - "prettier/prettier": "error", - "semi": "error", - "no-unused-expressions": "true" - } -} diff --git a/packages/medusa-interfaces/package.json b/packages/medusa-interfaces/package.json index 53ccd9d4fd..5d87cd08fe 100644 --- a/packages/medusa-interfaces/package.json +++ b/packages/medusa-interfaces/package.json @@ -11,8 +11,7 @@ "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" + "watch": "babel -w src --out-dir dist/ --ignore **/__tests__" }, "author": "Sebastian Rindom", "license": "AGPL-3.0-or-later", @@ -22,11 +21,8 @@ "@babel/plugin-proposal-class-properties": "^7.7.4", "@babel/plugin-transform-runtime": "^7.7.6", "@babel/preset-env": "^7.7.5", - "@babel/runtime": "^7.9.6", "cross-env": "^5.2.1", - "eslint": "^6.8.0", - "jest": "^25.5.0", - "prettier": "^1.19.1" + "eslint": "^6.8.0" }, "dependencies": { "mongoose": "^5.8.0" diff --git a/packages/medusa-interfaces/src/__tests__/base-service.js b/packages/medusa-interfaces/src/__tests__/base-service.js deleted file mode 100644 index 76b5055c1f..0000000000 --- a/packages/medusa-interfaces/src/__tests__/base-service.js +++ /dev/null @@ -1,66 +0,0 @@ -import BaseService from "../base-service" - -describe("BaseService", () => { - describe("addDecorator", () => { - const baseService = new BaseService() - - it("successfully adds decorator", () => { - baseService.addDecorator(obj => { - return (obj.decorator1 = true) - }) - - expect(baseService.decorators_.length).toEqual(1) - }) - - it("throws if decorator is not a function", () => { - expect(() => baseService.addDecorator("not a function")).toThrow( - "Decorators must be of type function" - ) - }) - }) - - describe("runDecorators_", () => { - it("returns success when passwords match", async () => { - const baseService = new BaseService() - - baseService.addDecorator(obj => { - obj.decorator1 = true - return obj - }) - baseService.addDecorator(obj => { - obj.decorator2 = true - return obj - }) - - const result = await baseService.runDecorators_({ data: "initial" }) - expect(result).toEqual({ - data: "initial", - decorator1: true, - decorator2: true, - }) - }) - - it("skips failing decorator", async () => { - const baseService = new BaseService() - - baseService.addDecorator(obj => { - obj.decorator1 = true - return obj - }) - baseService.addDecorator(obj => { - return Promise.reject("fail") - }) - baseService.addDecorator(obj => { - obj.decorator3 = true - return Promise.resolve(obj) - }) - - const result = await baseService.runDecorators_({ data: "initial" }) - expect(result).toEqual({ - data: "initial", - decorator1: true, - decorator3: true, - }) - }) - }) -}) diff --git a/packages/medusa-interfaces/src/base-service.js b/packages/medusa-interfaces/src/base-service.js index a6abcc8302..6d497f47dd 100644 --- a/packages/medusa-interfaces/src/base-service.js +++ b/packages/medusa-interfaces/src/base-service.js @@ -2,35 +2,5 @@ * Common functionality for Services * @interface */ -class BaseService { - constructor() { - this.decorators_ = [] - } - - /** - * Adds a decorator to a service. The decorator must be a function and should - * return a decorated object. - * @param {function} fn - the decorator to add to the service - */ - addDecorator(fn) { - if (typeof fn !== "function") { - throw Error("Decorators must be of type function") - } - - this.decorators_.push(fn) - } - - /** - * Runs the decorators registered on the service. The decorators are run in - * the order they have been registered in. Failing decorators will be skipped - * in order to ensure deliverability in spite of breaking code. - * @param {object} obj - the object to decorate. - * @return {object} the decorated object. - */ - runDecorators_(obj) { - return this.decorators_.reduce(async (acc, next) => { - return acc.then(res => next(res)).catch(() => acc) - }, Promise.resolve(obj)) - } -} +class BaseService {} export default BaseService 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-interfaces/.prettierrc b/packages/medusa-plugin-permissions/.prettierrc similarity index 100% rename from packages/medusa-interfaces/.prettierrc rename to packages/medusa-plugin-permissions/.prettierrc 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