diff --git a/packages/auth/src/cache/user.js b/packages/auth/src/cache/user.js index 46202cbfe9..b49721a541 100644 --- a/packages/auth/src/cache/user.js +++ b/packages/auth/src/cache/user.js @@ -1,15 +1,18 @@ -const { getDB } = require("../db") -const { StaticDatabases } = require("../db/utils") +const { getGlobalDB } = require("../db/utils") const redis = require("../redis/authRedis") +const { lookupTenantId } = require("../utils") const EXPIRY_SECONDS = 3600 -exports.getUser = async userId => { +exports.getUser = async (userId, tenantId = null) => { + if (!tenantId) { + tenantId = await lookupTenantId({ userId }) + } const client = await redis.getUserClient() // try cache let user = await client.get(userId) if (!user) { - user = await getDB(StaticDatabases.GLOBAL.name).get(userId) + user = await getGlobalDB(tenantId).get(userId) client.store(userId, user, EXPIRY_SECONDS) } return user diff --git a/packages/auth/src/db/utils.js b/packages/auth/src/db/utils.js index a39166a53e..f305d18d0d 100644 --- a/packages/auth/src/db/utils.js +++ b/packages/auth/src/db/utils.js @@ -1,5 +1,6 @@ const { newid } = require("../hashing") const Replication = require("./Replication") +const { getDB } = require("./index") const UNICODE_MAX = "\ufff0" const SEPARATOR = "_" @@ -18,6 +19,9 @@ exports.StaticDatabases = { // contains information about tenancy and so on PLATFORM_INFO: { name: "global-info", + docs: { + tenants: "tenants", + }, }, } @@ -64,6 +68,25 @@ function getDocParams(docType, docId = null, otherProps = {}) { } } +/** + * Gets the name of the global DB to connect to in a multi-tenancy system. + */ +exports.getGlobalDB = tenantId => { + const globalName = exports.StaticDatabases.GLOBAL.name + // fallback for system pre multi-tenancy + if (!tenantId) { + return globalName + } + return getDB(`${tenantId}${SEPARATOR}${globalName}`) +} + +/** + * Given a koa context this tries to find the correct tenant Global DB. + */ +exports.getGlobalDBFromCtx = ctx => { + return exports.getGlobalDB(ctx.user.tenantId) +} + /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. diff --git a/packages/auth/src/db/views.js b/packages/auth/src/db/views.js index 1f1f28b917..1b48786e24 100644 --- a/packages/auth/src/db/views.js +++ b/packages/auth/src/db/views.js @@ -1,5 +1,4 @@ -const { DocumentTypes, ViewNames, StaticDatabases } = require("./utils") -const { getDB } = require("./index") +const { DocumentTypes, ViewNames } = require("./utils") function DesignDoc() { return { @@ -10,8 +9,7 @@ function DesignDoc() { } } -exports.createUserEmailView = async () => { - const db = getDB(StaticDatabases.GLOBAL.name) +exports.createUserEmailView = async db => { let designDoc try { designDoc = await db.get("_design/database") diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index ff604b5a3a..2e398d8c55 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -1,9 +1,9 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy -const { StaticDatabases } = require("./db/utils") +const { getGlobalDB } = require("./db/utils") const { jwt, local, authenticated, google, auditLog } = require("./middleware") -const { setDB, getDB } = require("./db") +const { setDB } = require("./db") const userCache = require("./cache/user") // Strategies @@ -13,7 +13,7 @@ passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) passport.serializeUser((user, done) => done(null, user)) passport.deserializeUser(async (user, done) => { - const db = getDB(StaticDatabases.GLOBAL.name) + const db = getGlobalDB(user.tenantId) try { const user = await db.get(user._id) diff --git a/packages/auth/src/middleware/authenticated.js b/packages/auth/src/middleware/authenticated.js index db1fdfacd9..ebdf328cf7 100644 --- a/packages/auth/src/middleware/authenticated.js +++ b/packages/auth/src/middleware/authenticated.js @@ -56,7 +56,7 @@ module.exports = (noAuthPatterns = [], opts) => { error = "No session found" } else { try { - user = await getUser(userId) + user = await getUser(userId, session.tenantId) delete user.password authenticated = true } catch (err) { diff --git a/packages/auth/src/middleware/passport/google.js b/packages/auth/src/middleware/passport/google.js index b357eb4903..bc68121577 100644 --- a/packages/auth/src/middleware/passport/google.js +++ b/packages/auth/src/middleware/passport/google.js @@ -1,23 +1,22 @@ const env = require("../../environment") const jwt = require("jsonwebtoken") -const database = require("../../db") const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const { - StaticDatabases, generateGlobalUserID, + getGlobalDB, ViewNames, } = require("../../db/utils") const { newid } = require("../../hashing") const { createASession } = require("../../security/sessions") +const { lookupTenantId } = require("../../utils") async function authenticate(token, tokenSecret, profile, done) { // Check the user exists in the instance DB by email - const db = database.getDB(StaticDatabases.GLOBAL.name) + const userId = generateGlobalUserID(profile.id) + const tenantId = await lookupTenantId({ userId }) + const db = getGlobalDB(tenantId) let dbUser - - const userId = generateGlobalUserID(profile.id) - try { // use the google profile id dbUser = await db.get(userId) @@ -62,7 +61,7 @@ async function authenticate(token, tokenSecret, profile, done) { // authenticate const sessionId = newid() - await createASession(dbUser._id, sessionId) + await createASession(dbUser._id, { sessionId, tenantId: dbUser.tenantId }) dbUser.token = jwt.sign( { diff --git a/packages/auth/src/middleware/passport/local.js b/packages/auth/src/middleware/passport/local.js index 90303cb95f..147305e318 100644 --- a/packages/auth/src/middleware/passport/local.js +++ b/packages/auth/src/middleware/passport/local.js @@ -34,12 +34,14 @@ exports.authenticate = async function (email, password, done) { // authenticate if (await compare(password, dbUser.password)) { const sessionId = newid() - await createASession(dbUser._id, sessionId) + const tenantId = dbUser.tenantId + await createASession(dbUser._id, { sessionId, tenantId }) dbUser.token = jwt.sign( { userId: dbUser._id, sessionId, + tenantId, }, env.JWT_SECRET ) diff --git a/packages/auth/src/security/sessions.js b/packages/auth/src/security/sessions.js index 4051df7123..328f74c794 100644 --- a/packages/auth/src/security/sessions.js +++ b/packages/auth/src/security/sessions.js @@ -12,12 +12,13 @@ function makeSessionID(userId, sessionId) { return `${userId}/${sessionId}` } -exports.createASession = async (userId, sessionId) => { +exports.createASession = async (userId, session) => { const client = await redis.getSessionClient() - const session = { + const sessionId = session.sessionId + session = { createdAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(), - sessionId, + ...session, userId, } await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) diff --git a/packages/auth/src/utils.js b/packages/auth/src/utils.js index 8bd635e2e3..b5225881da 100644 --- a/packages/auth/src/utils.js +++ b/packages/auth/src/utils.js @@ -8,6 +8,7 @@ const jwt = require("jsonwebtoken") const { options } = require("./middleware/passport/jwt") const { createUserEmailView } = require("./db/views") const { getDB } = require("./db") +const { getGlobalDB } = require("./db/utils") const APP_PREFIX = DocumentTypes.APP + SEPARATOR @@ -100,17 +101,31 @@ exports.isClient = ctx => { return ctx.headers["x-budibase-type"] === "client" } +exports.lookupTenantId = async ({ email, userId }) => { + const toQuery = email || userId + const db = getDB(StaticDatabases.PLATFORM_INFO.name) + const doc = await db.get(toQuery) + if (!doc || !doc.tenantId) { + throw "Unable to find tenant" + } + return doc.tenantId +} + /** * Given an email address this will use a view to search through * all the users to find one with this email address. * @param {string} email the email to lookup the user by. + * @param {string|null} tenantId If tenant ID is known it can be specified * @return {Promise} */ -exports.getGlobalUserByEmail = async email => { +exports.getGlobalUserByEmail = async (email, tenantId = null) => { if (email == null) { throw "Must supply an email address to view" } - const db = getDB(StaticDatabases.GLOBAL.name) + if (!tenantId) { + tenantId = await exports.lookupTenantId({ email }) + } + const db = getGlobalDB(tenantId) try { let users = ( await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { @@ -122,7 +137,7 @@ exports.getGlobalUserByEmail = async email => { return users.length <= 1 ? users[0] : users } catch (err) { if (err != null && err.name === "not_found") { - await createUserEmailView() + await createUserEmailView(db) return exports.getGlobalUserByEmail(email) } else { throw err diff --git a/packages/server/src/api/controllers/apikeys.js b/packages/server/src/api/controllers/apikeys.js index 9c1acfbc2a..98dee46997 100644 --- a/packages/server/src/api/controllers/apikeys.js +++ b/packages/server/src/api/controllers/apikeys.js @@ -1,11 +1,10 @@ const CouchDB = require("../../db") -const { StaticDatabases } = require("@budibase/auth/db") +const { StaticDatabases, getGlobalDBFromCtx } = require("@budibase/auth/db") -const GLOBAL_DB = StaticDatabases.GLOBAL.name const KEYS_DOC = StaticDatabases.GLOBAL.docs.apiKeys -async function getBuilderMainDoc() { - const db = new CouchDB(GLOBAL_DB) +async function getBuilderMainDoc(ctx) { + const db = getGlobalDBFromCtx(ctx) try { return await db.get(KEYS_DOC) } catch (err) { @@ -16,17 +15,17 @@ async function getBuilderMainDoc() { } } -async function setBuilderMainDoc(doc) { +async function setBuilderMainDoc(ctx, doc) { // make sure to override the ID doc._id = KEYS_DOC - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) return db.put(doc) } exports.fetch = async function (ctx) { try { - const mainDoc = await getBuilderMainDoc() + const mainDoc = await getBuilderMainDoc(ctx) ctx.body = mainDoc.apiKeys ? mainDoc.apiKeys : {} } catch (err) { /* istanbul ignore next */ @@ -39,12 +38,12 @@ exports.update = async function (ctx) { const value = ctx.request.body.value try { - const mainDoc = await getBuilderMainDoc() + const mainDoc = await getBuilderMainDoc(ctx) if (mainDoc.apiKeys == null) { mainDoc.apiKeys = {} } mainDoc.apiKeys[key] = value - const resp = await setBuilderMainDoc(mainDoc) + const resp = await setBuilderMainDoc(ctx, mainDoc) ctx.body = { _id: resp.id, _rev: resp.rev, diff --git a/packages/server/src/api/controllers/application.js b/packages/server/src/api/controllers/application.js index a2e254461a..c01d43c869 100644 --- a/packages/server/src/api/controllers/application.js +++ b/packages/server/src/api/controllers/application.js @@ -295,7 +295,7 @@ exports.delete = async function (ctx) { await deleteApp(ctx.params.appId) } // make sure the app/role doesn't stick around after the app has been deleted - await removeAppFromUserRoles(ctx.params.appId) + await removeAppFromUserRoles(ctx, ctx.params.appId) ctx.status = 200 ctx.body = result diff --git a/packages/server/src/api/controllers/auth.js b/packages/server/src/api/controllers/auth.js index da863f5493..5078218fc7 100644 --- a/packages/server/src/api/controllers/auth.js +++ b/packages/server/src/api/controllers/auth.js @@ -22,7 +22,7 @@ exports.fetchSelf = async ctx => { const userTable = await db.get(InternalTables.USER_METADATA) const metadata = await db.get(userId) // specifically needs to make sure is enriched - ctx.body = await outputProcessing(appId, userTable, { + ctx.body = await outputProcessing(ctx, userTable, { ...user, ...metadata, }) diff --git a/packages/server/src/api/controllers/automation.js b/packages/server/src/api/controllers/automation.js index 2d164b415d..c54a6803f0 100644 --- a/packages/server/src/api/controllers/automation.js +++ b/packages/server/src/api/controllers/automation.js @@ -159,6 +159,7 @@ exports.create = async function (ctx) { automation._id = generateAutomationID() + automation.tenantId = ctx.user.tenantId automation.type = "automation" automation = cleanAutomationInputs(automation) automation = await checkForWebhooks({ diff --git a/packages/server/src/api/controllers/user.js b/packages/server/src/api/controllers/user.js index 6778f983c2..935ead38b6 100644 --- a/packages/server/src/api/controllers/user.js +++ b/packages/server/src/api/controllers/user.js @@ -17,7 +17,7 @@ function removeGlobalProps(user) { exports.fetchMetadata = async function (ctx) { const database = new CouchDB(ctx.appId) - const global = await getGlobalUsers(ctx.appId) + const global = await getGlobalUsers(ctx, ctx.appId) const metadata = ( await database.allDocs( getUserMetadataParams(null, { diff --git a/packages/server/src/automations/steps/sendSmtpEmail.js b/packages/server/src/automations/steps/sendSmtpEmail.js index 764972b402..7b25da801e 100644 --- a/packages/server/src/automations/steps/sendSmtpEmail.js +++ b/packages/server/src/automations/steps/sendSmtpEmail.js @@ -46,13 +46,13 @@ module.exports.definition = { }, } -module.exports.run = async function ({ inputs }) { +module.exports.run = async function ({ inputs, tenantId }) { let { to, from, subject, contents } = inputs if (!contents) { contents = "

No content

" } try { - let response = await sendSmtpEmail(to, from, subject, contents) + let response = await sendSmtpEmail(tenantId, to, from, subject, contents) return { success: true, response, diff --git a/packages/server/src/automations/thread.js b/packages/server/src/automations/thread.js index 7b6d969a98..a676afc042 100644 --- a/packages/server/src/automations/thread.js +++ b/packages/server/src/automations/thread.js @@ -22,6 +22,7 @@ class Orchestrator { // step zero is never used as the template string is zero indexed for customer facing this._context = { steps: [{}], trigger: triggerOutput } this._automation = automation + this._tenantId = automation.tenantId // create an emitter which has the chain count for this automation run in it, so it can block // excessive chaining if required this._emitter = new AutomationEmitter(this._chainCount + 1) @@ -57,6 +58,7 @@ class Orchestrator { apiKey: automation.apiKey, emitter: this._emitter, context: this._context, + tenantId: this._tenantId, }) if (step.stepId === FILTER_STEP_ID && !outputs.success) { break diff --git a/packages/server/src/db/linkedRows/index.js b/packages/server/src/db/linkedRows/index.js index 754340046d..dece78dcff 100644 --- a/packages/server/src/db/linkedRows/index.js +++ b/packages/server/src/db/linkedRows/index.js @@ -60,7 +60,7 @@ async function getLinksForRows(appId, rows) { ) } -async function getFullLinkedDocs(appId, links) { +async function getFullLinkedDocs(ctx, appId, links) { // create DBs const db = new CouchDB(appId) const linkedRowIds = links.map(link => link.id) @@ -71,7 +71,7 @@ async function getFullLinkedDocs(appId, links) { let [users, other] = partition(linked, linkRow => linkRow._id.startsWith(USER_METDATA_PREFIX) ) - const globalUsers = await getGlobalUsers(appId, users) + const globalUsers = await getGlobalUsers(ctx, appId, users) users = users.map(user => { const globalUser = globalUsers.find( globalUser => globalUser && user._id.includes(globalUser._id) @@ -166,12 +166,13 @@ exports.attachLinkIDs = async (appId, rows) => { /** * Given a table and a list of rows this will retrieve all of the attached docs and enrich them into the row. * This is required for formula fields, this may only be utilised internally (for now). - * @param {string} appId The app in which the tables/rows/links exist. + * @param {object} ctx The request which is looking for rows. * @param {object} table The table from which the rows originated. * @param {array} rows The rows which are to be enriched. * @return {Promise<*>} returns the rows with all of the enriched relationships on it. */ -exports.attachFullLinkedDocs = async (appId, table, rows) => { +exports.attachFullLinkedDocs = async (ctx, table, rows) => { + const appId = ctx.appId const linkedTableIds = getLinkedTableIDs(table) if (linkedTableIds.length === 0) { return rows @@ -182,7 +183,7 @@ exports.attachFullLinkedDocs = async (appId, table, rows) => { const links = (await getLinksForRows(appId, rows)).filter(link => rows.some(row => row._id === link.thisId) ) - let linked = await getFullLinkedDocs(appId, links) + let linked = await getFullLinkedDocs(ctx, appId, links) const linkedTables = [] for (let row of rows) { for (let link of links.filter(link => link.thisId === row._id)) { diff --git a/packages/server/src/tests/utilities/TestConfiguration.js b/packages/server/src/tests/utilities/TestConfiguration.js index a69ea35385..3a883b4a71 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.js +++ b/packages/server/src/tests/utilities/TestConfiguration.js @@ -16,14 +16,14 @@ const supertest = require("supertest") const { cleanup } = require("../../utilities/fileSystem") const { Cookies } = require("@budibase/auth").constants const { jwt } = require("@budibase/auth").auth -const { StaticDatabases } = require("@budibase/auth/db") +const { getGlobalDB } = require("@budibase/auth/db") const { createASession } = require("@budibase/auth/sessions") const { user: userCache } = require("@budibase/auth/cache") -const CouchDB = require("../../db") const GLOBAL_USER_ID = "us_uuid1" const EMAIL = "babs@babs.com" const PASSWORD = "babs_password" +const TENANT_ID = "tenant1" class TestConfiguration { constructor(openServer = true) { @@ -65,7 +65,7 @@ class TestConfiguration { } async globalUser(id = GLOBAL_USER_ID, builder = true, roles) { - const db = new CouchDB(StaticDatabases.GLOBAL.name) + const db = getGlobalDB(TENANT_ID) let existing try { existing = await db.get(id) @@ -76,6 +76,7 @@ class TestConfiguration { _id: id, ...existing, roles: roles || {}, + tenantId: TENANT_ID, } await createASession(id, "sessionid") if (builder) { diff --git a/packages/server/src/utilities/global.js b/packages/server/src/utilities/global.js index 3ce794b406..2dbb956d33 100644 --- a/packages/server/src/utilities/global.js +++ b/packages/server/src/utilities/global.js @@ -1,11 +1,9 @@ -const CouchDB = require("../db") const { getMultiIDParams, getGlobalIDFromUserMetadataID, - StaticDatabases, } = require("../db/utils") const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles") -const { getDeployedAppID } = require("@budibase/auth/db") +const { getDeployedAppID, getGlobalDBFromCtx } = require("@budibase/auth/db") const { getGlobalUserParams } = require("@budibase/auth/db") const { user: userCache } = require("@budibase/auth/cache") @@ -34,18 +32,18 @@ function processUser(appId, user) { } exports.getCachedSelf = async (ctx, appId) => { - const user = await userCache.getUser(ctx.user._id) + const user = await userCache.getUser(ctx.user._id, ctx.user.tenantId) return processUser(appId, user) } -exports.getGlobalUser = async (appId, userId) => { - const db = CouchDB(StaticDatabases.GLOBAL.name) +exports.getGlobalUser = async (ctx, appId, userId) => { + const db = getGlobalDBFromCtx(ctx) let user = await db.get(getGlobalIDFromUserMetadataID(userId)) return processUser(appId, user) } -exports.getGlobalUsers = async (appId = null, users = null) => { - const db = CouchDB(StaticDatabases.GLOBAL.name) +exports.getGlobalUsers = async (ctx, appId = null, users = null) => { + const db = getGlobalDBFromCtx(ctx) let globalUsers if (users) { const globalIds = users.map(user => getGlobalIDFromUserMetadataID(user._id)) diff --git a/packages/server/src/utilities/rowProcessor.js b/packages/server/src/utilities/rowProcessor.js index 766bc09b2f..2a83ae5d2b 100644 --- a/packages/server/src/utilities/rowProcessor.js +++ b/packages/server/src/utilities/rowProcessor.js @@ -193,13 +193,14 @@ exports.inputProcessing = (user = {}, table, row) => { /** * This function enriches the input rows with anything they are supposed to contain, for example * link records or attachment links. - * @param {string} appId the ID of the application for which rows are being enriched. + * @param {object} ctx the request which is looking for enriched rows. * @param {object} table the table from which these rows came from originally, this is used to determine * the schema of the rows and then enrich. * @param {object[]} rows the rows which are to be enriched. * @returns {object[]} the enriched rows will be returned. */ -exports.outputProcessing = async (appId, table, rows) => { +exports.outputProcessing = async (ctx, table, rows) => { + const appId = ctx.appId let wasArray = true if (!(rows instanceof Array)) { rows = [rows] diff --git a/packages/server/src/utilities/users.js b/packages/server/src/utilities/users.js index 6144397bf1..64fbfb7ea2 100644 --- a/packages/server/src/utilities/users.js +++ b/packages/server/src/utilities/users.js @@ -3,7 +3,7 @@ const { InternalTables } = require("../db/utils") const { getGlobalUser } = require("../utilities/global") exports.getFullUser = async (ctx, userId) => { - const global = await getGlobalUser(ctx.appId, userId) + const global = await getGlobalUser(ctx, ctx.appId, userId) let metadata try { // this will throw an error if the db doesn't exist, or there is no appId diff --git a/packages/server/src/utilities/workerRequests.js b/packages/server/src/utilities/workerRequests.js index cb06b5b8d4..d56111385e 100644 --- a/packages/server/src/utilities/workerRequests.js +++ b/packages/server/src/utilities/workerRequests.js @@ -4,11 +4,11 @@ const { checkSlashesInUrl } = require("./index") const { getDeployedAppID } = require("@budibase/auth/db") const { updateAppRole, getGlobalUser } = require("./global") -function request(ctx, request, noApiKey) { +function request(ctx, request) { if (!request.headers) { request.headers = {} } - if (!noApiKey) { + if (!ctx) { request.headers["x-budibase-api-key"] = env.INTERNAL_API_KEY } if (request.body && Object.keys(request.body).length > 0) { @@ -28,12 +28,13 @@ function request(ctx, request, noApiKey) { exports.request = request -exports.sendSmtpEmail = async (to, from, subject, contents) => { +exports.sendSmtpEmail = async (tenantId, to, from, subject, contents) => { const response = await fetch( checkSlashesInUrl(env.WORKER_URL + `/api/admin/email/send`), request(null, { method: "POST", body: { + tenantId, email: to, from, contents, @@ -77,7 +78,7 @@ exports.getGlobalSelf = async (ctx, appId = null) => { const response = await fetch( checkSlashesInUrl(env.WORKER_URL + endpoint), // we don't want to use API key when getting self - request(ctx, { method: "GET" }, true) + request(ctx, { method: "GET" }) ) if (response.status !== 200) { ctx.throw(400, "Unable to get self globally.") @@ -97,7 +98,7 @@ exports.addAppRoleToUser = async (ctx, appId, roleId, userId = null) => { user = await exports.getGlobalSelf(ctx) endpoint = `/api/admin/users/self` } else { - user = await getGlobalUser(appId, userId) + user = await getGlobalUser(ctx, appId, userId) body._id = userId endpoint = `/api/admin/users` } @@ -121,11 +122,11 @@ exports.addAppRoleToUser = async (ctx, appId, roleId, userId = null) => { return response.json() } -exports.removeAppFromUserRoles = async appId => { +exports.removeAppFromUserRoles = async (ctx, appId) => { const deployedAppId = getDeployedAppID(appId) const response = await fetch( checkSlashesInUrl(env.WORKER_URL + `/api/admin/roles/${deployedAppId}`), - request(null, { + request(ctx, { method: "DELETE", }) ) diff --git a/packages/worker/src/api/controllers/admin/auth.js b/packages/worker/src/api/controllers/admin/auth.js index cc86f32321..669ed3cfad 100644 --- a/packages/worker/src/api/controllers/admin/auth.js +++ b/packages/worker/src/api/controllers/admin/auth.js @@ -1,14 +1,12 @@ const authPkg = require("@budibase/auth") const { google } = require("@budibase/auth/src/middleware") const { Configs, EmailTemplatePurpose } = require("../../../constants") -const CouchDB = require("../../../db") const { sendEmail, isEmailConfigured } = require("../../../utilities/email") const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils const { Cookies } = authPkg.constants const { passport } = authPkg.auth const { checkResetPasswordCode } = require("../../../utilities/redis") - -const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name +const { getGlobalDB } = authPkg.db async function authInternal(ctx, user, err = null) { if (err) { @@ -46,7 +44,8 @@ exports.authenticate = async (ctx, next) => { */ exports.reset = async ctx => { const { email } = ctx.request.body - const configured = await isEmailConfigured() + const tenantId = ctx.params.tenantId + const configured = await isEmailConfigured(tenantId) if (!configured) { ctx.throw( 400, @@ -54,10 +53,10 @@ exports.reset = async ctx => { ) } try { - const user = await getGlobalUserByEmail(email) + const user = await getGlobalUserByEmail(email, tenantId) // only if user exists, don't error though if they don't if (user) { - await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { + await sendEmail(tenantId, email, EmailTemplatePurpose.PASSWORD_RECOVERY, { user, subject: "{{ company }} platform password reset", }) @@ -77,7 +76,7 @@ exports.resetUpdate = async ctx => { const { resetCode, password } = ctx.request.body try { const userId = await checkResetPasswordCode(resetCode) - const db = new CouchDB(GLOBAL_DB) + const db = new getGlobalDB(ctx.params.tenantId) const user = await db.get(userId) user.password = await hash(password) await db.put(user) @@ -99,7 +98,7 @@ exports.logout = async ctx => { * On a successful login, you will be redirected to the googleAuth callback route. */ exports.googlePreAuth = async (ctx, next) => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDB(ctx.params.tenantId) const config = await authPkg.db.getScopedConfig(db, { type: Configs.GOOGLE, workspace: ctx.query.workspace, @@ -112,7 +111,7 @@ exports.googlePreAuth = async (ctx, next) => { } exports.googleAuth = async (ctx, next) => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDB(ctx.params.tenantId) const config = await authPkg.db.getScopedConfig(db, { type: Configs.GOOGLE, diff --git a/packages/worker/src/api/controllers/admin/configs.js b/packages/worker/src/api/controllers/admin/configs.js index 55d2df6ee7..bf4ded0a29 100644 --- a/packages/worker/src/api/controllers/admin/configs.js +++ b/packages/worker/src/api/controllers/admin/configs.js @@ -1,21 +1,18 @@ const CouchDB = require("../../../db") const { generateConfigID, - StaticDatabases, getConfigParams, getGlobalUserParams, getScopedFullConfig, -} = require("@budibase/auth").db + getGlobalDBFromCtx, + getAllApps, +} = require("@budibase/auth/db") const { Configs } = require("../../../constants") const email = require("../../../utilities/email") const { upload, ObjectStoreBuckets } = require("@budibase/auth").objectStore -const APP_PREFIX = "app_" - -const GLOBAL_DB = StaticDatabases.GLOBAL.name - exports.save = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const { type, workspace, user, config } = ctx.request.body // Config does not exist yet @@ -51,7 +48,7 @@ exports.save = async function (ctx) { } exports.fetch = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const response = await db.allDocs( getConfigParams( { type: ctx.params.type }, @@ -68,7 +65,7 @@ exports.fetch = async function (ctx) { * The hierarchy is type -> workspace -> user. */ exports.find = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const { userId, workspaceId } = ctx.query if (workspaceId && userId) { @@ -101,7 +98,7 @@ exports.find = async function (ctx) { } exports.publicSettings = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) try { // Find the config with the most granular scope based on context const config = await getScopedFullConfig(db, { @@ -139,7 +136,7 @@ exports.upload = async function (ctx) { // add to configuration structure // TODO: right now this only does a global level - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) let cfgStructure = await getScopedFullConfig(db, { type }) if (!cfgStructure) { cfgStructure = { @@ -159,7 +156,7 @@ exports.upload = async function (ctx) { } exports.destroy = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const { id, rev } = ctx.params try { @@ -171,14 +168,13 @@ exports.destroy = async function (ctx) { } exports.configChecklist = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) try { // TODO: Watch get started video // Apps exist - let allDbs = await CouchDB.allDbs() - const appDbNames = allDbs.filter(dbName => dbName.startsWith(APP_PREFIX)) + const apps = (await getAllApps({ CouchDB })) // They have set up SMTP const smtpConfig = await getScopedFullConfig(db, { @@ -199,7 +195,7 @@ exports.configChecklist = async function (ctx) { const adminUser = users.rows.some(row => row.doc.admin) ctx.body = { - apps: appDbNames.length, + apps: apps.length, smtp: !!smtpConfig, adminUser, oauth: !!oauthConfig, diff --git a/packages/worker/src/api/controllers/admin/email.js b/packages/worker/src/api/controllers/admin/email.js index 4e5719e9c1..67b45a110c 100644 --- a/packages/worker/src/api/controllers/admin/email.js +++ b/packages/worker/src/api/controllers/admin/email.js @@ -1,18 +1,18 @@ const { sendEmail } = require("../../../utilities/email") -const CouchDB = require("../../../db") -const authPkg = require("@budibase/auth") - -const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name +const { getGlobalDBFromCtx } = require("@budibase/auth/db") exports.sendEmail = async ctx => { - const { workspaceId, email, userId, purpose, contents, from, subject } = + let { tenantId, workspaceId, email, userId, purpose, contents, from, subject } = ctx.request.body let user if (userId) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) user = await db.get(userId) } - const response = await sendEmail(email, purpose, { + if (!tenantId && ctx.user.tenantId) { + tenantId = ctx.user.tenantId + } + const response = await sendEmail(tenantId, email, purpose, { workspaceId, user, contents, diff --git a/packages/worker/src/api/controllers/admin/templates.js b/packages/worker/src/api/controllers/admin/templates.js index dde92ecca5..352182c197 100644 --- a/packages/worker/src/api/controllers/admin/templates.js +++ b/packages/worker/src/api/controllers/admin/templates.js @@ -1,5 +1,4 @@ -const { generateTemplateID, StaticDatabases } = require("@budibase/auth").db -const CouchDB = require("../../../db") +const { generateTemplateID, getGlobalDBFromCtx } = require("@budibase/auth/db") const { TemplateMetadata, TemplateBindings, @@ -7,10 +6,8 @@ const { } = require("../../../constants") const { getTemplates } = require("../../../constants/templates") -const GLOBAL_DB = StaticDatabases.GLOBAL.name - exports.save = async ctx => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) let template = ctx.request.body if (!template.ownerId) { template.ownerId = GLOBAL_OWNER @@ -42,29 +39,29 @@ exports.definitions = async ctx => { } exports.fetch = async ctx => { - ctx.body = await getTemplates() + ctx.body = await getTemplates(ctx) } exports.fetchByType = async ctx => { - ctx.body = await getTemplates({ + ctx.body = await getTemplates(ctx, { type: ctx.params.type, }) } exports.fetchByOwner = async ctx => { - ctx.body = await getTemplates({ + ctx.body = await getTemplates(ctx, { ownerId: ctx.params.ownerId, }) } exports.find = async ctx => { - ctx.body = await getTemplates({ + ctx.body = await getTemplates(ctx, { id: ctx.params.id, }) } exports.destroy = async ctx => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) await db.remove(ctx.params.id, ctx.params.rev) ctx.message = `Template ${ctx.params.id} deleted.` ctx.status = 200 diff --git a/packages/worker/src/api/controllers/admin/users.js b/packages/worker/src/api/controllers/admin/users.js index f524379266..73c10d007d 100644 --- a/packages/worker/src/api/controllers/admin/users.js +++ b/packages/worker/src/api/controllers/admin/users.js @@ -1,17 +1,43 @@ -const CouchDB = require("../../../db") -const { generateGlobalUserID, getGlobalUserParams, StaticDatabases } = - require("@budibase/auth").db -const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils +const { + generateGlobalUserID, + getGlobalUserParams, + getGlobalDB, + getGlobalDBFromCtx, + StaticDatabases +} = require("@budibase/auth/db") +const { hash, getGlobalUserByEmail, newid } = require("@budibase/auth").utils const { UserStatus, EmailTemplatePurpose } = require("../../../constants") const { checkInviteCode } = require("../../../utilities/redis") const { sendEmail } = require("../../../utilities/email") const { user: userCache } = require("@budibase/auth/cache") const { invalidateSessions } = require("@budibase/auth/sessions") +const CouchDB = require("../../../db") -const GLOBAL_DB = StaticDatabases.GLOBAL.name +const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name +const tenantDocId = StaticDatabases.PLATFORM_INFO.docs.tenants -async function allUsers() { - const db = new CouchDB(GLOBAL_DB) +async function noTenantsExist() { + const db = new CouchDB(PLATFORM_INFO_DB) + const tenants = await db.get(tenantDocId) + return !tenants || !tenants.tenantIds || tenants.tenantIds.length === 0 +} + +async function tryAddTenant(tenantId) { + const db = new CouchDB(PLATFORM_INFO_DB) + let tenants = await db.get(tenantDocId) + if (!tenants || !Array.isArray(tenants.tenantIds)) { + tenants = { + tenantIds: [], + } + } + if (tenants.tenantIds.indexOf(tenantId) === -1) { + tenants.tenantIds.push(tenantId) + await db.put(tenants) + } +} + +async function allUsers(ctx) { + const db = getGlobalDBFromCtx(ctx) const response = await db.allDocs( getGlobalUserParams(null, { include_docs: true, @@ -20,16 +46,19 @@ async function allUsers() { return response.rows.map(row => row.doc) } -exports.save = async ctx => { - const db = new CouchDB(GLOBAL_DB) - const { email, password, _id } = ctx.request.body - +async function saveUser(user, tenantId) { + if (!tenantId) { + throw "No tenancy specified." + } + const db = getGlobalDB(tenantId) + await tryAddTenant(tenantId) + const { email, password, _id } = user // make sure another user isn't using the same email let dbUser if (email) { dbUser = await getGlobalUserByEmail(email) if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) { - ctx.throw(400, "Email address already in use.") + throw "Email address already in use." } } else { dbUser = await db.get(_id) @@ -42,14 +71,15 @@ exports.save = async ctx => { } else if (dbUser) { hashedPassword = dbUser.password } else { - ctx.throw(400, "Password must be specified.") + throw "Password must be specified." } - let user = { + user = { ...dbUser, - ...ctx.request.body, + ...user, _id: _id || generateGlobalUserID(), password: hashedPassword, + tenantId, } // make sure the roles object is always present if (!user.roles) { @@ -65,34 +95,37 @@ exports.save = async ctx => { ...user, }) await userCache.invalidateUser(response.id) - ctx.body = { + return { _id: response.id, _rev: response.rev, email, } } catch (err) { if (err.status === 409) { - ctx.throw(400, "User exists already") + throw "User exists already" } else { - ctx.throw(err.status, err) + throw err } } } -exports.adminUser = async ctx => { - const db = new CouchDB(GLOBAL_DB) - const response = await db.allDocs( - getGlobalUserParams(null, { - include_docs: true, - }) - ) +exports.save = async ctx => { + // this always stores the user into the requesting users tenancy + const tenantId = ctx.user.tenantId + try { + ctx.body = await saveUser(ctx.request.body, tenantId) + } catch (err) { + ctx.throw(err.status || 400, err) + } +} - if (response.rows.some(row => row.doc.admin)) { +exports.adminUser = async ctx => { + if (!await noTenantsExist()) { ctx.throw(403, "You cannot initialise once an admin user has been created.") } const { email, password } = ctx.request.body - ctx.request.body = { + const user = { email: email, password: password, roles: {}, @@ -103,11 +136,15 @@ exports.adminUser = async ctx => { global: true, }, } - await exports.save(ctx) + try { + ctx.body = await saveUser(user, newid()) + } catch (err) { + ctx.throw(err.status || 400, err) + } } exports.destroy = async ctx => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const dbUser = await db.get(ctx.params.id) await db.remove(dbUser._id, dbUser._rev) await userCache.invalidateUser(dbUser._id) @@ -119,7 +156,7 @@ exports.destroy = async ctx => { exports.removeAppRole = async ctx => { const { appId } = ctx.params - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const users = await allUsers() const bulk = [] const cacheInvalidations = [] @@ -149,7 +186,7 @@ exports.getSelf = async ctx => { } exports.updateSelf = async ctx => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const user = await db.get(ctx.user._id) if (ctx.request.body.password) { ctx.request.body.password = await hash(ctx.request.body.password) @@ -170,7 +207,7 @@ exports.updateSelf = async ctx => { // called internally by app server user fetch exports.fetch = async ctx => { - const users = await allUsers() + const users = await allUsers(ctx) // user hashed password shouldn't ever be returned for (let user of users) { if (user) { @@ -182,7 +219,7 @@ exports.fetch = async ctx => { // called internally by app server user find exports.find = async ctx => { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) let user try { user = await db.get(ctx.params.id) @@ -198,11 +235,12 @@ exports.find = async ctx => { exports.invite = async ctx => { const { email, userInfo } = ctx.request.body - const existing = await getGlobalUserByEmail(email) + const tenantId = ctx.user.tenantId + const existing = await getGlobalUserByEmail(email, tenantId) if (existing) { ctx.throw(400, "Email address already in use.") } - await sendEmail(email, EmailTemplatePurpose.INVITATION, { + await sendEmail(tenantId, email, EmailTemplatePurpose.INVITATION, { subject: "{{ company }} platform invitation", info: userInfo, }) diff --git a/packages/worker/src/api/controllers/admin/workspaces.js b/packages/worker/src/api/controllers/admin/workspaces.js index e99155ffb6..233f34576c 100644 --- a/packages/worker/src/api/controllers/admin/workspaces.js +++ b/packages/worker/src/api/controllers/admin/workspaces.js @@ -1,11 +1,8 @@ -const CouchDB = require("../../../db") -const { getWorkspaceParams, generateWorkspaceID, StaticDatabases } = - require("@budibase/auth").db - -const GLOBAL_DB = StaticDatabases.GLOBAL.name +const { getWorkspaceParams, generateWorkspaceID, getGlobalDBFromCtx } = + require("@budibase/auth/db") exports.save = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const workspaceDoc = ctx.request.body // workspace does not exist yet @@ -25,7 +22,7 @@ exports.save = async function (ctx) { } exports.fetch = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const response = await db.allDocs( getWorkspaceParams(undefined, { include_docs: true, @@ -35,7 +32,7 @@ exports.fetch = async function (ctx) { } exports.find = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) try { ctx.body = await db.get(ctx.params.id) } catch (err) { @@ -44,7 +41,7 @@ exports.find = async function (ctx) { } exports.destroy = async function (ctx) { - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDBFromCtx(ctx) const { id, rev } = ctx.params try { diff --git a/packages/worker/src/api/routes/admin/auth.js b/packages/worker/src/api/routes/admin/auth.js index 04e30fc006..c26acec6c4 100644 --- a/packages/worker/src/api/routes/admin/auth.js +++ b/packages/worker/src/api/routes/admin/auth.js @@ -30,14 +30,14 @@ function buildResetUpdateValidation() { router .post("/api/admin/auth", buildAuthValidation(), authController.authenticate) - .post("/api/admin/auth/reset", buildResetValidation(), authController.reset) + .post("/api/admin/auth/:tenantId/reset", buildResetValidation(), authController.reset) .post( - "/api/admin/auth/reset/update", + "/api/admin/auth/:tenantId/reset/update", buildResetUpdateValidation(), authController.resetUpdate ) .post("/api/admin/auth/logout", authController.logout) - .get("/api/admin/auth/google", authController.googlePreAuth) - .get("/api/admin/auth/google/callback", authController.googleAuth) + .get("/api/admin/auth/:tenantId/google", authController.googlePreAuth) + .get("/api/admin/auth/:tenantId/google/callback", authController.googleAuth) module.exports = router diff --git a/packages/worker/src/constants/templates/index.js b/packages/worker/src/constants/templates/index.js index c677f504c4..026ebf6b91 100644 --- a/packages/worker/src/constants/templates/index.js +++ b/packages/worker/src/constants/templates/index.js @@ -6,8 +6,7 @@ const { GLOBAL_OWNER, } = require("../index") const { join } = require("path") -const CouchDB = require("../../db") -const { getTemplateParams, StaticDatabases } = require("@budibase/auth").db +const { getTemplateParams, getGlobalDBFromCtx } = require("@budibase/auth/db") exports.EmailTemplates = { [EmailTemplatePurpose.PASSWORD_RECOVERY]: readStaticFile( @@ -49,8 +48,8 @@ exports.addBaseTemplates = (templates, type = null) => { return templates } -exports.getTemplates = async ({ ownerId, type, id } = {}) => { - const db = new CouchDB(StaticDatabases.GLOBAL.name) +exports.getTemplates = async (ctx, { ownerId, type, id } = {}) => { + const db = getGlobalDBFromCtx(ctx) const response = await db.allDocs( getTemplateParams(ownerId || GLOBAL_OWNER, id, { include_docs: true, @@ -67,7 +66,7 @@ exports.getTemplates = async ({ ownerId, type, id } = {}) => { return exports.addBaseTemplates(templates, type) } -exports.getTemplateByPurpose = async (type, purpose) => { - const templates = await exports.getTemplates({ type }) +exports.getTemplateByPurpose = async (ctx, type, purpose) => { + const templates = await exports.getTemplates(ctx, { type }) return templates.find(template => template.purpose === purpose) } diff --git a/packages/worker/src/utilities/email.js b/packages/worker/src/utilities/email.js index d0441d5522..38fafd1014 100644 --- a/packages/worker/src/utilities/email.js +++ b/packages/worker/src/utilities/email.js @@ -1,6 +1,5 @@ const nodemailer = require("nodemailer") -const CouchDB = require("../db") -const { StaticDatabases, getScopedConfig } = require("@budibase/auth").db +const { getGlobalDB, getScopedConfig } = require("@budibase/auth/db") const { EmailTemplatePurpose, TemplateTypes, Configs } = require("../constants") const { getTemplateByPurpose } = require("../constants/templates") const { getSettingsTemplateContext } = require("./templates") @@ -8,7 +7,6 @@ const { processString } = require("@budibase/string-templates") const { getResetPasswordCode, getInviteCode } = require("../utilities/redis") const TEST_MODE = false -const GLOBAL_DB = StaticDatabases.GLOBAL.name const TYPE = TemplateTypes.EMAIL const FULL_EMAIL_PURPOSES = [ @@ -116,15 +114,14 @@ async function getSmtpConfiguration(db, workspaceId = null) { /** * Checks if a SMTP config exists based on passed in parameters. - * @param workspaceId * @return {Promise} returns true if there is a configuration that can be used. */ -exports.isEmailConfigured = async (workspaceId = null) => { +exports.isEmailConfigured = async (tenantId, workspaceId = null) => { // when "testing" simply return true if (TEST_MODE) { return true } - const db = new CouchDB(GLOBAL_DB) + const db = getGlobalDB(tenantId) const config = await getSmtpConfiguration(db, workspaceId) return config != null } @@ -132,6 +129,7 @@ exports.isEmailConfigured = async (workspaceId = null) => { /** * Given an email address and an email purpose this will retrieve the SMTP configuration and * send an email using it. + * @param {string} tenantId The tenant which is sending them email. * @param {string} email The email address to send to. * @param {string} purpose The purpose of the email being sent (e.g. reset password). * @param {string|undefined} workspaceId If finer grain controls being used then this will lookup config for workspace. @@ -144,11 +142,12 @@ exports.isEmailConfigured = async (workspaceId = null) => { * nodemailer response. */ exports.sendEmail = async ( + tenantId, email, purpose, { workspaceId, user, from, contents, subject, info } = {} ) => { - const db = new CouchDB(GLOBAL_DB) + const db = new getGlobalDB(tenantId) let config = (await getSmtpConfiguration(db, workspaceId)) || {} if (Object.keys(config).length === 0 && !TEST_MODE) { throw "Unable to find SMTP configuration." @@ -156,7 +155,7 @@ exports.sendEmail = async ( const transport = createSMTPTransport(config) // if there is a link code needed this will retrieve it const code = await getLinkCode(purpose, email, user, info) - const context = await getSettingsTemplateContext(purpose, code) + const context = await getSettingsTemplateContext(tenantId, purpose, code) const message = { from: from || config.from, to: email, diff --git a/packages/worker/src/utilities/templates.js b/packages/worker/src/utilities/templates.js index 3ac897c10f..dfd139fb84 100644 --- a/packages/worker/src/utilities/templates.js +++ b/packages/worker/src/utilities/templates.js @@ -1,5 +1,4 @@ -const CouchDB = require("../db") -const { getScopedConfig, StaticDatabases } = require("@budibase/auth").db +const { getScopedConfig, getGlobalDB } = require("@budibase/auth/db") const { Configs, InternalTemplateBindings, @@ -12,8 +11,8 @@ const env = require("../environment") const LOCAL_URL = `http://localhost:${env.CLUSTER_PORT || 10000}` const BASE_COMPANY = "Budibase" -exports.getSettingsTemplateContext = async (purpose, code = null) => { - const db = new CouchDB(StaticDatabases.GLOBAL.name) +exports.getSettingsTemplateContext = async (tenantId, purpose, code = null) => { + const db = new getGlobalDB(tenantId) // TODO: use more granular settings in the future if required let settings = (await getScopedConfig(db, { type: Configs.SETTINGS })) || {} if (!settings || !settings.platformUrl) {