Breaking out pro components back into the worker user SDK, and attempting to separate the pro components as much as possible from the user SDK itself, so that it can be easily re-created in other services.

This commit is contained in:
mike12345567 2023-07-25 18:39:40 +01:00
parent 90371b9d69
commit 66fbdfe4e8
7 changed files with 487 additions and 426 deletions

View File

@ -2,34 +2,64 @@ 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() {
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn }
type BuildUserFn = (
user: User,
opts: SaveUserOpts,
tenantId: string,
dbUser?: User,
account?: Account
) => Promise<any>
const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string
await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
}
export class UserDB {
quotas: QuotaFns
groups: GroupFns
ssoEnforcedFn: () => Promise<boolean>
buildUserFn: BuildUserFn
constructor(
quotaFns: QuotaFns,
groupFns: GroupFns,
ssoEnforcedFn: () => Promise<boolean>,
buildUserFn: BuildUserFn
) {
this.quotas = quotaFns
this.groups = groupFns
this.ssoEnforcedFn = ssoEnforcedFn
this.buildUserFn = buildUserFn
}
async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
@ -39,14 +69,14 @@ export async function allUsers() {
return response.rows.map((row: any) => row.doc)
}
export async function countUsersByApp(appId: string) {
async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
export async function getUsersByAppAccess(appId?: string) {
async getUsersByAppAccess(appId?: string) {
const opts: any = {
include_docs: true,
limit: 50,
@ -58,14 +88,14 @@ export async function getUsersByAppAccess(appId?: string) {
return response
}
export async function getUserByEmail(email: string) {
async 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) {
async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
@ -73,66 +103,7 @@ export async function getUser(userId: string) {
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> => {
async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true
if (opts.hashPassword == null) {
opts.hashPassword = true
@ -176,10 +147,10 @@ export const save = async (
}
const change = dbUser ? 0 : 1 // no change if there is existing user
return pro.quotas.addUsers(change, async () => {
return this.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await buildUser(user, opts, tenantId, dbUser)
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
@ -197,7 +168,7 @@ export const save = async (
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
groupPromises.push(this.groups.addUsers(groupId, [_id!]))
}
}
}
@ -225,10 +196,10 @@ export const save = async (
})
}
export const bulkCreate = async (
async bulkCreate(
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> => {
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
let usersToSave: any[] = []
@ -256,11 +227,11 @@ export const bulkCreate = async (
}
const account = await accountSdk.getAccountByTenantId(tenantId)
return pro.quotas.addUsers(newUsers.length, async () => {
return this.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
buildUser(
this.buildUserFn(
user,
{
hashPassword: true,
@ -296,7 +267,7 @@ export const bulkCreate = async (
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
groupPromises.push(this.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
@ -308,9 +279,7 @@ export const bulkCreate = async (
})
}
export const bulkDelete = async (
userIds: string[]
): Promise<BulkUserDeleted> => {
async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
@ -348,7 +317,7 @@ export const bulkDelete = async (
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await pro.quotas.removeUsers(toDelete.length)
await this.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
@ -378,7 +347,7 @@ export const bulkDelete = async (
return response
}
export const destroy = async (id: string) => {
async destroy(id: string) {
const db = getGlobalDB()
const dbUser = (await db.get(id)) as User
const userId = dbUser._id as string
@ -400,16 +369,9 @@ export const destroy = async (id: string) => {
await db.remove(userId, dbUser._rev)
await pro.quotas.removeUsers(1)
await this.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" })
}

View File

@ -1,4 +1,4 @@
export * from "./users"
export * from "./utils"
export * from "./lookup"
export * as db from "./db"
export { UserDB } from "./db"

View File

@ -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.
*/

View File

@ -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([])
})
})
})

View File

@ -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"

View File

@ -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<User> {
let { password, _id } = user
// don't require a password if the db user doesn't already have one
if (dbUser && !dbUser.password) {
opts.requirePassword = false
}
let hashedPassword
if (password) {
if (await isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await pro.features.isSSOEnforced())
if (!hashedPassword && requirePasswords) {
throw "Password must be specified."
}
_id = _id || dbUtils.generateGlobalUserID()
const fullUser = {
createdAt: Date.now(),
...dbUser,
...user,
_id,
password: hashedPassword,
tenantId,
}
// make sure the roles object is always present
if (!fullUser.roles) {
fullUser.roles = {}
}
// add the active status to a user if its not provided
if (fullUser.status == null) {
fullUser.status = UserStatus.ACTIVE
}
return fullUser
}
export async function invite(
users: InviteUsersRequest
): Promise<InviteUsersResponse> => {
): Promise<InviteUsersResponse> {
const response: InviteUsersResponse = {
successful: [],
unsuccessful: [],

View File

@ -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())