[medusa-interfaces] : Adds decorator functionality to BaseService (#39)

Plugins and projects can add decorators in services. E.g. if a plugin needs to load some additional information on carts the plugin can register a decorator via: `cartService.addDecorator(someFunc)` which will be available later through `cartService.runDecorators()`.
This commit is contained in:
Sebastian Rindom
2020-04-30 20:40:22 +02:00
committed by GitHub
parent a53822d298
commit 9c76754e79
15 changed files with 112 additions and 654 deletions

View File

@@ -0,0 +1,9 @@
{
"plugins": ["prettier"],
"extends": ["prettier"],
"rules": {
"prettier/prettier": "error",
"semi": "error",
"no-unused-expressions": "true"
}
}

View File

@@ -11,7 +11,8 @@
"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__"
"watch": "babel -w src --out-dir dist/ --ignore **/__tests__",
"test": "jest"
},
"author": "Sebastian Rindom",
"license": "AGPL-3.0-or-later",
@@ -21,8 +22,11 @@
"@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"
"eslint": "^6.8.0",
"jest": "^25.5.0",
"prettier": "^1.19.1"
},
"dependencies": {
"mongoose": "^5.8.0"

View File

@@ -0,0 +1,66 @@
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,
})
})
})
})

View File

@@ -2,5 +2,35 @@
* Common functionality for Services
* @interface
*/
class BaseService {}
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))
}
}
export default BaseService

View File

@@ -1,9 +0,0 @@
{
"plugins": ["@babel/plugin-proposal-class-properties"],
"presets": ["@babel/preset-env"],
"env": {
"test": {
"plugins": ["@babel/plugin-transform-runtime"]
}
}
}

View File

@@ -1,8 +0,0 @@
dist/
node_modules/
.DS_store
.env*
/*.js
!index.js
yarn.lock
yarn-error.log

View File

@@ -1,35 +0,0 @@
{
"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"
}
}

View File

@@ -1,14 +0,0 @@
// 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)
}
}
}

View File

@@ -1,36 +0,0 @@
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()
}),
}

View File

@@ -1,13 +0,0 @@
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

View File

@@ -1,6 +0,0 @@
import mongoose from "mongoose"
export default new mongoose.Schema({
method: { type: String },
endpoint: { type: String },
})

View File

@@ -1,15 +0,0 @@
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

View File

@@ -1,354 +0,0 @@
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)
})
})
})

View File

@@ -1,161 +0,0 @@
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