budibase/packages/backend-core/src/users/db.ts

378 lines
10 KiB
TypeScript
Raw Normal View History

import env from "../environment"
import * as eventHelpers from "./events"
import * as accounts from "../accounts"
import * as cache from "../cache"
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 usersCore from "./users"
import {
AllDocsResponse,
BulkUserCreated,
BulkUserDeleted,
RowResponse,
SaveUserOpts,
User,
Account,
} from "@budibase/types"
import * as accountSdk from "../accounts"
import { validateUniqueUser, getAccountHolderFromUserIds } from "./utils"
import { searchExistingEmails } from "./lookup"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn }
type BuildUserFn = (
user: User,
opts: SaveUserOpts,
tenantId: string,
dbUser?: User,
account?: Account
) => Promise<any>
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 class UserDB {
quotas: QuotaFns
groups: GroupFns
ssoEnforcedFn: () => Promise<boolean>
buildUserFn: BuildUserFn
constructor(
quotaFns: QuotaFns,
groupFns: GroupFns,
ssoEnforcedFn: () => Promise<boolean>,
buildUserFn: BuildUserFn
) {
this.quotas = quotaFns
this.groups = groupFns
this.ssoEnforcedFn = ssoEnforcedFn
this.buildUserFn = buildUserFn
}
async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map((row: any) => row.doc)
}
async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
async getUsersByAppAccess(appId?: string) {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
async getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// 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 this.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await this.buildUserFn(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(this.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
}
}
})
}
async bulkCreate(
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> {
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 this.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
this.buildUserFn(
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(this.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
}
async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
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<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
return user.doc
}
)
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await this.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
}
async destroy(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 this.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
}