diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index f51648074c..c0e6225a38 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -47,6 +47,13 @@ jobs: yarn yarn build popd + + - name: Build OpenAPI sepc + run: | + pushd packages/server + yarn + yarn specs + popd - name: Setup Helm uses: azure/setup-helm@v1 @@ -77,3 +84,5 @@ jobs: packages/cli/build/cli-win.exe packages/cli/build/cli-linux packages/cli/build/cli-macos + packages/server/specs/openapi.yaml + packages/server/specs/openapi.json diff --git a/.gitignore b/.gitignore index da62ecb153..41fed0978c 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,5 @@ hosting/proxy/.generated-nginx.prod.conf *.sublime-workspace bin/ +hosting/.generated* packages/builder/cypress.env.json diff --git a/.prettierignore b/.prettierignore index 4bdb64f60c..6103408e00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,4 @@ node_modules -public dist *.spec.js packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte @@ -8,4 +7,4 @@ packages/server/coverage packages/server/client packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js -packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js \ No newline at end of file +packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index 624b4c2653..441fffa9f7 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -76,6 +76,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; proxy_connect_timeout 300; proxy_http_version 1.1; @@ -91,4 +92,4 @@ http { gzip off; gzip_comp_level 4; } -} \ No newline at end of file +} diff --git a/lerna.json b/lerna.json index dfd7c6494b..07b77404de 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.79-alpha.5", + "version": "1.0.79-alpha.7", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 22f5963bd2..626daf8b51 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint", "test:e2e": "lerna run cy:test --stream", "test:e2e:ci": "lerna run cy:ci --stream", + "build:specs": "lerna run specs", "build:docker": "lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -", "build:docker:proxy": "docker build hosting/proxy -t proxy-service", "build:docker:proxy:compose": "lerna run generate:proxy:compose && npm run build:docker:proxy", diff --git a/packages/backend-core/encryption.js b/packages/backend-core/encryption.js new file mode 100644 index 0000000000..4ccb6e3a99 --- /dev/null +++ b/packages/backend-core/encryption.js @@ -0,0 +1 @@ +module.exports = require("./src/security/encryption") diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index b317e37d21..8b52691512 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.0.79-alpha.5", + "version": "1.0.79-alpha.7", "description": "Budibase backend core libraries used in server and worker", "main": "src/index.js", "author": "Budibase", diff --git a/packages/backend-core/src/cache/user.js b/packages/backend-core/src/cache/user.js index 60a2d341a8..b10f854002 100644 --- a/packages/backend-core/src/cache/user.js +++ b/packages/backend-core/src/cache/user.js @@ -32,11 +32,10 @@ const populateFromDB = async (userId, tenantId) => { * @param {*} populateUser function to provide the user for re-caching. default to couch db * @returns */ -exports.getUser = async ( - userId, - tenantId = null, - populateUser = populateFromDB -) => { +exports.getUser = async (userId, tenantId = null, populateUser = null) => { + if (!populateUser) { + populateUser = populateFromDB + } if (!tenantId) { try { tenantId = getTenantId() diff --git a/packages/backend-core/src/db/constants.js b/packages/backend-core/src/db/constants.js index b41a9a9c08..5ee8033e05 100644 --- a/packages/backend-core/src/db/constants.js +++ b/packages/backend-core/src/db/constants.js @@ -14,6 +14,7 @@ exports.DocumentTypes = { APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`, ROLE: "role", MIGRATIONS: "migrations", + DEV_INFO: "devinfo", } exports.StaticDatabases = { diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index c4dcb8248b..6d6f9a782b 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -30,6 +30,7 @@ const UNICODE_MAX = "\ufff0" exports.ViewNames = { USER_BY_EMAIL: "by_email", + BY_API_KEY: "by_api_key", } exports.StaticDatabases = StaticDatabases @@ -67,6 +68,7 @@ function getDocParams(docType, docId = null, otherProps = {}) { endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`, } } +exports.getDocParams = getDocParams /** * Generates a new workspace ID. @@ -339,6 +341,14 @@ const getConfigParams = ({ type, workspace, user }, otherProps = {}) => { } } +/** + * Generates a new dev info document ID - this is scoped to a user. + * @returns {string} The new dev info ID which info for dev (like api key) can be stored under. + */ +const generateDevInfoID = userId => { + return `${DocumentTypes.DEV_INFO}${SEPARATOR}${userId}` +} + /** * Returns the most granular configuration document from the DB based on the type, workspace and userID passed. * @param {Object} db - db instance to query @@ -454,3 +464,4 @@ exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams exports.getScopedFullConfig = getScopedFullConfig exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc +exports.generateDevInfoID = generateDevInfoID diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index fd004ca0c2..e5be8e6b40 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -1,4 +1,5 @@ const { DocumentTypes, ViewNames } = require("./utils") +const { getGlobalDB } = require("../tenancy") function DesignDoc() { return { @@ -9,7 +10,8 @@ function DesignDoc() { } } -exports.createUserEmailView = async db => { +exports.createUserEmailView = async () => { + const db = getGlobalDB() let designDoc try { designDoc = await db.get("_design/database") @@ -31,3 +33,51 @@ exports.createUserEmailView = async db => { } await db.put(designDoc) } + +exports.createApiKeyView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc._id.startsWith("${DocumentTypes.DEV_INFO}") && doc.apiKey) { + emit(doc.apiKey, doc.userId) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.BY_API_KEY]: view, + } + await db.put(designDoc) +} + +exports.queryGlobalView = async (viewName, params, db = null) => { + const CreateFuncByName = { + [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, + [ViewNames.BY_API_KEY]: exports.createApiKeyView, + } + // can pass DB in if working with something specific + if (!db) { + db = getGlobalDB() + } + try { + let response = (await db.query(`database/${viewName}`, params)).rows + response = response.map(resp => + params.include_docs ? resp.doc : resp.value + ) + return response.length <= 1 ? response[0] : response + } catch (err) { + if (err != null && err.name === "not_found") { + const createFunc = CreateFuncByName[viewName] + await createFunc() + return exports.queryGlobalView(viewName, params) + } else { + throw err + } + } +} diff --git a/packages/backend-core/src/middleware/authenticated.js b/packages/backend-core/src/middleware/authenticated.js index 4978f7b9dc..ee815ea330 100644 --- a/packages/backend-core/src/middleware/authenticated.js +++ b/packages/backend-core/src/middleware/authenticated.js @@ -4,6 +4,9 @@ const { getUser } = require("../cache/user") const { getSession, updateSessionTTL } = require("../security/sessions") const { buildMatcherRegex, matches } = require("./matchers") const env = require("../environment") +const { SEPARATOR, ViewNames, queryGlobalView } = require("../../db") +const { getGlobalDB } = require("../tenancy") +const { decrypt } = require("../security/encryption") function finalise( ctx, @@ -16,6 +19,28 @@ function finalise( ctx.version = version } +async function checkApiKey(apiKey, populateUser) { + if (apiKey === env.INTERNAL_API_KEY) { + return { valid: true } + } + const decrypted = decrypt(apiKey) + const tenantId = decrypted.split(SEPARATOR)[0] + const db = getGlobalDB(tenantId) + // api key is encrypted in the database + const userId = await queryGlobalView( + ViewNames.BY_API_KEY, + { + key: apiKey, + }, + db + ) + if (userId) { + return { valid: true, user: await getUser(userId, tenantId, populateUser) } + } else { + throw "Invalid API key" + } +} + /** * This middleware is tenancy aware, so that it does not depend on other middlewares being used. * The tenancy modules should not be used here and it should be assumed that the tenancy context @@ -79,9 +104,19 @@ module.exports = ( const apiKey = ctx.request.headers[Headers.API_KEY] const tenantId = ctx.request.headers[Headers.TENANT_ID] // this is an internal request, no user made it - if (!authenticated && apiKey && apiKey === env.INTERNAL_API_KEY) { - authenticated = true - internal = true + if (!authenticated && apiKey) { + const populateUser = opts.populateUser ? opts.populateUser(ctx) : null + const { valid, user: foundUser } = await checkApiKey( + apiKey, + populateUser + ) + if (valid && foundUser) { + authenticated = true + user = foundUser + } else if (valid) { + authenticated = true + internal = true + } } if (!user && tenantId) { user = { tenantId } @@ -101,6 +136,7 @@ module.exports = ( // allow configuring for public access if ((opts && opts.publicAllowed) || publicEndpoint) { finalise(ctx, { authenticated: false, version, publicEndpoint }) + return next() } else { ctx.throw(err.status || 403, err) } diff --git a/packages/backend-core/src/objectStore/index.js b/packages/backend-core/src/objectStore/index.js index b5d8475cee..2385149f4d 100644 --- a/packages/backend-core/src/objectStore/index.js +++ b/packages/backend-core/src/objectStore/index.js @@ -78,6 +78,7 @@ exports.ObjectStore = bucket => { const config = { s3ForcePathStyle: true, signatureVersion: "v4", + apiVersion: "2006-03-01", params: { Bucket: sanitizeBucket(bucket), }, @@ -102,17 +103,21 @@ exports.makeSureBucketExists = async (client, bucketName) => { .promise() } catch (err) { const promises = STATE.bucketCreationPromises + const doesntExist = err.statusCode === 404, + noAccess = err.statusCode === 403 if (promises[bucketName]) { await promises[bucketName] - } else if (err.statusCode === 404) { - // bucket doesn't exist create it - promises[bucketName] = client - .createBucket({ - Bucket: bucketName, - }) - .promise() - await promises[bucketName] - delete promises[bucketName] + } else if (doesntExist || noAccess) { + if (doesntExist) { + // bucket doesn't exist create it + promises[bucketName] = client + .createBucket({ + Bucket: bucketName, + }) + .promise() + await promises[bucketName] + delete promises[bucketName] + } // public buckets are quite hidden in the system, make sure // no bucket is set accidentally if (PUBLIC_BUCKETS.includes(bucketName)) { @@ -124,7 +129,7 @@ exports.makeSureBucketExists = async (client, bucketName) => { .promise() } } else { - throw err + throw new Error("Unable to write to object store bucket.") } } } diff --git a/packages/backend-core/src/security/apiKeys.js b/packages/backend-core/src/security/apiKeys.js new file mode 100644 index 0000000000..e90418abb8 --- /dev/null +++ b/packages/backend-core/src/security/apiKeys.js @@ -0,0 +1 @@ +exports.lookupApiKey = async () => {} diff --git a/packages/backend-core/src/security/encryption.js b/packages/backend-core/src/security/encryption.js new file mode 100644 index 0000000000..c31f597652 --- /dev/null +++ b/packages/backend-core/src/security/encryption.js @@ -0,0 +1,33 @@ +const crypto = require("crypto") +const env = require("../environment") + +const ALGO = "aes-256-ctr" +const SECRET = env.JWT_SECRET +const SEPARATOR = "-" +const ITERATIONS = 10000 +const RANDOM_BYTES = 16 +const STRETCH_LENGTH = 32 + +function stretchString(string, salt) { + return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512") +} + +exports.encrypt = input => { + const salt = crypto.randomBytes(RANDOM_BYTES) + const stretched = stretchString(SECRET, salt) + const cipher = crypto.createCipheriv(ALGO, stretched, salt) + const base = cipher.update(input) + const final = cipher.final() + const encrypted = Buffer.concat([base, final]).toString("hex") + return `${salt.toString("hex")}${SEPARATOR}${encrypted}` +} + +exports.decrypt = input => { + const [salt, encrypted] = input.split(SEPARATOR) + const saltBuffer = Buffer.from(salt, "hex") + const stretched = stretchString(SECRET, saltBuffer) + const decipher = crypto.createDecipheriv(ALGO, stretched, saltBuffer) + const base = decipher.update(Buffer.from(encrypted, "hex")) + const final = decipher.final() + return Buffer.concat([base, final]).toString() +} diff --git a/packages/backend-core/src/security/permissions.js b/packages/backend-core/src/security/permissions.js index 3b05c10e20..28044a5129 100644 --- a/packages/backend-core/src/security/permissions.js +++ b/packages/backend-core/src/security/permissions.js @@ -10,6 +10,7 @@ const PermissionLevels = { // these are the global types, that govern the underlying default behaviour const PermissionTypes = { + APP: "app", TABLE: "table", USER: "user", AUTOMATION: "automation", diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 45fb4acd55..4183fa64d5 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -6,7 +6,7 @@ const { } = require("./db/utils") const jwt = require("jsonwebtoken") const { options } = require("./middleware/passport/jwt") -const { createUserEmailView } = require("./db/views") +const { queryGlobalView } = require("./db/views") const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants") const { getGlobalDB, @@ -139,25 +139,11 @@ exports.getGlobalUserByEmail = async email => { if (email == null) { throw "Must supply an email address to view" } - const db = getGlobalDB() - try { - let users = ( - await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { - key: email.toLowerCase(), - include_docs: true, - }) - ).rows - users = users.map(user => user.doc) - return users.length <= 1 ? users[0] : users - } catch (err) { - if (err != null && err.name === "not_found") { - await createUserEmailView(db) - return exports.getGlobalUserByEmail(email) - } else { - throw err - } - } + return queryGlobalView(ViewNames.USER_BY_EMAIL, { + key: email.toLowerCase(), + include_docs: true, + }) } exports.saveUser = async ( diff --git a/packages/bbui/package.json b/packages/bbui/package.json index ace578e6fc..8c822980cf 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.0.79-alpha.5", + "version": "1.0.79-alpha.7", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "^1.0.79-alpha.5", + "@budibase/string-templates": "^1.0.79-alpha.7", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index c18be1e4e1..9043fb748f 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -165,4 +165,8 @@ .secondary-action { margin-right: auto; } + + .spectrum-Dialog-buttonGroup { + padding-left: 0; + } diff --git a/packages/builder/package.json b/packages/builder/package.json index 6c85b96cc7..33518612e5 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.79-alpha.5", + "version": "1.0.79-alpha.7", "license": "GPL-3.0", "private": true, "scripts": { @@ -65,10 +65,10 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.79-alpha.5", - "@budibase/client": "^1.0.79-alpha.5", - "@budibase/frontend-core": "^1.0.79-alpha.5", - "@budibase/string-templates": "^1.0.79-alpha.5", + "@budibase/bbui": "^1.0.79-alpha.7", + "@budibase/client": "^1.0.79-alpha.7", + "@budibase/frontend-core": "^1.0.79-alpha.7", + "@budibase/string-templates": "^1.0.79-alpha.7", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/components/automation/Shared/WebhookDisplay.svelte b/packages/builder/src/components/automation/Shared/WebhookDisplay.svelte index 857640896c..dcd96ce2b9 100644 --- a/packages/builder/src/components/automation/Shared/WebhookDisplay.svelte +++ b/packages/builder/src/components/automation/Shared/WebhookDisplay.svelte @@ -1,5 +1,5 @@ -
- -
copyToClipboard()}> - -
-
- - + diff --git a/packages/builder/src/components/common/inputs/CopyInput.svelte b/packages/builder/src/components/common/inputs/CopyInput.svelte new file mode 100644 index 0000000000..68974fb63a --- /dev/null +++ b/packages/builder/src/components/common/inputs/CopyInput.svelte @@ -0,0 +1,58 @@ + + +
+ +
copyToClipboard(value || copyValue)}> + +
+
+ + diff --git a/packages/builder/src/components/settings/UpdateAPIKeyModal.svelte b/packages/builder/src/components/settings/UpdateAPIKeyModal.svelte new file mode 100644 index 0000000000..4322822ec8 --- /dev/null +++ b/packages/builder/src/components/settings/UpdateAPIKeyModal.svelte @@ -0,0 +1,41 @@ + + + + + You can find information about your developer account here, such as the API + key used to access the Budibase API. + + + diff --git a/packages/builder/src/pages/builder/portal/_layout.svelte b/packages/builder/src/pages/builder/portal/_layout.svelte index f4679647ff..7aa4671f7c 100644 --- a/packages/builder/src/pages/builder/portal/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/_layout.svelte @@ -18,11 +18,13 @@ import { onMount } from "svelte" import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" + import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte" import Logo from "assets/bb-emblem.svg" let loaded = false let userInfoModal let changePasswordModal + let apiKeyModal let mobileMenuVisible = false $: menu = buildMenu($auth.isAdmin) @@ -162,6 +164,11 @@ userInfoModal.show()}> Update user information + {#if $auth.isBuilder} + apiKeyModal.show()}> + View API key + + {/if} changePasswordModal.show()} @@ -186,6 +193,9 @@ + + + {/if}