diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js index d31f9838f4..7cb0c0fc23 100644 --- a/packages/backend-core/src/errors/base.js +++ b/packages/backend-core/src/errors/base.js @@ -1,8 +1,8 @@ class BudibaseError extends Error { - constructor(message, type, code) { + constructor(message, code, type) { super(message) - this.type = type this.code = code + this.type = type } } diff --git a/packages/backend-core/src/errors/generic.js b/packages/backend-core/src/errors/generic.js new file mode 100644 index 0000000000..5c7661f035 --- /dev/null +++ b/packages/backend-core/src/errors/generic.js @@ -0,0 +1,11 @@ +const { BudibaseError } = require("./base") + +class GenericError extends BudibaseError { + constructor(message, code, type) { + super(message, code, type ? type : "generic") + } +} + +module.exports = { + GenericError, +} diff --git a/packages/backend-core/src/errors/http.js b/packages/backend-core/src/errors/http.js new file mode 100644 index 0000000000..342d663da7 --- /dev/null +++ b/packages/backend-core/src/errors/http.js @@ -0,0 +1,12 @@ +const { GenericError } = require("./generic") + +class HTTPError extends GenericError { + constructor(message, httpStatus, code, type) { + super(message, code ? code : "http", type) + this.status = httpStatus + } +} + +module.exports = { + HTTPError, +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.js index 4f3b4e0c41..58b4eea8c5 100644 --- a/packages/backend-core/src/errors/index.js +++ b/packages/backend-core/src/errors/index.js @@ -1,12 +1,11 @@ +const http = require("./http") const licensing = require("./licensing") const codes = { ...licensing.codes, } -const types = { - ...licensing.types, -} +const types = [licensing.type] const context = { ...licensing.context, @@ -36,6 +35,9 @@ const getPublicError = err => { module.exports = { codes, types, - UsageLimitError: licensing.UsageLimitError, + errors: { + UsageLimitError: licensing.UsageLimitError, + HTTPError: http.HTTPError, + }, getPublicError, } diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js index c05f9c561e..0d8ce08146 100644 --- a/packages/backend-core/src/errors/licensing.js +++ b/packages/backend-core/src/errors/licensing.js @@ -1,8 +1,6 @@ -const { BudibaseError } = require("./base") +const { HTTPError } = require("./http") -const types = { - LICENSE_ERROR: "license_error", -} +const type = "license_error" const codes = { USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", @@ -16,16 +14,15 @@ const context = { }, } -class UsageLimitError extends BudibaseError { +class UsageLimitError extends HTTPError { constructor(message, limitName) { - super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED) + super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type) this.limitName = limitName - this.status = 400 } } module.exports = { - types, + type, codes, context, UsageLimitError, diff --git a/packages/backend-core/src/events/constants.js b/packages/backend-core/src/events/constants.js index e99c45feb7..c35fc71ab8 100644 --- a/packages/backend-core/src/events/constants.js +++ b/packages/backend-core/src/events/constants.js @@ -59,8 +59,10 @@ exports.Events = { // ROLE ROLE_CREATED: "role:created", + ROLE_UPDATED: "role:updated", ROLE_DELETED: "role:deleted", ROLE_ASSIGNED: "role:assigned", + ROLE_UNASSIGNED: "role:unassigned", // APP / CLIENT CLIENT_SERVED: "client:served", diff --git a/packages/backend-core/src/events/events.js b/packages/backend-core/src/events/events.js index 8a51c602c7..56fe069c7e 100644 --- a/packages/backend-core/src/events/events.js +++ b/packages/backend-core/src/events/events.js @@ -4,7 +4,7 @@ const analytics = require("../analytics") const logEvent = messsage => { const tenantId = getTenantId() const userId = getTenantId() // TODO - console.log(`[tenant=${tenantId}] [user=${userId}] ${messsage}`) + console.log(`[audit] [tenant=${tenantId}] [user=${userId}] ${messsage}`) } exports.processEvent = (event, properties) => { diff --git a/packages/backend-core/src/events/handlers/index.js b/packages/backend-core/src/events/handlers/index.js index 5b825e7ad9..c9007ae28f 100644 --- a/packages/backend-core/src/events/handlers/index.js +++ b/packages/backend-core/src/events/handlers/index.js @@ -8,6 +8,7 @@ const license = require("./license") const layout = require("./layout") const org = require("./org") const query = require("./query") +const role = require("./role") const row = require("./screen") const table = require("./table") const serve = require("./serve") @@ -25,6 +26,7 @@ module.exports = { layout, org, query, + role, row, table, serve, diff --git a/packages/backend-core/src/events/handlers/license.js b/packages/backend-core/src/events/handlers/license.js index 2696fc5df0..68107e8fd9 100644 --- a/packages/backend-core/src/events/handlers/license.js +++ b/packages/backend-core/src/events/handlers/license.js @@ -1,26 +1,31 @@ const events = require("../events") const { Events } = require("../constants") +// TODO exports.updgraded = () => { const properties = {} events.processEvent(Events.LICENSE_UPGRADED, properties) } +// TODO exports.downgraded = () => { const properties = {} events.processEvent(Events.LICENSE_DOWNGRADED, properties) } +// TODO exports.updated = () => { const properties = {} events.processEvent(Events.LICENSE_UPDATED, properties) } +// TODO exports.activated = () => { const properties = {} events.processEvent(Events.LICENSE_ACTIVATED, properties) } +// TODO exports.quotaExceeded = (quotaName, value) => { const properties = { name: quotaName, diff --git a/packages/backend-core/src/events/handlers/query.js b/packages/backend-core/src/events/handlers/query.js index 9f01788a05..d878c79610 100644 --- a/packages/backend-core/src/events/handlers/query.js +++ b/packages/backend-core/src/events/handlers/query.js @@ -1,23 +1,24 @@ const events = require("../events") const { Events } = require("../constants") -exports.created = () => { +/* eslint-disable */ + +exports.created = (datasource, query) => { const properties = {} events.processEvent(Events.QUERY_CREATED, properties) } -exports.updated = () => { +exports.updated = (datasource, query) => { const properties = {} events.processEvent(Events.QUERY_UPDATED, properties) } -exports.deleted = () => { +exports.deleted = (datasource, query) => { const properties = {} events.processEvent(Events.QUERY_DELETED, properties) } -// TODO -exports.import = () => { +exports.import = (datasource, importSource, count) => { const properties = {} events.processEvent(Events.QUERY_IMPORT, properties) } @@ -28,8 +29,7 @@ exports.import = () => { // events.processEvent(Events.QUERY_RUN, properties) // } -// TODO -exports.previewed = () => { +exports.previewed = datasource => { const properties = {} events.processEvent(Events.QUERY_PREVIEWED, properties) } diff --git a/packages/backend-core/src/events/handlers/role.js b/packages/backend-core/src/events/handlers/role.js index 16b342f214..1181af01ed 100644 --- a/packages/backend-core/src/events/handlers/role.js +++ b/packages/backend-core/src/events/handlers/role.js @@ -1,19 +1,29 @@ const events = require("../events") const { Events } = require("../constants") -exports.created = () => { +/* eslint-disable */ + +exports.created = role => { const properties = {} events.processEvent(Events.ROLE_CREATED, properties) } -// TODO -exports.deleted = () => { +exports.updated = role => { + const properties = {} + events.processEvent(Events.ROLE_UPDATED, properties) +} + +exports.deleted = role => { const properties = {} events.processEvent(Events.ROLE_DELETED, properties) } -// TODO -exports.assigned = () => { +exports.assigned = (user, role) => { const properties = {} events.processEvent(Events.ROLE_ASSIGNED, properties) } + +exports.unassigned = (user, role) => { + const properties = {} + events.processEvent(Events.ROLE_UNASSIGNED, properties) +} diff --git a/packages/backend-core/src/events/handlers/user.js b/packages/backend-core/src/events/handlers/user.js index 7d1bc592e8..8cbb627797 100644 --- a/packages/backend-core/src/events/handlers/user.js +++ b/packages/backend-core/src/events/handlers/user.js @@ -1,85 +1,87 @@ const events = require("../events") const { Events } = require("../constants") -// TODO -exports.created = () => { +/* eslint-disable */ + +exports.created = user => { const properties = {} events.processEvent(Events.USER_CREATED, properties) } -// TODO -exports.updated = () => { +exports.updated = user => { const properties = {} events.processEvent(Events.USER_UPDATED, properties) } -exports.deleted = () => { +exports.deleted = user => { const properties = {} events.processEvent(Events.USER_DELETED, properties) } // TODO -exports.passwordForceReset = () => { +exports.passwordForceReset = user => { const properties = {} events.processEvent(Events.USER_PASSWORD_FORCE_RESET, properties) } // PERMISSIONS -// TODO -exports.permissionAdminAssigned = () => { +exports.permissionAdminAssigned = user => { const properties = {} events.processEvent(Events.USER_PERMISSION_ADMIN_ASSIGNED, properties) } -// TODO -exports.permissionAdminRemoved = () => { +exports.permissionAdminRemoved = user => { const properties = {} events.processEvent(Events.USER_PERMISSION_ADMIN_REMOVED, properties) } -// TODO -exports.permissionBuilderAssigned = () => { +exports.permissionBuilderAssigned = user => { const properties = {} events.processEvent(Events.USER_PERMISSION_BUILDER_ASSIGNED, properties) } -// TODO -exports.permissionBuilderRemoved = () => { +exports.permissionBuilderRemoved = user => { const properties = {} events.processEvent(Events.USER_PERMISSION_BUILDER_REMOVED, properties) } // INVITE -exports.invited = () => { +// TODO +exports.invited = user => { const properties = {} events.processEvent(Events.USER_INVITED, properties) } -exports.inviteAccepted = () => { +// TODO +exports.inviteAccepted = user => { const properties = {} events.processEvent(Events.USER_INVITED_ACCEPTED, properties) } // SELF -exports.selfUpdated = () => { +// TODO +exports.selfUpdated = user => { const properties = {} events.processEvent(Events.USER_SELF_UPDATED, properties) } -exports.selfPasswordUpdated = () => { +// TODO +exports.selfPasswordUpdated = user => { const properties = {} events.processEvent(Events.USER_SELF_PASSWORD_UPDATED, properties) } -exports.passwordResetRequested = () => { +// TODO +exports.passwordResetRequested = user => { const properties = {} events.processEvent(Events.USER_PASSWORD_RESET_REQUESTED, properties) } -exports.passwordReset = () => { +// TODO +exports.passwordReset = user => { const properties = {} events.processEvent(Events.USER_PASSWORD_RESET, properties) } diff --git a/packages/backend-core/src/index.js b/packages/backend-core/src/index.js index 8450ba58d6..b255567639 100644 --- a/packages/backend-core/src/index.js +++ b/packages/backend-core/src/index.js @@ -1,4 +1,5 @@ const db = require("./db") +const errors = require("./errors") module.exports = { init(opts = {}) { @@ -11,15 +12,19 @@ module.exports = { redis: require("../redis"), objectStore: require("../objectStore"), utils: require("../utils"), + users: require("./users"), cache: require("../cache"), auth: require("../auth"), constants: require("../constants"), migrations: require("../migrations"), errors: require("./errors"), + ...errors.errors, env: require("./environment"), accounts: require("./cloud/accounts"), tenancy: require("./tenancy"), featureFlags: require("./featureFlags"), events: require("./events"), analytics: require("./analytics"), + sessions: require("./security/sessions"), + deprovisioning: require("./context/deprovision"), } diff --git a/packages/backend-core/src/middleware/passport/local.js b/packages/backend-core/src/middleware/passport/local.js index 2149bd3e18..9377d895da 100644 --- a/packages/backend-core/src/middleware/passport/local.js +++ b/packages/backend-core/src/middleware/passport/local.js @@ -2,7 +2,7 @@ const jwt = require("jsonwebtoken") const { UserStatus } = require("../../constants") const { compare } = require("../../hashing") const env = require("../../environment") -const { getGlobalUserByEmail } = require("../../utils") +const users = require("../../users") const { authError } = require("./utils") const { newid } = require("../../hashing") const { createASession } = require("../../security/sessions") @@ -28,7 +28,7 @@ exports.authenticate = async function (ctx, email, password, done) { if (!email) return authError(done, "Email Required") if (!password) return authError(done, "Password Required") - const dbUser = await getGlobalUserByEmail(email) + const dbUser = await users.getGlobalUserByEmail(email) if (dbUser == null) { return authError(done, "User not found") } diff --git a/packages/backend-core/src/middleware/passport/third-party-common.js b/packages/backend-core/src/middleware/passport/third-party-common.js index 3fbfb145bc..44d80f7ee7 100644 --- a/packages/backend-core/src/middleware/passport/third-party-common.js +++ b/packages/backend-core/src/middleware/passport/third-party-common.js @@ -4,7 +4,7 @@ const { generateGlobalUserID } = require("../../db/utils") const { authError } = require("./utils") const { newid } = require("../../hashing") const { createASession } = require("../../security/sessions") -const { getGlobalUserByEmail } = require("../../utils") +const users = require("../../users") const { getGlobalDB, getTenantId } = require("../../tenancy") const fetch = require("node-fetch") @@ -52,7 +52,7 @@ exports.authenticateThirdParty = async function ( // fallback to loading by email if (!dbUser) { - dbUser = await getGlobalUserByEmail(thirdPartyUser.email) + dbUser = await users.getGlobalUserByEmail(thirdPartyUser.email) } // exit early if there is still no user and auto creation is disabled @@ -81,7 +81,7 @@ exports.authenticateThirdParty = async function ( // create or sync the user let response try { - response = await saveUserFn(dbUser, getTenantId(), false, false) + response = await saveUserFn(dbUser, false, false) } catch (err) { return authError(done, err) } diff --git a/packages/backend-core/src/tests/utilities/mocks/events.js b/packages/backend-core/src/tests/utilities/mocks/events.js index c33111501f..ad7ca3fee9 100644 --- a/packages/backend-core/src/tests/utilities/mocks/events.js +++ b/packages/backend-core/src/tests/utilities/mocks/events.js @@ -62,6 +62,29 @@ jest.mock("../../../events", () => { import: jest.fn(), previewed: jest.fn(), }, + role: { + created: jest.fn(), + updated: jest.fn(), + deleted: jest.fn(), + assigned: jest.fn(), + unassigned: jest.fn(), + }, + user: { + created: jest.fn(), + updated: jest.fn(), + deleted: jest.fn(), + passwordForceReset: jest.fn(), + permissionAdminAssigned: jest.fn(), + permissionAdminRemoved: jest.fn(), + permissionBuilderAssigned: jest.fn(), + permissionBuilderRemoved: jest.fn(), + invited: jest.fn(), + inviteAccepted: jest.fn(), + selfUpdated: jest.fn(), + selfPasswordUpdated: jest.fn(), + passwordResetRequested: jest.fn(), + passwordReset: jest.fn(), + }, } }) diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js new file mode 100644 index 0000000000..07a60f2884 --- /dev/null +++ b/packages/backend-core/src/users.js @@ -0,0 +1,19 @@ +const { ViewNames } = require("./db/utils") +const { queryGlobalView } = require("./db/views") + +/** + * 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. + * @return {Promise} + */ +exports.getGlobalUserByEmail = async email => { + if (email == null) { + throw "Must supply an email address to view" + } + + return queryGlobalView(ViewNames.USER_BY_EMAIL, { + key: email.toLowerCase(), + include_docs: true, + }) +} diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 302b4a7bf8..7d085d24c9 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -1,24 +1,10 @@ -const { - DocumentTypes, - SEPARATOR, - ViewNames, - generateGlobalUserID, -} = require("./db/utils") +const { DocumentTypes, SEPARATOR, ViewNames } = require("./db/utils") const jwt = require("jsonwebtoken") const { options } = require("./middleware/passport/jwt") const { queryGlobalView } = require("./db/views") -const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants") -const { - getGlobalDB, - updateTenantId, - getTenantUser, - tryAddTenant, -} = require("./tenancy") -const environment = require("./environment") -const accounts = require("./cloud/accounts") -const { hash } = require("./hashing") -const userCache = require("./cache/user") +const { Headers, Cookies, MAX_VALID_DATE } = require("./constants") const env = require("./environment") +const userCache = require("./cache/user") const { getUserSessions, invalidateSessions } = require("./security/sessions") const events = require("./events") @@ -106,8 +92,8 @@ exports.setCookie = (ctx, value, name = "builder", opts = { sign: true }) => { overwrite: true, } - if (environment.COOKIE_DOMAIN) { - config.domain = environment.COOKIE_DOMAIN + if (env.COOKIE_DOMAIN) { + config.domain = env.COOKIE_DOMAIN } ctx.cookies.set(name, value, config) @@ -130,23 +116,6 @@ exports.isClient = ctx => { return ctx.headers[Headers.TYPE] === "client" } -/** - * 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. - * @return {Promise} - */ -exports.getGlobalUserByEmail = async email => { - if (email == null) { - throw "Must supply an email address to view" - } - - return queryGlobalView(ViewNames.USER_BY_EMAIL, { - key: email.toLowerCase(), - include_docs: true, - }) -} - exports.getBuildersCount = async () => { const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, { include_docs: false, @@ -154,96 +123,6 @@ exports.getBuildersCount = async () => { return builders.length } -exports.saveUser = async ( - user, - tenantId, - hashPassword = true, - requirePassword = true -) => { - if (!tenantId) { - throw "No tenancy specified." - } - // need to set the context for this request, as specified - updateTenantId(tenantId) - // specify the tenancy incase we're making a new admin user (public) - const db = getGlobalDB(tenantId) - let { email, password, _id } = user - // make sure another user isn't using the same email - let dbUser - if (email) { - // check budibase users inside the tenant - dbUser = await exports.getGlobalUserByEmail(email) - if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) { - throw `Email address ${email} already in use.` - } - - // check budibase users in other tenants - if (env.MULTI_TENANCY) { - const tenantUser = await getTenantUser(email) - if (tenantUser != null && tenantUser.tenantId !== tenantId) { - throw `Email address ${email} already in use.` - } - } - - // check root account users in account portal - if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { - const account = await accounts.getAccount(email) - if (account && account.verified && account.tenantId !== tenantId) { - throw `Email address ${email} already in use.` - } - } - } else { - dbUser = await db.get(_id) - } - - // get the password, make sure one is defined - let hashedPassword - if (password) { - hashedPassword = hashPassword ? await hash(password) : password - } else if (dbUser) { - hashedPassword = dbUser.password - } else if (requirePassword) { - throw "Password must be specified." - } - - _id = _id || generateGlobalUserID() - user = { - createdAt: Date.now(), - ...dbUser, - ...user, - _id, - password: hashedPassword, - tenantId, - } - // make sure the roles object is always present - if (!user.roles) { - user.roles = {} - } - // add the active status to a user if its not provided - if (user.status == null) { - user.status = UserStatus.ACTIVE - } - try { - const response = await db.put({ - password: hashedPassword, - ...user, - }) - await tryAddTenant(tenantId, _id, email) - await userCache.invalidateUser(response.id) - return { - _id: response.id, - _rev: response.rev, - email, - } - } catch (err) { - if (err.status === 409) { - throw "User exists already" - } else { - throw err - } - } -} - /** * Logs a user out from budibase. Re-used across account portal and builder. */ diff --git a/packages/server/src/api/controllers/query/import/index.ts b/packages/server/src/api/controllers/query/import/index.ts index f2cf764bf5..745b9ea068 100644 --- a/packages/server/src/api/controllers/query/import/index.ts +++ b/packages/server/src/api/controllers/query/import/index.ts @@ -84,7 +84,7 @@ export class RestImporter { const count = successQueries.length const importSource = this.source.getImportSource() const datasource = await db.get(datasourceId) - events.query.import({ datasource, importSource, count }) + events.query.import(datasource, importSource, count) for (let query of successQueries) { events.query.created(query) } diff --git a/packages/server/src/api/controllers/query/import/tests/index.spec.js b/packages/server/src/api/controllers/query/import/tests/index.spec.js index ad4dd3baed..40818ce945 100644 --- a/packages/server/src/api/controllers/query/import/tests/index.spec.js +++ b/packages/server/src/api/controllers/query/import/tests/index.spec.js @@ -109,8 +109,7 @@ describe("Rest Importer", () => { expect(importResult.errorQueries.length).toBe(0) expect(importResult.queries.length).toBe(assertions[key].count) expect(events.query.import).toBeCalledTimes(1) - const eventData = { datasource, importSource: assertions[key].source, count: assertions[key].count} - expect(events.query.import).toBeCalledWith(eventData) + expect(events.query.import).toBeCalledWith(datasource, assertions[key].source, assertions[key].count) jest.clearAllMocks() } diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts index 4ee5c38fbf..a9bbdcf53c 100644 --- a/packages/server/src/api/controllers/query/index.ts +++ b/packages/server/src/api/controllers/query/index.ts @@ -216,9 +216,12 @@ const removeDynamicVariables = async (queryId: any) => { export async function destroy(ctx: any) { const db = getAppDB() - await removeDynamicVariables(ctx.params.queryId) + const queryId = ctx.params.queryId + await removeDynamicVariables(queryId) + const query = await db.get(queryId) + const datasource = await db.get(query.datasourceId) await db.remove(ctx.params.queryId, ctx.params.revId) ctx.message = `Query deleted.` ctx.status = 200 - events.query.deleted() + events.query.deleted(datasource, query) } diff --git a/packages/server/src/api/controllers/role.js b/packages/server/src/api/controllers/role.js index 11b4b9a520..8f28847266 100644 --- a/packages/server/src/api/controllers/role.js +++ b/packages/server/src/api/controllers/role.js @@ -10,6 +10,7 @@ const { InternalTables, } = require("../../db/utils") const { getAppDB } = require("@budibase/backend-core/context") +const { events } = require("@budibase/backend-core") const UpdateRolesOptions = { CREATED: "created", @@ -50,8 +51,10 @@ exports.find = async function (ctx) { exports.save = async function (ctx) { const db = getAppDB() let { _id, name, inherits, permissionId } = ctx.request.body + let isCreate = false if (!_id) { _id = generateRoleID() + isCreate = true } else if (isBuiltin(_id)) { ctx.throw(400, "Cannot update builtin roles.") } @@ -62,6 +65,11 @@ exports.save = async function (ctx) { role._rev = ctx.request.body._rev } const result = await db.put(role) + if (isCreate) { + events.role.created(role) + } else { + events.role.updated(role) + } await updateRolesOnUserTable(db, _id, UpdateRolesOptions.CREATED) role._rev = result.rev ctx.body = role @@ -71,6 +79,7 @@ exports.save = async function (ctx) { exports.destroy = async function (ctx) { const db = getAppDB() const roleId = ctx.params.roleId + const role = await db.get(roleId) if (isBuiltin(roleId)) { ctx.throw(400, "Cannot delete builtin role.") } @@ -88,6 +97,7 @@ exports.destroy = async function (ctx) { } await db.remove(roleId, ctx.params.rev) + events.role.deleted(role) await updateRolesOnUserTable( db, ctx.params.roleId, diff --git a/packages/server/src/api/routes/tests/query.spec.js b/packages/server/src/api/routes/tests/query.spec.js index 40d6890904..15c70f982f 100644 --- a/packages/server/src/api/routes/tests/query.spec.js +++ b/packages/server/src/api/routes/tests/query.spec.js @@ -189,6 +189,7 @@ describe("/queries", () => { expect(res.body).toEqual([]) expect(events.query.deleted).toBeCalledTimes(1) + expect(events.query.deleted).toBeCalledWith(datasource, query) }) it("should apply authorization to endpoint", async () => { diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js index 31fd9997d8..9f44cbd136 100644 --- a/packages/server/src/api/routes/tests/role.spec.js +++ b/packages/server/src/api/routes/tests/role.spec.js @@ -4,6 +4,7 @@ const { } = require("@budibase/backend-core/permissions") const setup = require("./utilities") const { basicRole } = setup.structures +const { events } = require("@budibase/backend-core") describe("/roles", () => { let request = setup.getRequest() @@ -15,20 +16,48 @@ describe("/roles", () => { await config.init() }) + const createRole = async (role) => { + if (!role) { + role = basicRole() + } + + return request + .post(`/api/roles`) + .send(role) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + describe("create", () => { it("returns a success message when role is successfully created", async () => { - const res = await request - .post(`/api/roles`) - .send(basicRole()) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const res = await createRole() expect(res.res.statusMessage).toEqual( "Role 'NewRole' created successfully." ) expect(res.body._id).toBeDefined() expect(res.body._rev).toBeDefined() + expect(events.role.updated).not.toBeCalled() + expect(events.role.created).toBeCalledTimes(1) + expect(events.role.created).toBeCalledWith(res.body) + }) + }) + + describe("update", () => { + it("updates a role", async () => { + let res = await createRole() + jest.clearAllMocks() + res = await createRole(res.body) + + expect(res.res.statusMessage).toEqual( + "Role 'NewRole' created successfully." + ) + expect(res.body._id).toBeDefined() + expect(res.body._rev).toBeDefined() + expect(events.role.created).not.toBeCalled() + expect(events.role.updated).toBeCalledTimes(1) + expect(events.role.updated).toBeCalledWith(res.body) }) }) @@ -80,8 +109,10 @@ describe("/roles", () => { it("should delete custom roles", async () => { const customRole = await config.createRole({ name: "user", - permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY + permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY, + inherits: BUILTIN_ROLE_IDS.BASIC, }) + delete customRole._rev_tree await request .delete(`/api/roles/${customRole._id}/${customRole._rev}`) .set(config.defaultHeaders()) @@ -90,6 +121,8 @@ describe("/roles", () => { .get(`/api/roles/${customRole._id}`) .set(config.defaultHeaders()) .expect(404) + expect(events.role.deleted).toBeCalledTimes(1) + expect(events.role.deleted).toBeCalledWith(customRole) }) }) }) diff --git a/packages/worker/src/api/controllers/global/auth.ts b/packages/worker/src/api/controllers/global/auth.ts index 5e6413bece..bbd35f96d0 100644 --- a/packages/worker/src/api/controllers/global/auth.ts +++ b/packages/worker/src/api/controllers/global/auth.ts @@ -4,14 +4,7 @@ const { google } = require("@budibase/backend-core/middleware") const { oidc } = require("@budibase/backend-core/middleware") const { Configs, EmailTemplatePurpose } = require("../../../constants") const { sendEmail, isEmailConfigured } = require("../../../utilities/email") -const { - setCookie, - getCookie, - clearCookie, - getGlobalUserByEmail, - hash, - platformLogout, -} = core.utils +const { setCookie, getCookie, clearCookie, hash, platformLogout } = core.utils const { Cookies, Headers } = core.constants const { passport } = core.auth const { checkResetPasswordCode } = require("../../../utilities/redis") @@ -21,8 +14,8 @@ const { isMultiTenant, } = require("@budibase/backend-core/tenancy") const env = require("../../../environment") -import { users } from "@budibase/pro" -const { events } = require("@budibase/backend-core") +const { events, users: usersCore } = require("@budibase/backend-core") +import { users } from "../../../sdk" const ssoCallbackUrl = async (config: any, type: any) => { // incase there is a callback URL from before @@ -112,7 +105,7 @@ export const reset = async (ctx: any) => { ) } try { - const user = await getGlobalUserByEmail(email) + const user = await usersCore.getGlobalUserByEmail(email) // only if user exists, don't error though if they don't if (user) { await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { diff --git a/packages/worker/src/api/controllers/global/roles.js b/packages/worker/src/api/controllers/global/roles.js index eb540860be..4b620f8691 100644 --- a/packages/worker/src/api/controllers/global/roles.js +++ b/packages/worker/src/api/controllers/global/roles.js @@ -7,7 +7,7 @@ const { const { doInAppContext, getAppDB } = require("@budibase/backend-core/context") const { user: userCache } = require("@budibase/backend-core/cache") const { getGlobalDB } = require("@budibase/backend-core/tenancy") -const { allUsers } = require("../../utilities") +const { users } = require("../../../sdk") exports.fetch = async ctx => { const tenantId = ctx.user.tenantId @@ -49,10 +49,10 @@ exports.find = async ctx => { exports.removeAppRole = async ctx => { const { appId } = ctx.params const db = getGlobalDB() - const users = await allUsers(ctx) + const allUsers = await users.allUsers(ctx) const bulk = [] const cacheInvalidations = [] - for (let user of users) { + for (let user of allUsers) { if (user.roles[appId]) { cacheInvalidations.push(userCache.invalidateUser(user._id)) delete user.roles[appId] diff --git a/packages/worker/src/api/controllers/global/self.js b/packages/worker/src/api/controllers/global/self.js index 935fc4259e..fd615ba795 100644 --- a/packages/worker/src/api/controllers/global/self.js +++ b/packages/worker/src/api/controllers/global/self.js @@ -13,7 +13,7 @@ const { } = require("@budibase/backend-core/utils") const { encrypt } = require("@budibase/backend-core/encryption") const { newid } = require("@budibase/backend-core/utils") -const { getUser } = require("../../utilities") +const { users } = require("../../../sdk") const { Cookies } = require("@budibase/backend-core/constants") function newApiKey() { @@ -103,7 +103,7 @@ exports.getSelf = async ctx => { checkCurrentApp(ctx) // get the main body of the user - ctx.body = await getUser(userId) + ctx.body = await users.getUser(userId) addSessionAttributesToUser(ctx) } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index d13b08f94c..9f5bf08155 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -1,32 +1,18 @@ -const { - getGlobalUserParams, - StaticDatabases, -} = require("@budibase/backend-core/db") -const { getGlobalUserByEmail } = require("@budibase/backend-core/utils") import { EmailTemplatePurpose } from "../../../constants" import { checkInviteCode } from "../../../utilities/redis" import { sendEmail } from "../../../utilities/email" -const { user: userCache } = require("@budibase/backend-core/cache") -const { invalidateSessions } = require("@budibase/backend-core/sessions") -const accounts = require("@budibase/backend-core/accounts") +import { users } from "../../../sdk" + const { - getGlobalDB, - getTenantId, - getTenantUser, - doesTenantExist, -} = require("@budibase/backend-core/tenancy") -const { removeUserFromInfoDB } = require("@budibase/backend-core/deprovision") -import env from "../../../environment" -import { syncUserInApps } from "../../../utilities/appService" -import { quotas, users } from "@budibase/pro" -const { errors } = require("@budibase/backend-core") -import { allUsers, getUser } from "../../utilities" + errors, + users: usersCore, + tenancy, + db: dbUtils, +} = require("@budibase/backend-core") export const save = async (ctx: any) => { try { - const user: any = await users.save(ctx.request.body, getTenantId()) - // let server know to sync user - await syncUserInApps(user._id) + const user = await users.save(ctx.request.body) ctx.body = user } catch (err: any) { ctx.throw(err.status || 400, err) @@ -45,36 +31,19 @@ export const adminUser = async (ctx: any) => { // account portal sends no password for SSO users const requirePassword = parseBooleanParam(ctx.request.query.requirePassword) - if (await doesTenantExist(tenantId)) { + if (await tenancy.doesTenantExist(tenantId)) { ctx.throw(403, "Organisation already exists.") } - const db = getGlobalDB(tenantId) + const db = tenancy.getGlobalDB(tenantId) const response = await db.allDocs( - getGlobalUserParams(null, { + dbUtils.getGlobalUserParams(null, { include_docs: true, }) ) - // write usage quotas for cloud - if (!env.SELF_HOSTED) { - // could be a scenario where it exists, make sure its clean - try { - const usageQuota = await db.get(StaticDatabases.GLOBAL.docs.usageQuota) - if (usageQuota) { - await db.remove(usageQuota._id, usageQuota._rev) - } - } catch (err) { - // don't worry about errors - } - await db.put(quotas.generateNewQuotaUsage()) - } - if (response.rows.some((row: any) => row.doc.admin)) { - ctx.throw( - 403, - "You cannot initialise once an global user has been created." - ) + ctx.throw(403, "You cannot initialise once a global user has been created.") } const user = { @@ -91,44 +60,25 @@ export const adminUser = async (ctx: any) => { tenantId, } try { - ctx.body = await users.save(user, tenantId, hashPassword, requirePassword) + ctx.body = await tenancy.doInTenant(tenantId, async () => { + return users.save(user, hashPassword, requirePassword) + }) } catch (err: any) { ctx.throw(err.status || 400, err) } } export const destroy = async (ctx: any) => { - const db = getGlobalDB() - const dbUser = await db.get(ctx.params.id) - - if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { - // root account holder can't be deleted from inside budibase - const email = dbUser.email - const account = await accounts.getAccount(email) - if (account) { - if (email === ctx.user.email) { - ctx.throw(400, 'Please visit "Account" to delete this user') - } else { - ctx.throw(400, "Account holder cannot be deleted") - } - } - } - - await removeUserFromInfoDB(dbUser) - await db.remove(dbUser._id, dbUser._rev) - await quotas.removeUser(dbUser) - await userCache.invalidateUser(dbUser._id) - await invalidateSessions(dbUser._id) - // let server know to sync user - await syncUserInApps(dbUser._id) + const id = ctx.params.id + await users.destroy(id, ctx.user) ctx.body = { - message: `User ${ctx.params.id} deleted.`, + message: `User ${id} deleted.`, } } // called internally by app server user fetch export const fetch = async (ctx: any) => { - const all = await allUsers() + const all = await users.allUsers() // user hashed password shouldn't ever be returned for (let user of all) { if (user) { @@ -140,12 +90,12 @@ export const fetch = async (ctx: any) => { // called internally by app server user find export const find = async (ctx: any) => { - ctx.body = await getUser(ctx.params.id) + ctx.body = await users.getUser(ctx.params.id) } export const tenantUserLookup = async (ctx: any) => { const id = ctx.params.id - const user = await getTenantUser(id) + const user = await tenancy.getTenantUser(id) if (user) { ctx.body = user } else { @@ -155,14 +105,14 @@ export const tenantUserLookup = async (ctx: any) => { export const invite = async (ctx: any) => { let { email, userInfo } = ctx.request.body - const existing = await getGlobalUserByEmail(email) + const existing = await usersCore.getGlobalUserByEmail(email) if (existing) { ctx.throw(400, "Email address already in use.") } if (!userInfo) { userInfo = {} } - userInfo.tenantId = getTenantId() + userInfo.tenantId = tenancy.getTenantId() const opts: any = { subject: "{{ company }} platform invitation", info: userInfo, @@ -178,16 +128,15 @@ export const inviteAccept = async (ctx: any) => { try { // info is an extension of the user object that was stored by global const { email, info }: any = await checkInviteCode(inviteCode) - ctx.body = await users.save( - { + ctx.body = await tenancy.doInTenant(info.tenantId, () => { + return users.save({ firstName, lastName, password, email, ...info, - }, - info.tenantId - ) + }) + }) } catch (err: any) { if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { // explicitly re-throw limit exceeded errors diff --git a/packages/worker/src/api/routes/global/self.js b/packages/worker/src/api/routes/global/self.js index eae16857a8..e1af7c2146 100644 --- a/packages/worker/src/api/routes/global/self.js +++ b/packages/worker/src/api/routes/global/self.js @@ -1,7 +1,7 @@ const Router = require("@koa/router") const controller = require("../../controllers/global/self") const builderOnly = require("../../../middleware/builderOnly") -const { buildUserSaveValidation } = require("../../utilities/validation") +const { users } = require("../validation") const router = Router() @@ -11,7 +11,7 @@ router .get("/api/global/self", controller.getSelf) .post( "/api/global/self", - buildUserSaveValidation(true), + users.buildUserSaveValidation(true), controller.updateSelf ) diff --git a/packages/worker/src/api/routes/global/users.js b/packages/worker/src/api/routes/global/users.js index 5d28d18eb7..3b5421e91f 100644 --- a/packages/worker/src/api/routes/global/users.js +++ b/packages/worker/src/api/routes/global/users.js @@ -4,7 +4,7 @@ const joiValidator = require("../../../middleware/joi-validator") const adminOnly = require("../../../middleware/adminOnly") const Joi = require("joi") const cloudRestricted = require("../../../middleware/cloudRestricted") -const { buildUserSaveValidation } = require("../../utilities/validation") +const { users } = require("../validation") const selfController = require("../../controllers/global/self") const router = Router() @@ -41,7 +41,7 @@ router .post( "/api/global/users", adminOnly, - buildUserSaveValidation(), + users.buildUserSaveValidation(), controller.save ) .get("/api/global/users", adminOnly, controller.fetch) @@ -72,7 +72,7 @@ router .get("/api/global/users/self", selfController.getSelf) .post( "/api/global/users/self", - buildUserSaveValidation(true), + users.buildUserSaveValidation(true), selfController.updateSelf ) diff --git a/packages/worker/src/api/routes/tests/users.spec.js b/packages/worker/src/api/routes/tests/users.spec.js index be9807c5d4..4d94aa0148 100644 --- a/packages/worker/src/api/routes/tests/users.spec.js +++ b/packages/worker/src/api/routes/tests/users.spec.js @@ -1,6 +1,7 @@ jest.mock("nodemailer") -const { config, request, mocks } = require("../../../tests") +const { config, request, mocks, structures } = require("../../../tests") const sendMailMock = mocks.email.mock() +const { events } = require("@budibase/backend-core") describe("/api/global/users", () => { let code @@ -48,4 +49,258 @@ describe("/api/global/users", () => { expect(user).toBeDefined() expect(user._id).toEqual(res.body._id) }) + + const createUser = async (user) => { + const existing = await config.getUser(user.email) + if (existing) { + await deleteUser(existing._id) + } + return saveUser(user) + } + + const updateUser = async (user) => { + const existing = await config.getUser(user.email) + user._id = existing._id + return saveUser(user) + } + + const saveUser = async (user) => { + const res = await request + .post(`/api/global/users`) + .send(user) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + // .expect(200) + return res.body + } + + const deleteUser = async (email) => { + const user = await config.getUser(email) + if (user) { + await request + .delete(`/api/global/users/${user._id}`) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + } + + describe("create", () => { + it("should be able to create a basic user", async () => { + jest.clearAllMocks() + const user = structures.users.user({ email: "basic@test.com" }) + await createUser(user) + + expect(events.user.created).toBeCalledTimes(1) + expect(events.user.updated).not.toBeCalled() + expect(events.user.permissionBuilderAssigned).not.toBeCalled() + expect(events.user.permissionAdminAssigned).not.toBeCalled() + }) + + it("should be able to create an admin user", async () => { + jest.clearAllMocks() + const user = structures.users.adminUser({ email: "admin@test.com" }) + await createUser(user) + + expect(events.user.created).toBeCalledTimes(1) + expect(events.user.updated).not.toBeCalled() + expect(events.user.permissionBuilderAssigned).not.toBeCalled() + expect(events.user.permissionAdminAssigned).toBeCalledTimes(1) + }) + + it("should be able to create a builder user", async () => { + jest.clearAllMocks() + const user = structures.users.builderUser({ email: "builder@test.com" }) + await createUser(user) + + expect(events.user.created).toBeCalledTimes(1) + expect(events.user.updated).not.toBeCalled() + expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1) + expect(events.user.permissionAdminAssigned).not.toBeCalled() + }) + + it("should be able to assign app roles", async () => { + jest.clearAllMocks() + const user = structures.users.user({ email: "assign-roles@test.com" }) + user.roles = { + "app_123": "role1", + "app_456": "role2", + } + + await createUser(user) + + expect(events.user.created).toBeCalledTimes(1) + expect(events.user.updated).not.toBeCalled() + expect(events.role.assigned).toBeCalledTimes(2) + expect(events.role.assigned).toBeCalledWith("role1") + expect(events.role.assigned).toBeCalledWith("role2") + }) + }) + + describe("update", () => { + it("should be able to update a basic user", async () => { + let user = structures.users.user({ email: "basic-update@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionBuilderAssigned).not.toBeCalled() + expect(events.user.permissionAdminAssigned).not.toBeCalled() + }) + + it("should be able to update a basic user to an admin user", async () => { + let user = structures.users.user({ email: "basic-update-admin@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await updateUser(structures.users.adminUser(user)) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionBuilderAssigned).not.toBeCalled() + expect(events.user.permissionAdminAssigned).toBeCalledTimes(1) + }) + + it("should be able to update a basic user to a builder user", async () => { + let user = structures.users.user({ email: "basic-update-builder@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await updateUser(structures.users.builderUser(user)) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionBuilderAssigned).toBeCalledTimes(1) + expect(events.user.permissionAdminAssigned).not.toBeCalled() + }) + + it("should be able to update an admin user to a basic user", async () => { + let user = structures.users.adminUser({ email: "admin-update-basic@test.com" }) + await createUser(user) + jest.clearAllMocks() + + user.admin.global = false + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionAdminRemoved).toBeCalledTimes(1) + expect(events.user.permissionBuilderRemoved).not.toBeCalled() + }) + + it("should be able to update an builder user to a basic user", async () => { + let user = structures.users.builderUser({ email: "builder-update-basic@test.com" }) + await createUser(user) + jest.clearAllMocks() + + user.builder.global = false + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1) + expect(events.user.permissionAdminRemoved).not.toBeCalled() + }) + + it("should be able to assign app roles", async () => { + const user = structures.users.user({ email: "assign-roles-update@test.com" }) + await createUser(user) + jest.clearAllMocks() + + user.roles = { + "app_123": "role1", + "app_456": "role2", + } + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.role.assigned).toBeCalledTimes(2) + expect(events.role.assigned).toBeCalledWith("role1") + expect(events.role.assigned).toBeCalledWith("role2") + }) + + it("should be able to unassign app roles", async () => { + const user = structures.users.user({ email: "unassign-roles@test.com" }) + user.roles = { + "app_123": "role1", + "app_456": "role2", + } + await createUser(user) + jest.clearAllMocks() + + user.roles = {} + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.role.unassigned).toBeCalledTimes(2) + expect(events.role.unassigned).toBeCalledWith("role1") + expect(events.role.unassigned).toBeCalledWith("role2") + }) + + it("should be able to update existing app roles", async () => { + const user = structures.users.user({ email: "update-roles@test.com" }) + user.roles = { + "app_123": "role1", + "app_456": "role2", + } + await createUser(user) + jest.clearAllMocks() + + user.roles = { + "app_123": "role1", + "app_456": "role2-edit", + } + await updateUser(user) + + expect(events.user.created).not.toBeCalled() + expect(events.user.updated).toBeCalledTimes(1) + expect(events.role.unassigned).toBeCalledTimes(1) + expect(events.role.unassigned).toBeCalledWith("role2") + expect(events.role.assigned).toBeCalledTimes(1) + expect(events.role.assigned).toBeCalledWith("role2-edit") + }) + }) + + describe("destroy", () => { + it("should be able to destroy a basic user", async () => { + let user = structures.users.user({ email: "destroy@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await deleteUser(user.email) + + expect(events.user.deleted).toBeCalledTimes(1) + expect(events.user.permissionBuilderRemoved).not.toBeCalled() + expect(events.user.permissionAdminRemoved).not.toBeCalled() + }) + + it("should be able to destroy an admin user", async () => { + let user = structures.users.adminUser({ email: "destroy-admin@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await deleteUser(user.email) + + expect(events.user.deleted).toBeCalledTimes(1) + expect(events.user.permissionBuilderRemoved).not.toBeCalled() + expect(events.user.permissionAdminRemoved).toBeCalledTimes(1) + }) + + it("should be able to destroy a builder user", async () => { + let user = structures.users.builderUser({ email: "destroy-admin@test.com" }) + await createUser(user) + jest.clearAllMocks() + + await deleteUser(user.email) + + expect(events.user.deleted).toBeCalledTimes(1) + expect(events.user.permissionBuilderRemoved).toBeCalledTimes(1) + expect(events.user.permissionAdminRemoved).not.toBeCalled() + }) + }) }) \ No newline at end of file diff --git a/packages/worker/src/api/routes/validation/index.ts b/packages/worker/src/api/routes/validation/index.ts new file mode 100644 index 0000000000..1b1c47b924 --- /dev/null +++ b/packages/worker/src/api/routes/validation/index.ts @@ -0,0 +1 @@ +export * as users from "./users" diff --git a/packages/worker/src/api/utilities/validation.js b/packages/worker/src/api/routes/validation/users.ts similarity index 80% rename from packages/worker/src/api/utilities/validation.js rename to packages/worker/src/api/routes/validation/users.ts index d1b36a9206..81372358ff 100644 --- a/packages/worker/src/api/utilities/validation.js +++ b/packages/worker/src/api/routes/validation/users.ts @@ -1,8 +1,8 @@ -const joiValidator = require("../../middleware/joi-validator") -const Joi = require("joi") +import joiValidator from "../../../middleware/joi-validator" +import Joi from "joi" -exports.buildUserSaveValidation = (isSelf = false) => { - let schema = { +export const buildUserSaveValidation = (isSelf = false) => { + let schema: any = { email: Joi.string().allow(null, ""), password: Joi.string().allow(null, ""), forceResetPassword: Joi.boolean().optional(), diff --git a/packages/worker/src/api/utilities/index.js b/packages/worker/src/api/utilities/index.js deleted file mode 100644 index 2b3605481d..0000000000 --- a/packages/worker/src/api/utilities/index.js +++ /dev/null @@ -1,33 +0,0 @@ -const { getGlobalDB } = require("@budibase/backend-core/tenancy") -const { getGlobalUserParams } = require("@budibase/backend-core/db") - -/** - * Retrieves all users from the current tenancy. - */ -exports.allUsers = async () => { - const db = getGlobalDB() - const response = await db.allDocs( - getGlobalUserParams(null, { - include_docs: true, - }) - ) - return response.rows.map(row => row.doc) -} - -/** - * Gets a user by ID from the global database, based on the current tenancy. - */ -exports.getUser = async userId => { - const db = getGlobalDB() - let user - try { - user = await db.get(userId) - } catch (err) { - // no user found, just return nothing - user = {} - } - if (user) { - delete user.password - } - return user -} diff --git a/packages/worker/src/sdk/index.ts b/packages/worker/src/sdk/index.ts new file mode 100644 index 0000000000..1b1c47b924 --- /dev/null +++ b/packages/worker/src/sdk/index.ts @@ -0,0 +1 @@ +export * as users from "./users" diff --git a/packages/worker/src/sdk/users/events.ts b/packages/worker/src/sdk/users/events.ts new file mode 100644 index 0000000000..adebfbff46 --- /dev/null +++ b/packages/worker/src/sdk/users/events.ts @@ -0,0 +1,136 @@ +const { events } = require("@budibase/backend-core") + +export const handleDeleteEvents = (user: any) => { + events.user.deleted(user) + + if (isBuilder(user)) { + events.user.permissionBuilderRemoved(user) + } + + if (isAdmin(user)) { + events.user.permissionAdminRemoved(user) + } +} + +const assignAppRoleEvents = (roles: any, existingRoles: any) => { + for (const [appId, role] of Object.entries(roles)) { + // app role in existing is not same as new + if (!existingRoles || existingRoles[appId] !== role) { + events.role.assigned(role) + } + } +} + +const unassignAppRoleEvents = (roles: any, existingRoles: any) => { + if (!existingRoles) { + return + } + for (const [appId, role] of Object.entries(existingRoles)) { + // app role in new is not same as existing + if (!roles || roles[appId] !== role) { + events.role.unassigned(role) + } + } +} + +const handleAppRoleEvents = (user: any, existingUser: any) => { + const roles = user.roles + const existingRoles = existingUser?.roles + + assignAppRoleEvents(roles, existingRoles) + unassignAppRoleEvents(roles, existingRoles) +} + +export const handleSaveEvents = (user: any, existingUser: any) => { + if (existingUser) { + events.user.updated(user) + + if (isRemovingBuilder(user, existingUser)) { + events.user.permissionBuilderRemoved(user) + } + + if (isRemovingAdmin(user, existingUser)) { + events.user.permissionAdminRemoved(user) + } + } else { + events.user.created(user) + } + + if (isAddingBuilder(user, existingUser)) { + events.user.permissionBuilderAssigned(user) + } + + if (isAddingAdmin(user, existingUser)) { + events.user.permissionAdminAssigned(user) + } + + handleAppRoleEvents(user, existingUser) +} + +const isBuilder = (user: any) => user.builder && user.builder.global +const isAdmin = (user: any) => user.admin && user.admin.global + +export const isAddingBuilder = (user: any, existingUser: any) => { + return isAddingPermission(user, existingUser, isBuilder) +} + +export const isRemovingBuilder = (user: any, existingUser: any) => { + return isRemovingPermission(user, existingUser, isBuilder) +} + +const isAddingAdmin = (user: any, existingUser: any) => { + return isAddingPermission(user, existingUser, isAdmin) +} + +const isRemovingAdmin = (user: any, existingUser: any) => { + return isRemovingPermission(user, existingUser, isAdmin) +} + +/** + * Check if a permission is being added to a new or existing user. + */ +const isAddingPermission = ( + user: any, + existingUser: any, + hasPermission: any +) => { + // new user doesn't have the permission + if (!hasPermission(user)) { + return false + } + + // existing user has the permission + if (existingUser && hasPermission(existingUser)) { + return false + } + + // permission is being added + return true +} + +/** + * Check if a permission is being removed from an existing user. + */ +const isRemovingPermission = ( + user: any, + existingUser: any, + hasPermission: any +) => { + // new user has the permission + if (hasPermission(user)) { + return false + } + + // no existing user or existing user doesn't have the permission + if (!existingUser) { + return false + } + + // existing user doesn't have the permission + if (!hasPermission(existingUser)) { + return false + } + + // permission is being removed + return true +} diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts new file mode 100644 index 0000000000..056d6e5675 --- /dev/null +++ b/packages/worker/src/sdk/users/index.ts @@ -0,0 +1 @@ +export * from "./users" diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts new file mode 100644 index 0000000000..dbaee8baaa --- /dev/null +++ b/packages/worker/src/sdk/users/users.ts @@ -0,0 +1,180 @@ +import env from "../../environment" +import { quotas } from "@budibase/pro" +import * as apps from "../../utilities/appService" +const { events } = require("@budibase/backend-core") +import * as eventHelpers from "./events" + +const { + tenancy, + accounts, + utils, + db: dbUtils, + constants, + cache, + users: usersCore, + deprovisioning, + sessions, + HTTPError, +} = require("@budibase/backend-core") + +/** + * Retrieves all users from the current tenancy. + */ +export const allUsers = async () => { + const db = tenancy.getGlobalDB() + const response = await db.allDocs( + dbUtils.getGlobalUserParams(null, { + include_docs: true, + }) + ) + return response.rows.map((row: any) => row.doc) +} + +/** + * Gets a user by ID from the global database, based on the current tenancy. + */ +export const getUser = async (userId: string) => { + const db = tenancy.getGlobalDB() + let user + try { + user = await db.get(userId) + } catch (err: any) { + // no user found, just return nothing + if (err.status === 404) { + return {} + } + throw err + } + if (user) { + delete user.password + } + return user +} + +export const save = async ( + user: any, + hashPassword = true, + requirePassword = true +) => { + const tenantId = tenancy.getTenantId() + + // specify the tenancy incase we're making a new admin user (public) + const db = tenancy.getGlobalDB(tenantId) + let { email, password, _id } = user + // make sure another user isn't using the same email + let dbUser + if (email) { + // check budibase users inside the tenant + dbUser = await usersCore.getGlobalUserByEmail(email) + if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) { + throw `Email address ${email} already in use.` + } + + // check budibase users in other tenants + if (env.MULTI_TENANCY) { + const tenantUser = await tenancy.getTenantUser(email) + if (tenantUser != null && tenantUser.tenantId !== tenantId) { + throw `Email address ${email} already in use.` + } + } + + // check root account users in account portal + if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { + const account = await accounts.getAccount(email) + if (account && account.verified && account.tenantId !== tenantId) { + throw `Email address ${email} already in use.` + } + } + } else if (_id) { + dbUser = await db.get(_id) + } + + // get the password, make sure one is defined + let hashedPassword + if (password) { + hashedPassword = hashPassword ? await utils.hash(password) : password + } else if (dbUser) { + hashedPassword = dbUser.password + } else if (requirePassword) { + throw "Password must be specified." + } + + if (!_id) { + _id = dbUtils.generateGlobalUserID(email) + } + + user = { + createdAt: Date.now(), + ...dbUser, + ...user, + _id, + password: hashedPassword, + tenantId, + } + // make sure the roles object is always present + if (!user.roles) { + user.roles = {} + } + // add the active status to a user if its not provided + if (user.status == null) { + user.status = constants.UserStatus.ACTIVE + } + try { + // save the user to db + let response + const putUserFn = () => { + return db.put(user) + } + if (await eventHelpers.isAddingBuilder(user, dbUser)) { + response = await quotas.addDeveloper(putUserFn) + } else { + response = await putUserFn() + } + + eventHelpers.handleSaveEvents(user, dbUser) + + await tenancy.tryAddTenant(tenantId, _id, email) + await cache.user.invalidateUser(response.id) + // let server know to sync user + await apps.syncUserInApps(user._id) + + return { + _id: response.id, + _rev: response.rev, + email, + } + } catch (err: any) { + if (err.status === 409) { + throw "User exists already" + } else { + throw err + } + } +} + +export const destroy = async (id: string, currentUser: any) => { + const db = tenancy.getGlobalDB() + const dbUser = await db.get(id) + + if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { + // root account holder can't be deleted from inside budibase + const email = dbUser.email + const account = await accounts.getAccount(email) + if (account) { + if (email === currentUser.email) { + throw new HTTPError('Please visit "Account" to delete this user', 400) + } else { + throw new HTTPError("Account holder cannot be deleted", 400) + } + } + } + + await deprovisioning.removeUserFromInfoDB(dbUser) + await db.remove(dbUser._id, dbUser._rev) + eventHelpers.handleDeleteEvents(dbUser) + await quotas.removeUser(dbUser) + await cache.user.invalidateUser(dbUser._id) + await sessions.invalidateSessions(dbUser._id) + // let server know to sync user + await apps.syncUserInApps(dbUser._id) +} diff --git a/packages/worker/src/tests/TestConfiguration.js b/packages/worker/src/tests/TestConfiguration.js index a718f7b8b9..9c45217e3e 100644 --- a/packages/worker/src/tests/TestConfiguration.js +++ b/packages/worker/src/tests/TestConfiguration.js @@ -6,7 +6,7 @@ const supertest = require("supertest") const { jwt } = require("@budibase/backend-core/auth") const { Cookies, Headers } = require("@budibase/backend-core/constants") const { Configs } = require("../constants") -const { getGlobalUserByEmail } = require("@budibase/backend-core/utils") +const { users } = require("@budibase/backend-core") const { createASession } = require("@budibase/backend-core/sessions") const { TENANT_ID, CSRF_TOKEN } = require("./structures") const structures = require("./structures") @@ -112,20 +112,17 @@ class TestConfiguration { async getUser(email) { return doInTenant(TENANT_ID, () => { - return getGlobalUserByEmail(email) + return users.getGlobalUserByEmail(email) }) } - async createUser(email = "test@test.com", password = "test") { - const user = await this.getUser(email) + async createUser(email, password) { + const user = await this.getUser(structures.users.email) if (user) { return user } await this._req( - { - email, - password, - }, + structures.users.user({ email, password }), null, controllers.users.save ) @@ -133,11 +130,7 @@ class TestConfiguration { async saveAdminUser() { await this._req( - { - email: "testuser@test.com", - password: "test@test.com", - tenantId: TENANT_ID, - }, + structures.users.user({ tenantId: TENANT_ID }), null, controllers.users.adminUser ) diff --git a/packages/worker/src/tests/structures/index.js b/packages/worker/src/tests/structures/index.js index 0d82b67d5e..115d22e731 100644 --- a/packages/worker/src/tests/structures/index.js +++ b/packages/worker/src/tests/structures/index.js @@ -1,10 +1,12 @@ const configs = require("./configs") +const users = require("./users") const TENANT_ID = "default" const CSRF_TOKEN = "e3727778-7af0-4226-b5eb-f43cbe60a306" module.exports = { configs, + users, TENANT_ID, CSRF_TOKEN, } diff --git a/packages/worker/src/tests/structures/users.ts b/packages/worker/src/tests/structures/users.ts new file mode 100644 index 0000000000..dce771aaa7 --- /dev/null +++ b/packages/worker/src/tests/structures/users.ts @@ -0,0 +1,28 @@ +export const email = "test@test.com" + +export const user = (userProps: any) => { + return { + email: "test@test.com", + password: "test", + roles: {}, + ...userProps, + } +} + +export const adminUser = (userProps: any) => { + return { + ...user(userProps), + admin: { + global: true, + }, + } +} + +export const builderUser = (userProps: any) => { + return { + ...user(userProps), + builder: { + global: true, + }, + } +}