Refactoring users core to move into backend, allowing app builder endpoints to move into pro.
This commit is contained in:
parent
01815cd61f
commit
90371b9d69
|
@ -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<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 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 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" })
|
||||||
|
}
|
|
@ -1,18 +1,18 @@
|
||||||
import env from "../../environment"
|
import env from "../environment"
|
||||||
import { events, accounts, tenancy, users } from "@budibase/backend-core"
|
import * as events from "../events"
|
||||||
|
import * as accounts from "../accounts"
|
||||||
|
import { getTenantId } from "../context"
|
||||||
import { User, UserRoles, CloudAccount } from "@budibase/types"
|
import { User, UserRoles, CloudAccount } from "@budibase/types"
|
||||||
|
import { hasBuilderPermissions, hasAdminPermissions } from "./utils"
|
||||||
const hasBuilderPerm = users.hasBuilderPermissions
|
|
||||||
const hasAdminPerm = users.hasAdminPermissions
|
|
||||||
|
|
||||||
export const handleDeleteEvents = async (user: any) => {
|
export const handleDeleteEvents = async (user: any) => {
|
||||||
await events.user.deleted(user)
|
await events.user.deleted(user)
|
||||||
|
|
||||||
if (hasBuilderPerm(user)) {
|
if (hasBuilderPermissions(user)) {
|
||||||
await events.user.permissionBuilderRemoved(user)
|
await events.user.permissionBuilderRemoved(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAdminPerm(user)) {
|
if (hasAdminPermissions(user)) {
|
||||||
await events.user.permissionAdminRemoved(user)
|
await events.user.permissionAdminRemoved(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ export const handleSaveEvents = async (
|
||||||
user: User,
|
user: User,
|
||||||
existingUser: User | undefined
|
existingUser: User | undefined
|
||||||
) => {
|
) => {
|
||||||
const tenantId = tenancy.getTenantId()
|
const tenantId = getTenantId()
|
||||||
let tenantAccount: CloudAccount | undefined
|
let tenantAccount: CloudAccount | undefined
|
||||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||||
tenantAccount = await accounts.getAccountByTenantId(tenantId)
|
tenantAccount = await accounts.getAccountByTenantId(tenantId)
|
||||||
|
@ -107,19 +107,19 @@ export const handleSaveEvents = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isAddingBuilder = (user: any, existingUser: any) => {
|
export const isAddingBuilder = (user: any, existingUser: any) => {
|
||||||
return isAddingPermission(user, existingUser, hasBuilderPerm)
|
return isAddingPermission(user, existingUser, hasBuilderPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRemovingBuilder = (user: any, existingUser: any) => {
|
export const isRemovingBuilder = (user: any, existingUser: any) => {
|
||||||
return isRemovingPermission(user, existingUser, hasBuilderPerm)
|
return isRemovingPermission(user, existingUser, hasBuilderPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAddingAdmin = (user: any, existingUser: any) => {
|
const isAddingAdmin = (user: any, existingUser: any) => {
|
||||||
return isAddingPermission(user, existingUser, hasAdminPerm)
|
return isAddingPermission(user, existingUser, hasAdminPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRemovingAdmin = (user: any, existingUser: any) => {
|
const isRemovingAdmin = (user: any, existingUser: any) => {
|
||||||
return isRemovingPermission(user, existingUser, hasAdminPerm)
|
return isRemovingPermission(user, existingUser, hasAdminPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOnboardingComplete = (user: any, existingUser: any) => {
|
const isOnboardingComplete = (user: any, existingUser: any) => {
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./users"
|
||||||
|
export * from "./utils"
|
||||||
|
export * from "./lookup"
|
||||||
|
export * as db from "./db"
|
|
@ -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<PlatformUser | null> {
|
||||||
|
// 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<User[]> {
|
||||||
|
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<PlatformUserByEmail[]> {
|
||||||
|
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<AccountMetadata[]> {
|
||||||
|
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[]
|
||||||
|
}
|
|
@ -2,7 +2,6 @@ import {
|
||||||
directCouchFind,
|
directCouchFind,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
generateAppUserID,
|
generateAppUserID,
|
||||||
getGlobalIDFromUserMetadataID,
|
|
||||||
getGlobalUserParams,
|
getGlobalUserParams,
|
||||||
getProdAppID,
|
getProdAppID,
|
||||||
getUsersByAppParams,
|
getUsersByAppParams,
|
||||||
|
@ -12,17 +11,16 @@ import {
|
||||||
SEPARATOR,
|
SEPARATOR,
|
||||||
UNICODE_MAX,
|
UNICODE_MAX,
|
||||||
ViewName,
|
ViewName,
|
||||||
} from "./db"
|
} from "../db"
|
||||||
import {
|
import {
|
||||||
BulkDocsResponse,
|
BulkDocsResponse,
|
||||||
SearchUsersRequest,
|
SearchUsersRequest,
|
||||||
User,
|
User,
|
||||||
ContextUser,
|
ContextUser,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { getGlobalDB } from "../context"
|
||||||
import { getGlobalDB } from "./context"
|
import * as context from "../context"
|
||||||
import * as context from "./context"
|
import { user as userCache } from "../cache"
|
||||||
import { user as userCache } from "./cache"
|
|
||||||
|
|
||||||
type GetOpts = { cleanup?: boolean }
|
type GetOpts = { cleanup?: boolean }
|
||||||
|
|
||||||
|
@ -41,14 +39,6 @@ function removeUserPassword(users: User | User[]) {
|
||||||
return users
|
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 (
|
export const bulkGetGlobalUsersById = async (
|
||||||
userIds: string[],
|
userIds: string[],
|
||||||
opts?: GetOpts
|
opts?: GetOpts
|
|
@ -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<CloudAccount | undefined> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,12 +3,12 @@ import {
|
||||||
roles,
|
roles,
|
||||||
context,
|
context,
|
||||||
cache,
|
cache,
|
||||||
|
users as usersCore,
|
||||||
tenancy,
|
tenancy,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import { BBContext, App } from "@budibase/types"
|
import { Ctx, App } from "@budibase/types"
|
||||||
import { allUsers } from "../../../sdk/users"
|
|
||||||
|
|
||||||
export async function fetch(ctx: BBContext) {
|
export async function fetch(ctx: Ctx) {
|
||||||
const tenantId = ctx.user!.tenantId
|
const tenantId = ctx.user!.tenantId
|
||||||
// always use the dev apps as they'll be most up to date (true)
|
// always use the dev apps as they'll be most up to date (true)
|
||||||
const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[]
|
const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[]
|
||||||
|
@ -31,7 +31,7 @@ export async function fetch(ctx: BBContext) {
|
||||||
ctx.body = response
|
ctx.body = response
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function find(ctx: BBContext) {
|
export async function find(ctx: Ctx) {
|
||||||
const appId = ctx.params.appId
|
const appId = ctx.params.appId
|
||||||
await context.doInAppContext(dbCore.getDevAppID(appId), async () => {
|
await context.doInAppContext(dbCore.getDevAppID(appId), async () => {
|
||||||
const db = context.getAppDB()
|
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 { appId } = ctx.params
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const users = await allUsers()
|
const users = await usersCore.db.allUsers()
|
||||||
const bulk = []
|
const bulk = []
|
||||||
const cacheInvalidations = []
|
const cacheInvalidations = []
|
||||||
for (let user of users) {
|
for (let user of users) {
|
||||||
|
|
|
@ -42,7 +42,7 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
|
||||||
const currentUserId = ctx.user?._id
|
const currentUserId = ctx.user?._id
|
||||||
const requestUser = ctx.request.body
|
const requestUser = ctx.request.body
|
||||||
|
|
||||||
const user = await userSdk.save(requestUser, { currentUserId })
|
const user = await userSdk.db.save(requestUser, { currentUserId })
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
_id: user._id!,
|
_id: user._id!,
|
||||||
|
@ -58,7 +58,7 @@ const bulkDelete = async (userIds: string[], currentUserId: string) => {
|
||||||
if (userIds?.indexOf(currentUserId) !== -1) {
|
if (userIds?.indexOf(currentUserId) !== -1) {
|
||||||
throw new Error("Unable to delete self.")
|
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[]) => {
|
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."
|
"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 (
|
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
|
// always bust checklist beforehand, if an error occurs but can proceed, don't get
|
||||||
// stuck in a cycle
|
// stuck in a cycle
|
||||||
await cache.bustCache(cache.CacheKey.CHECKLIST)
|
await cache.bustCache(cache.CacheKey.CHECKLIST)
|
||||||
const finalUser = await userSdk.save(user, {
|
const finalUser = await userSdk.db.save(user, {
|
||||||
hashPassword,
|
hashPassword,
|
||||||
requirePassword,
|
requirePassword,
|
||||||
})
|
})
|
||||||
|
@ -168,7 +168,7 @@ export const adminUser = async (
|
||||||
export const countByApp = async (ctx: any) => {
|
export const countByApp = async (ctx: any) => {
|
||||||
const appId = ctx.params.appId
|
const appId = ctx.params.appId
|
||||||
try {
|
try {
|
||||||
ctx.body = await userSdk.countUsersByApp(appId)
|
ctx.body = await userSdk.db.countUsersByApp(appId)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
ctx.throw(err.status || 400, err)
|
ctx.throw(err.status || 400, err)
|
||||||
}
|
}
|
||||||
|
@ -180,7 +180,7 @@ export const destroy = async (ctx: any) => {
|
||||||
ctx.throw(400, "Unable to delete self.")
|
ctx.throw(400, "Unable to delete self.")
|
||||||
}
|
}
|
||||||
|
|
||||||
await userSdk.destroy(id)
|
await userSdk.db.destroy(id)
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: `User ${id} deleted.`,
|
message: `User ${id} deleted.`,
|
||||||
|
@ -189,7 +189,7 @@ export const destroy = async (ctx: any) => {
|
||||||
|
|
||||||
export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
|
export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
|
||||||
const body = ctx.request.body
|
const body = ctx.request.body
|
||||||
const users = await userSdk.getUsersByAppAccess(body?.appId)
|
const users = await userSdk.db.getUsersByAppAccess(body?.appId)
|
||||||
|
|
||||||
ctx.body = { data: users }
|
ctx.body = { data: users }
|
||||||
}
|
}
|
||||||
|
@ -213,7 +213,7 @@ export const search = async (ctx: Ctx<SearchUsersRequest>) => {
|
||||||
|
|
||||||
// called internally by app server user fetch
|
// called internally by app server user fetch
|
||||||
export const fetch = async (ctx: any) => {
|
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
|
// user hashed password shouldn't ever be returned
|
||||||
for (let user of all) {
|
for (let user of all) {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
@ -225,12 +225,12 @@ export const fetch = async (ctx: any) => {
|
||||||
|
|
||||||
// called internally by app server user find
|
// called internally by app server user find
|
||||||
export const find = async (ctx: any) => {
|
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) => {
|
export const tenantUserLookup = async (ctx: any) => {
|
||||||
const id = ctx.params.id
|
const id = ctx.params.id
|
||||||
const user = await userSdk.getPlatformUser(id)
|
const user = await userSdk.core.getPlatformUser(id)
|
||||||
if (user) {
|
if (user) {
|
||||||
ctx.body = user
|
ctx.body = user
|
||||||
} else {
|
} else {
|
||||||
|
@ -253,7 +253,7 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const { users, groups, roles } = request.create
|
const { users, groups, roles } = request.create
|
||||||
const assignUsers = users.map((user: User) => (user.roles = roles))
|
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
|
ctx.body = onboardingResponse
|
||||||
} else if (emailConfigured) {
|
} else if (emailConfigured) {
|
||||||
onboardingResponse = await inviteMultiple(ctx)
|
onboardingResponse = await inviteMultiple(ctx)
|
||||||
|
@ -278,7 +278,7 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
|
||||||
tenantId: tenancy.getTenantId(),
|
tenantId: tenancy.getTenantId(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
|
let bulkCreateReponse = await userSdk.db.bulkCreate(users, [])
|
||||||
|
|
||||||
// Apply temporary credentials
|
// Apply temporary credentials
|
||||||
let createWithCredentials = {
|
let createWithCredentials = {
|
||||||
|
@ -411,7 +411,7 @@ export const inviteAccept = async (
|
||||||
...info,
|
...info,
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await userSdk.save(request)
|
const saved = await userSdk.db.save(request)
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const user = await db.get<User>(saved._id)
|
const user = await db.get<User>(saved._id)
|
||||||
await events.user.inviteAccepted(user)
|
await events.user.inviteAccepted(user)
|
||||||
|
@ -435,18 +435,18 @@ export const inviteAccept = async (
|
||||||
|
|
||||||
export const grantAppBuilder = async (ctx: Ctx) => {
|
export const grantAppBuilder = async (ctx: Ctx) => {
|
||||||
const { userId } = ctx.params
|
const { userId } = ctx.params
|
||||||
const user = await userSdk.getUser(userId)
|
const user = await userSdk.db.getUser(userId)
|
||||||
if (!user.builder) {
|
if (!user.builder) {
|
||||||
user.builder = {}
|
user.builder = {}
|
||||||
}
|
}
|
||||||
user.builder.appBuilder = true
|
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` }
|
ctx.body = { message: `User "${user.email}" granted app builder permissions` }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const addAppBuilder = async (ctx: Ctx) => {
|
export const addAppBuilder = async (ctx: Ctx) => {
|
||||||
const { userId, appId } = ctx.params
|
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) {
|
if (!user.builder?.global || user.admin?.global) {
|
||||||
ctx.body = { message: "User already admin - no permissions updated." }
|
ctx.body = { message: "User already admin - no permissions updated." }
|
||||||
return
|
return
|
||||||
|
@ -462,13 +462,13 @@ export const addAppBuilder = async (ctx: Ctx) => {
|
||||||
user.builder.apps = []
|
user.builder.apps = []
|
||||||
}
|
}
|
||||||
user.builder.apps.push(prodAppId)
|
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.` }
|
ctx.body = { message: `User "${user.email}" app builder access updated.` }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const removeAppBuilder = async (ctx: Ctx) => {
|
export const removeAppBuilder = async (ctx: Ctx) => {
|
||||||
const { userId, appId } = ctx.params
|
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) {
|
if (!user.builder?.global || user.admin?.global) {
|
||||||
ctx.body = { message: "User already admin - no permissions removed." }
|
ctx.body = { message: "User already admin - no permissions removed." }
|
||||||
return
|
return
|
||||||
|
@ -484,6 +484,6 @@ export const removeAppBuilder = async (ctx: Ctx) => {
|
||||||
if (indexOf && indexOf !== -1) {
|
if (indexOf && indexOf !== -1) {
|
||||||
user.builder.apps = user.builder.apps!.splice(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.` }
|
ctx.body = { message: `User "${user.email}" app builder access removed.` }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<User> {
|
||||||
|
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", () => {})
|
||||||
|
})
|
|
@ -66,7 +66,7 @@ describe("/api/global/users", () => {
|
||||||
expect(res.body._id).toBeDefined()
|
expect(res.body._id).toBeDefined()
|
||||||
const user = await config.getUser(email)
|
const user = await config.getUser(email)
|
||||||
expect(user).toBeDefined()
|
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).toBeCalledTimes(1)
|
||||||
expect(events.user.inviteAccepted).toBeCalledWith(user)
|
expect(events.user.inviteAccepted).toBeCalledWith(user)
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,2 +1,4 @@
|
||||||
export * from "./users"
|
export * from "./users"
|
||||||
|
import { users } from "@budibase/backend-core"
|
||||||
|
export const db = users.db
|
||||||
export { users as core } from "@budibase/backend-core"
|
export { users as core } from "@budibase/backend-core"
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { structures } from "../../../tests"
|
import { structures } from "../../../tests"
|
||||||
import { mocks } from "@budibase/backend-core/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 * as users from "../users"
|
||||||
import { CloudAccount } from "@budibase/types"
|
import { CloudAccount } from "@budibase/types"
|
||||||
import { isPreventPasswordActions } from "../users"
|
|
||||||
|
|
||||||
jest.mock("@budibase/pro")
|
jest.mock("@budibase/pro")
|
||||||
import * as _pro from "@budibase/pro"
|
import * as _pro from "@budibase/pro"
|
||||||
|
@ -18,7 +17,7 @@ describe("users", () => {
|
||||||
it("returns false for non sso user", async () => {
|
it("returns false for non sso user", async () => {
|
||||||
await context.doInTenant(structures.tenant.id(), async () => {
|
await context.doInTenant(structures.tenant.id(), async () => {
|
||||||
const user = structures.users.user()
|
const user = structures.users.user()
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -29,7 +28,7 @@ describe("users", () => {
|
||||||
const account = structures.accounts.ssoAccount() as CloudAccount
|
const account = structures.accounts.ssoAccount() as CloudAccount
|
||||||
account.email = user.email
|
account.email = user.email
|
||||||
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
|
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -39,7 +38,7 @@ describe("users", () => {
|
||||||
const user = structures.users.user()
|
const user = structures.users.user()
|
||||||
const account = structures.accounts.ssoAccount() as CloudAccount
|
const account = structures.accounts.ssoAccount() as CloudAccount
|
||||||
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
|
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -47,7 +46,7 @@ describe("users", () => {
|
||||||
it("returns true for sso user", async () => {
|
it("returns true for sso user", async () => {
|
||||||
await context.doInTenant(structures.tenant.id(), async () => {
|
await context.doInTenant(structures.tenant.id(), async () => {
|
||||||
const user = structures.users.ssoUser()
|
const user = structures.users.ssoUser()
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -57,7 +56,7 @@ describe("users", () => {
|
||||||
await context.doInTenant(structures.tenant.id(), async () => {
|
await context.doInTenant(structures.tenant.id(), async () => {
|
||||||
const user = structures.users.user()
|
const user = structures.users.user()
|
||||||
pro.features.isSSOEnforced.mockResolvedValueOnce(true)
|
pro.features.isSSOEnforced.mockResolvedValueOnce(true)
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -75,7 +74,7 @@ describe("users", () => {
|
||||||
describe("non-admin user", () => {
|
describe("non-admin user", () => {
|
||||||
it("returns true", async () => {
|
it("returns true", async () => {
|
||||||
const user = structures.users.ssoUser()
|
const user = structures.users.ssoUser()
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(true)
|
expect(result).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -85,7 +84,7 @@ describe("users", () => {
|
||||||
const user = structures.users.ssoUser({
|
const user = structures.users.ssoUser({
|
||||||
user: structures.users.adminUser(),
|
user: structures.users.adminUser(),
|
||||||
})
|
})
|
||||||
const result = await users.isPreventPasswordActions(user)
|
const result = await usersCore.isPreventPasswordActions(user)
|
||||||
expect(result).toBe(false)
|
expect(result).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,590 +1,7 @@
|
||||||
import env from "../../environment"
|
import { events, tenancy, users as usersCore } from "@budibase/backend-core"
|
||||||
import * as eventHelpers from "./events"
|
import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
|
||||||
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 { sendEmail } from "../../utilities/email"
|
import { sendEmail } from "../../utilities/email"
|
||||||
import { EmailTemplatePurpose } from "../../constants"
|
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<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 = constants.UserStatus.ACTIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
return fullUser
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookup, could be email or userId, either will return a doc
|
|
||||||
export const getPlatformUser = async (
|
|
||||||
identifier: string
|
|
||||||
): Promise<PlatformUser | null> => {
|
|
||||||
// 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<PlatformUser>
|
|
||||||
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<User> => {
|
|
||||||
// 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<User[]> => {
|
|
||||||
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<User[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExistingPlatformUsers = async (
|
|
||||||
emails: string[]
|
|
||||||
): Promise<PlatformUserByEmail[]> => {
|
|
||||||
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<PlatformUserByEmail[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExistingAccounts = async (
|
|
||||||
emails: string[]
|
|
||||||
): Promise<AccountMetadata[]> => {
|
|
||||||
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<AccountMetadata[]>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<BulkUserCreated> => {
|
|
||||||
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<CloudAccount | undefined> => {
|
|
||||||
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<BulkUserDeleted> => {
|
|
||||||
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<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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
export const invite = async (
|
||||||
users: InviteUsersRequest
|
users: InviteUsersRequest
|
||||||
|
@ -594,7 +11,9 @@ export const invite = async (
|
||||||
unsuccessful: [],
|
unsuccessful: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedEmails = await searchExistingEmails(users.map(u => u.email))
|
const matchedEmails = await usersCore.searchExistingEmails(
|
||||||
|
users.map(u => u.email)
|
||||||
|
)
|
||||||
const newUsers = []
|
const newUsers = []
|
||||||
|
|
||||||
// separate duplicates from new users
|
// separate duplicates from new users
|
||||||
|
|
|
@ -263,7 +263,7 @@ class TestConfiguration {
|
||||||
}
|
}
|
||||||
const response = await this._req(user, null, controllers.users.save)
|
const response = await this._req(user, null, controllers.users.save)
|
||||||
const body = response as SaveUserResponse
|
const body = response as SaveUserResponse
|
||||||
return this.getUser(body.email) as Promise<User>
|
return (await this.getUser(body.email)) as User
|
||||||
}
|
}
|
||||||
|
|
||||||
// CONFIGS
|
// CONFIGS
|
||||||
|
|
|
@ -140,4 +140,28 @@ export class UserAPI extends TestAPI {
|
||||||
.expect("Content-Type", /json/)
|
.expect("Content-Type", /json/)
|
||||||
.expect(opts?.status ? opts.status : 200)
|
.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue