From 66fbdfe4e89e42a7d350772a10afcf9ce9b26256 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 25 Jul 2023 18:39:40 +0100 Subject: [PATCH] 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. --- packages/backend-core/src/users/db.ts | 728 +++++++++--------- packages/backend-core/src/users/index.ts | 2 +- packages/backend-core/src/users/utils.ts | 33 +- .../routes/global/tests/appBuilder.spec.ts | 18 +- packages/worker/src/sdk/users/index.ts | 10 +- packages/worker/src/sdk/users/users.ts | 108 ++- packages/worker/src/tests/api/users.ts | 14 +- 7 files changed, 487 insertions(+), 426 deletions(-) diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index cb1129d079..5e0a2bf31d 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -2,409 +2,36 @@ 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, + Account, } from "@budibase/types" -import * as pro from "@budibase/pro" import * as accountSdk from "../accounts" -import { - isPreventPasswordActions, - validateUniqueUser, - getAccountHolderFromUserIds, -} from "./utils" +import { 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( +type QuotaUpdateFn = (change: number, cb?: () => Promise) => Promise +type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise +type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn } +type GroupFns = { addUsers: GroupUpdateFn } +type BuildUserFn = ( user: User, - opts: SaveUserOpts = { - hashPassword: true, - requirePassword: true, - }, + opts: SaveUserOpts, tenantId: string, - dbUser?: any, + dbUser?: User, account?: Account -): Promise { - 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 => { - // 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 => { - 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 => { - 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 = await db.allDocs({ - include_docs: true, - keys: userIds, - }) - const usersToDelete: User[] = allDocsResponse.rows.map( - (user: RowResponse) => { - 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" }) -} +) => Promise const bulkDeleteProcessing = async (dbUser: User) => { const userId = dbUser._id as string @@ -413,3 +40,338 @@ const bulkDeleteProcessing = async (dbUser: User) => { await cache.user.invalidateUser(userId) await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) } + +export class UserDB { + quotas: QuotaFns + groups: GroupFns + ssoEnforcedFn: () => Promise + buildUserFn: BuildUserFn + + constructor( + quotaFns: QuotaFns, + groupFns: GroupFns, + ssoEnforcedFn: () => Promise, + 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 { + // 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 { + 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 { + 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 = await db.allDocs({ + include_docs: true, + keys: userIds, + }) + const usersToDelete: User[] = allDocsResponse.rows.map( + (user: RowResponse) => { + 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" }) + } +} diff --git a/packages/backend-core/src/users/index.ts b/packages/backend-core/src/users/index.ts index 2e5e2f948c..c11d2a2c62 100644 --- a/packages/backend-core/src/users/index.ts +++ b/packages/backend-core/src/users/index.ts @@ -1,4 +1,4 @@ export * from "./users" export * from "./utils" export * from "./lookup" -export * as db from "./db" +export { UserDB } from "./db" diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index 0a9abd50bc..b6bf3f0abb 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -1,11 +1,4 @@ -import { - Account, - CloudAccount, - isSSOAccount, - isSSOUser, - User, -} from "@budibase/types" -import * as pro from "@budibase/pro" +import { CloudAccount } from "@budibase/types" import * as accountSdk from "../accounts" import env from "../environment" 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. */ diff --git a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts index 2974588cae..138ebbc595 100644 --- a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts +++ b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts @@ -40,13 +40,27 @@ describe("/api/global/users/:userId/app/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 () => { 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 () => { 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([]) + }) + }) }) diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts index b37ea07d94..d40ed1b045 100644 --- a/packages/worker/src/sdk/users/index.ts +++ b/packages/worker/src/sdk/users/index.ts @@ -1,4 +1,12 @@ export * from "./users" +import { buildUser } from "./users" 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" diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index f45c7dda20..45a69f5b32 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -1,11 +1,111 @@ -import { events, tenancy, users as usersCore } from "@budibase/backend-core" -import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" +import { + 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 { 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 { + 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 -): Promise => { +): Promise { const response: InviteUsersResponse = { successful: [], unsuccessful: [], diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 39f7b64d59..605ac79416 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -149,15 +149,23 @@ export class UserAPI extends TestAPI { .expect(200) } - grantBuilderToApp = (userId: string, appId: string) => { + grantBuilderToApp = ( + userId: string, + appId: string, + statusCode: number = 200 + ) => { return this.request .patch(`/api/global/users/${userId}/app/${appId}/builder`) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) - .expect(200) + .expect(statusCode) } - revokeBuilderToApp = (userId: string, appId: string) => { + revokeBuilderToApp = ( + userId: string, + appId: string, + statusCode: number = 200 + ) => { return this.request .delete(`/api/global/users/${userId}/app/${appId}/builder`) .set(this.config.defaultHeaders())