import { utils as backendCoreUtils, cache, context, db, events, locks, platform, tenancy, users, } from "@budibase/backend-core" import { db as proDb } from "@budibase/pro" import { BpmStatusKey, BpmStatusValue, dataFilters, utils, } from "@budibase/shared-core" import { AcceptUserInviteRequest, AcceptUserInviteResponse, AddSSoUserRequest, AddSSoUserResponse, BulkUserRequest, BulkUserResponse, ChangeTenantOwnerEmailRequest, CheckInviteResponse, CountUserResponse, CreateAdminUserRequest, CreateAdminUserResponse, Ctx, DeleteInviteUserRequest, DeleteInviteUsersRequest, DeleteInviteUsersResponse, DeleteUserResponse, EditUserPermissionsResponse, APIWarningCode, FetchUsersResponse, FindUserResponse, GetUserInvitesResponse, Hosting, InviteUserRequest, InviteUserResponse, InviteUsersRequest, InviteUsersResponse, LockName, LockType, LookupAccountHolderResponse, LookupTenantUserResponse, OIDCUser, SaveUserResponse, SearchUsersRequest, SearchUsersResponse, StrippedUser, UnsavedUser, UpdateInviteResponse, User, UserGroup, UserCtx, UserIdentifier, } from "@budibase/types" import emailValidator from "email-validator" import env from "../../../environment" import * as userSdk from "../../../sdk/users" import { checkAnyUserExists } from "../../../utilities/users" const MAX_USERS_UPLOAD_LIMIT = 1000 const stripUsers = (users: (User | StrippedUser)[]): StrippedUser[] => { return users.map(user => ({ _id: user._id, email: user.email, tenantId: user.tenantId, userId: user.userId, })) } export const save = async (ctx: UserCtx) => { try { const currentUserId = ctx.user?._id const tenantId = context.getTenantId() const requestUser: User = { ...ctx.request.body, tenantId } // Do not allow the account holder role to be changed if ( requestUser.admin?.global !== true || requestUser.builder?.global !== true ) { const accountMetadata = await users.getExistingAccounts([ requestUser.email, ]) if (accountMetadata?.length > 0) { throw Error("Cannot set role of account holder") } } const user = await userSdk.db.save(requestUser, { currentUserId }) ctx.body = { _id: user._id!, _rev: user._rev!, email: user.email, } } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } } export const changeTenantOwnerEmail = async ( ctx: Ctx ) => { const { newAccountEmail, originalEmail, tenantIds } = ctx.request.body try { for (const tenantId of tenantIds) { await tenancy.doInTenant(tenantId, async () => { const tenantUser = (await userSdk.db.getUserByEmail( originalEmail )) as OIDCUser if (!tenantUser) { return } tenantUser.email = newAccountEmail tenantUser.provider = undefined tenantUser.providerType = undefined tenantUser.thirdPartyProfile = undefined tenantUser.profile = undefined tenantUser.oauth2 = undefined await userSdk.db.save(tenantUser, { currentUserId: tenantUser._id, isAccountHolder: true, allowChangingEmail: true, }) }) } ctx.status = 200 } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } } export const addSsoSupport = async ( ctx: Ctx ) => { if (!ctx.internal) { ctx.throw(403, "Unauthorized") } const { email, ssoId } = ctx.request.body try { const [userByEmail] = await users.getExistingPlatformUsers([email]) if (!userByEmail) { ctx.throw(404, "Not Found") } await platform.users.addSsoUser( ssoId, email, userByEmail.userId, userByEmail.tenantId ) // Need to get the _rev of the user doc to update const userById = await platform.users.getUserDoc(userByEmail.userId) await platform.users.updateUserDoc({ ...userById, email, ssoId, }) ctx.body = { message: "SSO support added." } } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } } const bulkDelete = async ( users: Array, currentUserId: string ) => { if (users.find(u => u.userId === currentUserId)) { throw new Error("Unable to delete self.") } return await userSdk.db.bulkDelete(users) } const bulkCreate = async (users: User[], groupIds: string[]) => { if (!env.SELF_HOSTED && users.length > MAX_USERS_UPLOAD_LIMIT) { throw new Error( "Max limit for upload is 1000 users. Please reduce file size and try again." ) } return await userSdk.db.bulkCreate(users, groupIds) } export const bulkUpdate = async ( ctx: Ctx ) => { const currentUserId = ctx.user._id const input = ctx.request.body let created, deleted try { if (input.create) { const tenantId = context.getTenantId() const users: User[] = input.create.users.map(user => ({ ...user, tenantId, })) created = await bulkCreate(users, input.create.groups) } if (input.delete) { deleted = await bulkDelete(input.delete.users, currentUserId) } } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } ctx.body = { created, deleted } } const parseBooleanParam = (param: any) => { return !(param && param === "false") } export const adminUser = async ( ctx: Ctx ) => { const { email, password, tenantId, ssoId, givenName, familyName } = ctx.request.body await tenancy.doInTenant(tenantId, async () => { // account portal sends a pre-hashed password - honour param to prevent double hashing const hashPassword = parseBooleanParam(ctx.request.query.hashPassword) // account portal sends no password for SSO users const requirePassword = parseBooleanParam(ctx.request.query.requirePassword) const userExists = await checkAnyUserExists() if (userExists) { ctx.throw( 403, "You cannot initialise once an global user has been created." ) } try { const finalUser = await userSdk.db.createAdminUser(email, tenantId, { password, ssoId, hashPassword, requirePassword, firstName: givenName, lastName: familyName, }) await events.identification.identifyTenantGroup( tenantId, env.SELF_HOSTED ? Hosting.SELF : Hosting.CLOUD, Date.now() ) ctx.body = { _id: finalUser._id!, _rev: finalUser._rev!, email: finalUser.email, } } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } }) } export const countByWorkspace = async ( ctx: UserCtx ) => { const workspaceId = ctx.params.workspaceId try { ctx.body = await userSdk.db.countUsersByWorkspace(workspaceId) } catch (err: any) { ctx.throw(err.status || 400, err?.message || err) } } export const destroy = async (ctx: UserCtx) => { const id = ctx.params.id if (id === ctx.user._id) { ctx.throw(400, "Unable to delete self.") } await userSdk.db.destroy(id) ctx.body = { message: `User ${id} deleted.`, } } export const search = async ( ctx: UserCtx ) => { const body = ctx.request.body // TODO: for now only two supported search keys; string.email and equal._id if (body?.query) { // Clean numeric prefixing. This will overwrite duplicate search fields, // but this is fine because we only support a single custom search on // email and id for (let filters of Object.values(body.query)) { if (filters && typeof filters === "object") { for (let [field, value] of Object.entries(filters)) { delete filters[field] const cleanedField = db.removeKeyNumbering(field) if (filters[cleanedField] !== undefined) { ctx.throw(400, "Only 1 filter per field is supported") } filters[cleanedField] = value } } } // Validate we aren't trying to search on any illegal fields if (!utils.isSupportedUserSearch(body.query)) { ctx.throw(400, "Can only search by string.email, equal._id or oneOf._id") } } const hasWorkspaceId = body && Object.prototype.hasOwnProperty.call(body, "workspaceId") if (hasWorkspaceId) { let response = await searchWorkspaceUsers(body) if (!users.hasBuilderPermissions(ctx.user)) { response.data = stripUsers(response.data) } ctx.body = response return } let response: SearchUsersResponse = { data: [] } if (body.paginate === false) { response.data = await userSdk.db.getUsersByAppAccess({ appId: body.appId, limit: body.limit, }) } else { const paginated = await userSdk.core.paginatedUsers(body) // user hashed password shouldn't ever be returned for (let user of paginated.data) { if (user) { delete user.password } } response = { data: paginated.data, hasNextPage: paginated.hasNextPage, nextPage: paginated.nextPage, } } if (!users.hasBuilderPermissions(ctx.user)) { response.data = stripUsers(response.data) } ctx.body = response } const DEFAULT_USER_LIMIT = 8 const GLOBAL_PERMISSION_USER_PAGE_LIMIT = 1000 const getGlobalPermissionUsers = async () => { const globalDb = context.getGlobalDB() const globalPermissionUsers = new Map() let bookmark: string | undefined let nextBookmark: string | undefined do { const response = await globalDb.find({ selector: { _id: { $regex: `^${db.DocumentType.USER}${db.SEPARATOR}`, }, $or: [{ "admin.global": true }, { "builder.global": true }], }, limit: GLOBAL_PERMISSION_USER_PAGE_LIMIT, ...(bookmark ? { bookmark } : {}), }) for (const user of response.docs) { if (user?._id) { globalPermissionUsers.set(user._id, user) } } nextBookmark = response.docs.length === GLOBAL_PERMISSION_USER_PAGE_LIMIT ? response.bookmark : undefined if (!nextBookmark || nextBookmark === bookmark) { break } bookmark = nextBookmark } while (bookmark) return [...globalPermissionUsers.values()] } const searchWorkspaceUsers = async ( body: SearchUsersRequest ): Promise => { const workspaceId = body.workspaceId if (!workspaceId) { return { data: [], hasNextPage: false } } const prodWorkspaceId = db.getProdWorkspaceID(workspaceId) const limit = body.limit ?? DEFAULT_USER_LIMIT const query = body.query const globalDb = context.getGlobalDB() const [workspaceUsers, globalPermissionUsers, workspaceGroups] = await Promise.all([ db.queryGlobalView( db.ViewName.USER_BY_WORKSPACE, db.getUsersByWorkspaceParams(prodWorkspaceId, { include_docs: true, }), undefined, { arrayResponse: true } ) as Promise, getGlobalPermissionUsers(), globalDb .allDocs( db.getDocParams(db.DocumentType.GROUP, null, { include_docs: true, }) ) .then( response => response.rows .map(row => row.doc) .filter(group => !!group?.roles?.[prodWorkspaceId]) as UserGroup[] ), ]) const workspaceGroupIds = workspaceGroups.map(group => group._id!) const workspaceGroupIdSet = new Set(workspaceGroupIds) let groupUsers: User[] = [] if (workspaceGroupIds.length) { const usersByGroup = await Promise.all( workspaceGroupIds.map(groupId => proDb.groups.getGroupUsers(groupId)) ) const groupUserIds = [ ...new Set( usersByGroup .flat() .map(user => user._id) .filter(Boolean) ), ] as string[] if (groupUserIds.length) { groupUsers = await userSdk.db.bulkGet(groupUserIds) } } const getBookmarkValue = (user: User) => { if (query?.string?.email && user.email) { return user.email.toLowerCase() } return user._id } const hasWorkspaceAccess = (user: User) => { if (users.isAdminOrBuilder(user, prodWorkspaceId)) { return true } if (user.roles?.[prodWorkspaceId]) { return true } return (user.userGroups || []).some(groupId => workspaceGroupIdSet.has(groupId) ) } const dedupedUsers = new Map() for (const user of [ ...workspaceUsers, ...globalPermissionUsers, ...groupUsers, ]) { if (user?._id) { dedupedUsers.set(user._id, user) } } let filtered = [...dedupedUsers.values()].filter(hasWorkspaceAccess) if (query) { filtered = dataFilters.search(filtered, { query }).rows } filtered.sort((userA, userB) => { if (query?.string?.email) { const emailA = userA.email?.toLowerCase() || "" const emailB = userB.email?.toLowerCase() || "" if (emailA !== emailB) { return emailA.localeCompare(emailB) } } return (userA._id || "").localeCompare(userB._id || "") }) const bookmark = body.bookmark const pagedUsers = bookmark ? filtered.filter(user => (getBookmarkValue(user) || "") >= bookmark) : filtered const pageData = pagedUsers.slice(0, limit + 1) const hasNextPage = pageData.length > limit const data = hasNextPage ? pageData.slice(0, limit) : pageData const nextPage = hasNextPage ? getBookmarkValue(pageData[limit]) : undefined for (let user of data) { if (user) { delete user.password } } return { data, hasNextPage, nextPage: hasNextPage ? nextPage : undefined, } } // called internally by app server user fetch export const fetch = async (ctx: UserCtx) => { const all = await userSdk.db.allUsers() // user hashed password shouldn't ever be returned for (let user of all) { if (user) { delete user.password } } ctx.body = all } // called internally by app server user find export const find = async (ctx: UserCtx) => { ctx.body = await userSdk.db.getUser(ctx.params.id) } export const tenantUserLookup = async ( ctx: UserCtx ) => { const id = ctx.params.id // is email, check its valid if (id.includes("@") && !emailValidator.validate(id)) { ctx.throw(400, `${id} is not a valid email address to lookup.`) } const user = await userSdk.core.getFirstPlatformUser(id) if (user) { ctx.body = user } else { ctx.throw(400, "No tenant user found.") } } /** * This will be paginated to a default of the first 50 users, * So the account holder may not be found until further pagination has occurred */ export const accountHolderLookup = async ( ctx: Ctx ) => { try { const users = await userSdk.core.getAllUsers() const response = await userSdk.core.getExistingAccounts( users.map(u => u.email) ) const holder = response[0] if (!holder) { ctx.body = null return } holder._id = users.find(u => u.email === holder.email)?._id ctx.body = holder } catch (e) { ctx.body = null } } export const invite = async ( ctx: Ctx ) => { const request = ctx.request.body let multiRequest = [request] const response = await userSdk.invite(multiRequest) // explicitly throw for single user invite if (response.unsuccessful.length) { const reason = response.unsuccessful[0].reason if (reason === "Unavailable") { ctx.throw(400, reason) } else { ctx.throw(500, reason) } } ctx.body = { message: "Invitation has been sent.", successful: response.successful, unsuccessful: response.unsuccessful, } } export const inviteMultiple = async ( ctx: Ctx ) => { ctx.body = await userSdk.invite(ctx.request.body) } export const removeMultipleInvites = async ( ctx: Ctx ) => { const tenantId = context.getTenantId() const inviteCodesToRemove = ctx.request.body.map( (invite: DeleteInviteUserRequest) => invite.code ) for (const code of inviteCodesToRemove) { await cache.invite.deleteCode(code, tenantId) } ctx.body = { message: "User invites successfully removed.", } } export const checkInvite = async (ctx: UserCtx) => { const { code } = ctx.params const tenantId = typeof ctx.request.query.tenantId === "string" ? ctx.request.query.tenantId : undefined let invite try { invite = await cache.invite.getCode(code, tenantId) } catch (e) { console.warn("Error getting invite from code", e) ctx.throw(400, "There was a problem with the invite") } ctx.body = { email: invite.email, } } export const getUserInvites = async ( ctx: UserCtx ) => { try { // Restricted to the currently authenticated tenant ctx.body = await cache.invite.getInviteCodes() } catch (e) { ctx.throw(400, "There was a problem fetching invites") } } export const addWorkspaceIdToInvite = async ( ctx: UserCtx ) => { const { code, role } = ctx.params const workspaceId = await backendCoreUtils.getWorkspaceIdFromCtx(ctx) if (!workspaceId) { ctx.throw(400, "Workspace id not set") } const prodWorkspaceId = db.getProdWorkspaceID(workspaceId) try { const tenantId = context.getTenantId() const invite = await cache.invite.getCode(code, tenantId) invite.info.apps ??= {} invite.info.apps[prodWorkspaceId] = role await cache.invite.updateCode(code, invite) ctx.body = { ...invite } } catch (e) { ctx.throw(400, "Invitation is not valid or has expired.") } } export const removeWorkspaceIdFromInvite = async ( ctx: UserCtx ) => { const { code } = ctx.params const workspaceId = await backendCoreUtils.getWorkspaceIdFromCtx(ctx) if (!workspaceId) { ctx.throw(400, "Workspace id not set") } const prodWorkspaceId = db.getProdWorkspaceID(workspaceId) try { const tenantId = context.getTenantId() const invite = await cache.invite.getCode(code, tenantId) invite.info.apps ??= {} delete invite.info.apps[prodWorkspaceId] await cache.invite.updateCode(code, invite) ctx.body = { ...invite } } catch (e) { ctx.throw(400, "Invitation is not valid or has expired.") } } export const inviteAccept = async ( ctx: Ctx ) => { const { inviteCode, password, firstName, lastName, tenantId } = ctx.request.body const queryTenantId = typeof ctx.request.query.tenantId === "string" ? ctx.request.query.tenantId : undefined const resolvedTenantId = tenantId || queryTenantId try { await locks.doWithLock( { type: LockType.AUTO_EXTEND, name: LockName.PROCESS_USER_INVITE, resource: inviteCode, systemLock: true, }, async () => { // info is an extension of the user object that was stored by global const { email, info } = await cache.invite.getCode( inviteCode, resolvedTenantId ) const user = await tenancy.doInTenant(info.tenantId, async () => { const appRoles = info.apps || {} const creatorAppsFromRoles = Object.keys(appRoles).filter( appId => appRoles[appId] === "CREATOR" ) const builderApps = [ ...(info?.builder?.apps || []), ...creatorAppsFromRoles, ] const uniqueBuilderApps = [...new Set(builderApps)] const hasCreatorPerms = !!info?.builder?.creator || creatorAppsFromRoles.length > 0 let request: any = { firstName, lastName, password, email, admin: { global: info?.admin?.global || false }, roles: appRoles, tenantId: info.tenantId, } const builder: { global: boolean creator?: boolean apps?: string[] } = { global: info?.builder?.global || false, } if (hasCreatorPerms) { builder.creator = true } if (uniqueBuilderApps.length) { builder.apps = uniqueBuilderApps } const cleanedInviteInfo = { ...info } delete cleanedInviteInfo.apps delete cleanedInviteInfo.builder request = { ...request, ...cleanedInviteInfo, } if (info?.builder || hasCreatorPerms || uniqueBuilderApps.length) { request.builder = builder } const saved = await userSdk.db.save(request) await events.user.inviteAccepted(saved) return saved }) await cache.invite.deleteCode(inviteCode, resolvedTenantId) // make sure onboarding flow is cleared ctx.cookies.set(BpmStatusKey.ONBOARDING, BpmStatusValue.COMPLETED, { expires: new Date(0), }) ctx.body = { _id: user._id!, _rev: user._rev!, email: user.email, tenantId: user.tenantId, } } ) } catch (err: any) { if (err.code === APIWarningCode.USAGE_LIMIT_EXCEEDED) { // explicitly re-throw limit exceeded errors ctx.throw(400, err?.message || err) } console.warn("Error inviting user", err) ctx.throw( 400, err?.message || err || "Unable to create new user, invitation invalid." ) } } export const addUserToWorkspace = async ( ctx: UserCtx< EditUserPermissionsResponse, SaveUserResponse, { userId: string; role: string } > ) => handleUserWorkspacePermission(ctx, ctx.params.userId, ctx.params.role) export const removeUserFromWorkspace = async ( ctx: UserCtx< EditUserPermissionsResponse, SaveUserResponse, { userId: string } > ) => handleUserWorkspacePermission(ctx, ctx.params.userId, undefined) async function handleUserWorkspacePermission( ctx: UserCtx, userId: string, role: string | undefined ) { const { _rev } = ctx.request.body if (!_rev) { ctx.throw(400, "rev is required") } const currentUserId = ctx.user?._id const workspaceId = await backendCoreUtils.getWorkspaceIdFromCtx(ctx) if (!workspaceId) { ctx.throw(400, "Workspace id not set") } const prodWorkspaceId = db.getProdWorkspaceID(workspaceId) const existingUser = await users.getById(userId) existingUser._rev = ctx.request.body._rev if (role) { existingUser.roles[prodWorkspaceId] = role } else { delete existingUser.roles[prodWorkspaceId] } const creatorForApps = Object.entries(existingUser.roles) .filter(([_appId, role]) => role === "CREATOR") .map(([appId]) => appId) const shouldHaveCreatorRole = existingUser.builder?.creator || creatorForApps.length if (!shouldHaveCreatorRole) { delete existingUser.builder?.creator delete existingUser.builder?.apps } else { existingUser.builder ??= {} existingUser.builder.creator = true existingUser.builder.apps = creatorForApps } const user = await userSdk.db.save(existingUser, { currentUserId, hashPassword: false, }) ctx.body = { _id: user._id!, _rev: user._rev!, email: user.email, } }