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

583 lines
16 KiB
TypeScript

import env from "../environment"
import * as eventHelpers from "./events"
import * as accountSdk from "../accounts"
import * as cache from "../cache"
import { getGlobalDB, getIdentity, getTenantId } 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 {
Account,
BulkUserCreated,
BulkUserDeleted,
isSSOAccount,
isSSOUser,
SaveUserOpts,
User,
UserGroup,
UserIdentifier,
UserStatus,
PlatformUserBySsoId,
PlatformUserById,
AnyDocument,
} from "@budibase/types"
import {
getAccountHolderFromUsers,
isAdmin,
isCreator,
validateUniqueUser,
} from "./utils"
import {
getFirstPlatformUser,
getPlatformUsers,
searchExistingEmails,
} from "./lookup"
import { hash } from "../utils"
import { validatePassword } from "../security"
type QuotaUpdateFn = (
change: number,
creatorsChange: number,
cb?: () => Promise<any>
) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
type GroupBuildersFn = (user: User) => Promise<string[]>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = {
addUsers: GroupUpdateFn
getBulk: GroupGetFn
getGroupBuilderAppIds: GroupBuildersFn
}
type CreateAdminUserOpts = {
password?: string
ssoId?: string
hashPassword?: boolean
requirePassword?: boolean
skipPasswordValidation?: boolean
firstName?: string
lastName?: string
}
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
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 {
static quotas: QuotaFns
static groups: GroupFns
static features: FeatureFns
static init(quotaFns: QuotaFns, groupFns: GroupFns, featureFns: FeatureFns) {
UserDB.quotas = quotaFns
UserDB.groups = groupFns
UserDB.features = featureFns
}
static async 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 UserDB.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))
}
static async buildUser(
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
},
tenantId: string,
dbUser?: any,
account?: Account
): Promise<User> {
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 UserDB.isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
if (!opts.skipPasswordValidation) {
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
throw new HTTPError(passwordValidation.error, 400)
}
}
hashedPassword = opts.hashPassword ? await hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await UserDB.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 it's not provided
if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE
}
return fullUser
}
static async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs<User>(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map(row => row.doc!)
}
static async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
static async getUsersByAppAccess(opts: { appId?: string; limit?: number }) {
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
opts.appId,
{ limit: opts.limit || 50 }
)
return response
}
static async getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
static async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
static async bulkGet(userIds: string[]) {
return await usersCore.bulkGetGlobalUsersById(userIds)
}
static async bulkUpdate(users: User[]) {
return await usersCore.bulkUpdateGlobalUsers(users)
}
static 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()
const { 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 usersCore.getById(_id)
if (email && dbUser.email !== email && !opts.allowChangingEmail) {
throw new Error("Email address cannot be changed")
}
} 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
const creatorsChange =
(await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0
return UserDB.quotas.addUsers(change, creatorsChange, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.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
const groupPromises = []
if (!_id) {
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(
UserDB.groups.addUsers(groupId, [builtUser._id!])
)
}
}
}
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
if (dbUser && builtUser.email !== dbUser.email) {
// Remove the plaform email reference if the email changed
await platform.users.removeUser({ email: dbUser.email } as User)
}
await platform.users.addUser(
tenantId,
builtUser._id!,
builtUser.email,
builtUser.ssoId
)
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
}
}
})
}
static async bulkCreate(
newUsersRequested: User[],
groups?: string[]
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
let newCreators: 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)
if (await isCreator(newUser)) {
newCreators.push(newUser)
}
}
const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(
newUsers.length,
newCreators.length,
async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
UserDB.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(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
}
)
}
static async bulkDelete(
users: Array<UserIdentifier>
): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const accountHolder = await getAccountHolderFromUsers(users)
if (accountHolder) {
users = users.filter(u => u.userId !== accountHolder.userId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: accountHolder.userId,
email: accountHolder.email,
reason: "Account holder cannot be deleted",
})
}
// Get users and delete
const allDocsResponse = await db.allDocs<User>({
include_docs: true,
keys: users.map(u => u.userId),
})
const usersToDelete = allDocsResponse.rows.map(user => {
return user.doc!
})
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsEval = await Promise.all(usersToDelete.map(isCreator))
const creatorsToDeleteCount = creatorsEval.filter(
creator => !!creator
).length
const ssoUsersToDelete: AnyDocument[] = []
for (let user of usersToDelete) {
const platformUser = (await getFirstPlatformUser(
user._id!
)) as PlatformUserById
const ssoId = platformUser.ssoId
if (ssoId) {
// Need to get the _rev of the SSO user doc to delete it. The view also returns docs that have the ssoId property, so we need to ignore those.
const ssoUsers = (await getPlatformUsers(
ssoId
)) as PlatformUserBySsoId[]
ssoUsers
.filter(user => user.ssoId == null)
.forEach(user => {
ssoUsersToDelete.push({
...user,
_deleted: true,
})
})
}
await bulkDeleteProcessing(user)
}
// Delete any associated SSO user docs
await platform.getPlatformDB().bulkDocs(ssoUsersToDelete)
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
// 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
}
static 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 accountSdk.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!)
const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0
await UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
static async createAdminUser(
email: string,
tenantId: string,
opts?: CreateAdminUserOpts
) {
const password = opts?.password
const user: User = {
email: email,
password,
createdAt: Date.now(),
roles: {},
builder: {
global: true,
},
admin: {
global: true,
},
tenantId,
firstName: opts?.firstName,
lastName: opts?.lastName,
}
if (opts?.ssoId) {
user.ssoId = opts.ssoId
}
// always bust checklist beforehand, if an error occurs but can proceed, don't get
// stuck in a cycle
await cache.bustCache(cache.CacheKey.CHECKLIST)
return await UserDB.save(user, {
hashPassword: opts?.hashPassword,
requirePassword: opts?.requirePassword,
skipPasswordValidation: opts?.skipPasswordValidation,
})
}
static async getGroups(groupIds: string[]) {
return await this.groups.getBulk(groupIds)
}
static async getGroupBuilderAppIds(user: User) {
return await this.groups.getGroupBuilderAppIds(user)
}
}