Breaking out pro components back into the worker user SDK, and attempting to separate the pro components as much as possible from the user SDK itself, so that it can be easily re-created in other services.
This commit is contained in:
parent
90371b9d69
commit
66fbdfe4e8
|
@ -2,409 +2,36 @@ import env from "../environment"
|
||||||
import * as eventHelpers from "./events"
|
import * as eventHelpers from "./events"
|
||||||
import * as accounts from "../accounts"
|
import * as accounts from "../accounts"
|
||||||
import * as cache from "../cache"
|
import * as cache from "../cache"
|
||||||
import { UserStatus, ViewName } from "../constants"
|
|
||||||
import { getIdentity, getTenantId, getGlobalDB } from "../context"
|
import { getIdentity, getTenantId, getGlobalDB } from "../context"
|
||||||
import * as dbUtils from "../db"
|
import * as dbUtils from "../db"
|
||||||
import { EmailUnavailableError, HTTPError } from "../errors"
|
import { EmailUnavailableError, HTTPError } from "../errors"
|
||||||
import * as platform from "../platform"
|
import * as platform from "../platform"
|
||||||
import * as sessions from "../security/sessions"
|
import * as sessions from "../security/sessions"
|
||||||
import * as utils from "../utils"
|
|
||||||
import * as usersCore from "./users"
|
import * as usersCore from "./users"
|
||||||
import {
|
import {
|
||||||
Account,
|
|
||||||
AllDocsResponse,
|
AllDocsResponse,
|
||||||
BulkUserCreated,
|
BulkUserCreated,
|
||||||
BulkUserDeleted,
|
BulkUserDeleted,
|
||||||
PlatformUser,
|
|
||||||
RowResponse,
|
RowResponse,
|
||||||
SaveUserOpts,
|
SaveUserOpts,
|
||||||
User,
|
User,
|
||||||
|
Account,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import * as pro from "@budibase/pro"
|
|
||||||
import * as accountSdk from "../accounts"
|
import * as accountSdk from "../accounts"
|
||||||
import {
|
import { validateUniqueUser, getAccountHolderFromUserIds } from "./utils"
|
||||||
isPreventPasswordActions,
|
|
||||||
validateUniqueUser,
|
|
||||||
getAccountHolderFromUserIds,
|
|
||||||
} from "./utils"
|
|
||||||
import { searchExistingEmails } from "./lookup"
|
import { searchExistingEmails } from "./lookup"
|
||||||
|
|
||||||
export async function allUsers() {
|
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
|
||||||
const db = getGlobalDB()
|
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
|
||||||
const response = await db.allDocs(
|
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
|
||||||
dbUtils.getGlobalUserParams(null, {
|
type GroupFns = { addUsers: GroupUpdateFn }
|
||||||
include_docs: true,
|
type BuildUserFn = (
|
||||||
})
|
|
||||||
)
|
|
||||||
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,
|
user: User,
|
||||||
opts: SaveUserOpts = {
|
opts: SaveUserOpts,
|
||||||
hashPassword: true,
|
|
||||||
requirePassword: true,
|
|
||||||
},
|
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
dbUser?: any,
|
dbUser?: User,
|
||||||
account?: Account
|
account?: Account
|
||||||
): Promise<User> {
|
) => Promise<any>
|
||||||
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<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 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<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 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<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 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 bulkDeleteProcessing = async (dbUser: User) => {
|
||||||
const userId = dbUser._id as string
|
const userId = dbUser._id as string
|
||||||
|
@ -413,3 +40,338 @@ const bulkDeleteProcessing = async (dbUser: User) => {
|
||||||
await cache.user.invalidateUser(userId)
|
await cache.user.invalidateUser(userId)
|
||||||
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
|
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" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
export * from "./utils"
|
export * from "./utils"
|
||||||
export * from "./lookup"
|
export * from "./lookup"
|
||||||
export * as db from "./db"
|
export { UserDB } from "./db"
|
||||||
|
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import { CloudAccount } from "@budibase/types"
|
||||||
Account,
|
|
||||||
CloudAccount,
|
|
||||||
isSSOAccount,
|
|
||||||
isSSOUser,
|
|
||||||
User,
|
|
||||||
} from "@budibase/types"
|
|
||||||
import * as pro from "@budibase/pro"
|
|
||||||
import * as accountSdk from "../accounts"
|
import * as accountSdk from "../accounts"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
import { getPlatformUser } from "./lookup"
|
import { getPlatformUser } from "./lookup"
|
||||||
|
@ -40,30 +33,6 @@ export async function validateUniqueUser(email: string, tenantId: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
* For the given user id's, return the account holder if it is in the ids.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -40,13 +40,27 @@ describe("/api/global/users/:userId/app/builder", () => {
|
||||||
describe("PATCH /api/global/users/:userId/app/:appId/builder", () => {
|
describe("PATCH /api/global/users/:userId/app/:appId/builder", () => {
|
||||||
it("shouldn't allow granting access to an app to a non-app builder", async () => {
|
it("shouldn't allow granting access to an app to a non-app builder", async () => {
|
||||||
const user = await newUser()
|
const user = await newUser()
|
||||||
await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
|
await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID, 400)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to grant a user access to a particular app", async () => {
|
it("should be able to grant a user access to a particular app", async () => {
|
||||||
const user = await grantAppBuilder()
|
const user = await grantAppBuilder()
|
||||||
|
await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
|
||||||
|
const updated = await getUser(user._id!)
|
||||||
|
expect(updated.builder?.appBuilder).toBe(true)
|
||||||
|
expect(updated.builder?.apps).toBe([MOCK_APP_ID])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("DELETE /api/global/users/:userId/app/:appId/builder", () => {})
|
describe("DELETE /api/global/users/:userId/app/:appId/builder", () => {
|
||||||
|
it("should allow revoking access", async () => {
|
||||||
|
const user = await grantAppBuilder()
|
||||||
|
await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
|
||||||
|
let updated = await getUser(user._id!)
|
||||||
|
expect(updated.builder?.apps).toBe([MOCK_APP_ID])
|
||||||
|
await config.api.users.revokeBuilderToApp(user._id!, MOCK_APP_ID)
|
||||||
|
updated = await getUser(user._id!)
|
||||||
|
expect(updated.builder?.apps).toBe([])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
|
import { buildUser } from "./users"
|
||||||
import { users } from "@budibase/backend-core"
|
import { users } from "@budibase/backend-core"
|
||||||
export const db = users.db
|
import * as pro from "@budibase/pro"
|
||||||
|
// pass in the components which are specific to the worker/the parts of pro which backend-core cannot access
|
||||||
|
export const db = new users.UserDB(
|
||||||
|
pro.quotas,
|
||||||
|
pro.groups,
|
||||||
|
pro.features.isSSOEnforced,
|
||||||
|
buildUser
|
||||||
|
)
|
||||||
export { users as core } from "@budibase/backend-core"
|
export { users as core } from "@budibase/backend-core"
|
||||||
|
|
|
@ -1,11 +1,111 @@
|
||||||
import { events, tenancy, users as usersCore } from "@budibase/backend-core"
|
import {
|
||||||
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
|
events,
|
||||||
|
HTTPError,
|
||||||
|
tenancy,
|
||||||
|
users as usersCore,
|
||||||
|
UserStatus,
|
||||||
|
db as dbUtils,
|
||||||
|
utils,
|
||||||
|
accounts as accountSdk,
|
||||||
|
context,
|
||||||
|
env as coreEnv,
|
||||||
|
} from "@budibase/backend-core"
|
||||||
|
import {
|
||||||
|
Account,
|
||||||
|
InviteUsersRequest,
|
||||||
|
InviteUsersResponse,
|
||||||
|
isSSOAccount,
|
||||||
|
isSSOUser,
|
||||||
|
SaveUserOpts,
|
||||||
|
User,
|
||||||
|
} from "@budibase/types"
|
||||||
import { sendEmail } from "../../utilities/email"
|
import { sendEmail } from "../../utilities/email"
|
||||||
import { EmailTemplatePurpose } from "../../constants"
|
import { EmailTemplatePurpose } from "../../constants"
|
||||||
|
import * as pro from "@budibase/pro"
|
||||||
|
|
||||||
export const invite = async (
|
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.getAccountByTenantId(context.getTenantId())
|
||||||
|
}
|
||||||
|
return !!(account && account.email === user.email && isSSOAccount(account))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function 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 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 async function invite(
|
||||||
users: InviteUsersRequest
|
users: InviteUsersRequest
|
||||||
): Promise<InviteUsersResponse> => {
|
): Promise<InviteUsersResponse> {
|
||||||
const response: InviteUsersResponse = {
|
const response: InviteUsersResponse = {
|
||||||
successful: [],
|
successful: [],
|
||||||
unsuccessful: [],
|
unsuccessful: [],
|
||||||
|
|
|
@ -149,15 +149,23 @@ export class UserAPI extends TestAPI {
|
||||||
.expect(200)
|
.expect(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
grantBuilderToApp = (userId: string, appId: string) => {
|
grantBuilderToApp = (
|
||||||
|
userId: string,
|
||||||
|
appId: string,
|
||||||
|
statusCode: number = 200
|
||||||
|
) => {
|
||||||
return this.request
|
return this.request
|
||||||
.patch(`/api/global/users/${userId}/app/${appId}/builder`)
|
.patch(`/api/global/users/${userId}/app/${appId}/builder`)
|
||||||
.set(this.config.defaultHeaders())
|
.set(this.config.defaultHeaders())
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(200)
|
.expect(statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeBuilderToApp = (userId: string, appId: string) => {
|
revokeBuilderToApp = (
|
||||||
|
userId: string,
|
||||||
|
appId: string,
|
||||||
|
statusCode: number = 200
|
||||||
|
) => {
|
||||||
return this.request
|
return this.request
|
||||||
.delete(`/api/global/users/${userId}/app/${appId}/builder`)
|
.delete(`/api/global/users/${userId}/app/${appId}/builder`)
|
||||||
.set(this.config.defaultHeaders())
|
.set(this.config.defaultHeaders())
|
||||||
|
|
Loading…
Reference in New Issue