From 90371b9d69b232459025ec997fd6e20ce4a34201 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 25 Jul 2023 17:48:57 +0100 Subject: [PATCH] Refactoring users core to move into backend, allowing app builder endpoints to move into pro. --- packages/backend-core/src/users/db.ts | 415 ++++++++++++ .../sdk => backend-core/src}/users/events.ts | 24 +- packages/backend-core/src/users/index.ts | 4 + packages/backend-core/src/users/lookup.ts | 102 +++ .../backend-core/src/{ => users}/users.ts | 18 +- packages/backend-core/src/users/utils.ts | 85 +++ .../src/api/controllers/global/roles.ts | 12 +- .../src/api/controllers/global/users.ts | 38 +- .../routes/global/tests/appBuilder.spec.ts | 52 ++ .../src/api/routes/global/tests/users.spec.ts | 2 +- packages/worker/src/sdk/users/index.ts | 2 + .../worker/src/sdk/users/tests/users.spec.ts | 17 +- packages/worker/src/sdk/users/users.ts | 591 +----------------- .../worker/src/tests/TestConfiguration.ts | 2 +- packages/worker/src/tests/api/users.ts | 24 + 15 files changed, 740 insertions(+), 648 deletions(-) create mode 100644 packages/backend-core/src/users/db.ts rename packages/{worker/src/sdk => backend-core/src}/users/events.ts (87%) create mode 100644 packages/backend-core/src/users/index.ts create mode 100644 packages/backend-core/src/users/lookup.ts rename packages/backend-core/src/{ => users}/users.ts (91%) create mode 100644 packages/backend-core/src/users/utils.ts create mode 100644 packages/worker/src/api/routes/global/tests/appBuilder.spec.ts diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts new file mode 100644 index 0000000000..cb1129d079 --- /dev/null +++ b/packages/backend-core/src/users/db.ts @@ -0,0 +1,415 @@ +import env from "../environment" +import * as eventHelpers from "./events" +import * as accounts from "../accounts" +import * as cache from "../cache" +import { UserStatus, ViewName } from "../constants" +import { getIdentity, getTenantId, getGlobalDB } from "../context" +import * as dbUtils from "../db" +import { EmailUnavailableError, HTTPError } from "../errors" +import * as platform from "../platform" +import * as sessions from "../security/sessions" +import * as utils from "../utils" +import * as usersCore from "./users" +import { + Account, + AllDocsResponse, + BulkUserCreated, + BulkUserDeleted, + PlatformUser, + RowResponse, + SaveUserOpts, + User, +} from "@budibase/types" +import * as pro from "@budibase/pro" +import * as accountSdk from "../accounts" +import { + isPreventPasswordActions, + validateUniqueUser, + getAccountHolderFromUserIds, +} from "./utils" +import { searchExistingEmails } from "./lookup" + +export async function allUsers() { + const db = getGlobalDB() + const response = await db.allDocs( + dbUtils.getGlobalUserParams(null, { + include_docs: true, + }) + ) + return response.rows.map((row: any) => row.doc) +} + +export async function countUsersByApp(appId: string) { + let response: any = await usersCore.searchGlobalUsersByApp(appId, {}) + return { + userCount: response.length, + } +} + +export async function getUsersByAppAccess(appId?: string) { + const opts: any = { + include_docs: true, + limit: 50, + } + let response: User[] = await usersCore.searchGlobalUsersByAppAccess( + appId, + opts + ) + return response +} + +export async function getUserByEmail(email: string) { + return usersCore.getGlobalUserByEmail(email) +} + +/** + * Gets a user by ID from the global database, based on the current tenancy. + */ +export async function getUser(userId: string) { + const user = await usersCore.getById(userId) + if (user) { + delete user.password + } + return user +} + +export async function buildUser( + user: User, + opts: SaveUserOpts = { + hashPassword: true, + requirePassword: true, + }, + tenantId: string, + dbUser?: any, + account?: Account +): Promise { + let { password, _id } = user + + // don't require a password if the db user doesn't already have one + if (dbUser && !dbUser.password) { + opts.requirePassword = false + } + + let hashedPassword + if (password) { + if (await isPreventPasswordActions(user, account)) { + throw new HTTPError("Password change is disabled for this user", 400) + } + hashedPassword = opts.hashPassword ? await utils.hash(password) : password + } else if (dbUser) { + hashedPassword = dbUser.password + } + + // passwords are never required if sso is enforced + const requirePasswords = + opts.requirePassword && !(await pro.features.isSSOEnforced()) + if (!hashedPassword && requirePasswords) { + throw "Password must be specified." + } + + _id = _id || dbUtils.generateGlobalUserID() + + const fullUser = { + createdAt: Date.now(), + ...dbUser, + ...user, + _id, + password: hashedPassword, + tenantId, + } + // make sure the roles object is always present + if (!fullUser.roles) { + fullUser.roles = {} + } + // add the active status to a user if its not provided + if (fullUser.status == null) { + fullUser.status = UserStatus.ACTIVE + } + + return fullUser +} + +export const save = async ( + user: User, + opts: SaveUserOpts = {} +): Promise => { + // default booleans to true + if (opts.hashPassword == null) { + opts.hashPassword = true + } + if (opts.requirePassword == null) { + opts.requirePassword = true + } + const tenantId = getTenantId() + const db = getGlobalDB() + + let { email, _id, userGroups = [], roles } = user + + if (!email && !_id) { + throw new Error("_id or email is required") + } + + let dbUser: User | undefined + if (_id) { + // try to get existing user from db + try { + dbUser = (await db.get(_id)) as User + if (email && dbUser.email !== email) { + throw "Email address cannot be changed" + } + email = dbUser.email + } catch (e: any) { + if (e.status === 404) { + // do nothing, save this new user with the id specified - required for SSO auth + } else { + throw e + } + } + } + + if (!dbUser && email) { + // no id was specified - load from email instead + dbUser = await usersCore.getGlobalUserByEmail(email) + if (dbUser && dbUser._id !== _id) { + throw new EmailUnavailableError(email) + } + } + + const change = dbUser ? 0 : 1 // no change if there is existing user + return pro.quotas.addUsers(change, async () => { + await validateUniqueUser(email, tenantId) + + let builtUser = await buildUser(user, opts, tenantId, dbUser) + // don't allow a user to update its own roles/perms + if (opts.currentUserId && opts.currentUserId === dbUser?._id) { + builtUser = usersCore.cleanseUserObject(builtUser, dbUser) as User + } + + if (!dbUser && roles?.length) { + builtUser.roles = { ...roles } + } + + // make sure we set the _id field for a new user + // Also if this is a new user, associate groups with them + let groupPromises = [] + if (!_id) { + _id = builtUser._id! + + if (userGroups.length > 0) { + for (let groupId of userGroups) { + groupPromises.push(pro.groups.addUsers(groupId, [_id])) + } + } + } + + try { + // save the user to db + let response = await db.put(builtUser) + builtUser._rev = response.rev + + await eventHelpers.handleSaveEvents(builtUser, dbUser) + await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) + await cache.user.invalidateUser(response.id) + + await Promise.all(groupPromises) + + // finally returned the saved user from the db + return db.get(builtUser._id!) + } catch (err: any) { + if (err.status === 409) { + throw "User exists already" + } else { + throw err + } + } + }) +} + +export const bulkCreate = async ( + newUsersRequested: User[], + groups: string[] +): Promise => { + const tenantId = getTenantId() + + let usersToSave: any[] = [] + let newUsers: any[] = [] + + const emails = newUsersRequested.map((user: User) => user.email) + const existingEmails = await searchExistingEmails(emails) + const unsuccessful: { email: string; reason: string }[] = [] + + for (const newUser of newUsersRequested) { + if ( + newUsers.find( + (x: User) => x.email.toLowerCase() === newUser.email.toLowerCase() + ) || + existingEmails.includes(newUser.email.toLowerCase()) + ) { + unsuccessful.push({ + email: newUser.email, + reason: `Unavailable`, + }) + continue + } + newUser.userGroups = groups + newUsers.push(newUser) + } + + const account = await accountSdk.getAccountByTenantId(tenantId) + return pro.quotas.addUsers(newUsers.length, async () => { + // create the promises array that will be called by bulkDocs + newUsers.forEach((user: any) => { + usersToSave.push( + buildUser( + user, + { + hashPassword: true, + requirePassword: user.requirePassword, + }, + tenantId, + undefined, // no dbUser + account + ) + ) + }) + + const usersToBulkSave = await Promise.all(usersToSave) + await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) + + // Post-processing of bulk added users, e.g. events and cache operations + for (const user of usersToBulkSave) { + // TODO: Refactor to bulk insert users into the info db + // instead of relying on looping tenant creation + await platform.users.addUser(tenantId, user._id, user.email) + await eventHelpers.handleSaveEvents(user, undefined) + } + + const saved = usersToBulkSave.map(user => { + return { + _id: user._id, + email: user.email, + } + }) + + // now update the groups + if (Array.isArray(saved) && groups) { + const groupPromises = [] + const createdUserIds = saved.map(user => user._id) + for (let groupId of groups) { + groupPromises.push(pro.groups.addUsers(groupId, createdUserIds)) + } + await Promise.all(groupPromises) + } + + return { + successful: saved, + unsuccessful, + } + }) +} + +export const bulkDelete = async ( + userIds: string[] +): Promise => { + const db = getGlobalDB() + + const response: BulkUserDeleted = { + successful: [], + unsuccessful: [], + } + + // remove the account holder from the delete request if present + const account = await getAccountHolderFromUserIds(userIds) + if (account) { + userIds = userIds.filter(u => u !== account.budibaseUserId) + // mark user as unsuccessful + response.unsuccessful.push({ + _id: account.budibaseUserId, + email: account.email, + reason: "Account holder cannot be deleted", + }) + } + + // Get users and delete + const allDocsResponse: AllDocsResponse = await db.allDocs({ + include_docs: true, + keys: userIds, + }) + const usersToDelete: User[] = allDocsResponse.rows.map( + (user: RowResponse) => { + return user.doc + } + ) + + // Delete from DB + const toDelete = usersToDelete.map(user => ({ + ...user, + _deleted: true, + })) + const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) + + await pro.quotas.removeUsers(toDelete.length) + for (let user of usersToDelete) { + await bulkDeleteProcessing(user) + } + + // Build Response + // index users by id + const userIndex: { [key: string]: User } = {} + usersToDelete.reduce((prev, current) => { + prev[current._id!] = current + return prev + }, userIndex) + + // add the successful and unsuccessful users to response + dbResponse.forEach(item => { + const email = userIndex[item.id].email + if (item.ok) { + response.successful.push({ _id: item.id, email }) + } else { + response.unsuccessful.push({ + _id: item.id, + email, + reason: "Database error", + }) + } + }) + + return response +} + +export const destroy = async (id: string) => { + const db = getGlobalDB() + const dbUser = (await db.get(id)) as User + const userId = dbUser._id as string + + 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 (dbUser.userId === getIdentity()!._id) { + throw new HTTPError('Please visit "Account" to delete this user', 400) + } else { + throw new HTTPError("Account holder cannot be deleted", 400) + } + } + } + + await platform.users.removeUser(dbUser) + + await db.remove(userId, dbUser._rev) + + await pro.quotas.removeUsers(1) + await eventHelpers.handleDeleteEvents(dbUser) + await cache.user.invalidateUser(userId) + await sessions.invalidateSessions(userId, { reason: "deletion" }) +} + +const bulkDeleteProcessing = async (dbUser: User) => { + const userId = dbUser._id as string + await platform.users.removeUser(dbUser) + await eventHelpers.handleDeleteEvents(dbUser) + await cache.user.invalidateUser(userId) + await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) +} diff --git a/packages/worker/src/sdk/users/events.ts b/packages/backend-core/src/users/events.ts similarity index 87% rename from packages/worker/src/sdk/users/events.ts rename to packages/backend-core/src/users/events.ts index d8af13a82f..f170c9ffe9 100644 --- a/packages/worker/src/sdk/users/events.ts +++ b/packages/backend-core/src/users/events.ts @@ -1,18 +1,18 @@ -import env from "../../environment" -import { events, accounts, tenancy, users } from "@budibase/backend-core" +import env from "../environment" +import * as events from "../events" +import * as accounts from "../accounts" +import { getTenantId } from "../context" import { User, UserRoles, CloudAccount } from "@budibase/types" - -const hasBuilderPerm = users.hasBuilderPermissions -const hasAdminPerm = users.hasAdminPermissions +import { hasBuilderPermissions, hasAdminPermissions } from "./utils" export const handleDeleteEvents = async (user: any) => { await events.user.deleted(user) - if (hasBuilderPerm(user)) { + if (hasBuilderPermissions(user)) { await events.user.permissionBuilderRemoved(user) } - if (hasAdminPerm(user)) { + if (hasAdminPermissions(user)) { await events.user.permissionAdminRemoved(user) } } @@ -58,7 +58,7 @@ export const handleSaveEvents = async ( user: User, existingUser: User | undefined ) => { - const tenantId = tenancy.getTenantId() + const tenantId = getTenantId() let tenantAccount: CloudAccount | undefined if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { tenantAccount = await accounts.getAccountByTenantId(tenantId) @@ -107,19 +107,19 @@ export const handleSaveEvents = async ( } export const isAddingBuilder = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, hasBuilderPerm) + return isAddingPermission(user, existingUser, hasBuilderPermissions) } export const isRemovingBuilder = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, hasBuilderPerm) + return isRemovingPermission(user, existingUser, hasBuilderPermissions) } const isAddingAdmin = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, hasAdminPerm) + return isAddingPermission(user, existingUser, hasAdminPermissions) } const isRemovingAdmin = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, hasAdminPerm) + return isRemovingPermission(user, existingUser, hasAdminPermissions) } const isOnboardingComplete = (user: any, existingUser: any) => { diff --git a/packages/backend-core/src/users/index.ts b/packages/backend-core/src/users/index.ts new file mode 100644 index 0000000000..2e5e2f948c --- /dev/null +++ b/packages/backend-core/src/users/index.ts @@ -0,0 +1,4 @@ +export * from "./users" +export * from "./utils" +export * from "./lookup" +export * as db from "./db" diff --git a/packages/backend-core/src/users/lookup.ts b/packages/backend-core/src/users/lookup.ts new file mode 100644 index 0000000000..17d0e91d88 --- /dev/null +++ b/packages/backend-core/src/users/lookup.ts @@ -0,0 +1,102 @@ +import { + AccountMetadata, + PlatformUser, + PlatformUserByEmail, + User, +} from "@budibase/types" +import * as dbUtils from "../db" +import { ViewName } from "../constants" + +/** + * Apply a system-wide search on emails: + * - in tenant + * - cross tenant + * - accounts + * return an array of emails that match the supplied emails. + */ +export async function searchExistingEmails(emails: string[]) { + let matchedEmails: string[] = [] + + const existingTenantUsers = await getExistingTenantUsers(emails) + matchedEmails.push(...existingTenantUsers.map(user => user.email)) + + const existingPlatformUsers = await getExistingPlatformUsers(emails) + matchedEmails.push(...existingPlatformUsers.map(user => user._id!)) + + const existingAccounts = await getExistingAccounts(emails) + matchedEmails.push(...existingAccounts.map(account => account.email)) + + return [...new Set(matchedEmails.map(email => email.toLowerCase()))] +} + +// lookup, could be email or userId, either will return a doc +export async function getPlatformUser( + identifier: string +): Promise { + // use the view here and allow to find anyone regardless of casing + // Use lowercase to ensure email login is case insensitive + return (await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, { + keys: [identifier.toLowerCase()], + include_docs: true, + })) as PlatformUser +} + +export async function getExistingTenantUsers( + emails: string[] +): Promise { + const lcEmails = emails.map(email => email.toLowerCase()) + const params = { + keys: lcEmails, + include_docs: true, + } + + const opts = { + arrayResponse: true, + } + + return (await dbUtils.queryGlobalView( + ViewName.USER_BY_EMAIL, + params, + undefined, + opts + )) as User[] +} + +export async function getExistingPlatformUsers( + emails: string[] +): Promise { + const lcEmails = emails.map(email => email.toLowerCase()) + const params = { + keys: lcEmails, + include_docs: true, + } + + const opts = { + arrayResponse: true, + } + return (await dbUtils.queryPlatformView( + ViewName.PLATFORM_USERS_LOWERCASE, + params, + opts + )) as PlatformUserByEmail[] +} + +export async function getExistingAccounts( + emails: string[] +): Promise { + const lcEmails = emails.map(email => email.toLowerCase()) + const params = { + keys: lcEmails, + include_docs: true, + } + + const opts = { + arrayResponse: true, + } + + return (await dbUtils.queryPlatformView( + ViewName.ACCOUNT_BY_EMAIL, + params, + opts + )) as AccountMetadata[] +} diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users/users.ts similarity index 91% rename from packages/backend-core/src/users.ts rename to packages/backend-core/src/users/users.ts index 05abe70fe3..2f869a69d2 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -2,7 +2,6 @@ import { directCouchFind, DocumentType, generateAppUserID, - getGlobalIDFromUserMetadataID, getGlobalUserParams, getProdAppID, getUsersByAppParams, @@ -12,17 +11,16 @@ import { SEPARATOR, UNICODE_MAX, ViewName, -} from "./db" +} from "../db" import { BulkDocsResponse, SearchUsersRequest, User, ContextUser, } from "@budibase/types" -import { sdk } from "@budibase/shared-core" -import { getGlobalDB } from "./context" -import * as context from "./context" -import { user as userCache } from "./cache" +import { getGlobalDB } from "../context" +import * as context from "../context" +import { user as userCache } from "../cache" type GetOpts = { cleanup?: boolean } @@ -41,14 +39,6 @@ function removeUserPassword(users: User | User[]) { return users } -// extract from shared-core to make easily accessible from backend-core -export const isBuilder = sdk.users.isBuilder -export const isAdmin = sdk.users.isAdmin -export const isAdminOrBuilder = sdk.users.isAdminOrBuilder -export const hasAdminPermissions = sdk.users.hasAdminPermissions -export const hasBuilderPermissions = sdk.users.hasBuilderPermissions -export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions - export const bulkGetGlobalUsersById = async ( userIds: string[], opts?: GetOpts diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts new file mode 100644 index 0000000000..0a9abd50bc --- /dev/null +++ b/packages/backend-core/src/users/utils.ts @@ -0,0 +1,85 @@ +import { + Account, + CloudAccount, + isSSOAccount, + isSSOUser, + User, +} from "@budibase/types" +import * as pro from "@budibase/pro" +import * as accountSdk from "../accounts" +import env from "../environment" +import { getPlatformUser } from "./lookup" +import { EmailUnavailableError } from "../errors" +import { getTenantId } from "../context" +import { sdk } from "@budibase/shared-core" +import { getAccountByTenantId } from "../accounts" + +// extract from shared-core to make easily accessible from backend-core +export const isBuilder = sdk.users.isBuilder +export const isAdmin = sdk.users.isAdmin +export const isAdminOrBuilder = sdk.users.isAdminOrBuilder +export const hasAdminPermissions = sdk.users.hasAdminPermissions +export const hasBuilderPermissions = sdk.users.hasBuilderPermissions +export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions + +export async function validateUniqueUser(email: string, tenantId: string) { + // check budibase users in other tenants + if (env.MULTI_TENANCY) { + const tenantUser = await getPlatformUser(email) + if (tenantUser != null && tenantUser.tenantId !== tenantId) { + throw new EmailUnavailableError(email) + } + } + + // check root account users in account portal + if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { + const account = await accountSdk.getAccount(email) + if (account && account.verified && account.tenantId !== tenantId) { + throw new EmailUnavailableError(email) + } + } +} + +export async function isPreventPasswordActions(user: User, account?: Account) { + // when in maintenance mode we allow sso users with the admin role + // to perform any password action - this prevents lockout + if (env.ENABLE_SSO_MAINTENANCE_MODE && isAdmin(user)) { + return false + } + + // SSO is enforced for all users + if (await pro.features.isSSOEnforced()) { + return true + } + + // Check local sso + if (isSSOUser(user)) { + return true + } + + // Check account sso + if (!account) { + account = await accountSdk.getAccountByTenantId(getTenantId()) + } + return !!(account && account.email === user.email && isSSOAccount(account)) +} + +/** + * For the given user id's, return the account holder if it is in the ids. + */ +export async function getAccountHolderFromUserIds( + userIds: string[] +): Promise { + if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { + const tenantId = getTenantId() + const account = await getAccountByTenantId(tenantId) + if (!account) { + throw new Error(`Account not found for tenantId=${tenantId}`) + } + + const budibaseUserId = account.budibaseUserId + if (userIds.includes(budibaseUserId)) { + return account + } + } +} diff --git a/packages/worker/src/api/controllers/global/roles.ts b/packages/worker/src/api/controllers/global/roles.ts index 572c3328b6..457587120f 100644 --- a/packages/worker/src/api/controllers/global/roles.ts +++ b/packages/worker/src/api/controllers/global/roles.ts @@ -3,12 +3,12 @@ import { roles, context, cache, + users as usersCore, tenancy, } from "@budibase/backend-core" -import { BBContext, App } from "@budibase/types" -import { allUsers } from "../../../sdk/users" +import { Ctx, App } from "@budibase/types" -export async function fetch(ctx: BBContext) { +export async function fetch(ctx: Ctx) { const tenantId = ctx.user!.tenantId // always use the dev apps as they'll be most up to date (true) const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[] @@ -31,7 +31,7 @@ export async function fetch(ctx: BBContext) { ctx.body = response } -export async function find(ctx: BBContext) { +export async function find(ctx: Ctx) { const appId = ctx.params.appId await context.doInAppContext(dbCore.getDevAppID(appId), async () => { const db = context.getAppDB() @@ -45,10 +45,10 @@ export async function find(ctx: BBContext) { }) } -export async function removeAppRole(ctx: BBContext) { +export async function removeAppRole(ctx: Ctx) { const { appId } = ctx.params const db = tenancy.getGlobalDB() - const users = await allUsers() + const users = await usersCore.db.allUsers() const bulk = [] const cacheInvalidations = [] for (let user of users) { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 6862e44b05..5984f39ef8 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -42,7 +42,7 @@ export const save = async (ctx: UserCtx) => { const currentUserId = ctx.user?._id const requestUser = ctx.request.body - const user = await userSdk.save(requestUser, { currentUserId }) + const user = await userSdk.db.save(requestUser, { currentUserId }) ctx.body = { _id: user._id!, @@ -58,7 +58,7 @@ const bulkDelete = async (userIds: string[], currentUserId: string) => { if (userIds?.indexOf(currentUserId) !== -1) { throw new Error("Unable to delete self.") } - return await userSdk.bulkDelete(userIds) + return await userSdk.db.bulkDelete(userIds) } const bulkCreate = async (users: User[], groupIds: string[]) => { @@ -67,7 +67,7 @@ const bulkCreate = async (users: User[], groupIds: string[]) => { "Max limit for upload is 1000 users. Please reduce file size and try again." ) } - return await userSdk.bulkCreate(users, groupIds) + return await userSdk.db.bulkCreate(users, groupIds) } export const bulkUpdate = async ( @@ -142,7 +142,7 @@ export const adminUser = async ( // always bust checklist beforehand, if an error occurs but can proceed, don't get // stuck in a cycle await cache.bustCache(cache.CacheKey.CHECKLIST) - const finalUser = await userSdk.save(user, { + const finalUser = await userSdk.db.save(user, { hashPassword, requirePassword, }) @@ -168,7 +168,7 @@ export const adminUser = async ( export const countByApp = async (ctx: any) => { const appId = ctx.params.appId try { - ctx.body = await userSdk.countUsersByApp(appId) + ctx.body = await userSdk.db.countUsersByApp(appId) } catch (err: any) { ctx.throw(err.status || 400, err) } @@ -180,7 +180,7 @@ export const destroy = async (ctx: any) => { ctx.throw(400, "Unable to delete self.") } - await userSdk.destroy(id) + await userSdk.db.destroy(id) ctx.body = { message: `User ${id} deleted.`, @@ -189,7 +189,7 @@ export const destroy = async (ctx: any) => { export const getAppUsers = async (ctx: Ctx) => { const body = ctx.request.body - const users = await userSdk.getUsersByAppAccess(body?.appId) + const users = await userSdk.db.getUsersByAppAccess(body?.appId) ctx.body = { data: users } } @@ -213,7 +213,7 @@ export const search = async (ctx: Ctx) => { // called internally by app server user fetch export const fetch = async (ctx: any) => { - const all = await userSdk.allUsers() + const all = await userSdk.db.allUsers() // user hashed password shouldn't ever be returned for (let user of all) { if (user) { @@ -225,12 +225,12 @@ export const fetch = async (ctx: any) => { // called internally by app server user find export const find = async (ctx: any) => { - ctx.body = await userSdk.getUser(ctx.params.id) + ctx.body = await userSdk.db.getUser(ctx.params.id) } export const tenantUserLookup = async (ctx: any) => { const id = ctx.params.id - const user = await userSdk.getPlatformUser(id) + const user = await userSdk.core.getPlatformUser(id) if (user) { ctx.body = user } else { @@ -253,7 +253,7 @@ export const onboardUsers = async (ctx: Ctx) => { // @ts-ignore const { users, groups, roles } = request.create const assignUsers = users.map((user: User) => (user.roles = roles)) - onboardingResponse = await userSdk.bulkCreate(assignUsers, groups) + onboardingResponse = await userSdk.db.bulkCreate(assignUsers, groups) ctx.body = onboardingResponse } else if (emailConfigured) { onboardingResponse = await inviteMultiple(ctx) @@ -278,7 +278,7 @@ export const onboardUsers = async (ctx: Ctx) => { tenantId: tenancy.getTenantId(), } }) - let bulkCreateReponse = await userSdk.bulkCreate(users, []) + let bulkCreateReponse = await userSdk.db.bulkCreate(users, []) // Apply temporary credentials let createWithCredentials = { @@ -411,7 +411,7 @@ export const inviteAccept = async ( ...info, } - const saved = await userSdk.save(request) + const saved = await userSdk.db.save(request) const db = tenancy.getGlobalDB() const user = await db.get(saved._id) await events.user.inviteAccepted(user) @@ -435,18 +435,18 @@ export const inviteAccept = async ( export const grantAppBuilder = async (ctx: Ctx) => { const { userId } = ctx.params - const user = await userSdk.getUser(userId) + const user = await userSdk.db.getUser(userId) if (!user.builder) { user.builder = {} } user.builder.appBuilder = true - await userSdk.save(user, { hashPassword: false }) + await userSdk.db.save(user, { hashPassword: false }) ctx.body = { message: `User "${user.email}" granted app builder permissions` } } export const addAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params - const user = await userSdk.getUser(userId) + const user = await userSdk.db.getUser(userId) if (!user.builder?.global || user.admin?.global) { ctx.body = { message: "User already admin - no permissions updated." } return @@ -462,13 +462,13 @@ export const addAppBuilder = async (ctx: Ctx) => { user.builder.apps = [] } user.builder.apps.push(prodAppId) - await userSdk.save(user, { hashPassword: false }) + await userSdk.db.save(user, { hashPassword: false }) ctx.body = { message: `User "${user.email}" app builder access updated.` } } export const removeAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params - const user = await userSdk.getUser(userId) + const user = await userSdk.db.getUser(userId) if (!user.builder?.global || user.admin?.global) { ctx.body = { message: "User already admin - no permissions removed." } return @@ -484,6 +484,6 @@ export const removeAppBuilder = async (ctx: Ctx) => { if (indexOf && indexOf !== -1) { user.builder.apps = user.builder.apps!.splice(indexOf, 1) } - await userSdk.save(user, { hashPassword: false }) + await userSdk.db.save(user, { hashPassword: false }) ctx.body = { message: `User "${user.email}" app builder access removed.` } } diff --git a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts new file mode 100644 index 0000000000..2974588cae --- /dev/null +++ b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts @@ -0,0 +1,52 @@ +import { TestConfiguration, structures } from "../../../../tests" +import { User } from "@budibase/types" + +const MOCK_APP_ID = "app_a" + +describe("/api/global/users/:userId/app/builder", () => { + const config = new TestConfiguration() + + beforeAll(async () => { + await config.beforeAll() + }) + + afterAll(async () => { + await config.afterAll() + }) + + async function newUser() { + const base = structures.users.user() + return await config.createUser(base) + } + + async function getUser(userId: string) { + const response = await config.api.users.getUser(userId) + return response.body as User + } + + async function grantAppBuilder(): Promise { + const user = await newUser() + await config.api.users.grantAppBuilder(user._id!) + return await getUser(user._id!) + } + + describe("POST /api/global/users/:userId/app/builder", () => { + it("should be able to grant a user builder permissions", async () => { + const user = await grantAppBuilder() + expect(user.builder?.appBuilder).toBe(true) + }) + }) + + describe("PATCH /api/global/users/:userId/app/:appId/builder", () => { + it("shouldn't allow granting access to an app to a non-app builder", async () => { + const user = await newUser() + await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID) + }) + + it("should be able to grant a user access to a particular app", async () => { + const user = await grantAppBuilder() + }) + }) + + describe("DELETE /api/global/users/:userId/app/:appId/builder", () => {}) +}) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 52d77cbae6..df9c19f8ca 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -66,7 +66,7 @@ describe("/api/global/users", () => { expect(res.body._id).toBeDefined() const user = await config.getUser(email) expect(user).toBeDefined() - expect(user._id).toEqual(res.body._id) + expect(user!._id).toEqual(res.body._id) expect(events.user.inviteAccepted).toBeCalledTimes(1) expect(events.user.inviteAccepted).toBeCalledWith(user) }) diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts index 2eaa0e68a2..b37ea07d94 100644 --- a/packages/worker/src/sdk/users/index.ts +++ b/packages/worker/src/sdk/users/index.ts @@ -1,2 +1,4 @@ export * from "./users" +import { users } from "@budibase/backend-core" +export const db = users.db export { users as core } from "@budibase/backend-core" diff --git a/packages/worker/src/sdk/users/tests/users.spec.ts b/packages/worker/src/sdk/users/tests/users.spec.ts index a24f074512..5962e78e9a 100644 --- a/packages/worker/src/sdk/users/tests/users.spec.ts +++ b/packages/worker/src/sdk/users/tests/users.spec.ts @@ -1,9 +1,8 @@ import { structures } from "../../../tests" import { mocks } from "@budibase/backend-core/tests" -import { env, context } from "@budibase/backend-core" +import { env, context, users as usersCore } from "@budibase/backend-core" import * as users from "../users" import { CloudAccount } from "@budibase/types" -import { isPreventPasswordActions } from "../users" jest.mock("@budibase/pro") import * as _pro from "@budibase/pro" @@ -18,7 +17,7 @@ describe("users", () => { it("returns false for non sso user", async () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.user() - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -29,7 +28,7 @@ describe("users", () => { const account = structures.accounts.ssoAccount() as CloudAccount account.email = user.email mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -39,7 +38,7 @@ describe("users", () => { const user = structures.users.user() const account = structures.accounts.ssoAccount() as CloudAccount mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -47,7 +46,7 @@ describe("users", () => { it("returns true for sso user", async () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.ssoUser() - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -57,7 +56,7 @@ describe("users", () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.user() pro.features.isSSOEnforced.mockResolvedValueOnce(true) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -75,7 +74,7 @@ describe("users", () => { describe("non-admin user", () => { it("returns true", async () => { const user = structures.users.ssoUser() - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -85,7 +84,7 @@ describe("users", () => { const user = structures.users.ssoUser({ user: structures.users.adminUser(), }) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(false) }) }) diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index e0aa042995..f45c7dda20 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -1,590 +1,7 @@ -import env from "../../environment" -import * as eventHelpers from "./events" -import { - accounts, - cache, - constants, - db as dbUtils, - events, - HTTPError, - sessions, - tenancy, - platform, - users as usersCore, - utils, - ViewName, - env as coreEnv, - context, - EmailUnavailableError, -} from "@budibase/backend-core" -import { - AccountMetadata, - AllDocsResponse, - CloudAccount, - InviteUsersRequest, - InviteUsersResponse, - isSSOAccount, - isSSOUser, - PlatformUser, - PlatformUserByEmail, - RowResponse, - User, - SaveUserOpts, - BulkUserCreated, - BulkUserDeleted, - Account, -} from "@budibase/types" +import { events, tenancy, users as usersCore } from "@budibase/backend-core" +import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" import { sendEmail } from "../../utilities/email" import { EmailTemplatePurpose } from "../../constants" -import * as pro from "@budibase/pro" -import * as accountSdk from "../accounts" - -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) -} - -export const countUsersByApp = async (appId: string) => { - let response: any = await usersCore.searchGlobalUsersByApp(appId, {}) - return { - userCount: response.length, - } -} - -export const getUsersByAppAccess = async (appId?: string) => { - const opts: any = { - include_docs: true, - limit: 50, - } - let response: User[] = await usersCore.searchGlobalUsersByAppAccess( - appId, - opts - ) - return response -} - -export async function getUserByEmail(email: string) { - return usersCore.getGlobalUserByEmail(email) -} - -/** - * Gets a user by ID from the global database, based on the current tenancy. - */ -export const getUser = async (userId: string) => { - const user = await usersCore.getById(userId) - if (user) { - delete user.password - } - return user -} - -const buildUser = async ( - user: User, - opts: SaveUserOpts = { - hashPassword: true, - requirePassword: true, - }, - tenantId: string, - dbUser?: any, - account?: Account -): Promise => { - let { password, _id } = user - - // don't require a password if the db user doesn't already have one - if (dbUser && !dbUser.password) { - opts.requirePassword = false - } - - let hashedPassword - if (password) { - if (await isPreventPasswordActions(user, account)) { - throw new HTTPError("Password change is disabled for this user", 400) - } - hashedPassword = opts.hashPassword ? await utils.hash(password) : password - } else if (dbUser) { - hashedPassword = dbUser.password - } - - // passwords are never required if sso is enforced - const requirePasswords = - opts.requirePassword && !(await pro.features.isSSOEnforced()) - if (!hashedPassword && requirePasswords) { - throw "Password must be specified." - } - - _id = _id || dbUtils.generateGlobalUserID() - - const fullUser = { - createdAt: Date.now(), - ...dbUser, - ...user, - _id, - password: hashedPassword, - tenantId, - } - // make sure the roles object is always present - if (!fullUser.roles) { - fullUser.roles = {} - } - // add the active status to a user if its not provided - if (fullUser.status == null) { - fullUser.status = constants.UserStatus.ACTIVE - } - - return fullUser -} - -// lookup, could be email or userId, either will return a doc -export const getPlatformUser = async ( - identifier: string -): Promise => { - // use the view here and allow to find anyone regardless of casing - // Use lowercase to ensure email login is case insensitive - const response = dbUtils.queryPlatformView( - ViewName.PLATFORM_USERS_LOWERCASE, - { - keys: [identifier.toLowerCase()], - include_docs: true, - } - ) as Promise - return response -} - -const validateUniqueUser = async (email: string, tenantId: string) => { - // check budibase users in other tenants - if (env.MULTI_TENANCY) { - const tenantUser = await getPlatformUser(email) - if (tenantUser != null && tenantUser.tenantId !== tenantId) { - throw new EmailUnavailableError(email) - } - } - - // 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 new EmailUnavailableError(email) - } - } -} - -export async function isPreventPasswordActions(user: User, account?: Account) { - // when in maintenance mode we allow sso users with the admin role - // to perform any password action - this prevents lockout - if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && usersCore.isAdmin(user)) { - return false - } - - // SSO is enforced for all users - if (await pro.features.isSSOEnforced()) { - return true - } - - // Check local sso - if (isSSOUser(user)) { - return true - } - - // Check account sso - if (!account) { - account = await accountSdk.api.getAccountByTenantId(tenancy.getTenantId()) - } - return !!(account && account.email === user.email && isSSOAccount(account)) -} - -// TODO: The single save should re-use the bulk insert with a single -// user so that we don't need to duplicate logic -export const save = async ( - user: User, - opts: SaveUserOpts = {} -): Promise => { - // default booleans to true - if (opts.hashPassword == null) { - opts.hashPassword = true - } - if (opts.requirePassword == null) { - opts.requirePassword = true - } - const tenantId = tenancy.getTenantId() - const db = tenancy.getGlobalDB() - - let { email, _id, userGroups = [], roles } = user - - if (!email && !_id) { - throw new Error("_id or email is required") - } - - let dbUser: User | undefined - if (_id) { - // try to get existing user from db - try { - dbUser = (await db.get(_id)) as User - if (email && dbUser.email !== email) { - throw "Email address cannot be changed" - } - email = dbUser.email - } catch (e: any) { - if (e.status === 404) { - // do nothing, save this new user with the id specified - required for SSO auth - } else { - throw e - } - } - } - - if (!dbUser && email) { - // no id was specified - load from email instead - dbUser = await usersCore.getGlobalUserByEmail(email) - if (dbUser && dbUser._id !== _id) { - throw new EmailUnavailableError(email) - } - } - - const change = dbUser ? 0 : 1 // no change if there is existing user - return pro.quotas.addUsers(change, async () => { - await validateUniqueUser(email, tenantId) - - let builtUser = await buildUser(user, opts, tenantId, dbUser) - // don't allow a user to update its own roles/perms - if (opts.currentUserId && opts.currentUserId === dbUser?._id) { - builtUser = usersCore.cleanseUserObject(builtUser, dbUser) as User - } - - if (!dbUser && roles?.length) { - builtUser.roles = { ...roles } - } - - // make sure we set the _id field for a new user - // Also if this is a new user, associate groups with them - let groupPromises = [] - if (!_id) { - _id = builtUser._id! - - if (userGroups.length > 0) { - for (let groupId of userGroups) { - groupPromises.push(pro.groups.addUsers(groupId, [_id])) - } - } - } - - try { - // save the user to db - let response = await db.put(builtUser) - builtUser._rev = response.rev - - await eventHelpers.handleSaveEvents(builtUser, dbUser) - await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) - await cache.user.invalidateUser(response.id) - - await Promise.all(groupPromises) - - // finally returned the saved user from the db - return db.get(builtUser._id!) - } catch (err: any) { - if (err.status === 409) { - throw "User exists already" - } else { - throw err - } - } - }) -} - -const getExistingTenantUsers = async (emails: string[]): Promise => { - const lcEmails = emails.map(email => email.toLowerCase()) - const params = { - keys: lcEmails, - include_docs: true, - } - - const opts = { - arrayResponse: true, - } - - return dbUtils.queryGlobalView( - ViewName.USER_BY_EMAIL, - params, - undefined, - opts - ) as Promise -} - -const getExistingPlatformUsers = async ( - emails: string[] -): Promise => { - const lcEmails = emails.map(email => email.toLowerCase()) - const params = { - keys: lcEmails, - include_docs: true, - } - - const opts = { - arrayResponse: true, - } - return dbUtils.queryPlatformView( - ViewName.PLATFORM_USERS_LOWERCASE, - params, - opts - ) as Promise -} - -const getExistingAccounts = async ( - emails: string[] -): Promise => { - const lcEmails = emails.map(email => email.toLowerCase()) - const params = { - keys: lcEmails, - include_docs: true, - } - - const opts = { - arrayResponse: true, - } - - return dbUtils.queryPlatformView( - ViewName.ACCOUNT_BY_EMAIL, - params, - opts - ) as Promise -} - -/** - * Apply a system-wide search on emails: - * - in tenant - * - cross tenant - * - accounts - * return an array of emails that match the supplied emails. - */ -const searchExistingEmails = async (emails: string[]) => { - let matchedEmails: string[] = [] - - const existingTenantUsers = await getExistingTenantUsers(emails) - matchedEmails.push(...existingTenantUsers.map(user => user.email)) - - const existingPlatformUsers = await getExistingPlatformUsers(emails) - matchedEmails.push(...existingPlatformUsers.map(user => user._id!)) - - const existingAccounts = await getExistingAccounts(emails) - matchedEmails.push(...existingAccounts.map(account => account.email)) - - return [...new Set(matchedEmails.map(email => email.toLowerCase()))] -} - -export const bulkCreate = async ( - newUsersRequested: User[], - groups: string[] -): Promise => { - const tenantId = tenancy.getTenantId() - - let usersToSave: any[] = [] - let newUsers: any[] = [] - - const emails = newUsersRequested.map((user: User) => user.email) - const existingEmails = await searchExistingEmails(emails) - const unsuccessful: { email: string; reason: string }[] = [] - - for (const newUser of newUsersRequested) { - if ( - newUsers.find( - (x: User) => x.email.toLowerCase() === newUser.email.toLowerCase() - ) || - existingEmails.includes(newUser.email.toLowerCase()) - ) { - unsuccessful.push({ - email: newUser.email, - reason: `Unavailable`, - }) - continue - } - newUser.userGroups = groups - newUsers.push(newUser) - } - - const account = await accountSdk.api.getAccountByTenantId(tenantId) - return pro.quotas.addUsers(newUsers.length, async () => { - // create the promises array that will be called by bulkDocs - newUsers.forEach((user: any) => { - usersToSave.push( - buildUser( - user, - { - hashPassword: true, - requirePassword: user.requirePassword, - }, - tenantId, - undefined, // no dbUser - account - ) - ) - }) - - const usersToBulkSave = await Promise.all(usersToSave) - await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) - - // Post-processing of bulk added users, e.g. events and cache operations - for (const user of usersToBulkSave) { - // TODO: Refactor to bulk insert users into the info db - // instead of relying on looping tenant creation - await platform.users.addUser(tenantId, user._id, user.email) - await eventHelpers.handleSaveEvents(user, undefined) - } - - const saved = usersToBulkSave.map(user => { - return { - _id: user._id, - email: user.email, - } - }) - - // now update the groups - if (Array.isArray(saved) && groups) { - const groupPromises = [] - const createdUserIds = saved.map(user => user._id) - for (let groupId of groups) { - groupPromises.push(pro.groups.addUsers(groupId, createdUserIds)) - } - await Promise.all(groupPromises) - } - - return { - successful: saved, - unsuccessful, - } - }) -} - -/** - * For the given user id's, return the account holder if it is in the ids. - */ -const getAccountHolderFromUserIds = async ( - userIds: string[] -): Promise => { - if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { - const tenantId = tenancy.getTenantId() - const account = await accounts.getAccountByTenantId(tenantId) - if (!account) { - throw new Error(`Account not found for tenantId=${tenantId}`) - } - - const budibaseUserId = account.budibaseUserId - if (userIds.includes(budibaseUserId)) { - return account - } - } -} - -export const bulkDelete = async ( - userIds: string[] -): Promise => { - const db = tenancy.getGlobalDB() - - const response: BulkUserDeleted = { - successful: [], - unsuccessful: [], - } - - // remove the account holder from the delete request if present - const account = await getAccountHolderFromUserIds(userIds) - if (account) { - userIds = userIds.filter(u => u !== account.budibaseUserId) - // mark user as unsuccessful - response.unsuccessful.push({ - _id: account.budibaseUserId, - email: account.email, - reason: "Account holder cannot be deleted", - }) - } - - // Get users and delete - const allDocsResponse: AllDocsResponse = await db.allDocs({ - include_docs: true, - keys: userIds, - }) - const usersToDelete: User[] = allDocsResponse.rows.map( - (user: RowResponse) => { - return user.doc - } - ) - - // Delete from DB - const toDelete = usersToDelete.map(user => ({ - ...user, - _deleted: true, - })) - const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) - - await pro.quotas.removeUsers(toDelete.length) - for (let user of usersToDelete) { - await bulkDeleteProcessing(user) - } - - // Build Response - // index users by id - const userIndex: { [key: string]: User } = {} - usersToDelete.reduce((prev, current) => { - prev[current._id!] = current - return prev - }, userIndex) - - // add the successful and unsuccessful users to response - dbResponse.forEach(item => { - const email = userIndex[item.id].email - if (item.ok) { - response.successful.push({ _id: item.id, email }) - } else { - response.unsuccessful.push({ - _id: item.id, - email, - reason: "Database error", - }) - } - }) - - return response -} - -// TODO: The single delete should re-use the bulk delete with a single -// user so that we don't need to duplicate logic -export const destroy = async (id: string) => { - const db = tenancy.getGlobalDB() - const dbUser = (await db.get(id)) as User - const userId = dbUser._id as string - - 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 (dbUser.userId === context.getIdentity()!._id) { - throw new HTTPError('Please visit "Account" to delete this user', 400) - } else { - throw new HTTPError("Account holder cannot be deleted", 400) - } - } - } - - await platform.users.removeUser(dbUser) - - await db.remove(userId, dbUser._rev) - - await pro.quotas.removeUsers(1) - await eventHelpers.handleDeleteEvents(dbUser) - await cache.user.invalidateUser(userId) - await sessions.invalidateSessions(userId, { reason: "deletion" }) -} - -const bulkDeleteProcessing = async (dbUser: User) => { - const userId = dbUser._id as string - await platform.users.removeUser(dbUser) - await eventHelpers.handleDeleteEvents(dbUser) - await cache.user.invalidateUser(userId) - await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) -} export const invite = async ( users: InviteUsersRequest @@ -594,7 +11,9 @@ export const invite = async ( unsuccessful: [], } - const matchedEmails = await searchExistingEmails(users.map(u => u.email)) + const matchedEmails = await usersCore.searchExistingEmails( + users.map(u => u.email) + ) const newUsers = [] // separate duplicates from new users diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index b41b76efda..8fd6c46284 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -263,7 +263,7 @@ class TestConfiguration { } const response = await this._req(user, null, controllers.users.save) const body = response as SaveUserResponse - return this.getUser(body.email) as Promise + return (await this.getUser(body.email)) as User } // CONFIGS diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 16a43d70e0..39f7b64d59 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -140,4 +140,28 @@ export class UserAPI extends TestAPI { .expect("Content-Type", /json/) .expect(opts?.status ? opts.status : 200) } + + grantAppBuilder = (userId: string) => { + return this.request + .post(`/api/global/users/${userId}/app/builder`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + + grantBuilderToApp = (userId: string, appId: string) => { + return this.request + .patch(`/api/global/users/${userId}/app/${appId}/builder`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + + revokeBuilderToApp = (userId: string, appId: string) => { + return this.request + .delete(`/api/global/users/${userId}/app/${appId}/builder`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } }