diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index 5e0a2bf31d..005d55c345 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -16,22 +16,25 @@ import { SaveUserOpts, User, Account, + isSSOUser, + isSSOAccount, + UserStatus, } from "@budibase/types" import * as accountSdk from "../accounts" -import { validateUniqueUser, getAccountHolderFromUserIds } from "./utils" +import { + validateUniqueUser, + getAccountHolderFromUserIds, + isAdmin, +} from "./utils" import { searchExistingEmails } from "./lookup" +import { hash } from "../utils" type QuotaUpdateFn = (change: number, cb?: () => Promise) => Promise type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise +type FeatureFn = () => Promise type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn } type GroupFns = { addUsers: GroupUpdateFn } -type BuildUserFn = ( - user: User, - opts: SaveUserOpts, - tenantId: string, - dbUser?: User, - account?: Account -) => Promise +type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn } const bulkDeleteProcessing = async (dbUser: User) => { const userId = dbUser._id as string @@ -44,19 +47,92 @@ const bulkDeleteProcessing = async (dbUser: User) => { export class UserDB { quotas: QuotaFns groups: GroupFns - ssoEnforcedFn: () => Promise - buildUserFn: BuildUserFn + features: FeatureFns - constructor( - quotaFns: QuotaFns, - groupFns: GroupFns, - ssoEnforcedFn: () => Promise, - buildUserFn: BuildUserFn - ) { + constructor(quotaFns: QuotaFns, groupFns: GroupFns, featureFns: FeatureFns) { this.quotas = quotaFns this.groups = groupFns - this.ssoEnforcedFn = ssoEnforcedFn - this.buildUserFn = buildUserFn + this.features = featureFns + } + + async isPreventPasswordActions(user: User, account?: Account) { + // when in maintenance mode we allow sso users with the admin role + // to perform any password action - this prevents lockout + if (env.ENABLE_SSO_MAINTENANCE_MODE && isAdmin(user)) { + return false + } + + // SSO is enforced for all users + if (await this.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)) + } + + async 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 this.isPreventPasswordActions(user, account)) { + throw new HTTPError("Password change is disabled for this user", 400) + } + hashedPassword = opts.hashPassword ? await hash(password) : password + } else if (dbUser) { + hashedPassword = dbUser.password + } + + // passwords are never required if sso is enforced + const requirePasswords = + opts.requirePassword && !(await this.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 } async allUsers() { @@ -150,7 +226,7 @@ export class UserDB { return this.quotas.addUsers(change, async () => { await validateUniqueUser(email, tenantId) - let builtUser = await this.buildUserFn(user, opts, tenantId, dbUser) + let builtUser = await this.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 @@ -231,7 +307,7 @@ export class UserDB { // create the promises array that will be called by bulkDocs newUsers.forEach((user: any) => { usersToSave.push( - this.buildUserFn( + this.buildUser( user, { hashPassword: true, diff --git a/packages/pro b/packages/pro index a60183319f..547b21c09a 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit a60183319f410d05aaa1c2f2718b772978b54d64 +Subproject commit 547b21c09a86c0cef204c89b7c179642ec70670f diff --git a/packages/types/src/sdk/licensing/feature.ts b/packages/types/src/sdk/licensing/feature.ts index f286a1cc44..20f6813409 100644 --- a/packages/types/src/sdk/licensing/feature.ts +++ b/packages/types/src/sdk/licensing/feature.ts @@ -9,6 +9,7 @@ export enum Feature { BRANDING = "branding", SCIM = "scim", SYNC_AUTOMATIONS = "syncAutomations", + APP_BUILDERS = "appBuilders", OFFLINE = "offline", } diff --git a/packages/worker/src/api/controllers/global/auth.ts b/packages/worker/src/api/controllers/global/auth.ts index b42a492fb1..279162fb08 100644 --- a/packages/worker/src/api/controllers/global/auth.ts +++ b/packages/worker/src/api/controllers/global/auth.ts @@ -56,7 +56,7 @@ export const login = async (ctx: Ctx, next: any) => { const email = ctx.request.body.username const user = await userSdk.db.getUserByEmail(email) - if (user && (await userSdk.isPreventPasswordActions(user))) { + if (user && (await userSdk.db.isPreventPasswordActions(user))) { ctx.throw(403, "Invalid credentials") } diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 99da08a5b8..621426abf3 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -432,38 +432,3 @@ export const inviteAccept = async ( ctx.throw(400, "Unable to create new user, invitation invalid.") } } - -export const addAppBuilder = async (ctx: Ctx) => { - const { userId, appId } = ctx.params - const user = await userSdk.db.getUser(userId) - if (userSdk.core.isGlobalBuilder(user)) { - ctx.body = { message: "User already admin - no permissions updated." } - return - } - const prodAppId = dbCore.getProdAppID(appId) - if (!user.builder) { - user.builder = {} - } - if (!user.builder.apps) { - user.builder.apps = [] - } - user.builder.apps.push(prodAppId) - await userSdk.db.save(user, { hashPassword: false }) - ctx.body = { message: `User "${user.email}" app builder access updated.` } -} - -export const removeAppBuilder = async (ctx: Ctx) => { - const { userId, appId } = ctx.params - const user = await userSdk.db.getUser(userId) - if (userSdk.core.isGlobalBuilder(user)) { - ctx.body = { message: "User already admin - no permissions removed." } - return - } - const prodAppId = dbCore.getProdAppID(appId) - const indexOf = user.builder?.apps?.indexOf(prodAppId) - if (user.builder && indexOf != undefined && indexOf !== -1) { - user.builder.apps = user.builder.apps!.splice(indexOf, 1) - } - await userSdk.db.save(user, { hashPassword: false }) - ctx.body = { message: `User "${user.email}" app builder access removed.` } -} diff --git a/packages/worker/src/api/routes/index.ts b/packages/worker/src/api/routes/index.ts index cbd6c96558..e6cacf110f 100644 --- a/packages/worker/src/api/routes/index.ts +++ b/packages/worker/src/api/routes/index.ts @@ -23,6 +23,7 @@ import env from "../../environment" export const routes: Router[] = [ configRoutes, userRoutes, + pro.users, workspaceRoutes, authRoutes, templateRoutes, diff --git a/packages/worker/src/sdk/auth/auth.ts b/packages/worker/src/sdk/auth/auth.ts index b3a9de5cd1..d3abbf7092 100644 --- a/packages/worker/src/sdk/auth/auth.ts +++ b/packages/worker/src/sdk/auth/auth.ts @@ -58,7 +58,7 @@ export const reset = async (email: string) => { } // exit if user has sso - if (await userSdk.isPreventPasswordActions(user)) { + if (await userSdk.db.isPreventPasswordActions(user)) { return } diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts index d40ed1b045..d016769c0f 100644 --- a/packages/worker/src/sdk/users/index.ts +++ b/packages/worker/src/sdk/users/index.ts @@ -1,12 +1,6 @@ export * from "./users" -import { buildUser } from "./users" import { users } from "@budibase/backend-core" 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 const db = new users.UserDB(pro.quotas, pro.groups, pro.features) export { users as core } from "@budibase/backend-core" diff --git a/packages/worker/src/sdk/users/tests/users.spec.ts b/packages/worker/src/sdk/users/tests/users.spec.ts index ff5eed0ef0..df1aa74200 100644 --- a/packages/worker/src/sdk/users/tests/users.spec.ts +++ b/packages/worker/src/sdk/users/tests/users.spec.ts @@ -1,6 +1,7 @@ import { structures, mocks } from "../../../tests" import { env, context } from "@budibase/backend-core" import * as users from "../users" +import { db as userDb } from "../" import { CloudAccount } from "@budibase/types" describe("users", () => { @@ -12,7 +13,7 @@ describe("users", () => { it("returns false for non sso user", async () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.user() - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -23,7 +24,7 @@ describe("users", () => { const account = structures.accounts.ssoAccount() as CloudAccount account.email = user.email mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -33,7 +34,7 @@ describe("users", () => { const user = structures.users.user() const account = structures.accounts.ssoAccount() as CloudAccount mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -41,7 +42,7 @@ describe("users", () => { it("returns true for sso user", async () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.ssoUser() - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -51,7 +52,7 @@ describe("users", () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.user() mocks.pro.features.isSSOEnforced.mockResolvedValueOnce(true) - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -69,7 +70,7 @@ describe("users", () => { describe("non-admin user", () => { it("returns true", async () => { const user = structures.users.ssoUser() - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -79,7 +80,7 @@ describe("users", () => { const user = structures.users.ssoUser({ user: structures.users.adminUser(), }) - const result = await users.isPreventPasswordActions(user) + const result = await userDb.isPreventPasswordActions(user) expect(result).toBe(false) }) }) diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 45a69f5b32..230a8fa146 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -1,107 +1,7 @@ -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 { events, tenancy, users as usersCore } from "@budibase/backend-core" +import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types" import { sendEmail } from "../../utilities/email" import { EmailTemplatePurpose } from "../../constants" -import * as pro from "@budibase/pro" - -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