merge with master

This commit is contained in:
Martin McKeaveney 2022-03-30 15:44:22 +01:00
commit 034408b3c2
14 changed files with 191 additions and 53 deletions

View File

@ -2,6 +2,9 @@ name: Budibase Smoke Test
on: on:
workflow_dispatch: workflow_dispatch:
schedule:
- cron: "0 5 * * *" # every day at 5AM
jobs: jobs:
release: release:
@ -23,10 +26,13 @@ jobs:
-o packages/builder/cypress.env.json \ -o packages/builder/cypress.env.json \
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json -L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json
wc -l packages/builder/cypress.env.json wc -l packages/builder/cypress.env.json
- run: yarn test:e2e:ci
env: - name: Cypress run
CI: true id: cypress
name: Budibase CI uses: cypress-io/github-action@v2
with:
install: false
command: yarn test:e2e:ci
# TODO: upload recordings to s3 # TODO: upload recordings to s3
# - name: Configure AWS Credentials # - name: Configure AWS Credentials
@ -36,11 +42,11 @@ jobs:
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: eu-west-1 # aws-region: eu-west-1
# TODO look at cypress reporters - name: Discord Webhook Action
# - name: Discord Webhook Action uses: tsickert/discord-webhook@v4.0.0
# uses: tsickert/discord-webhook@v4.0.0 with:
# with: webhook-url: ${{ secrets.BUDI_QA_WEBHOOK }}
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} content: "Smoke test run completed with ${{ steps.cypress.outcome }}. See results at ${{ steps.cypress.dashboardUrl }}"
# content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud." embed-title: ${{ steps.cypress.outcome }}
# embed-title: ${{ env.RELEASE_VERSION }} embed-color: ${{ steps.cypress.outcome == 'success' && '3066993' || '15548997' }}

View File

@ -48,7 +48,7 @@ http {
add_header X-Frame-Options SAMEORIGIN always; add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io ; font-src 'self' data https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self' https:; img-src http: https: data; manifest-src 'self'; media-src 'self'; worker-src 'none';" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io ; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self' https:; img-src http: https: data:; manifest-src 'self'; media-src 'self'; worker-src 'none';" always;
# upstreams # upstreams
set $apps {{ apps }}; set $apps {{ apps }};

View File

@ -1,5 +1,5 @@
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { BUILTIN_PERMISSION_IDS } = require("./permissions") const { BUILTIN_PERMISSION_IDS, PermissionLevels } = require("./permissions")
const { const {
generateRoleID, generateRoleID,
getRoleParams, getRoleParams,
@ -180,6 +180,20 @@ exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => {
return opts.idOnly ? roles.map(role => role._id) : roles return opts.idOnly ? roles.map(role => role._id) : roles
} }
// this function checks that the provided permissions are in an array format
// some templates/older apps will use a simple string instead of array for roles
// convert the string to an array using the theory that write is higher than read
exports.checkForRoleResourceArray = (rolePerms, resourceId) => {
if (rolePerms && !Array.isArray(rolePerms[resourceId])) {
const permLevel = rolePerms[resourceId]
rolePerms[resourceId] = [permLevel]
if (permLevel === PermissionLevels.WRITE) {
rolePerms[resourceId].push(PermissionLevels.READ)
}
}
return rolePerms
}
/** /**
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
@ -209,15 +223,27 @@ exports.getAllRoles = async appId => {
roles.push(Object.assign(builtinRole, dbBuiltin)) roles.push(Object.assign(builtinRole, dbBuiltin))
} }
} }
// check permissions
for (let role of roles) {
if (!role.permissions) {
continue
}
for (let resourceId of Object.keys(role.permissions)) {
role.permissions = exports.checkForRoleResourceArray(
role.permissions,
resourceId
)
}
}
return roles return roles
} }
/** /**
* This retrieves the required role * This retrieves the required role for a resource
* @param permLevel * @param permLevel The level of request
* @param resourceId * @param resourceId The resource being requested
* @param subResourceId * @param subResourceId The sub resource being requested
* @return {Promise<{permissions}|Object>} * @return {Promise<{permissions}|Object>} returns the permissions required to access.
*/ */
exports.getRequiredResourceRole = async ( exports.getRequiredResourceRole = async (
permLevel, permLevel,

View File

@ -1260,10 +1260,30 @@
] ]
}, },
"executeQuery": { "executeQuery": {
"description": "The query body must contain the required parameters for the query, this depends on query type, setup and bindings.", "description": "The parameters required for executing a query.",
"type": "object", "type": "object",
"additionalProperties": { "properties": {
"description": "Key value properties of any type, depending on the query output schema." "parameters": {
"type": "object",
"description": "This contains the required parameters for the query, this depends on query type, setup and bindings.",
"additionalProperties": {
"description": "Key value properties of any type, depending on the query output schema."
}
},
"pagination": {
"type": "object",
"description": "For supported query types (currently on REST) pagination can be performed using these properties.",
"properties": {
"page": {
"type": "string",
"description": "The page which has been returned from a previous query."
},
"limit": {
"type": "number",
"description": "The number of rows to return per page."
}
}
}
} }
}, },
"executeQueryOutput": { "executeQueryOutput": {

View File

@ -951,11 +951,27 @@ components:
required: required:
- data - data
executeQuery: executeQuery:
description: The query body must contain the required parameters for the query, description: The parameters required for executing a query.
this depends on query type, setup and bindings.
type: object type: object
additionalProperties: properties:
description: Key value properties of any type, depending on the query output schema. parameters:
type: object
description: This contains the required parameters for the query, this depends
on query type, setup and bindings.
additionalProperties:
description: Key value properties of any type, depending on the query output
schema.
pagination:
type: object
description: For supported query types (currently on REST) pagination can be
performed using these properties.
properties:
page:
type: string
description: The page which has been returned from a previous query.
limit:
type: number
description: The number of rows to return per page.
executeQueryOutput: executeQueryOutput:
type: object type: object
properties: properties:

View File

@ -124,12 +124,34 @@ const querySchema = object(
) )
const executeQuerySchema = { const executeQuerySchema = {
description: description: "The parameters required for executing a query.",
"The query body must contain the required parameters for the query, this depends on query type, setup and bindings.",
type: "object", type: "object",
additionalProperties: { properties: {
description: parameters: {
"Key value properties of any type, depending on the query output schema.", type: "object",
description:
"This contains the required parameters for the query, this depends on query type, setup and bindings.",
additionalProperties: {
description:
"Key value properties of any type, depending on the query output schema.",
},
},
pagination: {
type: "object",
description:
"For supported query types (currently on REST) pagination can be performed using these properties.",
properties: {
page: {
type: "string",
description:
"The page which has been returned from a previous query.",
},
limit: {
type: "number",
description: "The number of rows to return per page.",
},
},
},
}, },
} }

View File

@ -4,6 +4,7 @@ const {
getDBRoleID, getDBRoleID,
getExternalRoleID, getExternalRoleID,
getBuiltinRoles, getBuiltinRoles,
checkForRoleResourceArray,
} = require("@budibase/backend-core/roles") } = require("@budibase/backend-core/roles")
const { getRoleParams } = require("../../db/utils") const { getRoleParams } = require("../../db/utils")
const { const {
@ -144,12 +145,11 @@ exports.getResourcePerms = async function (ctx) {
for (let level of SUPPORTED_LEVELS) { for (let level of SUPPORTED_LEVELS) {
// update the various roleIds in the resource permissions // update the various roleIds in the resource permissions
for (let role of roles) { for (let role of roles) {
const rolePerms = role.permissions const rolePerms = checkForRoleResourceArray(role.permissions, resourceId)
if ( if (
rolePerms && rolePerms &&
rolePerms[resourceId] && rolePerms[resourceId] &&
(rolePerms[resourceId] === level || rolePerms[resourceId].indexOf(level) !== -1
rolePerms[resourceId].indexOf(level) !== -1)
) { ) {
permissions[level] = getExternalRoleID(role._id) permissions[level] = getExternalRoleID(role._id)
} }

View File

@ -40,7 +40,7 @@ router
.get( .get(
"/api/tables/:tableId", "/api/tables/:tableId",
paramResource("tableId"), paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ), authorized(PermissionTypes.TABLE, PermissionLevels.READ, { schema: true }),
tableController.find tableController.find
) )
/** /**

View File

@ -17,10 +17,14 @@ const Runner = new Thread(ThreadType.AUTOMATION)
exports.processEvent = async job => { exports.processEvent = async job => {
try { try {
// need to actually await these so that an error can be captured properly // need to actually await these so that an error can be captured properly
console.log(
`${job.data.automation.appId} automation ${job.data.automation._id} running`
)
return await Runner.run(job) return await Runner.run(job)
} catch (err) { } catch (err) {
const errJson = JSON.stringify(err)
console.error( console.error(
`${job.data.automation.appId} automation ${job.data.automation._id} was unable to run - ${err}` `${job.data.automation.appId} automation ${job.data.automation._id} was unable to run - ${errJson}`
) )
console.trace(err) console.trace(err)
return { err } return { err }

View File

@ -182,11 +182,7 @@ export interface QueryJson {
export interface SqlQuery { export interface SqlQuery {
sql: string sql: string
bindings?: bindings?: string[]
| string[]
| {
[key: string]: any
}
} }
export interface QueryOptions { export interface QueryOptions {

View File

@ -932,11 +932,21 @@ export interface components {
} }
} }
/** @description The ID of the table. */ /** @description The ID of the table. */
_id: string _id: string;
}[] }[];
} };
/** @description The query body must contain the required parameters for the query, this depends on query type, setup and bindings. */ /** @description The parameters required for executing a query. */
executeQuery: { [key: string]: unknown } executeQuery: {
/** @description This contains the required parameters for the query, this depends on query type, setup and bindings. */
parameters?: { [key: string]: unknown };
/** @description For supported query types (currently on REST) pagination can be performed using these properties. */
pagination?: {
/** @description The page which has been returned from a previous query. */
page?: string;
/** @description The number of rows to return per page. */
limit?: number;
};
};
executeQueryOutput: { executeQueryOutput: {
/** @description The data response from the query. */ /** @description The data response from the query. */
data: { [key: string]: unknown }[] data: { [key: string]: unknown }[]

View File

@ -80,6 +80,20 @@ module MySQLModule {
}, },
} }
function bindingTypeCoerce(bindings: any[]) {
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
if (typeof binding !== "string") {
continue
}
const matches = binding.match(/^\d*/g)
if (matches && matches[0] !== "" && !isNaN(Number(matches[0]))) {
bindings[i] = parseFloat(binding)
}
}
return bindings
}
class MySQLIntegration extends Sql implements DatasourcePlus { class MySQLIntegration extends Sql implements DatasourcePlus {
private config: MySQLConfig private config: MySQLConfig
private client: any private client: any
@ -122,7 +136,7 @@ module MySQLModule {
// Node MySQL is callback based, so we must wrap our call in a promise // Node MySQL is callback based, so we must wrap our call in a promise
const response = await this.client.query( const response = await this.client.query(
query.sql, query.sql,
query.bindings || [] bindingTypeCoerce(query.bindings || [])
) )
return response[0] return response[0]
} finally { } finally {

View File

@ -5,6 +5,7 @@ const {
} = require("@budibase/backend-core/roles") } = require("@budibase/backend-core/roles")
const { const {
PermissionTypes, PermissionTypes,
PermissionLevels,
doesHaveBasePermission, doesHaveBasePermission,
} = require("@budibase/backend-core/permissions") } = require("@budibase/backend-core/permissions")
const builderMiddleware = require("./builder") const builderMiddleware = require("./builder")
@ -64,7 +65,7 @@ const checkAuthorizedResource = async (
} }
module.exports = module.exports =
(permType, permLevel = null) => (permType, permLevel = null, opts = { schema: false }) =>
async (ctx, next) => { async (ctx, next) => {
// webhooks don't need authentication, each webhook unique // webhooks don't need authentication, each webhook unique
// also internal requests (between services) don't need authorized // also internal requests (between services) don't need authorized
@ -81,15 +82,25 @@ module.exports =
await builderMiddleware(ctx, permType) await builderMiddleware(ctx, permType)
// get the resource roles // get the resource roles
let resourceRoles = [] let resourceRoles = [],
otherLevelRoles
const otherLevel =
permLevel === PermissionLevels.READ
? PermissionLevels.WRITE
: PermissionLevels.READ
const appId = getAppId() const appId = getAppId()
if (appId && hasResource(ctx)) { if (appId && hasResource(ctx)) {
resourceRoles = await getRequiredResourceRole(permLevel, ctx) resourceRoles = await getRequiredResourceRole(permLevel, ctx)
if (opts && opts.schema) {
otherLevelRoles = await getRequiredResourceRole(otherLevel, ctx)
}
} }
// if the resource is public, proceed // if the resource is public, proceed
const isPublicResource = resourceRoles.includes(BUILTIN_ROLE_IDS.PUBLIC) if (
if (isPublicResource) { resourceRoles.includes(BUILTIN_ROLE_IDS.PUBLIC) ||
(otherLevelRoles && otherLevelRoles.includes(BUILTIN_ROLE_IDS.PUBLIC))
) {
return next() return next()
} }
@ -98,8 +109,17 @@ module.exports =
return ctx.throw(403, "Session not authenticated") return ctx.throw(403, "Session not authenticated")
} }
// check authorized try {
await checkAuthorized(ctx, resourceRoles, permType, permLevel) // check authorized
await checkAuthorized(ctx, resourceRoles, permType, permLevel)
} catch (err) {
// this is a schema, check if
if (opts && opts.schema && permLevel) {
await checkAuthorized(ctx, otherLevelRoles, permType, otherLevel)
} else {
throw err
}
}
// csrf protection // csrf protection
return csrf(ctx, next) return csrf(ctx, next)

View File

@ -3,6 +3,9 @@ const { EmailTemplatePurpose } = require("../../../constants")
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
const fetch = require("node-fetch") const fetch = require("node-fetch")
// for the real email tests give them a long time to try complete/fail
jest.setTimeout(30000)
describe("/api/global/email", () => { describe("/api/global/email", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
@ -27,6 +30,7 @@ describe("/api/global/email", () => {
userId: user._id, userId: user._id,
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.timeout(20000)
// ethereal hiccup, can't test right now // ethereal hiccup, can't test right now
if (res.status >= 300) { if (res.status >= 300) {
return return
@ -39,7 +43,7 @@ describe("/api/global/email", () => {
text = await response.text() text = await response.text()
} catch (err) { } catch (err) {
// ethereal hiccup, can't test right now // ethereal hiccup, can't test right now
if (parseInt(err.status) >= 300) { if (parseInt(err.status) >= 300 || (err && err.errno === "ETIME")) {
return return
} else { } else {
throw err throw err