From 6d24a30d91af98cb78611f893bb3442522b8af50 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 5 Jul 2023 18:28:04 +0100 Subject: [PATCH 01/65] Basic refactor work, the types required for the new API endpoints. --- packages/types/src/api/web/user.ts | 7 +++++++ packages/types/src/documents/global/user.ts | 2 ++ packages/worker/src/api/controllers/global/users.ts | 8 ++++++++ packages/worker/src/api/routes/global/users.ts | 3 +++ 4 files changed, 20 insertions(+) diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 619362805a..4a27f781af 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -85,3 +85,10 @@ export interface AcceptUserInviteResponse { export interface SyncUserRequest { previousUser?: User } + +export interface AddAppBuilderRequest { + userId: string + appId: string +} + +export interface RemoveAppBuilderRequest {} diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index 9b4aadf404..cae3c0e6e2 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -43,9 +43,11 @@ export interface User extends Document { roles: UserRoles builder?: { global: boolean + apps?: string[] } admin?: { global: boolean + apps?: string[] } password?: string status?: UserStatus diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 320f7be01a..b3720d578f 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -8,6 +8,8 @@ import env from "../../../environment" import { AcceptUserInviteRequest, AcceptUserInviteResponse, + AddAppBuilderRequest, + RemoveAppBuilderRequest, BulkUserRequest, BulkUserResponse, CloudAccount, @@ -431,3 +433,9 @@ export const inviteAccept = async ( ctx.throw(400, "Unable to create new user, invitation invalid.") } } + +export const addAppBuilder = async (ctx: Ctx) => {} + +export const removeAppBuilder = async ( + ctx: Ctx +) => {} diff --git a/packages/worker/src/api/routes/global/users.ts b/packages/worker/src/api/routes/global/users.ts index 47e76c17be..557065e9a4 100644 --- a/packages/worker/src/api/routes/global/users.ts +++ b/packages/worker/src/api/routes/global/users.ts @@ -5,6 +5,7 @@ import Joi from "joi" import cloudRestricted from "../../../middleware/cloudRestricted" import { users } from "../validation" import * as selfController from "../../controllers/global/self" +import { addAppBuilder } from "../../controllers/global/users" const router: Router = new Router() @@ -131,5 +132,7 @@ router users.buildUserSaveValidation(), selfController.updateSelf ) + .post("/api/global/users/builder", controller.addAppBuilder) + .delete("/api/global/users/builder", controller.removeAppBuilder) export default router From 39746e0bf0db3633723ee2df60551ec3b25469df Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 18 Jul 2023 16:57:48 +0100 Subject: [PATCH 02/65] Main body of work to handle the new approach of per app builders support. --- packages/backend-core/src/constants/db.ts | 2 - packages/backend-core/src/db/views.ts | 11 ---- .../backend-core/src/events/identification.ts | 5 +- .../src/middleware/builderOnly.ts | 12 ++-- .../src/middleware/builderOrAdmin.ts | 15 +++-- packages/backend-core/src/users.ts | 62 ++++++++++++++++++- .../server/src/api/controllers/application.ts | 4 +- packages/server/src/middleware/authorized.ts | 11 +++- packages/server/src/middleware/currentapp.ts | 14 ++--- .../functions/backfill/global/users.ts | 10 ++- packages/server/src/utilities/global.ts | 6 +- packages/types/src/documents/global/user.ts | 1 - packages/worker/src/sdk/users/events.ts | 20 +++--- packages/worker/src/sdk/users/users.ts | 4 +- 14 files changed, 115 insertions(+), 62 deletions(-) diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index be49b9f261..34dab6c088 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -14,8 +14,6 @@ export enum ViewName { USER_BY_APP = "by_app", USER_BY_EMAIL = "by_email2", BY_API_KEY = "by_api_key", - /** @deprecated - could be deleted */ - USER_BY_BUILDERS = "by_builders", LINK = "by_link", ROUTING = "screen_routes", AUTOMATION_LOGS = "automation_logs", diff --git a/packages/backend-core/src/db/views.ts b/packages/backend-core/src/db/views.ts index fddb1ab34b..7f5ef29a0a 100644 --- a/packages/backend-core/src/db/views.ts +++ b/packages/backend-core/src/db/views.ts @@ -105,16 +105,6 @@ export const createApiKeyView = async () => { await createView(db, viewJs, ViewName.BY_API_KEY) } -export const createUserBuildersView = async () => { - const db = getGlobalDB() - const viewJs = `function(doc) { - if (doc.builder && doc.builder.global === true) { - emit(doc._id, doc._id) - } - }` - await createView(db, viewJs, ViewName.USER_BY_BUILDERS) -} - export interface QueryViewOptions { arrayResponse?: boolean } @@ -223,7 +213,6 @@ export const queryPlatformView = async ( const CreateFuncByName: any = { [ViewName.USER_BY_EMAIL]: createNewUserEmailView, [ViewName.BY_API_KEY]: createApiKeyView, - [ViewName.USER_BY_BUILDERS]: createUserBuildersView, [ViewName.USER_BY_APP]: createUserAppView, } diff --git a/packages/backend-core/src/events/identification.ts b/packages/backend-core/src/events/identification.ts index 5eb11d1354..52fcb2431f 100644 --- a/packages/backend-core/src/events/identification.ts +++ b/packages/backend-core/src/events/identification.ts @@ -21,6 +21,7 @@ import { processors } from "./processors" import { newid } from "../utils" import * as installation from "../installation" import * as configs from "../configs" +import * as users from "../users" import { withCache, TTL, CacheKey } from "../cache/generic" /** @@ -164,8 +165,8 @@ const identifyUser = async ( const id = user._id as string const tenantId = await getEventTenantId(user.tenantId) const type = IdentityType.USER - let builder = user.builder?.global || false - let admin = user.admin?.global || false + let builder = users.hasBuilderPermissions(user) + let admin = users.hasAdminPermissions(user) let providerType if (isSSOUser(user)) { providerType = user.providerType diff --git a/packages/backend-core/src/middleware/builderOnly.ts b/packages/backend-core/src/middleware/builderOnly.ts index a00fd63a22..744321252e 100644 --- a/packages/backend-core/src/middleware/builderOnly.ts +++ b/packages/backend-core/src/middleware/builderOnly.ts @@ -1,10 +1,10 @@ -import { BBContext } from "@budibase/types" +import { UserCtx } from "@budibase/types" +import { isBuilder } from "../users" +import { getAppId } from "../context" -export default async (ctx: BBContext, next: any) => { - if ( - !ctx.internal && - (!ctx.user || !ctx.user.builder || !ctx.user.builder.global) - ) { +export default async (ctx: UserCtx, next: any) => { + const appId = getAppId() + if (!ctx.internal && !isBuilder(ctx.user, appId)) { ctx.throw(403, "Builder user only endpoint.") } return next() diff --git a/packages/backend-core/src/middleware/builderOrAdmin.ts b/packages/backend-core/src/middleware/builderOrAdmin.ts index 26bb3a1bda..2ba5bfe1e2 100644 --- a/packages/backend-core/src/middleware/builderOrAdmin.ts +++ b/packages/backend-core/src/middleware/builderOrAdmin.ts @@ -1,12 +1,11 @@ -import { BBContext } from "@budibase/types" +import { UserCtx } from "@budibase/types" +import { isBuilder, isAdmin } from "../users" +import { getAppId } from "../context" -export default async (ctx: BBContext, next: any) => { - if ( - !ctx.internal && - (!ctx.user || !ctx.user.builder || !ctx.user.builder.global) && - (!ctx.user || !ctx.user.admin || !ctx.user.admin.global) - ) { - ctx.throw(403, "Builder user only endpoint.") +export default async (ctx: UserCtx, next: any) => { + const appId = getAppId() + if (!ctx.internal && !isBuilder(ctx.user, appId) && !isAdmin(ctx.user)) { + ctx.throw(403, "Admin/Builder user only endpoint.") } return next() } diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index b49058f546..2e6ede3cf3 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -12,7 +12,12 @@ import { UNICODE_MAX, ViewName, } from "./db" -import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types" +import { + BulkDocsResponse, + SearchUsersRequest, + User, + ContextUser, +} from "@budibase/types" import { getGlobalDB } from "./context" import * as context from "./context" @@ -178,7 +183,7 @@ export const getGlobalUserByAppPage = (appId: string, user: User) => { * Performs a starts with search on the global email view. */ export const searchGlobalUsersByEmail = async ( - email: string, + email: string | unknown, opts: any, getOpts?: GetOpts ) => { @@ -248,3 +253,56 @@ export async function getUserCount() { }) return response.total_rows } + +// checks if a user is specifically a builder, given an app ID +export function isBuilder(user: User | ContextUser, appId?: string) { + if (user.builder?.global) { + return true + } else if (appId && user.builder?.apps?.includes(appId)) { + return true + } + return false +} + +// alias for hasAdminPermission, currently do the same thing +// in future whether someone has admin permissions and whether they are +// an admin for a specific resource could be separated +export function isAdmin(user: User | ContextUser) { + return hasAdminPermissions(user) +} + +// checks if a user is capable of building any app +export function hasBuilderPermissions(user?: User | ContextUser) { + if (!user) { + return false + } + return user.builder?.global || user.builder?.apps?.length !== 0 +} + +// checks if a user is capable of being an admin +export function hasAdminPermissions(user?: User | ContextUser) { + if (!user) { + return false + } + return user.admin?.global +} + +// used to remove the builder/admin permissions, for processing the +// user as an app user (they may have some specific role/group +export function removePortalUserPermissions(user: User | ContextUser) { + delete user.admin + delete user.builder + return user +} + +export function cleanseUserObject(user: User | ContextUser, base?: User) { + delete user.admin + delete user.builder + delete user.roles + if (base) { + user.admin = base.admin + user.builder = base.builder + user.roles = base.roles + } + return user +} diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 418ccf637e..6a0088d4dc 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -30,6 +30,7 @@ import { objectStore, roles, tenancy, + users, } from "@budibase/backend-core" import { USERS_TABLE_SCHEMA } from "../../constants" import { @@ -222,6 +223,7 @@ export async function fetchAppDefinition(ctx: UserCtx) { export async function fetchAppPackage(ctx: UserCtx) { const db = context.getAppDB() + const appId = context.getAppId() let application = await db.get(DocumentType.APP_METADATA) const layouts = await getLayouts() let screens = await getScreens() @@ -233,7 +235,7 @@ export async function fetchAppPackage(ctx: UserCtx) { ) // Only filter screens if the user is not a builder - if (!(ctx.user.builder && ctx.user.builder.global)) { + if (!users.isBuilder(ctx.user, appId)) { const userRoleId = getUserRoleId(ctx) const accessController = new roles.AccessController() screens = await accessController.checkScreensAccess(screens, userRoleId) diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts index 6268163fb2..81e42604bc 100644 --- a/packages/server/src/middleware/authorized.ts +++ b/packages/server/src/middleware/authorized.ts @@ -1,4 +1,10 @@ -import { roles, permissions, auth, context } from "@budibase/backend-core" +import { + roles, + permissions, + auth, + context, + users, +} from "@budibase/backend-core" import { Role } from "@budibase/types" import builderMiddleware from "./builder" import { isWebhookEndpoint } from "./utils" @@ -21,8 +27,9 @@ const checkAuthorized = async ( permType: any, permLevel: any ) => { + const appId = context.getAppId() // check if this is a builder api and the user is not a builder - const isBuilder = ctx.user && ctx.user.builder && ctx.user.builder.global + const isBuilder = users.isBuilder(ctx.user, appId) const isBuilderApi = permType === permissions.PermissionType.BUILDER if (isBuilderApi && !isBuilder) { return ctx.throw(403, "Not Authorized") diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts index 6879a103bc..10ee13a67a 100644 --- a/packages/server/src/middleware/currentapp.ts +++ b/packages/server/src/middleware/currentapp.ts @@ -4,12 +4,14 @@ import { roles, tenancy, context, + users, } from "@budibase/backend-core" import { generateUserMetadataID, isDevAppID } from "../db/utils" import { getCachedSelf } from "../utilities/global" import env from "../environment" import { isWebhookEndpoint } from "./utils" -import { UserCtx } from "@budibase/types" +import { UserCtx, ContextUser } from "@budibase/types" +import { removePortalUserPermissions } from "@budibase/backend-core/src/users" export default async (ctx: UserCtx, next: any) => { // try to get the appID from the request @@ -42,8 +44,7 @@ export default async (ctx: UserCtx, next: any) => { roleId = globalUser.roleId || roleId // Allow builders to specify their role via a header - const isBuilder = - globalUser && globalUser.builder && globalUser.builder.global + const isBuilder = users.isBuilder(globalUser, appId) const isDevApp = appId && isDevAppID(appId) const roleHeader = ctx.request && @@ -56,8 +57,7 @@ export default async (ctx: UserCtx, next: any) => { roleId = roleHeader // Delete admin and builder flags so that the specified role is honoured - delete ctx.user.builder - delete ctx.user.admin + ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser } } catch (error) { // Swallow error and do nothing @@ -81,9 +81,7 @@ export default async (ctx: UserCtx, next: any) => { !tenancy.isUserInAppTenant(requestAppId, ctx.user) ) { // don't error, simply remove the users rights (they are a public user) - delete ctx.user.builder - delete ctx.user.admin - delete ctx.user.roles + ctx.user = users.cleanseUserObject(ctx.user) as ContextUser ctx.isAuthenticated = false roleId = roles.BUILTIN_ROLE_IDS.PUBLIC skipCookie = true diff --git a/packages/server/src/migrations/functions/backfill/global/users.ts b/packages/server/src/migrations/functions/backfill/global/users.ts index 05c5f8f56e..9f536a97a5 100644 --- a/packages/server/src/migrations/functions/backfill/global/users.ts +++ b/packages/server/src/migrations/functions/backfill/global/users.ts @@ -1,4 +1,8 @@ -import { events, db as dbUtils } from "@budibase/backend-core" +import { + events, + db as dbUtils, + users as usersCore, +} from "@budibase/backend-core" import { User, CloudAccount } from "@budibase/types" import { DEFAULT_TIMESTAMP } from ".." @@ -30,11 +34,11 @@ export const backfill = async ( await events.identification.identifyUser(user, account, timestamp) await events.user.created(user, timestamp) - if (user.admin?.global) { + if (usersCore.hasAdminPermissions(user)) { await events.user.permissionAdminAssigned(user, timestamp) } - if (user.builder?.global) { + if (usersCore.hasBuilderPermissions(user)) { await events.user.permissionBuilderAssigned(user, timestamp) } diff --git a/packages/server/src/utilities/global.ts b/packages/server/src/utilities/global.ts index c9869b4920..63e33dafee 100644 --- a/packages/server/src/utilities/global.ts +++ b/packages/server/src/utilities/global.ts @@ -5,6 +5,7 @@ import { cache, tenancy, context, + users, } from "@budibase/backend-core" import env from "../environment" import { groups } from "@budibase/pro" @@ -22,8 +23,7 @@ export function updateAppRole( } // if in an multi-tenancy environment make sure roles are never updated if (env.MULTI_TENANCY && appId && !tenancy.isUserInAppTenant(appId, user)) { - delete user.builder - delete user.admin + user = users.removePortalUserPermissions(user) user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC return user } @@ -32,7 +32,7 @@ export function updateAppRole( user.roleId = user.roles[dbCore.getProdAppID(appId)] } // if a role wasn't found then either set as admin (builder) or public (everyone else) - if (!user.roleId && user.builder && user.builder.global) { + if (!user.roleId && users.isBuilder(user, appId)) { user.roleId = roles.BUILTIN_ROLE_IDS.ADMIN } else if (!user.roleId && !user?.userGroups?.length) { user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index cae3c0e6e2..e9e3ccc662 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -47,7 +47,6 @@ export interface User extends Document { } admin?: { global: boolean - apps?: string[] } password?: string status?: UserStatus diff --git a/packages/worker/src/sdk/users/events.ts b/packages/worker/src/sdk/users/events.ts index 7d86182a3c..d8af13a82f 100644 --- a/packages/worker/src/sdk/users/events.ts +++ b/packages/worker/src/sdk/users/events.ts @@ -1,15 +1,18 @@ import env from "../../environment" -import { events, accounts, tenancy } from "@budibase/backend-core" +import { events, accounts, tenancy, users } from "@budibase/backend-core" import { User, UserRoles, CloudAccount } from "@budibase/types" +const hasBuilderPerm = users.hasBuilderPermissions +const hasAdminPerm = users.hasAdminPermissions + export const handleDeleteEvents = async (user: any) => { await events.user.deleted(user) - if (isBuilder(user)) { + if (hasBuilderPerm(user)) { await events.user.permissionBuilderRemoved(user) } - if (isAdmin(user)) { + if (hasAdminPerm(user)) { await events.user.permissionAdminRemoved(user) } } @@ -103,23 +106,20 @@ export const handleSaveEvents = async ( await handleAppRoleEvents(user, existingUser) } -const isBuilder = (user: any) => user.builder && user.builder.global -const isAdmin = (user: any) => user.admin && user.admin.global - export const isAddingBuilder = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, isBuilder) + return isAddingPermission(user, existingUser, hasBuilderPerm) } export const isRemovingBuilder = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, isBuilder) + return isRemovingPermission(user, existingUser, hasBuilderPerm) } const isAddingAdmin = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, isAdmin) + return isAddingPermission(user, existingUser, hasAdminPerm) } const isRemovingAdmin = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, isAdmin) + return isRemovingPermission(user, existingUser, hasAdminPerm) } const isOnboardingComplete = (user: any, existingUser: any) => { diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index c3cb8500cb..cfa68932d2 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -252,9 +252,7 @@ export const save = async ( 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.builder = dbUser.builder - builtUser.admin = dbUser.admin - builtUser.roles = dbUser.roles + builtUser = usersCore.cleanseUserObject(builtUser, dbUser) as User } if (!dbUser && roles?.length) { From 91847504c8bd4e79f278f5d5287c5c6f5deda088 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 18 Jul 2023 18:10:15 +0100 Subject: [PATCH 03/65] Adding test cases for admin/builder checking middlewares. --- .../backend-core/src/middleware/adminOnly.ts | 4 +- .../src/middleware/tests/builder.spec.ts | 141 ++++++++++++++++++ packages/backend-core/src/users.ts | 2 +- .../tests/core/utilities/structures/users.ts | 19 +++ packages/server/src/middleware/currentapp.ts | 1 - packages/types/src/documents/global/user.ts | 11 +- 6 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 packages/backend-core/src/middleware/tests/builder.spec.ts diff --git a/packages/backend-core/src/middleware/adminOnly.ts b/packages/backend-core/src/middleware/adminOnly.ts index dbe1e3a501..dc2fe9064e 100644 --- a/packages/backend-core/src/middleware/adminOnly.ts +++ b/packages/backend-core/src/middleware/adminOnly.ts @@ -1,6 +1,6 @@ -import { BBContext } from "@budibase/types" +import { UserCtx } from "@budibase/types" -export default async (ctx: BBContext, next: any) => { +export default async (ctx: UserCtx, next: any) => { if ( !ctx.internal && (!ctx.user || !ctx.user.admin || !ctx.user.admin.global) diff --git a/packages/backend-core/src/middleware/tests/builder.spec.ts b/packages/backend-core/src/middleware/tests/builder.spec.ts new file mode 100644 index 0000000000..68c72ffe8a --- /dev/null +++ b/packages/backend-core/src/middleware/tests/builder.spec.ts @@ -0,0 +1,141 @@ +import adminOnly from "../adminOnly" +import builderOnly from "../builderOnly" +import builderOrAdmin from "../builderOrAdmin" +import { structures } from "../../../tests" +import { ContextUser } from "@budibase/types" +import { doInAppContext } from "../../context" + +const appId = "app_aaa" +const basicUser = structures.users.user() +const adminUser = structures.users.adminUser() +const adminOnlyUser = structures.users.adminOnlyUser() +const builderUser = structures.users.builderUser() +const appBuilderUser = structures.users.appBuilderUser(appId) + +function buildUserCtx(user: ContextUser) { + return { + internal: false, + user, + throw: jest.fn(), + } as any +} + +function passed(throwFn: jest.Func, nextFn: jest.Func) { + expect(throwFn).not.toBeCalled() + expect(nextFn).toBeCalled() +} + +function threw(throwFn: jest.Func) { + // cant check next, the throw function doesn't actually throw - so it still continues + expect(throwFn).toBeCalled() +} + +describe("adminOnly middleware", () => { + it("should allow admin user", () => { + const ctx = buildUserCtx(adminUser), + next = jest.fn() + adminOnly(ctx, next) + passed(ctx.throw, next) + }) + + it("should not allow basic user", () => { + const ctx = buildUserCtx(basicUser), + next = jest.fn() + adminOnly(ctx, next) + threw(ctx.throw) + }) + + it("should not allow builder user", () => { + const ctx = buildUserCtx(builderUser), + next = jest.fn() + adminOnly(ctx, next) + threw(ctx.throw) + }) +}) + +describe("builderOnly middleware", () => { + it("should allow builder user", () => { + const ctx = buildUserCtx(builderUser), + next = jest.fn() + builderOnly(ctx, next) + passed(ctx.throw, next) + }) + + it("should allow app builder user", () => { + const ctx = buildUserCtx(appBuilderUser), + next = jest.fn() + doInAppContext(appId, () => { + builderOnly(ctx, next) + }) + passed(ctx.throw, next) + }) + + it("should allow admin and builder user", () => { + const ctx = buildUserCtx(adminUser), + next = jest.fn() + builderOnly(ctx, next) + passed(ctx.throw, next) + }) + + it("should not allow admin user", () => { + const ctx = buildUserCtx(adminOnlyUser), + next = jest.fn() + builderOnly(ctx, next) + threw(ctx.throw) + }) + + it("should not allow app builder user to different app", () => { + const ctx = buildUserCtx(appBuilderUser), + next = jest.fn() + doInAppContext("app_bbb", () => { + builderOnly(ctx, next) + }) + threw(ctx.throw) + }) + + it("should not allow basic user", () => { + const ctx = buildUserCtx(basicUser), + next = jest.fn() + builderOnly(ctx, next) + threw(ctx.throw) + }) +}) + +describe("builderOrAdmin middleware", () => { + it("should allow builder user", () => { + const ctx = buildUserCtx(builderUser), + next = jest.fn() + builderOrAdmin(ctx, next) + passed(ctx.throw, next) + }) + + it("should allow builder and admin user", () => { + const ctx = buildUserCtx(adminUser), + next = jest.fn() + builderOrAdmin(ctx, next) + passed(ctx.throw, next) + }) + + it("should allow admin user", () => { + const ctx = buildUserCtx(adminOnlyUser), + next = jest.fn() + builderOrAdmin(ctx, next) + passed(ctx.throw, next) + }) + + it("should allow app builder user", () => { + const ctx = buildUserCtx(appBuilderUser), + next = jest.fn() + doInAppContext(appId, () => { + builderOrAdmin(ctx, next) + }) + passed(ctx.throw, next) + }) + + it("should not allow basic user", () => { + const ctx = buildUserCtx(basicUser), + next = jest.fn() + builderOrAdmin(ctx, next) + threw(ctx.throw) + }) +}) diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index 2e6ede3cf3..71cd599f87 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -258,7 +258,7 @@ export async function getUserCount() { export function isBuilder(user: User | ContextUser, appId?: string) { if (user.builder?.global) { return true - } else if (appId && user.builder?.apps?.includes(appId)) { + } else if (appId && user.builder?.apps?.includes(getProdAppID(appId))) { return true } return false diff --git a/packages/backend-core/tests/core/utilities/structures/users.ts b/packages/backend-core/tests/core/utilities/structures/users.ts index 7a6b4f0d80..0a4f2e8b54 100644 --- a/packages/backend-core/tests/core/utilities/structures/users.ts +++ b/packages/backend-core/tests/core/utilities/structures/users.ts @@ -1,5 +1,6 @@ import { AdminUser, + AdminOnlyUser, BuilderUser, SSOAuthDetails, SSOUser, @@ -21,6 +22,15 @@ export const adminUser = (userProps?: any): AdminUser => { } } +export const adminOnlyUser = (userProps?: any): AdminOnlyUser => { + return { + ...user(userProps), + admin: { + global: true, + }, + } +} + export const builderUser = (userProps?: any): BuilderUser => { return { ...user(userProps), @@ -30,6 +40,15 @@ export const builderUser = (userProps?: any): BuilderUser => { } } +export const appBuilderUser = (appId: string, userProps?: any): BuilderUser => { + return { + ...user(userProps), + builder: { + apps: [appId], + }, + } +} + export function ssoUser( opts: { user?: any; details?: SSOAuthDetails } = {} ): SSOUser { diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts index 10ee13a67a..1f580d80e4 100644 --- a/packages/server/src/middleware/currentapp.ts +++ b/packages/server/src/middleware/currentapp.ts @@ -11,7 +11,6 @@ import { getCachedSelf } from "../utilities/global" import env from "../environment" import { isWebhookEndpoint } from "./utils" import { UserCtx, ContextUser } from "@budibase/types" -import { removePortalUserPermissions } from "@budibase/backend-core/src/users" export default async (ctx: UserCtx, next: any) => { // try to get the appID from the request diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index e9e3ccc662..2ce714801d 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -42,7 +42,7 @@ export interface User extends Document { forceResetPassword?: boolean roles: UserRoles builder?: { - global: boolean + global?: boolean apps?: string[] } admin?: { @@ -70,7 +70,8 @@ export interface UserRoles { export interface BuilderUser extends User { builder: { - global: boolean + global?: boolean + apps?: string[] } } @@ -83,6 +84,12 @@ export interface AdminUser extends User { } } +export interface AdminOnlyUser extends User { + admin: { + global: boolean + } +} + export function isUser(user: object): user is User { return !!(user as User).roles } From 85dea47a31d12b859ef4a864e17213337c2c5f47 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 19 Jul 2023 16:19:34 +0100 Subject: [PATCH 04/65] Moving user admin/builder functions to shared-core for frontend to use. --- packages/backend-core/package.json | 1 + packages/backend-core/src/constants/db.ts | 44 ++----------------- packages/backend-core/src/users.ts | 40 +++-------------- packages/server/src/middleware/currentapp.ts | 4 +- packages/shared-core/src/index.ts | 1 + .../src/sdk/documents/applications.ts | 29 ++++++++++++ .../shared-core/src/sdk/documents/index.ts | 2 + .../shared-core/src/sdk/documents/users.ts | 35 +++++++++++++++ packages/shared-core/src/sdk/index.ts | 1 + packages/types/src/documents/document.ts | 41 +++++++++++++++++ packages/worker/src/sdk/users/users.ts | 2 +- 11 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 packages/shared-core/src/sdk/documents/applications.ts create mode 100644 packages/shared-core/src/sdk/documents/index.ts create mode 100644 packages/shared-core/src/sdk/documents/users.ts create mode 100644 packages/shared-core/src/sdk/index.ts diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 7f3c064c92..93f2529987 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -22,6 +22,7 @@ "dependencies": { "@budibase/nano": "10.1.2", "@budibase/pouchdb-replication-stream": "1.2.10", + "@budibase/shared-core": "0.0.0", "@budibase/types": "0.0.0", "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index 34dab6c088..83f8298f54 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -1,5 +1,5 @@ -export const SEPARATOR = "_" -export const UNICODE_MAX = "\ufff0" +import { prefixed, DocumentType } from "@budibase/types" +export { SEPARATOR, UNICODE_MAX, DocumentType } from "@budibase/types" /** * Can be used to create a few different forms of querying a view. @@ -34,42 +34,6 @@ export enum InternalTable { USER_METADATA = "ta_users", } -export enum DocumentType { - USER = "us", - GROUP = "gr", - WORKSPACE = "workspace", - CONFIG = "config", - TEMPLATE = "template", - APP = "app", - DEV = "dev", - APP_DEV = "app_dev", - APP_METADATA = "app_metadata", - ROLE = "role", - MIGRATIONS = "migrations", - DEV_INFO = "devinfo", - AUTOMATION_LOG = "log_au", - ACCOUNT_METADATA = "acc_metadata", - PLUGIN = "plg", - DATASOURCE = "datasource", - DATASOURCE_PLUS = "datasource_plus", - APP_BACKUP = "backup", - TABLE = "ta", - ROW = "ro", - AUTOMATION = "au", - LINK = "li", - WEBHOOK = "wh", - INSTANCE = "inst", - LAYOUT = "layout", - SCREEN = "screen", - QUERY = "query", - DEPLOYMENTS = "deployments", - METADATA = "metadata", - MEM_VIEW = "view", - USER_FLAG = "flag", - AUTOMATION_METADATA = "meta_au", - AUDIT_LOG = "al", -} - export const StaticDatabases = { GLOBAL: { name: "global-db", @@ -93,7 +57,7 @@ export const StaticDatabases = { }, } -export const APP_PREFIX = DocumentType.APP + SEPARATOR -export const APP_DEV = DocumentType.APP_DEV + SEPARATOR +export const APP_PREFIX = prefixed(DocumentType.APP) +export const APP_DEV = prefixed(DocumentType.APP_DEV) export const APP_DEV_PREFIX = APP_DEV export const BUDIBASE_DATASOURCE_TYPE = "budibase" diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index 71cd599f87..7224d827e8 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -18,6 +18,7 @@ import { User, ContextUser, } from "@budibase/types" +import { sdk } from "@budibase/shared-core" import { getGlobalDB } from "./context" import * as context from "./context" @@ -38,6 +39,12 @@ function removeUserPassword(users: User | User[]) { 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 hasAdminPermissions = sdk.users.hasAdminPermissions +export const hasBuilderPermissions = sdk.users.hasBuilderPermissions + export const bulkGetGlobalUsersById = async ( userIds: string[], opts?: GetOpts @@ -254,39 +261,6 @@ export async function getUserCount() { return response.total_rows } -// checks if a user is specifically a builder, given an app ID -export function isBuilder(user: User | ContextUser, appId?: string) { - if (user.builder?.global) { - return true - } else if (appId && user.builder?.apps?.includes(getProdAppID(appId))) { - return true - } - return false -} - -// alias for hasAdminPermission, currently do the same thing -// in future whether someone has admin permissions and whether they are -// an admin for a specific resource could be separated -export function isAdmin(user: User | ContextUser) { - return hasAdminPermissions(user) -} - -// checks if a user is capable of building any app -export function hasBuilderPermissions(user?: User | ContextUser) { - if (!user) { - return false - } - return user.builder?.global || user.builder?.apps?.length !== 0 -} - -// checks if a user is capable of being an admin -export function hasAdminPermissions(user?: User | ContextUser) { - if (!user) { - return false - } - return user.admin?.global -} - // used to remove the builder/admin permissions, for processing the // user as an app user (they may have some specific role/group export function removePortalUserPermissions(user: User | ContextUser) { diff --git a/packages/server/src/middleware/currentapp.ts b/packages/server/src/middleware/currentapp.ts index 1f580d80e4..800d43e69c 100644 --- a/packages/server/src/middleware/currentapp.ts +++ b/packages/server/src/middleware/currentapp.ts @@ -24,7 +24,7 @@ export default async (ctx: UserCtx, next: any) => { if ( isDevAppID(requestAppId) && !isWebhookEndpoint(ctx) && - (!ctx.user || !ctx.user.builder || !ctx.user.builder.global) + !users.isBuilder(ctx.user, requestAppId) ) { return ctx.redirect("/") } @@ -70,7 +70,6 @@ export default async (ctx: UserCtx, next: any) => { } return context.doInAppContext(appId, async () => { - let skipCookie = false // if the user not in the right tenant then make sure they have no permissions // need to judge this only based on the request app ID, if ( @@ -83,7 +82,6 @@ export default async (ctx: UserCtx, next: any) => { ctx.user = users.cleanseUserObject(ctx.user) as ContextUser ctx.isAuthenticated = false roleId = roles.BUILTIN_ROLE_IDS.PUBLIC - skipCookie = true } ctx.appId = appId diff --git a/packages/shared-core/src/index.ts b/packages/shared-core/src/index.ts index 21f2f2c639..4a47eda898 100644 --- a/packages/shared-core/src/index.ts +++ b/packages/shared-core/src/index.ts @@ -2,3 +2,4 @@ export * from "./constants" export * as dataFilters from "./filters" export * as helpers from "./helpers" export * as utils from "./utils" +export * as sdk from "./sdk" diff --git a/packages/shared-core/src/sdk/documents/applications.ts b/packages/shared-core/src/sdk/documents/applications.ts new file mode 100644 index 0000000000..05129f6e75 --- /dev/null +++ b/packages/shared-core/src/sdk/documents/applications.ts @@ -0,0 +1,29 @@ +import { DocumentType, prefixed } from "@budibase/types" + +const APP_PREFIX = prefixed(DocumentType.APP) +const APP_DEV_PREFIX = prefixed(DocumentType.APP_DEV) + +export function getDevAppID(appId: string) { + if (!appId || appId.startsWith(APP_DEV_PREFIX)) { + return appId + } + // split to take off the app_ element, then join it together incase any other app_ exist + const split = appId.split(APP_PREFIX) + split.shift() + const rest = split.join(APP_PREFIX) + return `${APP_DEV_PREFIX}${rest}` +} + +/** + * Convert a development app ID to a deployed app ID. + */ +export function getProdAppID(appId: string) { + if (!appId || !appId.startsWith(APP_DEV_PREFIX)) { + return appId + } + // split to take off the app_dev element, then join it together incase any other app_ exist + const split = appId.split(APP_DEV_PREFIX) + split.shift() + const rest = split.join(APP_DEV_PREFIX) + return `${APP_PREFIX}${rest}` +} diff --git a/packages/shared-core/src/sdk/documents/index.ts b/packages/shared-core/src/sdk/documents/index.ts new file mode 100644 index 0000000000..d20631eef4 --- /dev/null +++ b/packages/shared-core/src/sdk/documents/index.ts @@ -0,0 +1,2 @@ +export * as applications from "./applications" +export * as users from "./users" diff --git a/packages/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts new file mode 100644 index 0000000000..df3ef0025f --- /dev/null +++ b/packages/shared-core/src/sdk/documents/users.ts @@ -0,0 +1,35 @@ +import { ContextUser, User } from "@budibase/types" +import { getProdAppID } from "./applications" + +// checks if a user is specifically a builder, given an app ID +export function isBuilder(user: User | ContextUser, appId?: string) { + if (user.builder?.global) { + return true + } else if (appId && user.builder?.apps?.includes(getProdAppID(appId))) { + return true + } + return false +} + +// alias for hasAdminPermission, currently do the same thing +// in future whether someone has admin permissions and whether they are +// an admin for a specific resource could be separated +export function isAdmin(user: User | ContextUser) { + return hasAdminPermissions(user) +} + +// checks if a user is capable of building any app +export function hasBuilderPermissions(user?: User | ContextUser) { + if (!user) { + return false + } + return user.builder?.global || user.builder?.apps?.length !== 0 +} + +// checks if a user is capable of being an admin +export function hasAdminPermissions(user?: User | ContextUser) { + if (!user) { + return false + } + return user.admin?.global +} diff --git a/packages/shared-core/src/sdk/index.ts b/packages/shared-core/src/sdk/index.ts new file mode 100644 index 0000000000..87868e2723 --- /dev/null +++ b/packages/shared-core/src/sdk/index.ts @@ -0,0 +1 @@ +export * from "./documents" diff --git a/packages/types/src/documents/document.ts b/packages/types/src/documents/document.ts index ac05214b82..6ba318269b 100644 --- a/packages/types/src/documents/document.ts +++ b/packages/types/src/documents/document.ts @@ -1,3 +1,44 @@ +export const SEPARATOR = "_" +export const UNICODE_MAX = "\ufff0" + +export const prefixed = (type: DocumentType) => `${type}${SEPARATOR}` + +export enum DocumentType { + USER = "us", + GROUP = "gr", + WORKSPACE = "workspace", + CONFIG = "config", + TEMPLATE = "template", + APP = "app", + DEV = "dev", + APP_DEV = "app_dev", + APP_METADATA = "app_metadata", + ROLE = "role", + MIGRATIONS = "migrations", + DEV_INFO = "devinfo", + AUTOMATION_LOG = "log_au", + ACCOUNT_METADATA = "acc_metadata", + PLUGIN = "plg", + DATASOURCE = "datasource", + DATASOURCE_PLUS = "datasource_plus", + APP_BACKUP = "backup", + TABLE = "ta", + ROW = "ro", + AUTOMATION = "au", + LINK = "li", + WEBHOOK = "wh", + INSTANCE = "inst", + LAYOUT = "layout", + SCREEN = "screen", + QUERY = "query", + DEPLOYMENTS = "deployments", + METADATA = "metadata", + MEM_VIEW = "view", + USER_FLAG = "flag", + AUTOMATION_METADATA = "meta_au", + AUDIT_LOG = "al", +} + export interface Document { _id?: string _rev?: string diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index cfa68932d2..e0aa042995 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -176,7 +176,7 @@ const validateUniqueUser = async (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 (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) { + if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && usersCore.isAdmin(user)) { return false } From e469abb6793cecd1170f7c8c129dc3955539e1f5 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 19 Jul 2023 18:05:02 +0100 Subject: [PATCH 05/65] reworking frontend to use shared core functions to check if is admin or builder (needs further expansion). --- .../backend-core/src/middleware/adminOnly.ts | 6 +-- .../_components/BuilderSidePanel.svelte | 12 ++--- .../src/pages/builder/apps/index.svelte | 46 +++++++++---------- .../builder/src/pages/builder/index.svelte | 3 +- .../src/pages/builder/portal/_layout.svelte | 3 +- .../portal/users/users/[userId].svelte | 7 +-- .../_components/AppsTableRenderer.svelte | 3 +- .../users/_components/UpdateRolesModal.svelte | 3 +- packages/builder/src/stores/portal/auth.js | 9 ++-- packages/builder/src/stores/portal/users.js | 9 +++- .../shared-core/src/sdk/documents/users.ts | 4 ++ 11 files changed, 58 insertions(+), 47 deletions(-) diff --git a/packages/backend-core/src/middleware/adminOnly.ts b/packages/backend-core/src/middleware/adminOnly.ts index dc2fe9064e..6b2ee87c01 100644 --- a/packages/backend-core/src/middleware/adminOnly.ts +++ b/packages/backend-core/src/middleware/adminOnly.ts @@ -1,10 +1,8 @@ import { UserCtx } from "@budibase/types" +import { isAdmin } from "../users" export default async (ctx: UserCtx, next: any) => { - if ( - !ctx.internal && - (!ctx.user || !ctx.user.admin || !ctx.user.admin.global) - ) { + if (!ctx.internal && !isAdmin(ctx.user)) { ctx.throw(403, "Admin user only endpoint.") } return next() diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 1dd4453537..db56602463 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -12,12 +12,12 @@ } from "@budibase/bbui" import { store } from "builderStore" import { groups, licensing, apps, users, auth, admin } from "stores/portal" - import { fetchData } from "@budibase/frontend-core" + import { fetchData, Constants, Utils } from "@budibase/frontend-core" + import { sdk } from "@budibase/shared-core" import { API } from "api" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import RoleSelect from "components/common/RoleSelect.svelte" import UpgradeModal from "components/common/users/UpgradeModal.svelte" - import { Constants, Utils } from "@budibase/frontend-core" import { emailValidator } from "helpers/validation" import { roles } from "stores/backend" import { fly } from "svelte/transition" @@ -108,7 +108,7 @@ await usersFetch.refresh() filteredUsers = $usersFetch.rows.map(user => { - const isBuilderOrAdmin = user.admin?.global || user.builder?.global + const isBuilderOrAdmin = sdk.users.isBuilderOrAdmin(user, prodAppId) let role = undefined if (isBuilderOrAdmin) { role = Constants.Roles.ADMIN @@ -258,7 +258,7 @@ } // Must exclude users who have explicit privileges const userByEmail = filteredUsers.reduce((acc, user) => { - if (user.role || user.admin?.global || user.builder?.global) { + if (user.role || sdk.users.isBuilderOrAdmin(user, prodAppId)) { acc.push(user.email) } return acc @@ -389,9 +389,9 @@ } const userTitle = user => { - if (user.admin?.global) { + if (sdk.users.isAdmin(user)) { return "Admin" - } else if (user.builder?.global) { + } else if (sdk.users.isBuilder(user, prodAppId)) { return "Developer" } else { return "App user" diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index ab75c50747..f6c5df17c9 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -22,7 +22,7 @@ import Spaceman from "assets/bb-space-man.svg" import Logo from "assets/bb-emblem.svg" import { UserAvatar } from "@budibase/frontend-core" - import { helpers } from "@budibase/shared-core" + import { helpers, sdk } from "@budibase/shared-core" let loaded = false let userInfoModal @@ -43,32 +43,30 @@ $: userGroups = $groups.filter(group => group.users.find(user => user._id === $auth.user?._id) ) - let userApps = [] $: publishedApps = $apps.filter(publishedAppsOnly) + $: userApps = getUserApps($auth.user) - $: { - if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) { - userApps = - $auth.user?.builder?.global || $auth.user?.admin?.global - ? publishedApps - : publishedApps.filter(app => { - return userGroups.find(group => { - return groups.actions - .getGroupAppIds(group) - .map(role => apps.extractAppId(role)) - .includes(app.appId) - }) - }) - } else { - userApps = - $auth.user?.builder?.global || $auth.user?.admin?.global - ? publishedApps - : publishedApps.filter(app => - Object.keys($auth.user?.roles) - .map(x => apps.extractAppId(x)) - .includes(app.appId) - ) + function getUserApps(user) { + if (sdk.users.isAdmin(user)) { + return publishedApps } + return publishedApps.filter(app => { + if (sdk.users.isBuilder(user, app.appId)) { + return true + } + if (!Object.keys(user?.roles).length && user?.userGroups) { + return userGroups.find(group => { + return groups.actions + .getGroupAppIds(group) + .map(role => apps.extractAppId(role)) + .includes(app.appId) + }) + } else { + return Object.keys($auth.user?.roles) + .map(x => apps.extractAppId(x)) + .includes(app.appId) + } + }) } function getUrl(app) { diff --git a/packages/builder/src/pages/builder/index.svelte b/packages/builder/src/pages/builder/index.svelte index fcaa7fc55b..c6d9d3c1c3 100644 --- a/packages/builder/src/pages/builder/index.svelte +++ b/packages/builder/src/pages/builder/index.svelte @@ -1,11 +1,12 @@ diff --git a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte b/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte index a9399fcca7..9ad41ad652 100644 --- a/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/_components/UpdateRolesModal.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from "svelte" import { Body, Select, ModalContent, notifications } from "@budibase/bbui" import { users } from "stores/portal" + import { sdk } from "@budibase/shared-core" export let app export let user @@ -15,7 +16,7 @@ .filter(role => role._id !== "PUBLIC") .map(role => ({ value: role._id, label: role.name })) - if (!user?.builder?.global) { + if (!sdk.users.isBuilder(user, app?.appId)) { options.push({ value: NO_ACCESS, label: "No Access" }) } let selectedRole = user?.roles?.[app?._id] diff --git a/packages/builder/src/stores/portal/auth.js b/packages/builder/src/stores/portal/auth.js index ce64965af7..40113e2f76 100644 --- a/packages/builder/src/stores/portal/auth.js +++ b/packages/builder/src/stores/portal/auth.js @@ -2,6 +2,7 @@ import { derived, writable, get } from "svelte/store" import { API } from "api" import { admin } from "stores/portal" import analytics from "analytics" +import { sdk } from "@budibase/shared-core" export function createAuthStore() { const auth = writable({ @@ -17,8 +18,8 @@ export function createAuthStore() { let isBuilder = false if ($store.user) { const user = $store.user - isAdmin = !!user.admin?.global - isBuilder = !!user.builder?.global + isAdmin = sdk.users.isAdmin(user) + isBuilder = sdk.users.isBuilder(user) } return { user: $store.user, @@ -57,8 +58,8 @@ export function createAuthStore() { name: user.account?.name, user_id: user._id, tenant: user.tenantId, - admin: user?.admin?.global, - builder: user?.builder?.global, + admin: sdk.users.isAdmin(user), + builder: sdk.users.isBuilder(user), "Company size": user.account?.size, "Job role": user.account?.profession, }, diff --git a/packages/builder/src/stores/portal/users.js b/packages/builder/src/stores/portal/users.js index e522cd7958..992f6a5418 100644 --- a/packages/builder/src/stores/portal/users.js +++ b/packages/builder/src/stores/portal/users.js @@ -2,6 +2,7 @@ import { writable } from "svelte/store" import { API } from "api" import { update } from "lodash" import { licensing } from "." +import { sdk } from "@budibase/shared-core" export function createUsersStore() { const { subscribe, set } = writable({}) @@ -111,8 +112,12 @@ export function createUsersStore() { return await API.saveUser(user) } - const getUserRole = ({ admin, builder }) => - admin?.global ? "admin" : builder?.global ? "developer" : "appUser" + const getUserRole = user => + sdk.users.isAdmin(user) + ? "admin" + : sdk.users.isBuilder(user) + ? "developer" + : "appUser" const refreshUsage = fn => diff --git a/packages/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts index df3ef0025f..931f651a0e 100644 --- a/packages/shared-core/src/sdk/documents/users.ts +++ b/packages/shared-core/src/sdk/documents/users.ts @@ -18,6 +18,10 @@ export function isAdmin(user: User | ContextUser) { return hasAdminPermissions(user) } +export function isAdminOrBuilder(user: User | ContextUser, appId?: string) { + return isBuilder(user, appId) || isAdmin(user) +} + // checks if a user is capable of building any app export function hasBuilderPermissions(user?: User | ContextUser) { if (!user) { From 3abe5d4cb21dda1a9cba0f1764499ad96d87f817 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Thu, 20 Jul 2023 18:34:12 +0100 Subject: [PATCH 06/65] Frontend work to support logging in as an app builder - also making sure when a new app is created that the user is assigned app access to it. --- packages/backend-core/src/users.ts | 25 +++++++++ packages/builder/src/stores/portal/menu.js | 44 ++++++++------- .../server/src/api/controllers/application.ts | 37 ++++--------- packages/server/src/db/utils.ts | 8 +-- packages/server/src/middleware/authorized.ts | 11 ++-- .../src/sdk/app/applications/applications.ts | 54 +++++++++++++++++++ .../server/src/sdk/app/applications/index.ts | 2 + .../shared-core/src/sdk/documents/users.ts | 16 +++++- 8 files changed, 143 insertions(+), 54 deletions(-) create mode 100644 packages/server/src/sdk/app/applications/applications.ts diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index 7224d827e8..05abe70fe3 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -2,6 +2,7 @@ import { directCouchFind, DocumentType, generateAppUserID, + getGlobalIDFromUserMetadataID, getGlobalUserParams, getProdAppID, getUsersByAppParams, @@ -21,6 +22,7 @@ import { import { sdk } from "@budibase/shared-core" import { getGlobalDB } from "./context" import * as context from "./context" +import { user as userCache } from "./cache" type GetOpts = { cleanup?: boolean } @@ -42,8 +44,10 @@ function removeUserPassword(users: User | User[]) { // 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 ( userIds: string[], @@ -77,6 +81,27 @@ export const bulkUpdateGlobalUsers = async (users: User[]) => { return (await db.bulkDocs(users)) as BulkDocsResponse } +export const grantAppBuilderAccess = async (userId: string, appId: string) => { + const prodAppId = getProdAppID(appId) + const db = getGlobalDB() + const user = (await db.get(userId)) as User + if (!user.builder) { + user.builder = {} + } + if (!user.builder.apps) { + user.builder.apps = [] + } + if (!user.builder.apps.includes(prodAppId)) { + user.builder.apps.push(prodAppId) + } + try { + await db.put(user) + await userCache.invalidateUser(userId) + } catch (err: any) { + throw new Error(`Unable to grant user access: ${err.message}`) + } +} + export async function getById(id: string, opts?: GetOpts): Promise { const db = context.getGlobalDB() let user = await db.get(id) diff --git a/packages/builder/src/stores/portal/menu.js b/packages/builder/src/stores/portal/menu.js index c66c98d00f..2c17ce0b36 100644 --- a/packages/builder/src/stores/portal/menu.js +++ b/packages/builder/src/stores/portal/menu.js @@ -2,8 +2,12 @@ import { derived } from "svelte/store" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { admin } from "./admin" import { auth } from "./auth" +import { sdk } from "@budibase/shared-core" export const menu = derived([admin, auth], ([$admin, $auth]) => { + const user = $auth?.user + const isAdmin = sdk.users.isAdmin(user) + const cloud = $admin?.cloud // Determine user sub pages let userSubPages = [ { @@ -24,19 +28,24 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => { title: "Apps", href: "/builder/portal/apps", }, - { + ] + if ( + sdk.users.hasBuilderPermissions(user) && + !sdk.users.hasAppBuilderPermissions(user) + ) { + menu.push({ title: "Users", href: "/builder/portal/users", subPages: userSubPages, - }, - { - title: "Plugins", - href: "/builder/portal/plugins", - }, - ] + }) + } + menu.push({ + title: "Plugins", + href: "/builder/portal/plugins", + }) // Add settings page for admins - if ($auth.isAdmin) { + if (isAdmin) { let settingsSubPages = [ { title: "Auth", @@ -59,7 +68,7 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => { href: "/builder/portal/settings/environment", }, ] - if (!$admin.cloud) { + if (!cloud) { settingsSubPages.push({ title: "Version", href: "/builder/portal/settings/version", @@ -84,38 +93,35 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => { href: "/builder/portal/account/usage", }, ] - if ($auth.isAdmin) { + if (isAdmin) { accountSubPages.push({ title: "Audit Logs", href: "/builder/portal/account/auditLogs", }) - if (!$admin.cloud) { + if (!cloud) { accountSubPages.push({ title: "System Logs", href: "/builder/portal/account/systemLogs", }) } } - if ($admin.cloud && $auth?.user?.accountPortalAccess) { + if (cloud && user?.accountPortalAccess) { accountSubPages.push({ title: "Upgrade", - href: $admin.accountPortalUrl + "/portal/upgrade", + href: $admin?.accountPortalUrl + "/portal/upgrade", }) - } else if (!$admin.cloud && $auth.isAdmin) { + } else if (!cloud && isAdmin) { accountSubPages.push({ title: "Upgrade", href: "/builder/portal/account/upgrade", }) } // add license check here - if ( - $auth?.user?.accountPortalAccess && - $auth.user.account.stripeCustomerId - ) { + if (user?.accountPortalAccess && user.account.stripeCustomerId) { accountSubPages.push({ title: "Billing", - href: $admin.accountPortalUrl + "/portal/billing", + href: $admin?.accountPortalUrl + "/portal/billing", }) } menu.push({ diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 6a0088d4dc..9538790827 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -50,12 +50,13 @@ import { MigrationType, PlanType, Screen, - SocketSession, UserCtx, + ContextUser, } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import sdk from "../../sdk" import { builderSocket } from "../../websockets" +import { grantAppBuilderAccess } from "@budibase/backend-core/src/users" // utility function, need to do away with this async function getLayouts() { @@ -178,32 +179,10 @@ export const addSampleData = async (ctx: UserCtx) => { } export async function fetch(ctx: UserCtx) { - const dev = ctx.query && ctx.query.status === AppStatus.DEV - const all = ctx.query && ctx.query.status === AppStatus.ALL - const apps = (await dbCore.getAllApps({ dev, all })) as App[] - - const appIds = apps - .filter((app: any) => app.status === "development") - .map((app: any) => app.appId) - - // get the locks for all the dev apps - if (dev || all) { - const locks = await getLocksById(appIds) - for (let app of apps) { - const lock = locks[app.appId] - if (lock) { - app.lockedBy = lock - } else { - // make sure its definitely not present - delete app.lockedBy - } - } - } - - // Enrich apps with all builder user sessions - const enrichedApps = await sdk.users.sessions.enrichApps(apps) - - ctx.body = await checkAppMetadata(enrichedApps) + ctx.body = await sdk.applications.fetch( + ctx.query.status as AppStatus, + ctx.user + ) } export async function fetchAppDefinition(ctx: UserCtx) { @@ -395,6 +374,10 @@ async function appPostCreate(ctx: UserCtx, app: App) { tenantId, appId: app.appId, }) + // they are an app builder, creating a new app, make sure they can access it + if (users.hasAppBuilderPermissions(ctx.user)) { + await users.grantAppBuilderAccess(ctx.user._id!, app.appId) + } await creationEvents(ctx.request, app) // app import & template creation if (ctx.request.body.useTemplate === "true") { diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index e08392c3a1..eb5cbc27ef 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -3,10 +3,10 @@ import { db as dbCore } from "@budibase/backend-core" type Optional = string | null -export const AppStatus = { - DEV: "development", - ALL: "all", - DEPLOYED: "published", +export enum AppStatus { + DEV = "development", + ALL = "all", + DEPLOYED = "published", } export const BudibaseInternalDB = { diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts index 81e42604bc..dba5d47cb9 100644 --- a/packages/server/src/middleware/authorized.ts +++ b/packages/server/src/middleware/authorized.ts @@ -5,7 +5,7 @@ import { context, users, } from "@budibase/backend-core" -import { Role } from "@budibase/types" +import { Role, UserCtx } from "@budibase/types" import builderMiddleware from "./builder" import { isWebhookEndpoint } from "./utils" @@ -22,14 +22,19 @@ const csrf = auth.buildCsrfMiddleware() * - Otherwise the user must have the required role. */ const checkAuthorized = async ( - ctx: any, + ctx: UserCtx, resourceRoles: any, permType: any, permLevel: any ) => { const appId = context.getAppId() // check if this is a builder api and the user is not a builder - const isBuilder = users.isBuilder(ctx.user, appId) + let isBuilder + if (!appId) { + isBuilder = users.hasBuilderPermissions(ctx.user) + } else { + isBuilder = users.isBuilder(ctx.user, appId) + } const isBuilderApi = permType === permissions.PermissionType.BUILDER if (isBuilderApi && !isBuilder) { return ctx.throw(403, "Not Authorized") diff --git a/packages/server/src/sdk/app/applications/applications.ts b/packages/server/src/sdk/app/applications/applications.ts new file mode 100644 index 0000000000..73cacb8983 --- /dev/null +++ b/packages/server/src/sdk/app/applications/applications.ts @@ -0,0 +1,54 @@ +import { AppStatus } from "../../../db/utils" +import { App, ContextUser } from "@budibase/types" +import { getLocksById } from "../../../utilities/redis" +import { enrichApps } from "../../users/sessions" +import { checkAppMetadata } from "../../../automations/logging" +import { db as dbCore, users } from "@budibase/backend-core" + +export function filterAppList(user: ContextUser, apps: App[]) { + let appList: string[] = [] + if (users.hasAppBuilderPermissions(user)) { + appList = user.builder?.apps! + } else if (!users.isAdminOrBuilder(user)) { + appList = Object.keys(user.roles || {}) + } else { + return apps + } + const finalApps: App[] = [] + for (let app of apps) { + if (appList.includes(dbCore.getProdAppID(app.appId))) { + finalApps.push(app) + } + } + return finalApps +} + +export async function fetch(status: AppStatus, user: ContextUser) { + const dev = status === AppStatus.DEV + const all = status === AppStatus.ALL + let apps = (await dbCore.getAllApps({ dev, all })) as App[] + apps = filterAppList(user, apps) + + const appIds = apps + .filter((app: any) => app.status === "development") + .map((app: any) => app.appId) + + // get the locks for all the dev apps + if (dev || all) { + const locks = await getLocksById(appIds) + for (let app of apps) { + const lock = locks[app.appId] + if (lock) { + app.lockedBy = lock + } else { + // make sure its definitely not present + delete app.lockedBy + } + } + } + + // Enrich apps with all builder user sessions + const enrichedApps = await enrichApps(apps) + + return await checkAppMetadata(enrichedApps) +} diff --git a/packages/server/src/sdk/app/applications/index.ts b/packages/server/src/sdk/app/applications/index.ts index d917225e52..963d065ce2 100644 --- a/packages/server/src/sdk/app/applications/index.ts +++ b/packages/server/src/sdk/app/applications/index.ts @@ -1,7 +1,9 @@ import * as sync from "./sync" import * as utils from "./utils" +import * as applications from "./applications" export default { ...sync, ...utils, + ...applications, } diff --git a/packages/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts index 931f651a0e..1a9314f731 100644 --- a/packages/shared-core/src/sdk/documents/users.ts +++ b/packages/shared-core/src/sdk/documents/users.ts @@ -3,6 +3,9 @@ import { getProdAppID } from "./applications" // checks if a user is specifically a builder, given an app ID export function isBuilder(user: User | ContextUser, appId?: string) { + if (!user) { + return false + } if (user.builder?.global) { return true } else if (appId && user.builder?.apps?.includes(getProdAppID(appId))) { @@ -15,6 +18,9 @@ export function isBuilder(user: User | ContextUser, appId?: string) { // in future whether someone has admin permissions and whether they are // an admin for a specific resource could be separated export function isAdmin(user: User | ContextUser) { + if (!user) { + return false + } return hasAdminPermissions(user) } @@ -22,12 +28,20 @@ export function isAdminOrBuilder(user: User | ContextUser, appId?: string) { return isBuilder(user, appId) || isAdmin(user) } +// check if they are a builder within an app (not necessarily a global builder) +export function hasAppBuilderPermissions(user?: User | ContextUser) { + if (!user) { + return false + } + return !user.builder?.global && user.builder?.apps?.length !== 0 +} + // checks if a user is capable of building any app export function hasBuilderPermissions(user?: User | ContextUser) { if (!user) { return false } - return user.builder?.global || user.builder?.apps?.length !== 0 + return user.builder?.global || hasAppBuilderPermissions(user) } // checks if a user is capable of being an admin From d9c8e26f65467869c35317d31a0153d2cbdf9807 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Mon, 24 Jul 2023 18:29:46 +0100 Subject: [PATCH 07/65] Re-writing APIs based on most recent discussion about RBAC and per app builders. --- packages/types/src/api/web/user.ts | 7 --- packages/types/src/documents/global/user.ts | 1 + .../src/api/controllers/global/users.ts | 60 +++++++++++++++++-- .../worker/src/api/routes/global/users.ts | 11 +++- 4 files changed, 64 insertions(+), 15 deletions(-) diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 4a27f781af..619362805a 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -85,10 +85,3 @@ export interface AcceptUserInviteResponse { export interface SyncUserRequest { previousUser?: User } - -export interface AddAppBuilderRequest { - userId: string - appId: string -} - -export interface RemoveAppBuilderRequest {} diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index 2ce714801d..3249660624 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -43,6 +43,7 @@ export interface User extends Document { roles: UserRoles builder?: { global?: boolean + appBuilder?: boolean apps?: string[] } admin?: { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 38406dc239..6862e44b05 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -8,8 +8,6 @@ import env from "../../../environment" import { AcceptUserInviteRequest, AcceptUserInviteResponse, - AddAppBuilderRequest, - RemoveAppBuilderRequest, BulkUserRequest, BulkUserResponse, CloudAccount, @@ -32,6 +30,7 @@ import { tenancy, platform, ErrorCode, + db as dbCore, } from "@budibase/backend-core" import { checkAnyUserExists } from "../../../utilities/users" import { isEmailConfigured } from "../../../utilities/email" @@ -434,8 +433,57 @@ export const inviteAccept = async ( } } -export const addAppBuilder = async (ctx: Ctx) => {} +export const grantAppBuilder = async (ctx: Ctx) => { + const { userId } = ctx.params + const user = await userSdk.getUser(userId) + if (!user.builder) { + user.builder = {} + } + user.builder.appBuilder = true + await userSdk.save(user, { hashPassword: false }) + ctx.body = { message: `User "${user.email}" granted app builder permissions` } +} -export const removeAppBuilder = async ( - ctx: Ctx -) => {} +export const addAppBuilder = async (ctx: Ctx) => { + const { userId, appId } = ctx.params + const user = await userSdk.getUser(userId) + if (!user.builder?.global || user.admin?.global) { + ctx.body = { message: "User already admin - no permissions updated." } + return + } + if (!user.builder?.appBuilder) { + ctx.throw( + 400, + "Unable to update access, user must be granted app builder permissions." + ) + } + const prodAppId = dbCore.getProdAppID(appId) + if (!user.builder.apps) { + user.builder.apps = [] + } + user.builder.apps.push(prodAppId) + await userSdk.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.getUser(userId) + if (!user.builder?.global || user.admin?.global) { + ctx.body = { message: "User already admin - no permissions removed." } + return + } + if (!user.builder?.appBuilder) { + ctx.throw( + 400, + "Unable to update access, user must be granted app builder permissions." + ) + } + const prodAppId = dbCore.getProdAppID(appId) + const indexOf = user.builder?.apps?.indexOf(prodAppId) + if (indexOf && indexOf !== -1) { + user.builder.apps = user.builder.apps!.splice(indexOf, 1) + } + await userSdk.save(user, { hashPassword: false }) + ctx.body = { message: `User "${user.email}" app builder access removed.` } +} diff --git a/packages/worker/src/api/routes/global/users.ts b/packages/worker/src/api/routes/global/users.ts index 557065e9a4..9c1b5d9acb 100644 --- a/packages/worker/src/api/routes/global/users.ts +++ b/packages/worker/src/api/routes/global/users.ts @@ -122,6 +122,15 @@ router buildAdminInitValidation(), controller.adminUser ) + .post("/api/global/users/:userId/app/builder", controller.grantAppBuilder) + .patch( + "/api/global/users/:userId/app/:appId/builder", + controller.addAppBuilder + ) + .delete( + "/api/global/users/:userId/app/:appId/builder", + controller.removeAppBuilder + ) .get("/api/global/users/tenant/:id", controller.tenantUserLookup) // global endpoint but needs to come at end (blocks other endpoints otherwise) .get("/api/global/users/:id", auth.builderOrAdmin, controller.find) @@ -132,7 +141,5 @@ router users.buildUserSaveValidation(), selfController.updateSelf ) - .post("/api/global/users/builder", controller.addAppBuilder) - .delete("/api/global/users/builder", controller.removeAppBuilder) export default router From 90371b9d69b232459025ec997fd6e20ce4a34201 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 25 Jul 2023 17:48:57 +0100 Subject: [PATCH 08/65] Refactoring users core to move into backend, allowing app builder endpoints to move into pro. --- packages/backend-core/src/users/db.ts | 415 ++++++++++++ .../sdk => backend-core/src}/users/events.ts | 24 +- packages/backend-core/src/users/index.ts | 4 + packages/backend-core/src/users/lookup.ts | 102 +++ .../backend-core/src/{ => users}/users.ts | 18 +- packages/backend-core/src/users/utils.ts | 85 +++ .../src/api/controllers/global/roles.ts | 12 +- .../src/api/controllers/global/users.ts | 38 +- .../routes/global/tests/appBuilder.spec.ts | 52 ++ .../src/api/routes/global/tests/users.spec.ts | 2 +- packages/worker/src/sdk/users/index.ts | 2 + .../worker/src/sdk/users/tests/users.spec.ts | 17 +- packages/worker/src/sdk/users/users.ts | 591 +----------------- .../worker/src/tests/TestConfiguration.ts | 2 +- packages/worker/src/tests/api/users.ts | 24 + 15 files changed, 740 insertions(+), 648 deletions(-) create mode 100644 packages/backend-core/src/users/db.ts rename packages/{worker/src/sdk => backend-core/src}/users/events.ts (87%) create mode 100644 packages/backend-core/src/users/index.ts create mode 100644 packages/backend-core/src/users/lookup.ts rename packages/backend-core/src/{ => users}/users.ts (91%) create mode 100644 packages/backend-core/src/users/utils.ts create mode 100644 packages/worker/src/api/routes/global/tests/appBuilder.spec.ts diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts new file mode 100644 index 0000000000..cb1129d079 --- /dev/null +++ b/packages/backend-core/src/users/db.ts @@ -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 { + 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" }) +} + +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" }) +} diff --git a/packages/worker/src/sdk/users/events.ts b/packages/backend-core/src/users/events.ts similarity index 87% rename from packages/worker/src/sdk/users/events.ts rename to packages/backend-core/src/users/events.ts index d8af13a82f..f170c9ffe9 100644 --- a/packages/worker/src/sdk/users/events.ts +++ b/packages/backend-core/src/users/events.ts @@ -1,18 +1,18 @@ -import env from "../../environment" -import { events, accounts, tenancy, users } from "@budibase/backend-core" +import env from "../environment" +import * as events from "../events" +import * as accounts from "../accounts" +import { getTenantId } from "../context" import { User, UserRoles, CloudAccount } from "@budibase/types" - -const hasBuilderPerm = users.hasBuilderPermissions -const hasAdminPerm = users.hasAdminPermissions +import { hasBuilderPermissions, hasAdminPermissions } from "./utils" export const handleDeleteEvents = async (user: any) => { await events.user.deleted(user) - if (hasBuilderPerm(user)) { + if (hasBuilderPermissions(user)) { await events.user.permissionBuilderRemoved(user) } - if (hasAdminPerm(user)) { + if (hasAdminPermissions(user)) { await events.user.permissionAdminRemoved(user) } } @@ -58,7 +58,7 @@ export const handleSaveEvents = async ( user: User, existingUser: User | undefined ) => { - const tenantId = tenancy.getTenantId() + const tenantId = getTenantId() let tenantAccount: CloudAccount | undefined if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { tenantAccount = await accounts.getAccountByTenantId(tenantId) @@ -107,19 +107,19 @@ export const handleSaveEvents = async ( } export const isAddingBuilder = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, hasBuilderPerm) + return isAddingPermission(user, existingUser, hasBuilderPermissions) } export const isRemovingBuilder = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, hasBuilderPerm) + return isRemovingPermission(user, existingUser, hasBuilderPermissions) } const isAddingAdmin = (user: any, existingUser: any) => { - return isAddingPermission(user, existingUser, hasAdminPerm) + return isAddingPermission(user, existingUser, hasAdminPermissions) } const isRemovingAdmin = (user: any, existingUser: any) => { - return isRemovingPermission(user, existingUser, hasAdminPerm) + return isRemovingPermission(user, existingUser, hasAdminPermissions) } const isOnboardingComplete = (user: any, existingUser: any) => { diff --git a/packages/backend-core/src/users/index.ts b/packages/backend-core/src/users/index.ts new file mode 100644 index 0000000000..2e5e2f948c --- /dev/null +++ b/packages/backend-core/src/users/index.ts @@ -0,0 +1,4 @@ +export * from "./users" +export * from "./utils" +export * from "./lookup" +export * as db from "./db" diff --git a/packages/backend-core/src/users/lookup.ts b/packages/backend-core/src/users/lookup.ts new file mode 100644 index 0000000000..17d0e91d88 --- /dev/null +++ b/packages/backend-core/src/users/lookup.ts @@ -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 { + // 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 { + 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 { + 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 { + 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[] +} diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users/users.ts similarity index 91% rename from packages/backend-core/src/users.ts rename to packages/backend-core/src/users/users.ts index 05abe70fe3..2f869a69d2 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -2,7 +2,6 @@ import { directCouchFind, DocumentType, generateAppUserID, - getGlobalIDFromUserMetadataID, getGlobalUserParams, getProdAppID, getUsersByAppParams, @@ -12,17 +11,16 @@ import { SEPARATOR, UNICODE_MAX, ViewName, -} from "./db" +} from "../db" import { BulkDocsResponse, SearchUsersRequest, User, ContextUser, } from "@budibase/types" -import { sdk } from "@budibase/shared-core" -import { getGlobalDB } from "./context" -import * as context from "./context" -import { user as userCache } from "./cache" +import { getGlobalDB } from "../context" +import * as context from "../context" +import { user as userCache } from "../cache" type GetOpts = { cleanup?: boolean } @@ -41,14 +39,6 @@ function removeUserPassword(users: User | User[]) { 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 ( userIds: string[], opts?: GetOpts diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts new file mode 100644 index 0000000000..0a9abd50bc --- /dev/null +++ b/packages/backend-core/src/users/utils.ts @@ -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 { + 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 + } + } +} diff --git a/packages/worker/src/api/controllers/global/roles.ts b/packages/worker/src/api/controllers/global/roles.ts index 572c3328b6..457587120f 100644 --- a/packages/worker/src/api/controllers/global/roles.ts +++ b/packages/worker/src/api/controllers/global/roles.ts @@ -3,12 +3,12 @@ import { roles, context, cache, + users as usersCore, tenancy, } from "@budibase/backend-core" -import { BBContext, App } from "@budibase/types" -import { allUsers } from "../../../sdk/users" +import { Ctx, App } from "@budibase/types" -export async function fetch(ctx: BBContext) { +export async function fetch(ctx: Ctx) { const tenantId = ctx.user!.tenantId // always use the dev apps as they'll be most up to date (true) const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[] @@ -31,7 +31,7 @@ export async function fetch(ctx: BBContext) { ctx.body = response } -export async function find(ctx: BBContext) { +export async function find(ctx: Ctx) { const appId = ctx.params.appId await context.doInAppContext(dbCore.getDevAppID(appId), async () => { 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 db = tenancy.getGlobalDB() - const users = await allUsers() + const users = await usersCore.db.allUsers() const bulk = [] const cacheInvalidations = [] for (let user of users) { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 6862e44b05..5984f39ef8 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -42,7 +42,7 @@ export const save = async (ctx: UserCtx) => { const currentUserId = ctx.user?._id const requestUser = ctx.request.body - const user = await userSdk.save(requestUser, { currentUserId }) + const user = await userSdk.db.save(requestUser, { currentUserId }) ctx.body = { _id: user._id!, @@ -58,7 +58,7 @@ const bulkDelete = async (userIds: string[], currentUserId: string) => { if (userIds?.indexOf(currentUserId) !== -1) { 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[]) => { @@ -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." ) } - return await userSdk.bulkCreate(users, groupIds) + return await userSdk.db.bulkCreate(users, groupIds) } 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 // stuck in a cycle await cache.bustCache(cache.CacheKey.CHECKLIST) - const finalUser = await userSdk.save(user, { + const finalUser = await userSdk.db.save(user, { hashPassword, requirePassword, }) @@ -168,7 +168,7 @@ export const adminUser = async ( export const countByApp = async (ctx: any) => { const appId = ctx.params.appId try { - ctx.body = await userSdk.countUsersByApp(appId) + ctx.body = await userSdk.db.countUsersByApp(appId) } catch (err: any) { ctx.throw(err.status || 400, err) } @@ -180,7 +180,7 @@ export const destroy = async (ctx: any) => { ctx.throw(400, "Unable to delete self.") } - await userSdk.destroy(id) + await userSdk.db.destroy(id) ctx.body = { message: `User ${id} deleted.`, @@ -189,7 +189,7 @@ export const destroy = async (ctx: any) => { export const getAppUsers = async (ctx: Ctx) => { const body = ctx.request.body - const users = await userSdk.getUsersByAppAccess(body?.appId) + const users = await userSdk.db.getUsersByAppAccess(body?.appId) ctx.body = { data: users } } @@ -213,7 +213,7 @@ export const search = async (ctx: Ctx) => { // called internally by app server user fetch 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 for (let user of all) { if (user) { @@ -225,12 +225,12 @@ export const fetch = async (ctx: any) => { // called internally by app server user find 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) => { const id = ctx.params.id - const user = await userSdk.getPlatformUser(id) + const user = await userSdk.core.getPlatformUser(id) if (user) { ctx.body = user } else { @@ -253,7 +253,7 @@ export const onboardUsers = async (ctx: Ctx) => { // @ts-ignore const { users, groups, roles } = request.create 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 } else if (emailConfigured) { onboardingResponse = await inviteMultiple(ctx) @@ -278,7 +278,7 @@ export const onboardUsers = async (ctx: Ctx) => { tenantId: tenancy.getTenantId(), } }) - let bulkCreateReponse = await userSdk.bulkCreate(users, []) + let bulkCreateReponse = await userSdk.db.bulkCreate(users, []) // Apply temporary credentials let createWithCredentials = { @@ -411,7 +411,7 @@ export const inviteAccept = async ( ...info, } - const saved = await userSdk.save(request) + const saved = await userSdk.db.save(request) const db = tenancy.getGlobalDB() const user = await db.get(saved._id) await events.user.inviteAccepted(user) @@ -435,18 +435,18 @@ export const inviteAccept = async ( export const grantAppBuilder = async (ctx: Ctx) => { const { userId } = ctx.params - const user = await userSdk.getUser(userId) + const user = await userSdk.db.getUser(userId) if (!user.builder) { user.builder = {} } 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` } } export const addAppBuilder = async (ctx: Ctx) => { 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) { ctx.body = { message: "User already admin - no permissions updated." } return @@ -462,13 +462,13 @@ export const addAppBuilder = async (ctx: Ctx) => { user.builder.apps = [] } 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.` } } export const removeAppBuilder = async (ctx: Ctx) => { 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) { ctx.body = { message: "User already admin - no permissions removed." } return @@ -484,6 +484,6 @@ export const removeAppBuilder = async (ctx: Ctx) => { if (indexOf && 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.` } } diff --git a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts new file mode 100644 index 0000000000..2974588cae --- /dev/null +++ b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts @@ -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 { + 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", () => {}) +}) diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 52d77cbae6..df9c19f8ca 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -66,7 +66,7 @@ describe("/api/global/users", () => { expect(res.body._id).toBeDefined() const user = await config.getUser(email) 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).toBeCalledWith(user) }) diff --git a/packages/worker/src/sdk/users/index.ts b/packages/worker/src/sdk/users/index.ts index 2eaa0e68a2..b37ea07d94 100644 --- a/packages/worker/src/sdk/users/index.ts +++ b/packages/worker/src/sdk/users/index.ts @@ -1,2 +1,4 @@ export * from "./users" +import { users } from "@budibase/backend-core" +export const db = users.db 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 a24f074512..5962e78e9a 100644 --- a/packages/worker/src/sdk/users/tests/users.spec.ts +++ b/packages/worker/src/sdk/users/tests/users.spec.ts @@ -1,9 +1,8 @@ import { structures } from "../../../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 { CloudAccount } from "@budibase/types" -import { isPreventPasswordActions } from "../users" jest.mock("@budibase/pro") import * as _pro from "@budibase/pro" @@ -18,7 +17,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 usersCore.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -29,7 +28,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 usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -39,7 +38,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 usersCore.isPreventPasswordActions(user) expect(result).toBe(false) }) }) @@ -47,7 +46,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 usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -57,7 +56,7 @@ describe("users", () => { await context.doInTenant(structures.tenant.id(), async () => { const user = structures.users.user() pro.features.isSSOEnforced.mockResolvedValueOnce(true) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -75,7 +74,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 usersCore.isPreventPasswordActions(user) expect(result).toBe(true) }) }) @@ -85,7 +84,7 @@ describe("users", () => { const user = structures.users.ssoUser({ user: structures.users.adminUser(), }) - const result = await users.isPreventPasswordActions(user) + const result = await usersCore.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 e0aa042995..f45c7dda20 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -1,590 +1,7 @@ -import env from "../../environment" -import * as eventHelpers from "./events" -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 { 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" -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 => { - 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 => { - // 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 - 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 => { - // 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 => { - 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 -} - -const getExistingPlatformUsers = async ( - emails: string[] -): Promise => { - 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 -} - -const getExistingAccounts = async ( - emails: string[] -): Promise => { - 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 -} - -/** - * 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 => { - 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 => { - 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 => { - 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 = 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 -} - -// 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 ( users: InviteUsersRequest @@ -594,7 +11,9 @@ export const invite = async ( unsuccessful: [], } - const matchedEmails = await searchExistingEmails(users.map(u => u.email)) + const matchedEmails = await usersCore.searchExistingEmails( + users.map(u => u.email) + ) const newUsers = [] // separate duplicates from new users diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index b41b76efda..8fd6c46284 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -263,7 +263,7 @@ class TestConfiguration { } const response = await this._req(user, null, controllers.users.save) const body = response as SaveUserResponse - return this.getUser(body.email) as Promise + return (await this.getUser(body.email)) as User } // CONFIGS diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 16a43d70e0..39f7b64d59 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -140,4 +140,28 @@ export class UserAPI extends TestAPI { .expect("Content-Type", /json/) .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) + } } From 66fbdfe4e89e42a7d350772a10afcf9ce9b26256 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 25 Jul 2023 18:39:40 +0100 Subject: [PATCH 09/65] 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()) From 43bfb943a3238d1d6bcd68c50194a95df95fde11 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 25 Jul 2023 18:52:59 +0100 Subject: [PATCH 10/65] Some fixes post testing. --- packages/backend-core/src/users/utils.ts | 1 + .../shared-core/src/sdk/documents/users.ts | 4 +++ .../src/api/controllers/global/users.ts | 25 +++++++++++-------- .../routes/global/tests/appBuilder.spec.ts | 6 ++--- packages/worker/src/tests/api/users.ts | 3 +-- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index b6bf3f0abb..af0e8e10c7 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -10,6 +10,7 @@ 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 isGlobalBuilder = sdk.users.isGlobalBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions diff --git a/packages/shared-core/src/sdk/documents/users.ts b/packages/shared-core/src/sdk/documents/users.ts index 1a9314f731..92379a03ba 100644 --- a/packages/shared-core/src/sdk/documents/users.ts +++ b/packages/shared-core/src/sdk/documents/users.ts @@ -14,6 +14,10 @@ export function isBuilder(user: User | ContextUser, appId?: string) { return false } +export function isGlobalBuilder(user: User | ContextUser) { + return (isBuilder(user) && !hasAppBuilderPermissions(user)) || isAdmin(user) +} + // alias for hasAdminPermission, currently do the same thing // in future whether someone has admin permissions and whether they are // an admin for a specific resource could be separated diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 5984f39ef8..d1e66b4ac1 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -447,17 +447,20 @@ export const grantAppBuilder = async (ctx: Ctx) => { export const addAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params const user = await userSdk.db.getUser(userId) - if (!user.builder?.global || user.admin?.global) { - ctx.body = { message: "User already admin - no permissions updated." } - return - } - if (!user.builder?.appBuilder) { + if (!user.builder?.appBuilder && !userSdk.core.isGlobalBuilder(user)) { ctx.throw( 400, "Unable to update access, user must be granted app builder permissions." ) } + 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 = [] } @@ -469,19 +472,19 @@ export const addAppBuilder = async (ctx: Ctx) => { export const removeAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params const user = await userSdk.db.getUser(userId) - if (!user.builder?.global || user.admin?.global) { - ctx.body = { message: "User already admin - no permissions removed." } - return - } - if (!user.builder?.appBuilder) { + if (!user.builder?.appBuilder && !userSdk.core.isGlobalBuilder(user)) { ctx.throw( 400, "Unable to update access, user must be granted app builder permissions." ) } + 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 (indexOf && indexOf !== -1) { + if (user.builder && indexOf != undefined && indexOf !== -1) { user.builder.apps = user.builder.apps!.splice(indexOf, 1) } await userSdk.db.save(user, { hashPassword: false }) 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 138ebbc595..83bc401759 100644 --- a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts +++ b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts @@ -48,7 +48,7 @@ describe("/api/global/users/:userId/app/builder", () => { 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]) + expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID) }) }) @@ -57,10 +57,10 @@ describe("/api/global/users/:userId/app/builder", () => { 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]) + expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID) await config.api.users.revokeBuilderToApp(user._id!, MOCK_APP_ID) updated = await getUser(user._id!) - expect(updated.builder?.apps).toBe([]) + expect(updated.builder?.apps!.length).toBe(0) }) }) }) diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 605ac79416..bafc157e69 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -163,8 +163,7 @@ export class UserAPI extends TestAPI { revokeBuilderToApp = ( userId: string, - appId: string, - statusCode: number = 200 + appId: string ) => { return this.request .delete(`/api/global/users/${userId}/app/${appId}/builder`) From 64a5426d360c5ab29801c6f777d6c33bb57e9165 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 26 Jul 2023 17:32:21 +0100 Subject: [PATCH 11/65] Updates to remove app builder concept, denying access to app creation for app builders. --- .../backend-core/src/security/permissions.ts | 26 ++++--------------- packages/server/src/api/routes/application.ts | 2 +- packages/server/src/middleware/authorized.ts | 17 +++++++----- packages/server/src/middleware/builder.ts | 2 +- packages/types/src/documents/global/user.ts | 1 - packages/types/src/sdk/index.ts | 1 + packages/types/src/sdk/permissions.ts | 19 ++++++++++++++ .../src/api/controllers/global/users.ts | 23 ---------------- .../routes/global/tests/appBuilder.spec.ts | 22 ++-------------- .../worker/src/api/routes/global/users.ts | 3 +-- packages/worker/src/tests/api/users.ts | 15 ++--------- 11 files changed, 42 insertions(+), 89 deletions(-) create mode 100644 packages/types/src/sdk/permissions.ts diff --git a/packages/backend-core/src/security/permissions.ts b/packages/backend-core/src/security/permissions.ts index 6cacc12dd6..0edab188d9 100644 --- a/packages/backend-core/src/security/permissions.ts +++ b/packages/backend-core/src/security/permissions.ts @@ -1,29 +1,12 @@ -const { flatten } = require("lodash") -const { cloneDeep } = require("lodash/fp") +import { flatten } from "lodash" +import { cloneDeep } from "lodash/fp" +import { PermissionType, PermissionLevel } from "@budibase/types" +export { PermissionType, PermissionLevel } from "@budibase/types" export type RoleHierarchy = { permissionId: string }[] -export enum PermissionLevel { - READ = "read", - WRITE = "write", - EXECUTE = "execute", - ADMIN = "admin", -} - -// these are the global types, that govern the underlying default behaviour -export enum PermissionType { - APP = "app", - TABLE = "table", - USER = "user", - AUTOMATION = "automation", - WEBHOOK = "webhook", - BUILDER = "builder", - VIEW = "view", - QUERY = "query", -} - export class Permission { type: PermissionType level: PermissionLevel @@ -173,3 +156,4 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) { // utility as a lot of things need simply the builder permission export const BUILDER = PermissionType.BUILDER +export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER diff --git a/packages/server/src/api/routes/application.ts b/packages/server/src/api/routes/application.ts index 0c1fa364ff..04d5f67b96 100644 --- a/packages/server/src/api/routes/application.ts +++ b/packages/server/src/api/routes/application.ts @@ -15,7 +15,7 @@ router ) .post( "/api/applications", - authorized(permissions.BUILDER), + authorized(permissions.GLOBAL_BUILDER), applicationValidator(), controller.create ) diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts index dba5d47cb9..767c3d95ef 100644 --- a/packages/server/src/middleware/authorized.ts +++ b/packages/server/src/middleware/authorized.ts @@ -5,7 +5,7 @@ import { context, users, } from "@budibase/backend-core" -import { Role, UserCtx } from "@budibase/types" +import { Role, UserCtx, PermissionType, PermissionLevel } from "@budibase/types" import builderMiddleware from "./builder" import { isWebhookEndpoint } from "./utils" @@ -24,8 +24,8 @@ const csrf = auth.buildCsrfMiddleware() const checkAuthorized = async ( ctx: UserCtx, resourceRoles: any, - permType: any, - permLevel: any + permType: PermissionType, + permLevel: PermissionLevel ) => { const appId = context.getAppId() // check if this is a builder api and the user is not a builder @@ -47,10 +47,10 @@ const checkAuthorized = async ( } const checkAuthorizedResource = async ( - ctx: any, + ctx: UserCtx, resourceRoles: any, - permType: any, - permLevel: any + permType: PermissionType, + permLevel: PermissionLevel ) => { // get the user's roles const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC @@ -122,7 +122,10 @@ export default ( // check general builder stuff, this middleware is a good way // to find API endpoints which are builder focused - if (permType === permissions.PermissionType.BUILDER) { + if ( + permType === permissions.PermissionType.BUILDER || + permType === permissions.PermissionType.GLOBAL_BUILDER + ) { await builderMiddleware(ctx) } diff --git a/packages/server/src/middleware/builder.ts b/packages/server/src/middleware/builder.ts index 881ec843a4..7df135c86a 100644 --- a/packages/server/src/middleware/builder.ts +++ b/packages/server/src/middleware/builder.ts @@ -10,7 +10,7 @@ import { setDebounce, } from "../utilities/redis" import { db as dbCore, cache } from "@budibase/backend-core" -import { UserCtx, Database, App } from "@budibase/types" +import { UserCtx, Database } from "@budibase/types" const DEBOUNCE_TIME_SEC = 30 diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts index 3249660624..2ce714801d 100644 --- a/packages/types/src/documents/global/user.ts +++ b/packages/types/src/documents/global/user.ts @@ -43,7 +43,6 @@ export interface User extends Document { roles: UserRoles builder?: { global?: boolean - appBuilder?: boolean apps?: string[] } admin?: { diff --git a/packages/types/src/sdk/index.ts b/packages/types/src/sdk/index.ts index 49d0387a82..e4b5778ed9 100644 --- a/packages/types/src/sdk/index.ts +++ b/packages/types/src/sdk/index.ts @@ -18,3 +18,4 @@ export * from "./sso" export * from "./user" export * from "./cli" export * from "./websocket" +export * from "./permissions" diff --git a/packages/types/src/sdk/permissions.ts b/packages/types/src/sdk/permissions.ts new file mode 100644 index 0000000000..9e51e4c7b4 --- /dev/null +++ b/packages/types/src/sdk/permissions.ts @@ -0,0 +1,19 @@ +export enum PermissionLevel { + READ = "read", + WRITE = "write", + EXECUTE = "execute", + ADMIN = "admin", +} + +// these are the global types, that govern the underlying default behaviour +export enum PermissionType { + APP = "app", + TABLE = "table", + USER = "user", + AUTOMATION = "automation", + WEBHOOK = "webhook", + BUILDER = "builder", + GLOBAL_BUILDER = "globalBuilder", + VIEW = "view", + QUERY = "query", +} diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index d1e66b4ac1..99da08a5b8 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -433,26 +433,9 @@ export const inviteAccept = async ( } } -export const grantAppBuilder = async (ctx: Ctx) => { - const { userId } = ctx.params - const user = await userSdk.db.getUser(userId) - if (!user.builder) { - user.builder = {} - } - user.builder.appBuilder = true - await userSdk.db.save(user, { hashPassword: false }) - ctx.body = { message: `User "${user.email}" granted app builder permissions` } -} - export const addAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params const user = await userSdk.db.getUser(userId) - if (!user.builder?.appBuilder && !userSdk.core.isGlobalBuilder(user)) { - ctx.throw( - 400, - "Unable to update access, user must be granted app builder permissions." - ) - } if (userSdk.core.isGlobalBuilder(user)) { ctx.body = { message: "User already admin - no permissions updated." } return @@ -472,12 +455,6 @@ export const addAppBuilder = async (ctx: Ctx) => { export const removeAppBuilder = async (ctx: Ctx) => { const { userId, appId } = ctx.params const user = await userSdk.db.getUser(userId) - if (!user.builder?.appBuilder && !userSdk.core.isGlobalBuilder(user)) { - ctx.throw( - 400, - "Unable to update access, user must be granted app builder permissions." - ) - } if (userSdk.core.isGlobalBuilder(user)) { ctx.body = { message: "User already admin - no permissions removed." } return 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 83bc401759..22039471b4 100644 --- a/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts +++ b/packages/worker/src/api/routes/global/tests/appBuilder.spec.ts @@ -24,27 +24,9 @@ describe("/api/global/users/:userId/app/builder", () => { return response.body as User } - async function grantAppBuilder(): Promise { - 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, 400) - }) - it("should be able to grant a user access to a particular app", async () => { - const user = await grantAppBuilder() + const user = await newUser() await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID) const updated = await getUser(user._id!) expect(updated.builder?.appBuilder).toBe(true) @@ -54,7 +36,7 @@ describe("/api/global/users/:userId/app/builder", () => { describe("DELETE /api/global/users/:userId/app/:appId/builder", () => { it("should allow revoking access", async () => { - const user = await grantAppBuilder() + const user = await newUser() await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID) let updated = await getUser(user._id!) expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID) diff --git a/packages/worker/src/api/routes/global/users.ts b/packages/worker/src/api/routes/global/users.ts index 9c1b5d9acb..348939a2e7 100644 --- a/packages/worker/src/api/routes/global/users.ts +++ b/packages/worker/src/api/routes/global/users.ts @@ -122,8 +122,7 @@ router buildAdminInitValidation(), controller.adminUser ) - .post("/api/global/users/:userId/app/builder", controller.grantAppBuilder) - .patch( + .post( "/api/global/users/:userId/app/:appId/builder", controller.addAppBuilder ) diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index bafc157e69..d9f6595f1d 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -141,30 +141,19 @@ export class UserAPI extends TestAPI { .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, statusCode: number = 200 ) => { return this.request - .patch(`/api/global/users/${userId}/app/${appId}/builder`) + .post(`/api/global/users/${userId}/app/${appId}/builder`) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) .expect(statusCode) } - revokeBuilderToApp = ( - userId: string, - appId: string - ) => { + revokeBuilderToApp = (userId: string, appId: string) => { return this.request .delete(`/api/global/users/${userId}/app/${appId}/builder`) .set(this.config.defaultHeaders()) From c2793ede4c6c9d5970c06a6c8f35e3f75acf2f05 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 26 Jul 2023 17:48:35 +0100 Subject: [PATCH 12/65] Properly supporting the new global builder permission type to deny access to app creation. --- packages/server/src/middleware/authorized.ts | 39 ++++++++++---------- packages/types/src/sdk/koa.ts | 1 + 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/server/src/middleware/authorized.ts b/packages/server/src/middleware/authorized.ts index 767c3d95ef..915344f747 100644 --- a/packages/server/src/middleware/authorized.ts +++ b/packages/server/src/middleware/authorized.ts @@ -1,11 +1,11 @@ import { - roles, - permissions, auth, context, + permissions, + roles, users, } from "@budibase/backend-core" -import { Role, UserCtx, PermissionType, PermissionLevel } from "@budibase/types" +import { PermissionLevel, PermissionType, Role, UserCtx } from "@budibase/types" import builderMiddleware from "./builder" import { isWebhookEndpoint } from "./utils" @@ -28,15 +28,14 @@ const checkAuthorized = async ( permLevel: PermissionLevel ) => { const appId = context.getAppId() + const isGlobalBuilderApi = permType === PermissionType.GLOBAL_BUILDER + const isBuilderApi = permType === PermissionType.BUILDER + const globalBuilder = users.isGlobalBuilder(ctx.user) + let isBuilder = appId + ? users.isBuilder(ctx.user, appId) + : users.hasBuilderPermissions(ctx.user) // check if this is a builder api and the user is not a builder - let isBuilder - if (!appId) { - isBuilder = users.hasBuilderPermissions(ctx.user) - } else { - isBuilder = users.isBuilder(ctx.user, appId) - } - const isBuilderApi = permType === permissions.PermissionType.BUILDER - if (isBuilderApi && !isBuilder) { + if ((isGlobalBuilderApi && !globalBuilder) || (isBuilderApi && !isBuilder)) { return ctx.throw(403, "Not Authorized") } @@ -76,8 +75,8 @@ const checkAuthorizedResource = async ( } export default ( - permType: any, - permLevel: any = null, + permType: PermissionType, + permLevel?: PermissionLevel, opts = { schema: false } ) => async (ctx: any, next: any) => { @@ -95,12 +94,12 @@ export default ( let resourceRoles: any = [] let otherLevelRoles: any = [] const otherLevel = - permLevel === permissions.PermissionLevel.READ - ? permissions.PermissionLevel.WRITE - : permissions.PermissionLevel.READ + permLevel === PermissionLevel.READ + ? PermissionLevel.WRITE + : PermissionLevel.READ const appId = context.getAppId() if (appId && hasResource(ctx)) { - resourceRoles = await roles.getRequiredResourceRole(permLevel, ctx) + resourceRoles = await roles.getRequiredResourceRole(permLevel!, ctx) if (opts && opts.schema) { otherLevelRoles = await roles.getRequiredResourceRole(otherLevel, ctx) } @@ -123,15 +122,15 @@ export default ( // check general builder stuff, this middleware is a good way // to find API endpoints which are builder focused if ( - permType === permissions.PermissionType.BUILDER || - permType === permissions.PermissionType.GLOBAL_BUILDER + permType === PermissionType.BUILDER || + permType === PermissionType.GLOBAL_BUILDER ) { await builderMiddleware(ctx) } try { // check authorized - await checkAuthorized(ctx, resourceRoles, permType, permLevel) + await checkAuthorized(ctx, resourceRoles, permType, permLevel!) } catch (err) { // this is a schema, check if if (opts && opts.schema && permLevel) { diff --git a/packages/types/src/sdk/koa.ts b/packages/types/src/sdk/koa.ts index f824b73458..861f5e9329 100644 --- a/packages/types/src/sdk/koa.ts +++ b/packages/types/src/sdk/koa.ts @@ -38,6 +38,7 @@ export interface Ctx extends Context { export interface UserCtx extends Ctx { user: ContextUser + roleId?: string } /** From eefac0fe088a7532c5213f3a6acf136bd8b434e7 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Wed, 26 Jul 2023 18:13:18 +0100 Subject: [PATCH 13/65] Quick updates based on latest lerna version, as well as some fixes after running through flows (post refactoring). --- package.json | 4 +- .../pages/builder/portal/apps/index.svelte | 53 ++++++++++--------- .../worker/src/api/controllers/global/auth.ts | 8 +-- .../worker/src/api/controllers/global/self.ts | 6 +-- .../src/api/routes/global/tests/auth.spec.ts | 6 +-- .../src/api/routes/global/tests/users.spec.ts | 2 +- packages/worker/src/initPro.ts | 4 +- packages/worker/src/sdk/auth/auth.ts | 4 +- 8 files changed, 45 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 6a678f1bf3..2173e91055 100644 --- a/package.json +++ b/package.json @@ -51,8 +51,8 @@ "kill-builder": "kill-port 3000", "kill-server": "kill-port 4001 4002", "kill-all": "yarn run kill-builder && yarn run kill-server", - "dev": "yarn run kill-all && yarn nx run-many --target=dev:builder", - "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && yarn nx run-many --target=dev:builder --exclude=@budibase/backend-core,@budibase/server,@budibase/worker", + "dev": "yarn run kill-all && yarn nx run-many --target=dev:builder --parallel=10", + "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && yarn nx run-many --target=dev:builder --parallel=10 --exclude=@budibase/backend-core,@budibase/server,@budibase/worker", "dev:server": "yarn run kill-server && yarn nx run-many --target=dev:builder --projects=@budibase/worker,@budibase/server", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:docker": "yarn build:docker:pre && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte index dc5ee25688..00991dd39a 100644 --- a/packages/builder/src/pages/builder/portal/apps/index.svelte +++ b/packages/builder/src/pages/builder/portal/apps/index.svelte @@ -15,6 +15,7 @@ import CreateAppModal from "components/start/CreateAppModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte" + import { sdk } from "@budibase/shared-core" import { store, automationStore } from "builderStore" import { API } from "api" @@ -237,35 +238,37 @@ {#if enrichedApps.length}
-
- - {#if $apps?.length > 0 && !$admin.offlineMode} + {#if sdk.isGlobalBuilder($auth.user)} +
- {/if} - {#if !$apps?.length} - - {/if} -
+ {#if $apps?.length > 0 && !$admin.offlineMode} + + {/if} + {#if !$apps?.length} + + {/if} +
+ {/if} {#if enrichedApps.length > 1}
- -
- {/if} -
- -
- {#each filteredApps as app (app.appId)} - - {/each} -
-
- {/if} - - {#if creatingFromTemplate} -
- -

Creating your Budibase app from your selected template...

- + {/if} + {#if !$apps?.length} + + {/if} +
+ {/if} + {#if enrichedApps.length > 1} +
+ - + Remove diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index fa1c5fc8ee..e1fc0ca7eb 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -39,6 +39,7 @@ import { API } from "api" import { OnboardingType } from "../../../../../constants" import ScimBanner from "../_components/SCIMBanner.svelte" + import { sdk } from "@budibase/shared-core" const fetch = fetchData({ API, @@ -66,7 +67,7 @@ let userData = [] $: isOwner = $auth.accountPortalAccess && $admin.cloud - $: readonly = !$auth.isAdmin || $features.isScimEnabled + $: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled $: debouncedUpdateFetch(searchEmail) $: schema = { diff --git a/packages/builder/src/stores/portal/auth.js b/packages/builder/src/stores/portal/auth.js index 40113e2f76..8caf541f98 100644 --- a/packages/builder/src/stores/portal/auth.js +++ b/packages/builder/src/stores/portal/auth.js @@ -14,13 +14,6 @@ export function createAuthStore() { postLogout: false, }) const store = derived(auth, $store => { - let isAdmin = false - let isBuilder = false - if ($store.user) { - const user = $store.user - isAdmin = sdk.users.isAdmin(user) - isBuilder = sdk.users.isBuilder(user) - } return { user: $store.user, accountPortalAccess: $store.accountPortalAccess, @@ -28,8 +21,6 @@ export function createAuthStore() { tenantSet: $store.tenantSet, loaded: $store.loaded, postLogout: $store.postLogout, - isAdmin, - isBuilder, isSSO: !!$store.user?.provider, } }) From 202963c864abfc09d0991a7420d306580f6d8eaa Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:34:50 +0200 Subject: [PATCH 38/65] Fix type tests --- .../worker/src/api/routes/global/tests/auth.spec.ts | 2 +- .../worker/src/api/routes/global/tests/self.spec.ts | 4 ++-- .../worker/src/api/routes/global/tests/users.spec.ts | 2 +- .../worker/src/api/routes/system/tests/status.spec.ts | 11 +++++++---- packages/worker/src/tests/mocks/index.ts | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/worker/src/api/routes/global/tests/auth.spec.ts b/packages/worker/src/api/routes/global/tests/auth.spec.ts index 5e62b2123f..47533a13d0 100644 --- a/packages/worker/src/api/routes/global/tests/auth.spec.ts +++ b/packages/worker/src/api/routes/global/tests/auth.spec.ts @@ -206,7 +206,7 @@ describe("/api/global/auth", () => { const newPassword = "newpassword" const res = await config.api.auth.updatePassword(code!, newPassword) - user = await config.getUser(user.email) + user = (await config.getUser(user.email))! delete user.password expect(res.body).toEqual({ message: "password reset successfully." }) diff --git a/packages/worker/src/api/routes/global/tests/self.spec.ts b/packages/worker/src/api/routes/global/tests/self.spec.ts index f3959c7521..274efb0aff 100644 --- a/packages/worker/src/api/routes/global/tests/self.spec.ts +++ b/packages/worker/src/api/routes/global/tests/self.spec.ts @@ -36,7 +36,7 @@ describe("/api/global/self", () => { }) .expect(200) - const dbUser = await config.getUser(user.email) + const dbUser = (await config.getUser(user.email))! user._rev = dbUser._rev user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() @@ -58,7 +58,7 @@ describe("/api/global/self", () => { }) .expect(200) - const dbUser = await config.getUser(user.email) + const dbUser = (await config.getUser(user.email))! user._rev = dbUser._rev user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString() diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 52d77cbae6..df9c19f8ca 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -66,7 +66,7 @@ describe("/api/global/users", () => { expect(res.body._id).toBeDefined() const user = await config.getUser(email) 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).toBeCalledWith(user) }) diff --git a/packages/worker/src/api/routes/system/tests/status.spec.ts b/packages/worker/src/api/routes/system/tests/status.spec.ts index fe0ff13551..48a0fc005d 100644 --- a/packages/worker/src/api/routes/system/tests/status.spec.ts +++ b/packages/worker/src/api/routes/system/tests/status.spec.ts @@ -1,3 +1,4 @@ +import { HealthStatusResponse } from "@budibase/types" import { TestConfiguration } from "../../../../tests" import { accounts as _accounts } from "@budibase/backend-core" const accounts = jest.mocked(_accounts) @@ -31,13 +32,15 @@ describe("/api/system/status", () => { }) it("returns status in cloud", async () => { - const value = { - health: { - passing: false, + const value: HealthStatusResponse = { + passing: false, + checks: { + login: false, + search: false, }, } - accounts.getStatus.mockReturnValueOnce(Promise.resolve(value)) + accounts.getStatus.mockResolvedValue(value) const res = await config.api.status.getStatus() diff --git a/packages/worker/src/tests/mocks/index.ts b/packages/worker/src/tests/mocks/index.ts index cab019bb46..917e5f9ffc 100644 --- a/packages/worker/src/tests/mocks/index.ts +++ b/packages/worker/src/tests/mocks/index.ts @@ -2,7 +2,7 @@ import * as email from "./email" import { mocks } from "@budibase/backend-core/tests" import * as _pro from "@budibase/pro" -const pro = jest.mocked(_pro, true) +const pro = jest.mocked(_pro, { shallow: false }) export default { email, From 96c598688f73a327635e9f51d969ee48b5f9a75c Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:35:17 +0200 Subject: [PATCH 39/65] Run tests checks for worker --- packages/worker/package.json | 2 +- packages/worker/tsconfig.json | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/worker/package.json b/packages/worker/package.json index 3767100925..a1b9e68878 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -14,7 +14,7 @@ "scripts": { "prebuild": "rimraf dist/", "build": "node ../../scripts/build.js", - "check:types": "tsc -p tsconfig.build.json --noEmit", + "check:types": "tsc -p tsconfig.json --noEmit --paths null", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "run:docker": "node dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js", diff --git a/packages/worker/tsconfig.json b/packages/worker/tsconfig.json index 147ef1f700..4f3d5cbac8 100644 --- a/packages/worker/tsconfig.json +++ b/packages/worker/tsconfig.json @@ -2,14 +2,12 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "declaration": true, - "sourceMap": true, "baseUrl": "." }, "ts-node": { "require": ["tsconfig-paths/register"], "swc": true }, - "include": ["src/**/*"], - "exclude": ["dist"] + "include": ["src/**/*", "__mocks__/**/*"], + "exclude": ["node_modules", "dist"] } From b3a110e609cbf54c33b6f74ccc59fcb19aad3420 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:36:32 +0200 Subject: [PATCH 40/65] Run tests checks for server --- packages/server/package.json | 2 +- packages/server/tsconfig.json | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index 4145caf0ca..ba663df9c3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -11,7 +11,7 @@ "scripts": { "prebuild": "rimraf dist/", "build": "node ./scripts/build.js", - "check:types": "tsc -p tsconfig.build.json --noEmit", + "check:types": "tsc -p tsconfig.json --noEmit --paths null", "postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js", diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 6e88b99c34..d9f84f6a5e 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -2,15 +2,12 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "declaration": true, - "sourceMap": true, - "baseUrl": ".", - "outDir": "dist" + "baseUrl": "." }, "ts-node": { "require": ["tsconfig-paths/register"], "swc": true }, - "include": ["src/**/*", "specs"], + "include": ["src/**/*", "specs", "__mocks__"], "exclude": ["node_modules", "dist"] } From f4675aab6e8d812e7beb3373de1911c17e76e4ef Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:49:50 +0200 Subject: [PATCH 41/65] Fix import --- packages/server/src/integrations/tests/googlesheets.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts index fcb24c152a..748baddc39 100644 --- a/packages/server/src/integrations/tests/googlesheets.spec.ts +++ b/packages/server/src/integrations/tests/googlesheets.spec.ts @@ -30,7 +30,7 @@ GoogleSpreadsheet.mockImplementation(() => mockGoogleIntegration) import { structures } from "@budibase/backend-core/tests" import TestConfiguration from "../../tests/utilities/TestConfiguration" import GoogleSheetsIntegration from "../googlesheets" -import { FieldType, Table, TableSchema } from "../../../../types/src/documents" +import { FieldType, Table, TableSchema } from "@budibase/types" describe("Google Sheets Integration", () => { let integration: any, From c6c1450d06c7378171dfdca0d86b4146396d749e Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:52:00 +0200 Subject: [PATCH 42/65] Fixe types --- .../server/src/utilities/rowProcessor/tests/utils.spec.ts | 7 ++++--- packages/server/src/utilities/rowProcessor/utils.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utilities/rowProcessor/tests/utils.spec.ts b/packages/server/src/utilities/rowProcessor/tests/utils.spec.ts index a9ab59c15a..68b62a3291 100644 --- a/packages/server/src/utilities/rowProcessor/tests/utils.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/utils.spec.ts @@ -1,18 +1,19 @@ import { fixAutoColumnSubType } from "../utils" import { AutoFieldDefaultNames, AutoFieldSubTypes } from "../../../constants" +import { FieldSchema, FieldType, RelationshipType } from "@budibase/types" describe("rowProcessor utility", () => { describe("fixAutoColumnSubType", () => { - let schema = { + let schema: FieldSchema = { name: "", - type: "link", + type: FieldType.LINK, subtype: "", // missing subtype icon: "ri-magic-line", autocolumn: true, constraints: { type: "array", presence: false }, tableId: "ta_users", fieldName: "test-Updated By", - relationshipType: "many-to-many", + relationshipType: RelationshipType.MANY_TO_MANY, sortable: false, } diff --git a/packages/server/src/utilities/rowProcessor/utils.ts b/packages/server/src/utilities/rowProcessor/utils.ts index 66ca969663..0d7eace369 100644 --- a/packages/server/src/utilities/rowProcessor/utils.ts +++ b/packages/server/src/utilities/rowProcessor/utils.ts @@ -5,7 +5,7 @@ import { FormulaTypes, } from "../../constants" import { processStringSync } from "@budibase/string-templates" -import { FieldSchema, FieldType, Row, Table } from "@budibase/types" +import { FieldSchema, Row, Table } from "@budibase/types" /** * If the subtype has been lost for any reason this works out what From ff7120cf7aebde8419269f4edbe2a0b04605a41b Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 12:57:44 +0200 Subject: [PATCH 43/65] Fix types on tests --- packages/server/src/automations/tests/openai.spec.ts | 2 +- packages/server/src/migrations/tests/index.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/automations/tests/openai.spec.ts b/packages/server/src/automations/tests/openai.spec.ts index 31f7e48305..07ac73a7c8 100644 --- a/packages/server/src/automations/tests/openai.spec.ts +++ b/packages/server/src/automations/tests/openai.spec.ts @@ -68,7 +68,7 @@ describe("test the openai action", () => { }) it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => { - openai.OpenAIApi.mockImplementation(() => ({ + ;(openai.OpenAIApi as any).mockImplementation(() => ({ createChatCompletion: jest.fn(() => { throw new Error("An error occurred while calling createChatCompletion") }), diff --git a/packages/server/src/migrations/tests/index.spec.ts b/packages/server/src/migrations/tests/index.spec.ts index b0fd971f42..2465c930b4 100644 --- a/packages/server/src/migrations/tests/index.spec.ts +++ b/packages/server/src/migrations/tests/index.spec.ts @@ -17,7 +17,7 @@ tk.freeze(timestamp) const clearMigrations = async () => { const dbs = [context.getDevAppDB(), context.getProdAppDB()] for (const db of dbs) { - const doc = await db.get(DocumentType.MIGRATIONS) + const doc = await db.get(DocumentType.MIGRATIONS) const newDoc = { _id: doc._id, _rev: doc._rev } await db.put(newDoc) } @@ -52,7 +52,7 @@ describe("migrations", () => { await config.createTable() await config.createView() await config.createTable() - await config.createView(structures.view(config.table._id)) + await config.createView(structures.view(config.table!._id!)) await config.createScreen() await config.createScreen() From 8b693a6fa34fbf54f64daf1506ed333a0e64f2c2 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:08:09 +0200 Subject: [PATCH 44/65] Check types on backend-core --- packages/backend-core/package.json | 1 + .../src/cache/tests/writethrough.spec.ts | 14 +++++++------- packages/backend-core/tsconfig.json | 1 - 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 345762348c..5883a53387 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -16,6 +16,7 @@ "prepack": "cp package.json dist", "build": "tsc -p tsconfig.build.json", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", + "check:types": "tsc -p tsconfig.json --noEmit --paths null", "test": "bash scripts/test.sh", "test:watch": "jest --watchAll" }, diff --git a/packages/backend-core/src/cache/tests/writethrough.spec.ts b/packages/backend-core/src/cache/tests/writethrough.spec.ts index 92b073ed64..97d3ece7a6 100644 --- a/packages/backend-core/src/cache/tests/writethrough.spec.ts +++ b/packages/backend-core/src/cache/tests/writethrough.spec.ts @@ -36,7 +36,7 @@ describe("writethrough", () => { _id: docId, value: 1, }) - const output = await db.get(response.id) + const output = await db.get(response.id) current = output expect(output.value).toBe(1) }) @@ -45,7 +45,7 @@ describe("writethrough", () => { it("second put shouldn't update DB", async () => { await config.doInTenant(async () => { const response = await writethrough.put({ ...current, value: 2 }) - const output = await db.get(response.id) + const output = await db.get(response.id) expect(current._rev).toBe(output._rev) expect(output.value).toBe(1) }) @@ -55,7 +55,7 @@ describe("writethrough", () => { await config.doInTenant(async () => { tk.freeze(Date.now() + DELAY + 1) const response = await writethrough.put({ ...current, value: 3 }) - const output = await db.get(response.id) + const output = await db.get(response.id) expect(response.rev).not.toBe(current._rev) expect(output.value).toBe(3) @@ -79,7 +79,7 @@ describe("writethrough", () => { expect.arrayContaining([current._rev, current._rev, newRev]) ) - const output = await db.get(current._id) + const output = await db.get(current._id) expect(output.value).toBe(4) expect(output._rev).toBe(newRev) @@ -107,7 +107,7 @@ describe("writethrough", () => { }) expect(res.ok).toBe(true) - const output = await db.get(id) + const output = await db.get(id) expect(output.value).toBe(3) expect(output._rev).toBe(res.rev) }) @@ -130,8 +130,8 @@ describe("writethrough", () => { const resp2 = await writethrough2.put({ _id: "db1", value: "second" }) expect(resp1.rev).toBeDefined() expect(resp2.rev).toBeDefined() - expect((await db.get("db1")).value).toBe("first") - expect((await db2.get("db1")).value).toBe("second") + expect((await db.get("db1")).value).toBe("first") + expect((await db2.get("db1")).value).toBe("second") }) }) }) diff --git a/packages/backend-core/tsconfig.json b/packages/backend-core/tsconfig.json index 2b1419b051..2993ff84ed 100644 --- a/packages/backend-core/tsconfig.json +++ b/packages/backend-core/tsconfig.json @@ -7,6 +7,5 @@ "@budibase/types": ["../types/src"] } }, - "exclude": ["node_modules", "dist"] } From 9b9081d43715a7348da6e658bfb62f98de059db3 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:11:04 +0200 Subject: [PATCH 45/65] Check types on cli --- packages/backend-core/tests/core/utilities/mocks/events.ts | 2 -- packages/cli/package.json | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/backend-core/tests/core/utilities/mocks/events.ts b/packages/backend-core/tests/core/utilities/mocks/events.ts index 81de1f8175..fef730768a 100644 --- a/packages/backend-core/tests/core/utilities/mocks/events.ts +++ b/packages/backend-core/tests/core/utilities/mocks/events.ts @@ -1,5 +1,3 @@ -import * as events from "../../../../src/events" - beforeAll(async () => { const processors = await import("../../../../src/events/processors") const events = await import("../../../../src/events") diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e6c612478..8e00d2cd91 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,6 +14,7 @@ "tsc": "tsc -p tsconfig.build.json", "pkg": "pkg . --out-path build --no-bytecode --public --public-packages \"*\" -C GZip", "build": "yarn prebuild && yarn rename && yarn tsc && yarn pkg && yarn postbuild", + "check:types": "tsc -p tsconfig.json --noEmit --paths null", "postbuild": "rm -rf prebuilds 2> /dev/null" }, "pkg": { From 27517fc6c8cdcc7af25f2f6c59d058fd90a113ad Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:22:58 +0200 Subject: [PATCH 46/65] Update pro --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 63fa1b15f6..7a1a21c8d7 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 63fa1b15f6e2afa8a264d597157fd798c9ce031c +Subproject commit 7a1a21c8d794be3f174eaa186ca33b9505467cda From 5bea5bf97433813ea106b6ed76eaff3424bfc49a Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:24:55 +0200 Subject: [PATCH 47/65] Check types --- packages/shared-core/package.json | 3 ++- packages/shared-core/tsconfig-base.build.json | 5 ++++- packages/shared-core/tsconfig.json | 9 ++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/shared-core/package.json b/packages/shared-core/package.json index b51f9732a0..f79075eb2e 100644 --- a/packages/shared-core/package.json +++ b/packages/shared-core/package.json @@ -17,7 +17,8 @@ "prebuild": "rimraf dist/", "build": "tsc -p tsconfig.build.json && tsc -p tsconfig-cjs.build.json", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", - "dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\"" + "dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\"", + "check:types": "tsc -p tsconfig.json --noEmit --paths null" }, "dependencies": { "@budibase/types": "0.0.0" diff --git a/packages/shared-core/tsconfig-base.build.json b/packages/shared-core/tsconfig-base.build.json index 6930e3cb99..31dc1afc10 100644 --- a/packages/shared-core/tsconfig-base.build.json +++ b/packages/shared-core/tsconfig-base.build.json @@ -12,7 +12,10 @@ "declaration": true, "types": ["node"], "outDir": "dist", - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@budibase/types": ["../types/src"] + } }, "include": ["**/*.js", "**/*.ts"], "exclude": [ diff --git a/packages/shared-core/tsconfig.json b/packages/shared-core/tsconfig.json index ebfec9c3a5..52e07ddb5c 100644 --- a/packages/shared-core/tsconfig.json +++ b/packages/shared-core/tsconfig.json @@ -2,9 +2,8 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "baseUrl": ".", - "paths": { - "@budibase/types": ["../types/src"] - } - } + "baseUrl": "." + }, + "include": ["**/*.js", "**/*.ts"], + "exclude": ["node_modules", "dist"] } From 672666d96b4c8eca42ee79128b8f496e43a6b5b1 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:26:04 +0200 Subject: [PATCH 48/65] Check types --- packages/types/package.json | 3 ++- packages/types/tsconfig.json | 7 ++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/types/package.json b/packages/types/package.json index 69eaa8b6b5..c419a4d527 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -16,7 +16,8 @@ "prebuild": "rimraf dist/", "build": "tsc -p tsconfig.build.json && tsc -p tsconfig-cjs.build.json", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", - "dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\"" + "dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\"", + "check:types": "tsc -p tsconfig.json --noEmit --paths null" }, "jest": {}, "devDependencies": { diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index 8342b99304..ad5356c2dc 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -3,8 +3,5 @@ "compilerOptions": { "composite": true }, - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "exclude": ["node_modules", "dist"] +} From 4e1aa4fbbdde802d913ebbcd678238c614d35e64 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 1 Aug 2023 12:33:59 +0100 Subject: [PATCH 49/65] Final PR comment. --- packages/server/src/sdk/app/applications/applications.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/server/src/sdk/app/applications/applications.ts b/packages/server/src/sdk/app/applications/applications.ts index e89aba5f48..865b277504 100644 --- a/packages/server/src/sdk/app/applications/applications.ts +++ b/packages/server/src/sdk/app/applications/applications.ts @@ -16,13 +16,7 @@ export function filterAppList(user: ContextUser, apps: App[]) { } else { return apps } - const finalApps: App[] = [] - for (let app of apps) { - if (appList.includes(dbCore.getProdAppID(app.appId))) { - finalApps.push(app) - } - } - return finalApps + return apps.filter(app => appList.includes(dbCore.getProdAppID(app.appId))) } export async function fetch(status: AppStatus, user: ContextUser) { From 559f52d9041fe818c769e5dc53ed1aad07596acf Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 13:36:38 +0200 Subject: [PATCH 50/65] Lint --- .../src/automations/tests/openai.spec.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/server/src/automations/tests/openai.spec.ts b/packages/server/src/automations/tests/openai.spec.ts index 07ac73a7c8..88cb63b95b 100644 --- a/packages/server/src/automations/tests/openai.spec.ts +++ b/packages/server/src/automations/tests/openai.spec.ts @@ -22,6 +22,10 @@ jest.mock( })) ) +const mockedOpenAIApi = openai.OpenAIApi as jest.MockedClass< + typeof openai.OpenAIApi +> + const OPENAI_PROMPT = "What is the meaning of life?" describe("test the openai action", () => { @@ -68,11 +72,16 @@ describe("test the openai action", () => { }) it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => { - ;(openai.OpenAIApi as any).mockImplementation(() => ({ - createChatCompletion: jest.fn(() => { - throw new Error("An error occurred while calling createChatCompletion") - }), - })) + mockedOpenAIApi.mockImplementation( + () => + ({ + createChatCompletion: jest.fn(() => { + throw new Error( + "An error occurred while calling createChatCompletion" + ) + }), + } as any) + ) const res = await setup.runStep("OPENAI", { prompt: OPENAI_PROMPT, From a71d2804c073818bceb2269ca4b35d918b42bff0 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 1 Aug 2023 13:07:37 +0100 Subject: [PATCH 51/65] Update pro to develop. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index b10f97aa63..faeaee6e80 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit b10f97aa6302cfbbf61c44edbc51af5d31ff84dd +Subproject commit faeaee6e8007260c68b40d453f307f6b6413aeb0 From 0449ba7a71779b1e532123bf54e109cbfd442379 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:12:28 +0200 Subject: [PATCH 52/65] Change refs --- packages/backend-core/tsconfig.build.json | 5 ++++- packages/backend-core/tsconfig.json | 5 +---- packages/pro | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend-core/tsconfig.build.json b/packages/backend-core/tsconfig.build.json index bfbed31e23..ba9058b131 100644 --- a/packages/backend-core/tsconfig.build.json +++ b/packages/backend-core/tsconfig.build.json @@ -12,7 +12,10 @@ "declaration": true, "types": ["node", "jest"], "outDir": "dist", - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@budibase/types": ["../types/src"] + } }, "include": ["**/*.js", "**/*.ts"], "exclude": [ diff --git a/packages/backend-core/tsconfig.json b/packages/backend-core/tsconfig.json index 2993ff84ed..4fd2295642 100644 --- a/packages/backend-core/tsconfig.json +++ b/packages/backend-core/tsconfig.json @@ -2,10 +2,7 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "baseUrl": ".", - "paths": { - "@budibase/types": ["../types/src"] - } + "baseUrl": "." }, "exclude": ["node_modules", "dist"] } diff --git a/packages/pro b/packages/pro index 7a1a21c8d7..4ef37991e5 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 7a1a21c8d794be3f174eaa186ca33b9505467cda +Subproject commit 4ef37991e585ea63e36a481f8251dcaf3e2da8b4 From bf2f0b5c3a287cf9699bb786a226309959b14f0a Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:16:37 +0200 Subject: [PATCH 53/65] Sync posthog-node package --- packages/cli/package.json | 2 +- yarn.lock | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e00d2cd91..4437469be2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,7 +46,7 @@ "lookpath": "1.1.0", "node-fetch": "2.6.7", "pkg": "5.8.0", - "posthog-node": "1.0.7", + "posthog-node": "1.3.0", "pouchdb": "7.3.0", "pouchdb-replication-stream": "1.2.9", "randomstring": "1.1.5", diff --git a/yarn.lock b/yarn.lock index d1f22e7699..827c94a176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20279,20 +20279,6 @@ posthog-js@^1.36.0: fflate "^0.4.1" rrweb-snapshot "^1.1.14" -posthog-node@1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-1.0.7.tgz#a7a9525eebff23312117e57cff3ddac82afb2262" - integrity sha512-KTCwyU+PU1eAQtjy5ZSJ47mrxv2d/mMkxo+vvV5c+YqfE4mBAY1UPEPMv1nElb5Vq0UnxvyQXaUnOn8d8Xr6Eg== - dependencies: - axios "^0.21.1" - axios-retry "^3.1.9" - component-type "^1.2.1" - join-component "^1.1.0" - md5 "^2.3.0" - ms "^2.1.3" - remove-trailing-slash "^0.1.1" - uuid "^8.3.2" - posthog-node@1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/posthog-node/-/posthog-node-1.3.0.tgz#804ed2f213a2f05253f798bf9569d55a9cad94f7" From 0f9ae3fa664e7e053dc14727dca36d6813060fc8 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:17:44 +0200 Subject: [PATCH 54/65] Revert paths on tsconfig.build --- packages/backend-core/tsconfig.build.json | 5 +---- packages/backend-core/tsconfig.json | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend-core/tsconfig.build.json b/packages/backend-core/tsconfig.build.json index ba9058b131..bfbed31e23 100644 --- a/packages/backend-core/tsconfig.build.json +++ b/packages/backend-core/tsconfig.build.json @@ -12,10 +12,7 @@ "declaration": true, "types": ["node", "jest"], "outDir": "dist", - "skipLibCheck": true, - "paths": { - "@budibase/types": ["../types/src"] - } + "skipLibCheck": true }, "include": ["**/*.js", "**/*.ts"], "exclude": [ diff --git a/packages/backend-core/tsconfig.json b/packages/backend-core/tsconfig.json index 4fd2295642..2993ff84ed 100644 --- a/packages/backend-core/tsconfig.json +++ b/packages/backend-core/tsconfig.json @@ -2,7 +2,10 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@budibase/types": ["../types/src"] + } }, "exclude": ["node_modules", "dist"] } From 953aead95cf8973bca0f450840a3b949edcd392b Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:18:54 +0200 Subject: [PATCH 55/65] Revert paths on tsconfig.build --- packages/shared-core/tsconfig-base.build.json | 5 +---- packages/shared-core/tsconfig.json | 5 ++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared-core/tsconfig-base.build.json b/packages/shared-core/tsconfig-base.build.json index 31dc1afc10..6930e3cb99 100644 --- a/packages/shared-core/tsconfig-base.build.json +++ b/packages/shared-core/tsconfig-base.build.json @@ -12,10 +12,7 @@ "declaration": true, "types": ["node"], "outDir": "dist", - "skipLibCheck": true, - "paths": { - "@budibase/types": ["../types/src"] - } + "skipLibCheck": true }, "include": ["**/*.js", "**/*.ts"], "exclude": [ diff --git a/packages/shared-core/tsconfig.json b/packages/shared-core/tsconfig.json index 52e07ddb5c..3f32e2adb8 100644 --- a/packages/shared-core/tsconfig.json +++ b/packages/shared-core/tsconfig.json @@ -2,7 +2,10 @@ "extends": "./tsconfig.build.json", "compilerOptions": { "composite": true, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@budibase/types": ["../types/src"] + } }, "include": ["**/*.js", "**/*.ts"], "exclude": ["node_modules", "dist"] From 068710922412c846842e499213712e7910010e79 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:20:39 +0200 Subject: [PATCH 56/65] Update refs --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 4ef37991e5..879c62c25f 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 4ef37991e585ea63e36a481f8251dcaf3e2da8b4 +Subproject commit 879c62c25f2c9772b624733901534b9adbf80a6b From 6ed80386b0db5ec4ec03df0ffbf26d9e0690f7c1 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 1 Aug 2023 13:20:48 +0100 Subject: [PATCH 57/65] Update pro. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index faeaee6e80..66b2626712 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit faeaee6e8007260c68b40d453f307f6b6413aeb0 +Subproject commit 66b2626712584d5e570db1492cffee7796d0bc3e From dc36829f7a1b50ddccce7dddf5df8e6d193ab2b2 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:25:52 +0200 Subject: [PATCH 58/65] Cache check:types --- nx.json | 2 +- package.json | 2 +- packages/pro | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nx.json b/nx.json index c2f44ef70d..9680f96dc1 100644 --- a/nx.json +++ b/nx.json @@ -3,7 +3,7 @@ "default": { "runner": "nx-cloud", "options": { - "cacheableOperations": ["build", "test"], + "cacheableOperations": ["build", "test", "check:types"], "accessToken": "MmM4OGYxNzItMDBlYy00ZmE3LTk4MTYtNmJhYWMyZjBjZTUyfHJlYWQ=" } } diff --git a/package.json b/package.json index a392aeee78..ab62955124 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "build": "yarn nx run-many -t=build", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", - "check:types": "lerna run check:types --skip-nx-cache", + "check:types": "lerna run check:types", "backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "build:sdk": "lerna run --stream build:sdk", diff --git a/packages/pro b/packages/pro index 879c62c25f..06789ccbb2 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 879c62c25f2c9772b624733901534b9adbf80a6b +Subproject commit 06789ccbb280e7f6aef522d974f6013124de9215 From e7c6724620cdac2151fd5c102ca2fe439c7ee317 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:39:17 +0200 Subject: [PATCH 59/65] Ad nx dependency --- packages/builder/package.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/builder/package.json b/packages/builder/package.json index 2f6d329877..6b5399c942 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -140,6 +140,17 @@ } ] }, + "dev:builder": { + "dependsOn": [ + { + "projects": [ + "@budibase/string-templates", + "@budibase/shared-core" + ], + "target": "build" + } + ] + }, "test": { "dependsOn": [ { From 49e65c693e9f5d8674429c0c3fcac1b60373a46c Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 1 Aug 2023 13:46:36 +0100 Subject: [PATCH 60/65] Update pro again. --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 66b2626712..fae546fb06 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 66b2626712584d5e570db1492cffee7796d0bc3e +Subproject commit fae546fb0678dfdad443199eb1e67497e3004370 From 56f49150f7dba44c886a2d6a55daa3a0f2cf98b4 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 14:39:54 +0200 Subject: [PATCH 61/65] Add nx dependency --- packages/builder/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/package.json b/packages/builder/package.json index 6b5399c942..b6adfc674c 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -145,7 +145,8 @@ { "projects": [ "@budibase/string-templates", - "@budibase/shared-core" + "@budibase/shared-core", + "@budibase/types" ], "target": "build" } From 7e8b3cdc30327e8de2cfb52af109087179f27731 Mon Sep 17 00:00:00 2001 From: Adria Navarro Date: Tue, 1 Aug 2023 16:28:40 +0200 Subject: [PATCH 62/65] Update pro ref --- packages/pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pro b/packages/pro index 06789ccbb2..cf3bef2aad 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 06789ccbb280e7f6aef522d974f6013124de9215 +Subproject commit cf3bef2aad9c739111b306fd0712397adc363f81 From ad42cfc6fbf8fcfb138711624e1f305b6be84398 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Tue, 1 Aug 2023 16:25:47 +0100 Subject: [PATCH 63/65] Linting. --- packages/pro | 2 +- packages/types/src/index.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/pro b/packages/pro index fae546fb06..cf3bef2aad 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit fae546fb0678dfdad443199eb1e67497e3004370 +Subproject commit cf3bef2aad9c739111b306fd0712397adc363f81 diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index fe1b4859ed..a0932829cf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -3,4 +3,3 @@ export * from "./sdk" export * from "./api" export * from "./core" export * from "./shared" - From 496fe0df8f36230519019ed5e70e4d4d55a031e3 Mon Sep 17 00:00:00 2001 From: Budibase Staging Release Bot <> Date: Tue, 1 Aug 2023 15:49:05 +0000 Subject: [PATCH 64/65] Bump version to 2.8.29-alpha.10 --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index 2b5b60e7a9..3e1cd55d75 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.8.29-alpha.9", + "version": "2.8.29-alpha.10", "npmClient": "yarn", "packages": [ "packages/*" From 590c3a8dfc23f6da804184cc19ff559f43a71874 Mon Sep 17 00:00:00 2001 From: Budibase Staging Release Bot <> Date: Tue, 1 Aug 2023 16:10:49 +0000 Subject: [PATCH 65/65] Bump version to 2.8.29-alpha.11 --- lerna.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lerna.json b/lerna.json index 3e1cd55d75..74a1392bf9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.8.29-alpha.10", + "version": "2.8.29-alpha.11", "npmClient": "yarn", "packages": [ "packages/*"