diff --git a/packages/server/src/middleware/authenticated.js b/packages/server/src/middleware/authenticated.js index 659baa8f6c..32ed3f63d0 100644 --- a/packages/server/src/middleware/authenticated.js +++ b/packages/server/src/middleware/authenticated.js @@ -31,6 +31,7 @@ module.exports = async (ctx, next) => { token = ctx.cookies.get(getCookieName()) authType = AuthTypes.BUILDER } + if (!token && appId) { token = ctx.cookies.get(getCookieName(appId)) authType = AuthTypes.APP @@ -58,6 +59,7 @@ module.exports = async (ctx, next) => { role: await getRole(appId, jwtPayload.roleId), } } catch (err) { + console.log(err) if (authType === AuthTypes.BUILDER) { clearCookie(ctx) ctx.status = 200 diff --git a/packages/server/src/middleware/authorized.js b/packages/server/src/middleware/authorized.js index 1f8b687ba8..2a1caef2a2 100644 --- a/packages/server/src/middleware/authorized.js +++ b/packages/server/src/middleware/authorized.js @@ -24,6 +24,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) { return next() } + if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) { // api key header passed by external webhook if (await isAPIKeyValid(ctx.headers["x-api-key"])) { @@ -37,14 +38,14 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { return next() } - ctx.throw(403, "API key invalid") + return ctx.throw(403, "API key invalid") } // don't expose builder endpoints in the cloud if (env.CLOUD && permType === PermissionTypes.BUILDER) return if (!ctx.user) { - ctx.throw(403, "No user info found") + return ctx.throw(403, "No user info found") } const role = ctx.user.role @@ -52,7 +53,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { ctx.appId, role._id ) - const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1 + const isAdmin = ADMIN_ROLES.includes(role._id) const isAuthed = ctx.auth.authenticated // this may need to change in the future, right now only admins @@ -61,7 +62,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => { if (isAdmin && isAuthed) { return next() } else if (permType === PermissionTypes.BUILDER) { - ctx.throw(403, "Not Authorized") + return ctx.throw(403, "Not Authorized") } if ( diff --git a/packages/server/src/middleware/resourceId.js b/packages/server/src/middleware/resourceId.js index 4351901dad..4216131119 100644 --- a/packages/server/src/middleware/resourceId.js +++ b/packages/server/src/middleware/resourceId.js @@ -36,6 +36,8 @@ class ResourceIdGetter { } } +module.exports.ResourceIdGetter = ResourceIdGetter + module.exports.paramResource = main => { return new ResourceIdGetter("params").mainResource(main).build() } diff --git a/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap b/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap new file mode 100644 index 0000000000..1583ecb51f --- /dev/null +++ b/packages/server/src/middleware/tests/__snapshots__/authenticated.spec.js.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Authenticated middleware sets the correct APP auth type information when the user is not in the builder 1`] = ` +Object { + "apiKey": "1234", + "appId": "budibase:app:local", + "role": Role { + "_id": "ADMIN", + "inherits": "POWER", + "name": "Admin", + "permissionId": "admin", + }, + "roleId": "ADMIN", +} +`; + +exports[`Authenticated middleware sets the correct BUILDER auth type information when the x-budibase-type header is not 'client' 1`] = ` +Object { + "apiKey": "1234", + "appId": "budibase:builder:local", + "role": Role { + "_id": "BUILDER", + "name": "Builder", + "permissionId": "admin", + }, + "roleId": "BUILDER", +} +`; diff --git a/packages/server/src/middleware/tests/authenticated.spec.js b/packages/server/src/middleware/tests/authenticated.spec.js new file mode 100644 index 0000000000..bb124d2f4a --- /dev/null +++ b/packages/server/src/middleware/tests/authenticated.spec.js @@ -0,0 +1,126 @@ +const { AuthTypes } = require("../../constants") +const authenticatedMiddleware = require("../authenticated") +const jwt = require("jsonwebtoken") +jest.mock("jsonwebtoken") + +class TestConfiguration { + constructor(middleware) { + this.middleware = authenticatedMiddleware + this.ctx = { + config: {}, + auth: {}, + request: {}, + cookies: { + set: jest.fn(), + get: jest.fn() + }, + headers: {}, + params: {}, + path: "", + request: { + headers: {} + }, + throw: jest.fn() + } + this.next = jest.fn() + } + + setHeaders(headers) { + this.ctx.headers = headers + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + afterEach() { + jest.resetAllMocks() + } +} + +describe("Authenticated middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + afterEach(() => { + config.afterEach() + }) + + it("calls next() when on the builder path", async () => { + config.ctx.path = "/_builder" + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + }) + + it("sets a new cookie when the current cookie does not match the app id from context", async () => { + const appId = "app_123" + config.setHeaders({ + "x-budibase-app-id": appId + }) + config.ctx.cookies.get.mockImplementation(() => "cookieAppId") + + await config.executeMiddleware() + + expect(config.ctx.cookies.set).toHaveBeenCalledWith( + "budibase:currentapp:local", + appId, + expect.any(Object) + ) + + }) + + it("sets the correct BUILDER auth type information when the x-budibase-type header is not 'client'", async () => { + config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local") + jwt.verify.mockImplementationOnce(() => ({ + apiKey: "1234", + roleId: "BUILDER" + })) + + await config.executeMiddleware() + + expect(config.ctx.auth.authenticated).toEqual(AuthTypes.BUILDER) + expect(config.ctx.user).toMatchSnapshot() + }) + + it("sets the correct APP auth type information when the user is not in the builder", async () => { + config.setHeaders({ + "x-budibase-type": "client" + }) + config.ctx.cookies.get.mockImplementation(() => `budibase:app:local`) + jwt.verify.mockImplementationOnce(() => ({ + apiKey: "1234", + roleId: "ADMIN" + })) + + await config.executeMiddleware() + + expect(config.ctx.auth.authenticated).toEqual(AuthTypes.APP) + expect(config.ctx.user).toMatchSnapshot() + }) + + it("marks the user as unauthenticated when a token cannot be determined from the users cookie", async () => { + config.executeMiddleware() + expect(config.ctx.auth.authenticated).toBe(false) + expect(config.ctx.user.role).toEqual({ + _id: "PUBLIC", + name: "Public", + permissionId: "public" + }) + }) + + it("clears the cookie when there is an error authenticating in the builder", async () => { + config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local") + jwt.verify.mockImplementationOnce(() => { + throw new Error() + }) + + await config.executeMiddleware() + + expect(config.ctx.cookies.set).toBeCalledWith("budibase:builder:local") + }) +}) \ No newline at end of file diff --git a/packages/server/src/middleware/tests/authorized.spec.js b/packages/server/src/middleware/tests/authorized.spec.js new file mode 100644 index 0000000000..d3e5e52d2d --- /dev/null +++ b/packages/server/src/middleware/tests/authorized.spec.js @@ -0,0 +1,196 @@ +const authorizedMiddleware = require("../authorized") +const env = require("../../environment") +const apiKey = require("../../utilities/security/apikey") +const { AuthTypes } = require("../../constants") +const { PermissionTypes, PermissionLevels } = require("../../utilities/security/permissions") +const { Test } = require("supertest") +jest.mock("../../environment") +jest.mock("../../utilities/security/apikey") + +class TestConfiguration { + constructor(role) { + this.middleware = authorizedMiddleware(role) + this.next = jest.fn() + this.throw = jest.fn() + this.ctx = { + headers: {}, + request: { + url: "" + }, + auth: {}, + next: this.next, + throw: this.throw + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + setUser(user) { + this.ctx.user = user + } + + setMiddlewareRequiredPermission(...perms) { + this.middleware = authorizedMiddleware(...perms) + } + + setResourceId(id) { + this.ctx.resourceId = id + } + + setAuthenticated(isAuthed) { + this.ctx.auth = { authenticated: isAuthed } + } + + setRequestUrl(url) { + this.ctx.request.url = url + } + + setCloudEnv(isCloud) { + env.CLOUD = isCloud + } + + setRequestHeaders(headers) { + this.ctx.headers = headers + } + + afterEach() { + jest.clearAllMocks() + } +} + + +describe("Authorization middleware", () => { + const next = jest.fn() + let config + + afterEach(() => { + config.afterEach() + }) + + beforeEach(() => { + config = new TestConfiguration() + }) + + it("passes the middleware for local webhooks", async () => { + config.setRequestUrl("https://something/webhooks/trigger") + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + describe("external web hook call", () => { + let ctx = {} + let middleware + + beforeEach(() => { + config = new TestConfiguration() + config.setCloudEnv(true) + config.setRequestHeaders({ + "x-api-key": "abc123", + "x-instanceid": "instance123", + }) + }) + + it("passes to next() if api key is valid", async () => { + apiKey.isAPIKeyValid.mockResolvedValueOnce(true) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.auth).toEqual({ + authenticated: AuthTypes.EXTERNAL, + apiKey: config.ctx.headers["x-api-key"], + }) + expect(config.ctx.user).toEqual({ + appId: config.ctx.headers["x-instanceid"], + }) + }) + + it("throws if api key is invalid", async () => { + apiKey.isAPIKeyValid.mockResolvedValueOnce(false) + + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "API key invalid") + }) + }) + + describe("non-webhook call", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + config.setCloudEnv(true) + config.setAuthenticated(true) + }) + + it("throws when no user data is present in context", async () => { + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "No user info found") + }) + + it("passes on to next() middleware if user is an admin", async () => { + config.setUser({ + role: { + _id: "ADMIN", + } + }) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user has only builder permissions", async () => { + config.setCloudEnv(false) + config.setMiddlewareRequiredPermission(PermissionTypes.BUILDER) + config.setUser({ + role: { + _id: "" + } + }) + await config.executeMiddleware() + + expect(config.throw).toHaveBeenCalledWith(403, "Not Authorized") + }) + + it("passes on to next() middleware if the user has resource permission", async () => { + config.setResourceId(PermissionTypes.QUERY) + config.setUser({ + role: { + _id: "" + } + }) + config.setMiddlewareRequiredPermission(PermissionTypes.QUERY) + + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("throws if the user session is not authenticated after permission checks", async () => { + config.setUser({ + role: { + _id: "" + }, + }) + config.setAuthenticated(false) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(403, "Session not authenticated") + }) + + it("throws if the user does not have base permissions to perform the operation", async () => { + config.setUser({ + role: { + _id: "" + }, + }) + config.setMiddlewareRequiredPermission(PermissionTypes.ADMIN, PermissionLevels.BASIC) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission") + }) + }) +}) diff --git a/packages/server/src/middleware/tests/resourceId.spec.js b/packages/server/src/middleware/tests/resourceId.spec.js new file mode 100644 index 0000000000..35e6e5af50 --- /dev/null +++ b/packages/server/src/middleware/tests/resourceId.spec.js @@ -0,0 +1,105 @@ +const { + paramResource, + paramSubResource, + bodyResource, + bodySubResource, + ResourceIdGetter +} = require("../resourceId") + +class TestConfiguration { + constructor(middleware) { + this.middleware = middleware + this.ctx = { + request: {}, + } + this.next = jest.fn() + } + + setParams(params) { + this.ctx.params = params + } + + setBody(body) { + this.ctx.body = body + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } +} + +describe("resourceId middleware", () => { + it("calls next() when there is no request object to parse", () => { + const config = new TestConfiguration(paramResource("main")) + + config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.resourceId).toBeUndefined() + }) + + it("generates a resourceId middleware for context query parameters", () => { + const config = new TestConfiguration(paramResource("main")) + config.setParams({ + main: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context query sub parameters", () => { + const config = new TestConfiguration(paramSubResource("main", "sub")) + config.setParams({ + main: "main", + sub: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("main") + expect(config.ctx.subResourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context request body", () => { + const config = new TestConfiguration(bodyResource("main")) + config.setBody({ + main: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + }) + + it("generates a resourceId middleware for context request body sub fields", () => { + const config = new TestConfiguration(bodySubResource("main", "sub")) + config.setBody({ + main: "main", + sub: "test" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("main") + expect(config.ctx.subResourceId).toEqual("test") + }) + + it("parses resourceIds correctly for custom middlewares", () => { + const middleware = new ResourceIdGetter("body") + .mainResource("custom") + .subResource("customSub") + .build() + config = new TestConfiguration(middleware) + config.setBody({ + custom: "test", + customSub: "subtest" + }) + + config.executeMiddleware() + + expect(config.ctx.resourceId).toEqual("test") + expect(config.ctx.subResourceId).toEqual("subtest") + }) +}) \ No newline at end of file diff --git a/packages/server/src/middleware/tests/selfhost.spec.js b/packages/server/src/middleware/tests/selfhost.spec.js new file mode 100644 index 0000000000..061da17f9c --- /dev/null +++ b/packages/server/src/middleware/tests/selfhost.spec.js @@ -0,0 +1,75 @@ +const selfHostMiddleware = require("../selfhost"); +const env = require("../../environment") +const hosting = require("../../utilities/builder/hosting"); +jest.mock("../../environment") +jest.mock("../../utilities/builder/hosting") + +class TestConfiguration { + constructor() { + this.next = jest.fn() + this.throw = jest.fn() + this.middleware = selfHostMiddleware + + this.ctx = { + next: this.next, + throw: this.throw + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + setCloudHosted() { + env.CLOUD = 1 + env.SELF_HOSTED = 0 + } + + setSelfHosted() { + env.CLOUD = 0 + env.SELF_HOSTED = 1 + } + + afterEach() { + jest.clearAllMocks() + } +} + +describe("Self host middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + afterEach(() => { + config.afterEach() + }) + + it("calls next() when CLOUD and SELF_HOSTED env vars are set", async () => { + env.CLOUD = 1 + env.SELF_HOSTED = 1 + + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("throws when hostingInfo type is cloud", async () => { + config.setSelfHosted() + + hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.CLOUD })) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(400, "Endpoint unavailable in cloud hosting.") + expect(config.next).not.toHaveBeenCalled() + }) + + it("calls the self hosting middleware to pass through to next() when the hostingInfo type is self", async () => { + config.setSelfHosted() + + hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.SELF })) + + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) +}) diff --git a/packages/server/src/middleware/tests/usageQuota.spec.js b/packages/server/src/middleware/tests/usageQuota.spec.js new file mode 100644 index 0000000000..c76acb47d2 --- /dev/null +++ b/packages/server/src/middleware/tests/usageQuota.spec.js @@ -0,0 +1,129 @@ +const usageQuotaMiddleware = require("../usageQuota") +const usageQuota = require("../../utilities/usageQuota") +const CouchDB = require("../../db") +const env = require("../../environment") + +jest.mock("../../db"); +jest.mock("../../utilities/usageQuota") +jest.mock("../../environment") + +class TestConfiguration { + constructor() { + this.throw = jest.fn() + this.next = jest.fn() + this.middleware = usageQuotaMiddleware + this.ctx = { + throw: this.throw, + next: this.next, + user: { + appId: "test" + }, + request: { + body: {} + }, + req: { + method: "POST", + url: "/rows" + } + } + } + + executeMiddleware() { + return this.middleware(this.ctx, this.next) + } + + cloudHosted(bool) { + if (bool) { + env.CLOUD = 1 + this.ctx.auth = { apiKey: "test" } + } else { + env.CLOUD = 0 + } + } + + setMethod(method) { + this.ctx.req.method = method + } + + setUrl(url) { + this.ctx.req.url = url + } + + setBody(body) { + this.ctx.request.body = body + } + + setFiles(files) { + this.ctx.request.files = { file: files } + } +} + +describe("usageQuota middleware", () => { + let config + + beforeEach(() => { + config = new TestConfiguration() + }) + + it("skips the middleware if there is no usage property or method", async () => { + await config.executeMiddleware() + expect(config.next).toHaveBeenCalled() + }) + + it("passes through to next middleware if document already exists", async () => { + config.setBody({ + _id: "test" + }) + + CouchDB.mockImplementationOnce(() => ({ + get: async () => true + })) + + await config.executeMiddleware() + + expect(config.next).toHaveBeenCalled() + expect(config.ctx.preExisting).toBe(true) + }) + + it("throws if request has _id, but the document no longer exists", async () => { + config.setBody({ + _id: "123" + }) + + CouchDB.mockImplementationOnce(() => ({ + get: async () => { + throw new Error() + } + })) + + await config.executeMiddleware() + expect(config.throw).toHaveBeenCalledWith(404, `${config.ctx.request.body._id} does not exist`) + }) + + it("calculates and persists the correct usage quota for the relevant action", async () => { + config.setUrl("/rows") + config.cloudHosted(true) + + await config.executeMiddleware() + + expect(usageQuota.update).toHaveBeenCalledWith("test", "rows", 1) + expect(config.next).toHaveBeenCalled() + }) + + it("calculates the correct file size from a file upload call and adds it to quota", async () => { + config.setUrl("/upload") + config.cloudHosted(true) + config.setFiles([ + { + size: 100 + }, + { + size: 10000 + }, + ]) + await config.executeMiddleware() + + expect(usageQuota.update).toHaveBeenCalledWith("test", "storage", 10100) + expect(config.next).toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/packages/server/src/middleware/usageQuota.js b/packages/server/src/middleware/usageQuota.js index e980afe678..1b809868be 100644 --- a/packages/server/src/middleware/usageQuota.js +++ b/packages/server/src/middleware/usageQuota.js @@ -43,6 +43,7 @@ module.exports = async (ctx, next) => { return } } + // if running in builder or a self hosted cloud usage quotas should not be executed if (!env.CLOUD || env.SELF_HOSTED) { return next()