Merge branch 'develop' into chore/typecheck_tests

This commit is contained in:
Adria Navarro 2023-08-01 16:49:06 +01:00 committed by GitHub
commit e011ccd0c4
93 changed files with 1663 additions and 1037 deletions

View File

@ -23,6 +23,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.2", "@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/shared-core": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",

View File

@ -1,5 +1,5 @@
export const SEPARATOR = "_" import { prefixed, DocumentType } from "@budibase/types"
export const UNICODE_MAX = "\ufff0" export { SEPARATOR, UNICODE_MAX, DocumentType } from "@budibase/types"
/** /**
* Can be used to create a few different forms of querying a view. * Can be used to create a few different forms of querying a view.
@ -14,8 +14,6 @@ export enum ViewName {
USER_BY_APP = "by_app", USER_BY_APP = "by_app",
USER_BY_EMAIL = "by_email2", USER_BY_EMAIL = "by_email2",
BY_API_KEY = "by_api_key", BY_API_KEY = "by_api_key",
/** @deprecated - could be deleted */
USER_BY_BUILDERS = "by_builders",
LINK = "by_link", LINK = "by_link",
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
@ -36,42 +34,6 @@ export enum InternalTable {
USER_METADATA = "ta_users", 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 = { export const StaticDatabases = {
GLOBAL: { GLOBAL: {
name: "global-db", name: "global-db",
@ -95,7 +57,7 @@ export const StaticDatabases = {
}, },
} }
export const APP_PREFIX = DocumentType.APP + SEPARATOR export const APP_PREFIX = prefixed(DocumentType.APP)
export const APP_DEV = DocumentType.APP_DEV + SEPARATOR export const APP_DEV = prefixed(DocumentType.APP_DEV)
export const APP_DEV_PREFIX = APP_DEV export const APP_DEV_PREFIX = APP_DEV
export const BUDIBASE_DATASOURCE_TYPE = "budibase" export const BUDIBASE_DATASOURCE_TYPE = "budibase"

View File

@ -105,16 +105,6 @@ export const createApiKeyView = async () => {
await createView(db, viewJs, ViewName.BY_API_KEY) 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 { export interface QueryViewOptions {
arrayResponse?: boolean arrayResponse?: boolean
} }
@ -223,7 +213,6 @@ export const queryPlatformView = async <T>(
const CreateFuncByName: any = { const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView, [ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView, [ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView,
[ViewName.USER_BY_APP]: createUserAppView, [ViewName.USER_BY_APP]: createUserAppView,
} }

View File

@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "fs" import { existsSync, readFileSync } from "fs"
import { ServiceType } from "@budibase/types"
function isTest() { function isTest() {
return isCypress() || isJest() return isCypress() || isJest()
@ -83,10 +84,20 @@ function getPackageJsonFields(): {
} }
} }
function isWorker() {
return environment.SERVICE_TYPE === ServiceType.WORKER
}
function isApps() {
return environment.SERVICE_TYPE === ServiceType.APPS
}
const environment = { const environment = {
isTest, isTest,
isJest, isJest,
isDev, isDev,
isWorker,
isApps,
isProd: () => { isProd: () => {
return !isDev() return !isDev()
}, },
@ -153,6 +164,7 @@ const environment = {
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS, SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING, DISABLE_JWT_WARNING: process.env.DISABLE_JWT_WARNING,
BLACKLIST_IPS: process.env.BLACKLIST_IPS, BLACKLIST_IPS: process.env.BLACKLIST_IPS,
SERVICE_TYPE: "unknown",
/** /**
* Enable to allow an admin user to login using a password. * Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO. * This can be useful to prevent lockout when configuring SSO.

View File

@ -21,6 +21,7 @@ import { processors } from "./processors"
import { newid } from "../utils" import { newid } from "../utils"
import * as installation from "../installation" import * as installation from "../installation"
import * as configs from "../configs" import * as configs from "../configs"
import * as users from "../users"
import { withCache, TTL, CacheKey } from "../cache/generic" import { withCache, TTL, CacheKey } from "../cache/generic"
/** /**
@ -164,8 +165,8 @@ const identifyUser = async (
const id = user._id as string const id = user._id as string
const tenantId = await getEventTenantId(user.tenantId) const tenantId = await getEventTenantId(user.tenantId)
const type = IdentityType.USER const type = IdentityType.USER
let builder = user.builder?.global || false let builder = users.hasBuilderPermissions(user)
let admin = user.admin?.global || false let admin = users.hasAdminPermissions(user)
let providerType let providerType
if (isSSOUser(user)) { if (isSSOUser(user)) {
providerType = user.providerType providerType = user.providerType

View File

@ -1,10 +1,8 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isAdmin } from "../users"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( if (!ctx.internal && !isAdmin(ctx.user)) {
!ctx.internal &&
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global)
) {
ctx.throw(403, "Admin user only endpoint.") ctx.throw(403, "Admin user only endpoint.")
} }
return next() return next()

View File

@ -1,10 +1,19 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isBuilder, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( const appId = getAppId()
!ctx.internal && const builderFn = env.isWorker()
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) ? hasBuilderPermissions
) { : env.isApps()
? isBuilder
: undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId)) {
ctx.throw(403, "Builder user only endpoint.") ctx.throw(403, "Builder user only endpoint.")
} }
return next() return next()

View File

@ -1,12 +1,20 @@
import { BBContext } from "@budibase/types" import { UserCtx } from "@budibase/types"
import { isBuilder, isAdmin, hasBuilderPermissions } from "../users"
import { getAppId } from "../context"
import env from "../environment"
export default async (ctx: BBContext, next: any) => { export default async (ctx: UserCtx, next: any) => {
if ( const appId = getAppId()
!ctx.internal && const builderFn = env.isWorker()
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) && ? hasBuilderPermissions
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global) : env.isApps()
) { ? isBuilder
ctx.throw(403, "Builder user only endpoint.") : undefined
if (!builderFn) {
throw new Error("Service name unknown - middleware inactive.")
}
if (!ctx.internal && !builderFn(ctx.user, appId) && !isAdmin(ctx.user)) {
ctx.throw(403, "Admin/Builder user only endpoint.")
} }
return next() return next()
} }

View File

@ -0,0 +1,180 @@
import adminOnly from "../adminOnly"
import builderOnly from "../builderOnly"
import builderOrAdmin from "../builderOrAdmin"
import { structures } from "../../../tests"
import { ContextUser, ServiceType } from "@budibase/types"
import { doInAppContext } from "../../context"
import env from "../../environment"
env._set("SERVICE_TYPE", ServiceType.APPS)
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)
})
})
describe("check service difference", () => {
it("should not allow without app ID in apps", () => {
env._set("SERVICE_TYPE", ServiceType.APPS)
const appId = "app_a"
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: [appId],
},
})
const next = jest.fn()
doInAppContext(appId, () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
threw(ctx.throw)
})
it("should allow without app ID in worker", () => {
env._set("SERVICE_TYPE", ServiceType.WORKER)
const ctx = buildUserCtx({
...basicUser,
builder: {
apps: ["app_a"],
},
})
const next = jest.fn()
doInAppContext("app_b", () => {
builderOnly(ctx, next)
})
passed(ctx.throw, next)
})
})

View File

@ -1,3 +1,5 @@
import { PermissionType, PermissionLevel } from "@budibase/types"
export { PermissionType, PermissionLevel } from "@budibase/types"
import flatten from "lodash/flatten" import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
@ -5,25 +7,6 @@ export type RoleHierarchy = {
permissionId: string 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 { export class Permission {
type: PermissionType type: PermissionType
level: PermissionLevel level: PermissionLevel
@ -173,3 +156,4 @@ export function isPermissionLevelHigherThanRead(level: PermissionLevel) {
// utility as a lot of things need simply the builder permission // utility as a lot of things need simply the builder permission
export const BUILDER = PermissionType.BUILDER export const BUILDER = PermissionType.BUILDER
export const GLOBAL_BUILDER = PermissionType.GLOBAL_BUILDER

View File

@ -0,0 +1,460 @@
import env from "../environment"
import * as eventHelpers from "./events"
import * as accounts from "../accounts"
import * as cache from "../cache"
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 usersCore from "./users"
import {
AllDocsResponse,
BulkUserCreated,
BulkUserDeleted,
RowResponse,
SaveUserOpts,
User,
Account,
isSSOUser,
isSSOAccount,
UserStatus,
} from "@budibase/types"
import * as accountSdk from "../accounts"
import {
validateUniqueUser,
getAccountHolderFromUserIds,
isAdmin,
} from "./utils"
import { searchExistingEmails } from "./lookup"
import { hash } from "../utils"
type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any>
type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean>
type QuotaFns = { addUsers: QuotaUpdateFn; removeUsers: QuotaUpdateFn }
type GroupFns = { addUsers: GroupUpdateFn }
type FeatureFns = { isSSOEnforced: FeatureFn; isAppBuildersEnabled: FeatureFn }
const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string
await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
}
export class UserDB {
static quotas: QuotaFns
static groups: GroupFns
static features: FeatureFns
static init(quotaFns: QuotaFns, groupFns: GroupFns, featureFns: FeatureFns) {
UserDB.quotas = quotaFns
UserDB.groups = groupFns
UserDB.features = featureFns
}
static async isPreventPasswordActions(user: User, account?: Account) {
// when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout
if (env.ENABLE_SSO_MAINTENANCE_MODE && isAdmin(user)) {
return false
}
// SSO is enforced for all users
if (await UserDB.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))
}
static async buildUser(
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
},
tenantId: string,
dbUser?: any,
account?: Account
): Promise<User> {
let { password, _id } = user
// don't require a password if the db user doesn't already have one
if (dbUser && !dbUser.password) {
opts.requirePassword = false
}
let hashedPassword
if (password) {
if (await UserDB.isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await UserDB.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
}
static async allUsers() {
const db = getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map((row: any) => row.doc)
}
static async countUsersByApp(appId: string) {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
static async getUsersByAppAccess(appId?: string) {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
static async getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
static async getUser(userId: string) {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
static async save(user: User, opts: SaveUserOpts = {}): Promise<User> {
// default booleans to true
if (opts.hashPassword == null) {
opts.hashPassword = true
}
if (opts.requirePassword == null) {
opts.requirePassword = true
}
const tenantId = getTenantId()
const db = getGlobalDB()
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
}
if (
user.builder?.apps?.length &&
!(await UserDB.features.isAppBuildersEnabled())
) {
throw new Error("Unable to update app builders, please check license")
}
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 UserDB.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.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(UserDB.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
}
}
})
}
static async bulkCreate(
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> {
const tenantId = getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails)
const unsuccessful: { email: string; reason: string }[] = []
for (const newUser of newUsersRequested) {
if (
newUsers.find(
(x: User) => x.email.toLowerCase() === newUser.email.toLowerCase()
) ||
existingEmails.includes(newUser.email.toLowerCase())
) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue
}
newUser.userGroups = groups
newUsers.push(newUser)
}
const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
UserDB.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(UserDB.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
}
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
reason: "Account holder cannot be deleted",
})
}
// Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
return user.doc
}
)
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await UserDB.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
}
static 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 UserDB.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
}

View File

@ -1,15 +1,18 @@
import env from "../../environment" import env from "../environment"
import { events, accounts, tenancy } from "@budibase/backend-core" import * as events from "../events"
import * as accounts from "../accounts"
import { getTenantId } from "../context"
import { User, UserRoles, CloudAccount } from "@budibase/types" import { User, UserRoles, CloudAccount } from "@budibase/types"
import { hasBuilderPermissions, hasAdminPermissions } from "./utils"
export const handleDeleteEvents = async (user: any) => { export const handleDeleteEvents = async (user: any) => {
await events.user.deleted(user) await events.user.deleted(user)
if (isBuilder(user)) { if (hasBuilderPermissions(user)) {
await events.user.permissionBuilderRemoved(user) await events.user.permissionBuilderRemoved(user)
} }
if (isAdmin(user)) { if (hasAdminPermissions(user)) {
await events.user.permissionAdminRemoved(user) await events.user.permissionAdminRemoved(user)
} }
} }
@ -55,7 +58,7 @@ export const handleSaveEvents = async (
user: User, user: User,
existingUser: User | undefined existingUser: User | undefined
) => { ) => {
const tenantId = tenancy.getTenantId() const tenantId = getTenantId()
let tenantAccount: CloudAccount | undefined let tenantAccount: CloudAccount | undefined
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
tenantAccount = await accounts.getAccountByTenantId(tenantId) tenantAccount = await accounts.getAccountByTenantId(tenantId)
@ -103,23 +106,20 @@ export const handleSaveEvents = async (
await handleAppRoleEvents(user, existingUser) 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) => { export const isAddingBuilder = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isBuilder) return isAddingPermission(user, existingUser, hasBuilderPermissions)
} }
export const isRemovingBuilder = (user: any, existingUser: any) => { export const isRemovingBuilder = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isBuilder) return isRemovingPermission(user, existingUser, hasBuilderPermissions)
} }
const isAddingAdmin = (user: any, existingUser: any) => { const isAddingAdmin = (user: any, existingUser: any) => {
return isAddingPermission(user, existingUser, isAdmin) return isAddingPermission(user, existingUser, hasAdminPermissions)
} }
const isRemovingAdmin = (user: any, existingUser: any) => { const isRemovingAdmin = (user: any, existingUser: any) => {
return isRemovingPermission(user, existingUser, isAdmin) return isRemovingPermission(user, existingUser, hasAdminPermissions)
} }
const isOnboardingComplete = (user: any, existingUser: any) => { const isOnboardingComplete = (user: any, existingUser: any) => {

View File

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

View File

@ -0,0 +1,102 @@
import {
AccountMetadata,
PlatformUser,
PlatformUserByEmail,
User,
} from "@budibase/types"
import * as dbUtils from "../db"
import { ViewName } from "../constants"
/**
* Apply a system-wide search on emails:
* - in tenant
* - cross tenant
* - accounts
* return an array of emails that match the supplied emails.
*/
export async function searchExistingEmails(emails: string[]) {
let matchedEmails: string[] = []
const existingTenantUsers = await getExistingTenantUsers(emails)
matchedEmails.push(...existingTenantUsers.map(user => user.email))
const existingPlatformUsers = await getExistingPlatformUsers(emails)
matchedEmails.push(...existingPlatformUsers.map(user => user._id!))
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
}
// lookup, could be email or userId, either will return a doc
export async function getPlatformUser(
identifier: string
): Promise<PlatformUser | null> {
// use the view here and allow to find anyone regardless of casing
// Use lowercase to ensure email login is case insensitive
return (await dbUtils.queryPlatformView(ViewName.PLATFORM_USERS_LOWERCASE, {
keys: [identifier.toLowerCase()],
include_docs: true,
})) as PlatformUser
}
export async function getExistingTenantUsers(
emails: string[]
): Promise<User[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryGlobalView(
ViewName.USER_BY_EMAIL,
params,
undefined,
opts
)) as User[]
}
export async function getExistingPlatformUsers(
emails: string[]
): Promise<PlatformUserByEmail[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
params,
opts
)) as PlatformUserByEmail[]
}
export async function getExistingAccounts(
emails: string[]
): Promise<AccountMetadata[]> {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return (await dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
)) as AccountMetadata[]
}

View File

@ -11,10 +11,16 @@ import {
SEPARATOR, SEPARATOR,
UNICODE_MAX, UNICODE_MAX,
ViewName, ViewName,
} from "./db" } from "../db"
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types" import {
import { getGlobalDB } from "./context" BulkDocsResponse,
import * as context from "./context" SearchUsersRequest,
User,
ContextUser,
} from "@budibase/types"
import { getGlobalDB } from "../context"
import * as context from "../context"
import { user as userCache } from "../cache"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -178,7 +184,7 @@ export const getGlobalUserByAppPage = (appId: string, user: User) => {
* Performs a starts with search on the global email view. * Performs a starts with search on the global email view.
*/ */
export const searchGlobalUsersByEmail = async ( export const searchGlobalUsersByEmail = async (
email: string, email: string | unknown,
opts: any, opts: any,
getOpts?: GetOpts getOpts?: GetOpts
) => { ) => {
@ -248,3 +254,23 @@ export async function getUserCount() {
}) })
return response.total_rows return response.total_rows
} }
// 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
}

View File

@ -0,0 +1,55 @@
import { CloudAccount } from "@budibase/types"
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 isGlobalBuilder = sdk.users.isGlobalBuilder
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)
}
}
}
/**
* For the given user id's, return the account holder if it is in the ids.
*/
export async function getAccountHolderFromUserIds(
userIds: string[]
): Promise<CloudAccount | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId()
const account = await getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
}
}

View File

@ -94,6 +94,10 @@ export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS) return useFeature(Feature.SYNC_AUTOMATIONS)
} }
export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {

View File

@ -1,5 +1,6 @@
import { import {
AdminUser, AdminUser,
AdminOnlyUser,
BuilderUser, BuilderUser,
SSOAuthDetails, SSOAuthDetails,
SSOUser, 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 => { export const builderUser = (userProps?: any): BuilderUser => {
return { return {
...user(userProps), ...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( export function ssoUser(
opts: { user?: any; details?: SSOAuthDetails } = {} opts: { user?: any; details?: SSOAuthDetails } = {}
): SSOUser { ): SSOUser {

View File

@ -1,16 +1,21 @@
<script> <script>
import { Heading, Body, Button, Icon } from "@budibase/bbui" import { Heading, Body, Button, Icon } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { auth } from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
export let app export let app
export let lockedAction export let lockedAction
$: editing = app.sessions?.length $: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (!isBuilder) {
goToApp()
} else if (window.innerWidth < 640) {
goToOverview() goToOverview()
} else { } else {
goToBuilder() goToBuilder()
@ -24,6 +29,10 @@
const goToOverview = () => { const goToOverview = () => {
$goto(`../../app/${app.devId}/settings`) $goto(`../../app/${app.devId}/settings`)
} }
const goToApp = () => {
window.open(`/app/${app.name}`, "_blank")
}
</script> </script>
<div class="app-row" on:click={lockedAction || handleDefaultClick}> <div class="app-row" on:click={lockedAction || handleDefaultClick}>
@ -39,7 +48,7 @@
</div> </div>
<div class="updated"> <div class="updated">
{#if editing} {#if editing && isBuilder}
Currently editing Currently editing
<UserAvatars users={app.sessions} /> <UserAvatars users={app.sessions} />
{:else if app.updatedAt} {:else if app.updatedAt}
@ -56,14 +65,21 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body> <Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div> </div>
<div class="app-row-actions"> {#if isBuilder}
<Button size="S" secondary on:click={lockedAction || goToOverview}> <div class="app-row-actions">
Manage <Button size="S" secondary on:click={lockedAction || goToOverview}>
</Button> Manage
<Button size="S" primary on:click={lockedAction || goToBuilder}> </Button>
Edit <Button size="S" primary on:click={lockedAction || goToBuilder}>
</Button> Edit
</div> </Button>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<div class="app-row-actions">
<Button size="S" secondary>View</Button>
</div>
{/if}
</div> </div>
<style> <style>

View File

@ -12,12 +12,12 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" 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 { API } from "api"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
import UpgradeModal from "components/common/users/UpgradeModal.svelte" import UpgradeModal from "components/common/users/UpgradeModal.svelte"
import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation" import { emailValidator } from "helpers/validation"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { fly } from "svelte/transition" import { fly } from "svelte/transition"
@ -108,9 +108,9 @@
await usersFetch.refresh() await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => { filteredUsers = $usersFetch.rows.map(user => {
const isBuilderOrAdmin = user.admin?.global || user.builder?.global const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId)
let role = undefined let role = undefined
if (isBuilderOrAdmin) { if (isAdminOrBuilder) {
role = Constants.Roles.ADMIN role = Constants.Roles.ADMIN
} else { } else {
const appRole = Object.keys(user.roles).find(x => x === prodAppId) const appRole = Object.keys(user.roles).find(x => x === prodAppId)
@ -122,7 +122,7 @@
return { return {
...user, ...user,
role, role,
isBuilderOrAdmin, isAdminOrBuilder,
} }
}) })
} }
@ -258,7 +258,7 @@
} }
// Must exclude users who have explicit privileges // Must exclude users who have explicit privileges
const userByEmail = filteredUsers.reduce((acc, user) => { const userByEmail = filteredUsers.reduce((acc, user) => {
if (user.role || user.admin?.global || user.builder?.global) { if (user.role || sdk.users.isAdminOrBuilder(user, prodAppId)) {
acc.push(user.email) acc.push(user.email)
} }
return acc return acc
@ -389,9 +389,9 @@
} }
const userTitle = user => { const userTitle = user => {
if (user.admin?.global) { if (sdk.users.isAdmin(user)) {
return "Admin" return "Admin"
} else if (user.builder?.global) { } else if (sdk.users.isBuilder(user, prodAppId)) {
return "Developer" return "Developer"
} else { } else {
return "App user" return "App user"
@ -403,7 +403,7 @@
const role = $roles.find(role => role._id === user.role) const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group` return `This user has been given ${role?.name} access from the ${user.group} group`
} }
if (user.isBuilderOrAdmin) { if (user.isAdminOrBuilder) {
return "This user's role grants admin access to all apps" return "This user's role grants admin access to all apps"
} }
return null return null
@ -614,7 +614,7 @@
}} }}
autoWidth autoWidth
align="right" align="right"
allowedRoles={user.isBuilderOrAdmin allowedRoles={user.isAdminOrBuilder
? [Constants.Roles.ADMIN] ? [Constants.Roles.ADMIN]
: null} : null}
/> />

View File

@ -4,6 +4,8 @@
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore" import { isOnlyUser } from "builderStore"
import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
let deleteModal let deleteModal
</script> </script>
@ -44,22 +46,24 @@
url={$url("./version")} url={$url("./version")}
active={$isActive("./version")} active={$isActive("./version")}
/> />
<div class="delete-action"> {#if sdk.users.isGlobalBuilder($auth.user)}
<AbsTooltip <div class="delete-action">
position={TooltipPosition.Bottom} <AbsTooltip
text={$isOnlyUser position={TooltipPosition.Bottom}
? null text={$isOnlyUser
: "Unavailable - another user is editing this app"} ? null
> : "Unavailable - another user is editing this app"}
<SideNavItem >
text="Delete app" <SideNavItem
disabled={!$isOnlyUser} text="Delete app"
on:click={() => { disabled={!$isOnlyUser}
deleteModal.show() on:click={() => {
}} deleteModal.show()
/> }}
</AbsTooltip> />
</div> </AbsTooltip>
</div>
{/if}
</SideNav> </SideNav>
<slot /> <slot />
</Content> </Content>

View File

@ -22,7 +22,7 @@
import Spaceman from "assets/bb-space-man.svg" import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core" import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core" import { helpers, sdk } from "@budibase/shared-core"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
@ -43,32 +43,30 @@
$: userGroups = $groups.filter(group => $: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id) group.users.find(user => user._id === $auth.user?._id)
) )
let userApps = []
$: publishedApps = $apps.filter(publishedAppsOnly) $: publishedApps = $apps.filter(publishedAppsOnly)
$: userApps = getUserApps($auth.user)
$: { function getUserApps(user) {
if (!Object.keys($auth.user?.roles).length && $auth.user?.userGroups) { if (sdk.users.isAdmin(user)) {
userApps = return publishedApps
$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)
)
} }
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) { function getUrl(app) {
@ -109,7 +107,7 @@
> >
Update password Update password
</MenuItem> </MenuItem>
{#if $auth.isBuilder} {#if sdk.users.hasBuilderPermissions($auth.user)}
<MenuItem <MenuItem
icon="UserDeveloper" icon="UserDeveloper"
on:click={() => $goto("../portal")} on:click={() => $goto("../portal")}

View File

@ -1,11 +1,12 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
auth.checkQueryString() auth.checkQueryString()
$: { $: {
if ($auth.user?.builder?.global) { if (sdk.users.hasBuilderPermissions($auth.user)) {
$redirect(`./portal`) $redirect(`./portal`)
} else if ($auth.user) { } else if ($auth.user) {
$redirect(`./apps`) $redirect(`./apps`)

View File

@ -3,6 +3,7 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { auth, admin, licensing } from "stores/portal" import { auth, admin, licensing } from "stores/portal"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
</script> </script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan} {#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan}
@ -17,7 +18,7 @@
> >
Upgrade Upgrade
</Button> </Button>
{:else if !$admin.cloud && $auth.isAdmin} {:else if !$admin.cloud && sdk.users.isAdmin($auth.user)}
<Button <Button
cta cta
size="S" size="S"

View File

@ -8,13 +8,14 @@
import Logo from "./_components/Logo.svelte" import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte" import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "components/common/HelpMenu.svelte" import HelpMenu from "components/common/HelpMenu.svelte"
import { sdk } from "@budibase/shared-core"
let loaded = false let loaded = false
let mobileMenuVisible = false let mobileMenuVisible = false
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: fullscreen = !$apps.length $: isOnboarding = !$apps.length && sdk.users.isGlobalBuilder($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -33,7 +34,7 @@
onMount(async () => { onMount(async () => {
// Prevent non-builders from accessing the portal // Prevent non-builders from accessing the portal
if ($auth.user) { if ($auth.user) {
if (!$auth.user?.builder?.global) { if (!sdk.users.hasBuilderPermissions($auth.user)) {
$redirect("../") $redirect("../")
} else { } else {
try { try {
@ -49,7 +50,7 @@
</script> </script>
{#if $auth.user && loaded} {#if $auth.user && loaded}
{#if fullscreen} {#if isOnboarding}
<slot /> <slot />
{:else} {:else}
<HelpMenu /> <HelpMenu />

View File

@ -19,6 +19,7 @@
import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte" import DeleteLicenseKeyModal from "../../../../components/portal/licensing/DeleteLicenseKeyModal.svelte"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
$: license = $auth.user.license $: license = $auth.user.license
$: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` $: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
@ -176,7 +177,7 @@
}) })
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<DeleteLicenseKeyModal <DeleteLicenseKeyModal
bind:this={deleteLicenseKeyModal} bind:this={deleteLicenseKeyModal}
onConfirm={deleteLicenseKey} onConfirm={deleteLicenseKey}

View File

@ -14,6 +14,7 @@
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { DashCard, Usage } from "components/usage" import { DashCard, Usage } from "components/usage"
import { PlanModel } from "constants" import { PlanModel } from "constants"
import { sdk } from "@budibase/shared-core"
let staticUsage = [] let staticUsage = []
let monthlyUsage = [] let monthlyUsage = []
@ -51,7 +52,8 @@
$: accountPortalAccess = $auth?.user?.accountPortalAccess $: accountPortalAccess = $auth?.user?.accountPortalAccess
$: quotaReset = quotaUsage?.quotaReset $: quotaReset = quotaUsage?.quotaReset
$: canManagePlan = $: canManagePlan =
($admin.cloud && accountPortalAccess) || (!$admin.cloud && $auth.isAdmin) ($admin.cloud && accountPortalAccess) ||
(!$admin.cloud && sdk.users.isAdmin($auth.user))
$: showButton = !usesInvoicing && accountPortalAccess $: showButton = !usesInvoicing && accountPortalAccess

View File

@ -1,11 +1,19 @@
<script> <script>
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { admin, apps, templates, licensing, groups } from "stores/portal" import {
admin,
apps,
templates,
licensing,
groups,
auth,
} from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = $apps.length > 0 let loaded = $apps.length != null
onMount(async () => { onMount(async () => {
try { try {
@ -25,7 +33,7 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length) { if (!$apps.length && sdk.users.isGlobalBuilder($auth.user)) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {

View File

@ -15,6 +15,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte" import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { sdk } from "@budibase/shared-core"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { API } from "api" import { API } from "api"
@ -203,40 +204,40 @@
}) })
</script> </script>
{#if $apps.length} <Page>
<Page> <Layout noPadding gap="L">
<Layout noPadding gap="L"> {#each Object.keys(automationErrors || {}) as appId}
{#each Object.keys(automationErrors || {}) as appId} <Notification
<Notification wide
wide dismissable
dismissable action={() => goToAutomationError(appId)}
action={() => goToAutomationError(appId)} type="error"
type="error" icon="Alert"
icon="Alert" actionMessage={errorCount(automationErrors[appId]) > 1
actionMessage={errorCount(automationErrors[appId]) > 1 ? "View errors"
? "View errors" : "View error"}
: "View error"} on:dismiss={async () => {
on:dismiss={async () => { await automationStore.actions.clearLogErrors({ appId })
await automationStore.actions.clearLogErrors({ appId }) await apps.load()
await apps.load() }}
}} message={automationErrorMessage(appId)}
message={automationErrorMessage(appId)} />
/> {/each}
{/each} <div class="title">
<div class="title"> <div class="welcome">
<div class="welcome"> <Layout noPadding gap="XS">
<Layout noPadding gap="XS"> <Heading size="L">{welcomeHeader}</Heading>
<Heading size="L">{welcomeHeader}</Heading> <Body size="M">
<Body size="M"> Below you'll find the list of apps that you have access to
Manage your apps and get a head start with templates </Body>
</Body> </Layout>
</Layout>
</div>
</div> </div>
</div>
{#if enrichedApps.length} {#if enrichedApps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.isGlobalBuilder($auth.user)}
<div class="buttons"> <div class="buttons">
<Button <Button
size="M" size="M"
@ -266,41 +267,46 @@
</Button> </Button>
{/if} {/if}
</div> </div>
{#if enrichedApps.length > 1} {/if}
<div class="app-actions"> {#if enrichedApps.length > 1}
<Select <div class="app-actions">
autoWidth <Select
bind:value={sortBy} autoWidth
placeholder={null} bind:value={sortBy}
options={[ placeholder={null}
{ label: "Sort by name", value: "name" }, options={[
{ label: "Sort by recently updated", value: "updated" }, { label: "Sort by name", value: "name" },
{ label: "Sort by status", value: "status" }, { label: "Sort by recently updated", value: "updated" },
]} { label: "Sort by status", value: "status" },
/> ]}
<Search placeholder="Search" bind:value={searchTerm} /> />
</div> <Search placeholder="Search" bind:value={searchTerm} />
{/if} </div>
</div> {/if}
<div class="app-table">
{#each filteredApps as app (app.appId)}
<AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div> </div>
{/if}
</Layout> <div class="app-table">
</Page> {#each filteredApps as app (app.appId)}
{/if} <AppRow {app} lockedAction={usersLimitLockAction} />
{/each}
</div>
</Layout>
{:else}
<div class="no-apps">
<img class="spaceman" alt="spaceman" src={Logo} width="100px" />
<Body weight="700">You haven't been given access to any apps yet</Body>
</div>
{/if}
{#if creatingFromTemplate}
<div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" />
</div>
{/if}
</Layout>
</Page>
<Modal <Modal
bind:this={creationModal} bind:this={creationModal}
@ -368,6 +374,16 @@
height: 160px; height: 160px;
} }
.no-apps {
background-color: var(--spectrum-global-color-gray-100);
padding: calc(var(--spacing-xl) * 2);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: var(--spacing-xl);
}
@media (max-width: 1000px) { @media (max-width: 1000px) {
.img-logo { .img-logo {
display: none; display: none;

View File

@ -18,6 +18,7 @@
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
const imageExtensions = [ const imageExtensions = [
".png", ".png",
@ -206,7 +207,7 @@
}) })
</script> </script>
{#if $auth.isAdmin && mounted} {#if sdk.users.isAdmin($auth.user) && mounted}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<div class="title"> <div class="title">
@ -400,7 +401,7 @@
on:click={() => { on:click={() => {
if (isCloud && $auth?.user?.accountPortalAccess) { if (isCloud && $auth?.user?.accountPortalAccess) {
window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank") window.open($admin.accountPortalUrl + "/portal/upgrade", "_blank")
} else if ($auth.isAdmin) { } else if (sdk.users.isAdmin($auth.user)) {
$goto("/builder/portal/account/upgrade") $goto("/builder/portal/account/upgrade")
} }
}} }}

View File

@ -13,6 +13,7 @@
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { sdk } from "@budibase/shared-core"
let diagnosticInfo = "" let diagnosticInfo = ""
@ -46,7 +47,7 @@
}) })
</script> </script>
{#if $auth.isAdmin && diagnosticInfo} {#if sdk.users.isAdmin($auth.user) && diagnosticInfo}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS"> <Layout gap="XS">
<Heading size="M">Diagnostics</Heading> <Heading size="M">Diagnostics</Heading>

View File

@ -13,10 +13,11 @@
import { auth, organisation, admin } from "stores/portal" import { auth, organisation, admin } from "stores/portal"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
// Only admins allowed here // Only admins allowed here
$: { $: {
if (!$auth.isAdmin) { if (!sdk.users.isAdmin($auth.user)) {
$redirect("../../portal") $redirect("../../portal")
} }
} }
@ -50,7 +51,7 @@
} }
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Organisation</Heading> <Heading size="M">Organisation</Heading>

View File

@ -14,6 +14,7 @@
import { API } from "api" import { API } from "api"
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
let version let version
let loaded = false let loaded = false
@ -25,7 +26,7 @@
// Only admins allowed here // Only admins allowed here
$: { $: {
if (!$auth.isAdmin || $admin.cloud) { if (!sdk.users.isAdmin($auth.user) || $admin.cloud) {
$redirect("../../portal") $redirect("../../portal")
} }
} }
@ -89,7 +90,7 @@
}) })
</script> </script>
{#if $auth.isAdmin} {#if sdk.users.isAdmin($auth.user)}
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">Version</Heading> <Heading size="M">Version</Heading>

View File

@ -20,6 +20,7 @@
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte"
import GroupIcon from "./_components/GroupIcon.svelte" import GroupIcon from "./_components/GroupIcon.svelte"
import GroupUsers from "./_components/GroupUsers.svelte" import GroupUsers from "./_components/GroupUsers.svelte"
import { sdk } from "@budibase/shared-core"
export let groupId export let groupId
@ -46,7 +47,7 @@
let editModal, deleteModal let editModal, deleteModal
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
$: groupApps = $apps $: groupApps = $apps
.filter(app => .filter(app =>

View File

@ -3,6 +3,7 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { auth, groups, users } from "stores/portal" import { auth, groups, users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let groupId export let groupId
export let onUsersUpdated export let onUsersUpdated
@ -13,7 +14,7 @@
let prevSearch = undefined let prevSearch = undefined
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
$: readonly = !$auth.isAdmin $: readonly = !sdk.users.isAdmin($auth.user)
$: page = $pageInfo.page $: page = $pageInfo.page
$: searchUsers(page, searchTerm) $: searchUsers(page, searchTerm)
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)

View File

@ -9,6 +9,7 @@
import { setContext } from "svelte" import { setContext } from "svelte"
import ScimBanner from "../../_components/SCIMBanner.svelte" import ScimBanner from "../../_components/SCIMBanner.svelte"
import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte" import RemoveUserTableRenderer from "../_components/RemoveUserTableRenderer.svelte"
import { sdk } from "@budibase/shared-core"
export let groupId export let groupId
@ -49,7 +50,7 @@
] ]
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
const removeUser = async id => { const removeUser = async id => {
await groups.actions.removeUser(groupId, id) await groups.actions.removeUser(groupId, id)

View File

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
@ -13,6 +14,10 @@
} }
</script> </script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}> <ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove Remove
</ActionButton> </ActionButton>

View File

@ -22,6 +22,7 @@
import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte" import GroupNameTableRenderer from "./_components/GroupNameTableRenderer.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const DefaultGroup = { const DefaultGroup = {
name: "", name: "",
@ -40,7 +41,7 @@
{ column: "roles", component: GroupAppsTableRenderer }, { column: "roles", component: GroupAppsTableRenderer },
] ]
$: readonly = !$auth.isAdmin $: readonly = !sdk.users.isAdmin($auth.user)
$: schema = { $: schema = {
name: { displayName: "Group", width: "2fr", minWidth: "200px" }, name: { displayName: "Group", width: "2fr", minWidth: "200px" },
users: { sortable: false, width: "1fr" }, users: { sortable: false, width: "1fr" },

View File

@ -31,6 +31,7 @@
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
export let userId export let userId
@ -87,8 +88,8 @@
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = sdk.users.isAdminOrBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
@ -97,9 +98,9 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = user?.admin?.global $: globalRole = sdk.users.isAdmin(user)
? "admin" ? "admin"
: user?.builder?.global : sdk.users.isBuilder(user)
? "developer" ? "developer"
: "appUser" : "appUser"
@ -285,7 +286,7 @@
<div class="field"> <div class="field">
<Label size="L">Role</Label> <Label size="L">Role</Label>
<Select <Select
disabled={!$auth.isAdmin} disabled={!sdk.users.isAdmin($auth.user)}
value={globalRole} value={globalRole}
options={Constants.BudibaseRoleOptions} options={Constants.BudibaseRoleOptions}
on:change={updateUserRole} on:change={updateUserRole}

View File

@ -1,11 +1,12 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
export let row export let row
$: priviliged = row?.admin?.global || row?.builder?.global $: priviliged = sdk.users.isAdminOrBuilder(row)
$: count = priviliged ? $apps.length : value?.length || 0 $: count = priviliged ? $apps.length : value?.length || 0
</script> </script>

View File

@ -2,6 +2,7 @@
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let value export let value
@ -13,6 +14,10 @@
} }
</script> </script>
<ActionButton disabled={!$auth.isAdmin} size="S" on:click={onClick}> <ActionButton
disabled={!sdk.users.isAdmin($auth.user)}
size="S"
on:click={onClick}
>
Remove Remove
</ActionButton> </ActionButton>

View File

@ -2,6 +2,7 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { Body, Select, ModalContent, notifications } from "@budibase/bbui" import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
import { users } from "stores/portal" import { users } from "stores/portal"
import { sdk } from "@budibase/shared-core"
export let app export let app
export let user export let user
@ -15,7 +16,7 @@
.filter(role => role._id !== "PUBLIC") .filter(role => role._id !== "PUBLIC")
.map(role => ({ value: role._id, label: role.name })) .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" }) options.push({ value: NO_ACCESS, label: "No Access" })
} }
let selectedRole = user?.roles?.[app?._id] let selectedRole = user?.roles?.[app?._id]

View File

@ -39,6 +39,7 @@
import { API } from "api" import { API } from "api"
import { OnboardingType } from "../../../../../constants" import { OnboardingType } from "../../../../../constants"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { sdk } from "@budibase/shared-core"
const fetch = fetchData({ const fetch = fetchData({
API, API,
@ -66,7 +67,7 @@
let userData = [] let userData = []
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
$: readonly = !$auth.isAdmin || $features.isScimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled
$: debouncedUpdateFetch(searchEmail) $: debouncedUpdateFetch(searchEmail)
$: schema = { $: schema = {

View File

@ -2,6 +2,7 @@ import { derived, writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { admin } from "stores/portal" import { admin } from "stores/portal"
import analytics from "analytics" import analytics from "analytics"
import { sdk } from "@budibase/shared-core"
export function createAuthStore() { export function createAuthStore() {
const auth = writable({ const auth = writable({
@ -13,13 +14,6 @@ export function createAuthStore() {
postLogout: false, postLogout: false,
}) })
const store = derived(auth, $store => { const store = derived(auth, $store => {
let isAdmin = false
let isBuilder = false
if ($store.user) {
const user = $store.user
isAdmin = !!user.admin?.global
isBuilder = !!user.builder?.global
}
return { return {
user: $store.user, user: $store.user,
accountPortalAccess: $store.accountPortalAccess, accountPortalAccess: $store.accountPortalAccess,
@ -27,8 +21,6 @@ export function createAuthStore() {
tenantSet: $store.tenantSet, tenantSet: $store.tenantSet,
loaded: $store.loaded, loaded: $store.loaded,
postLogout: $store.postLogout, postLogout: $store.postLogout,
isAdmin,
isBuilder,
isSSO: !!$store.user?.provider, isSSO: !!$store.user?.provider,
} }
}) })
@ -57,8 +49,8 @@ export function createAuthStore() {
name: user.account?.name, name: user.account?.name,
user_id: user._id, user_id: user._id,
tenant: user.tenantId, tenant: user.tenantId,
admin: user?.admin?.global, admin: sdk.users.isAdmin(user),
builder: user?.builder?.global, builder: sdk.users.isBuilder(user),
"Company size": user.account?.size, "Company size": user.account?.size,
"Job role": user.account?.profession, "Job role": user.account?.profession,
}, },

View File

@ -2,8 +2,12 @@ import { derived } from "svelte/store"
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { admin } from "./admin" import { admin } from "./admin"
import { auth } from "./auth" import { auth } from "./auth"
import { sdk } from "@budibase/shared-core"
export const menu = derived([admin, auth], ([$admin, $auth]) => { 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 // Determine user sub pages
let userSubPages = [ let userSubPages = [
{ {
@ -24,19 +28,21 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
title: "Apps", title: "Apps",
href: "/builder/portal/apps", href: "/builder/portal/apps",
}, },
{ ]
if (sdk.users.isGlobalBuilder(user)) {
menu.push({
title: "Users", title: "Users",
href: "/builder/portal/users", href: "/builder/portal/users",
subPages: userSubPages, subPages: userSubPages,
}, })
{ menu.push({
title: "Plugins", title: "Plugins",
href: "/builder/portal/plugins", href: "/builder/portal/plugins",
}, })
] }
// Add settings page for admins // Add settings page for admins
if ($auth.isAdmin) { if (isAdmin) {
let settingsSubPages = [ let settingsSubPages = [
{ {
title: "Auth", title: "Auth",
@ -59,7 +65,7 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/settings/environment", href: "/builder/portal/settings/environment",
}, },
] ]
if (!$admin.cloud) { if (!cloud) {
settingsSubPages.push({ settingsSubPages.push({
title: "Version", title: "Version",
href: "/builder/portal/settings/version", href: "/builder/portal/settings/version",
@ -84,38 +90,35 @@ export const menu = derived([admin, auth], ([$admin, $auth]) => {
href: "/builder/portal/account/usage", href: "/builder/portal/account/usage",
}, },
] ]
if ($auth.isAdmin) { if (isAdmin) {
accountSubPages.push({ accountSubPages.push({
title: "Audit Logs", title: "Audit Logs",
href: "/builder/portal/account/auditLogs", href: "/builder/portal/account/auditLogs",
}) })
if (!$admin.cloud) { if (!cloud) {
accountSubPages.push({ accountSubPages.push({
title: "System Logs", title: "System Logs",
href: "/builder/portal/account/systemLogs", href: "/builder/portal/account/systemLogs",
}) })
} }
} }
if ($admin.cloud && $auth?.user?.accountPortalAccess) { if (cloud && user?.accountPortalAccess) {
accountSubPages.push({ accountSubPages.push({
title: "Upgrade", title: "Upgrade",
href: $admin.accountPortalUrl + "/portal/upgrade", href: $admin?.accountPortalUrl + "/portal/upgrade",
}) })
} else if (!$admin.cloud && $auth.isAdmin) { } else if (!cloud && isAdmin) {
accountSubPages.push({ accountSubPages.push({
title: "Upgrade", title: "Upgrade",
href: "/builder/portal/account/upgrade", href: "/builder/portal/account/upgrade",
}) })
} }
// add license check here // add license check here
if ( if (user?.accountPortalAccess && user.account.stripeCustomerId) {
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId
) {
accountSubPages.push({ accountSubPages.push({
title: "Billing", title: "Billing",
href: $admin.accountPortalUrl + "/portal/billing", href: $admin?.accountPortalUrl + "/portal/billing",
}) })
} }
menu.push({ menu.push({

View File

@ -2,6 +2,7 @@ import { writable } from "svelte/store"
import { API } from "api" import { API } from "api"
import { update } from "lodash" import { update } from "lodash"
import { licensing } from "." import { licensing } from "."
import { sdk } from "@budibase/shared-core"
export function createUsersStore() { export function createUsersStore() {
const { subscribe, set } = writable({}) const { subscribe, set } = writable({})
@ -111,8 +112,12 @@ export function createUsersStore() {
return await API.saveUser(user) return await API.saveUser(user)
} }
const getUserRole = ({ admin, builder }) => const getUserRole = user =>
admin?.global ? "admin" : builder?.global ? "developer" : "appUser" sdk.users.isAdmin(user)
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const refreshUsage = const refreshUsage =
fn => fn =>

View File

@ -30,6 +30,7 @@ import {
objectStore, objectStore,
roles, roles,
tenancy, tenancy,
users,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { USERS_TABLE_SCHEMA } from "../../constants" import { USERS_TABLE_SCHEMA } from "../../constants"
import { import {
@ -49,8 +50,8 @@ import {
MigrationType, MigrationType,
PlanType, PlanType,
Screen, Screen,
SocketSession,
UserCtx, UserCtx,
ContextUser,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -177,32 +178,10 @@ export const addSampleData = async (ctx: UserCtx) => {
} }
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx) {
const dev = ctx.query && ctx.query.status === AppStatus.DEV ctx.body = await sdk.applications.fetch(
const all = ctx.query && ctx.query.status === AppStatus.ALL ctx.query.status as AppStatus,
const apps = (await dbCore.getAllApps({ dev, all })) as App[] ctx.user
)
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)
} }
export async function fetchAppDefinition(ctx: UserCtx) { export async function fetchAppDefinition(ctx: UserCtx) {
@ -222,6 +201,7 @@ export async function fetchAppDefinition(ctx: UserCtx) {
export async function fetchAppPackage(ctx: UserCtx) { export async function fetchAppPackage(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const appId = context.getAppId()
let application = await db.get<any>(DocumentType.APP_METADATA) let application = await db.get<any>(DocumentType.APP_METADATA)
const layouts = await getLayouts() const layouts = await getLayouts()
let screens = await getScreens() let screens = await getScreens()
@ -233,7 +213,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
) )
// Only filter screens if the user is not a builder // 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 userRoleId = getUserRoleId(ctx)
const accessController = new roles.AccessController() const accessController = new roles.AccessController()
screens = await accessController.checkScreensAccess(screens, userRoleId) screens = await accessController.checkScreensAccess(screens, userRoleId)

View File

@ -15,7 +15,7 @@ router
) )
.post( .post(
"/api/applications", "/api/applications",
authorized(permissions.BUILDER), authorized(permissions.GLOBAL_BUILDER),
applicationValidator(), applicationValidator(),
controller.create controller.create
) )
@ -55,7 +55,7 @@ router
) )
.delete( .delete(
"/api/applications/:appId", "/api/applications/:appId",
authorized(permissions.BUILDER), authorized(permissions.GLOBAL_BUILDER),
controller.destroy controller.destroy
) )

View File

@ -8,14 +8,18 @@ const router: Router = new Router()
router router
.post( .post(
"/api/plugin/upload", "/api/plugin/upload",
authorized(permissions.BUILDER), authorized(permissions.GLOBAL_BUILDER),
controller.upload controller.upload
) )
.post("/api/plugin", authorized(permissions.BUILDER), controller.create) .post(
.get("/api/plugin", authorized(permissions.BUILDER), controller.fetch) "/api/plugin",
authorized(permissions.GLOBAL_BUILDER),
controller.create
)
.get("/api/plugin", authorized(permissions.GLOBAL_BUILDER), controller.fetch)
.delete( .delete(
"/api/plugin/:pluginId", "/api/plugin/:pluginId",
authorized(permissions.BUILDER), authorized(permissions.GLOBAL_BUILDER),
controller.destroy controller.destroy
) )

View File

@ -7,14 +7,14 @@ import userEndpoints from "./users"
import authorized from "../../../middleware/authorized" import authorized from "../../../middleware/authorized"
import publicApi from "../../../middleware/publicApi" import publicApi from "../../../middleware/publicApi"
import { paramResource, paramSubResource } from "../../../middleware/resourceId" import { paramResource, paramSubResource } from "../../../middleware/resourceId"
import { PermissionType, PermissionLevel } from "@budibase/types"
import { CtxFn } from "./utils/Endpoint" import { CtxFn } from "./utils/Endpoint"
import mapperMiddleware from "./middleware/mapper" import mapperMiddleware from "./middleware/mapper"
import env from "../../../environment" import env from "../../../environment"
// below imports don't have declaration files // below imports don't have declaration files
const Router = require("@koa/router") const Router = require("@koa/router")
const { RateLimit, Stores } = require("koa2-ratelimit") const { RateLimit, Stores } = require("koa2-ratelimit")
import { middleware, redis, permissions } from "@budibase/backend-core" import { middleware, redis } from "@budibase/backend-core"
const { PermissionType, PermissionLevel } = permissions
const PREFIX = "/api/public/v1" const PREFIX = "/api/public/v1"
// allow a lot more requests when in test // allow a lot more requests when in test
@ -111,7 +111,7 @@ function applyAdminRoutes(endpoints: any) {
function applyRoutes( function applyRoutes(
endpoints: any, endpoints: any,
permType: string, permType: PermissionType,
resource: string, resource: string,
subResource?: string subResource?: string
) { ) {

View File

@ -36,7 +36,10 @@ export const clearAllApps = async (
exceptions: Array<string> = [] exceptions: Array<string> = []
) => { ) => {
await tenancy.doInTenant(tenantId, async () => { await tenancy.doInTenant(tenantId, async () => {
const req: any = { query: { status: AppStatus.DEV }, user: { tenantId } } const req: any = {
query: { status: AppStatus.DEV },
user: { tenantId, builder: { global: true } },
}
await appController.fetch(req) await appController.fetch(req)
const apps = req.body const apps = req.body
if (!apps || apps.length <= 0) { if (!apps || apps.length <= 0) {

View File

@ -15,7 +15,15 @@ import * as api from "./api"
import * as automations from "./automations" import * as automations from "./automations"
import { Thread } from "./threads" import { Thread } from "./threads"
import * as redis from "./utilities/redis" import * as redis from "./utilities/redis"
import { events, logging, middleware, timers } from "@budibase/backend-core" import { ServiceType } from "@budibase/types"
import {
events,
logging,
middleware,
timers,
env as coreEnv,
} from "@budibase/backend-core"
coreEnv._set("SERVICE_TYPE", ServiceType.APPS)
import { startup } from "./startup" import { startup } from "./startup"
const Sentry = require("@sentry/node") const Sentry = require("@sentry/node")
const destroyable = require("server-destroy") const destroyable = require("server-destroy")

View File

@ -3,10 +3,10 @@ import { db as dbCore } from "@budibase/backend-core"
type Optional = string | null type Optional = string | null
export const AppStatus = { export const enum AppStatus {
DEV: "development", DEV = "development",
ALL: "all", ALL = "all",
DEPLOYED: "published", DEPLOYED = "published",
} }
export const BudibaseInternalDB = { export const BudibaseInternalDB = {

View File

@ -1,3 +1,6 @@
import { env as coreEnv } from "@budibase/backend-core"
import { ServiceType } from "@budibase/types"
coreEnv._set("SERVICE_TYPE", ServiceType.APPS)
import { join } from "path" import { join } from "path"
function isTest() { function isTest() {

View File

@ -1,5 +1,11 @@
import { roles, permissions, auth, context } from "@budibase/backend-core" import {
import { Role } from "@budibase/types" auth,
context,
permissions,
roles,
users,
} from "@budibase/backend-core"
import { PermissionLevel, PermissionType, Role, UserCtx } from "@budibase/types"
import builderMiddleware from "./builder" import builderMiddleware from "./builder"
import { isWebhookEndpoint } from "./utils" import { isWebhookEndpoint } from "./utils"
@ -16,15 +22,20 @@ const csrf = auth.buildCsrfMiddleware()
* - Otherwise the user must have the required role. * - Otherwise the user must have the required role.
*/ */
const checkAuthorized = async ( const checkAuthorized = async (
ctx: any, ctx: UserCtx,
resourceRoles: any, resourceRoles: any,
permType: any, permType: PermissionType,
permLevel: any 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 // 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 if ((isGlobalBuilderApi && !globalBuilder) || (isBuilderApi && !isBuilder)) {
const isBuilderApi = permType === permissions.PermissionType.BUILDER
if (isBuilderApi && !isBuilder) {
return ctx.throw(403, "Not Authorized") return ctx.throw(403, "Not Authorized")
} }
@ -35,10 +46,10 @@ const checkAuthorized = async (
} }
const checkAuthorizedResource = async ( const checkAuthorizedResource = async (
ctx: any, ctx: UserCtx,
resourceRoles: any, resourceRoles: any,
permType: any, permType: PermissionType,
permLevel: any permLevel: PermissionLevel
) => { ) => {
// get the user's roles // get the user's roles
const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC
@ -64,8 +75,8 @@ const checkAuthorizedResource = async (
} }
export default ( export default (
permType: any, permType: PermissionType,
permLevel: any = null, permLevel?: PermissionLevel,
opts = { schema: false } opts = { schema: false }
) => ) =>
async (ctx: any, next: any) => { async (ctx: any, next: any) => {
@ -83,12 +94,12 @@ export default (
let resourceRoles: any = [] let resourceRoles: any = []
let otherLevelRoles: any = [] let otherLevelRoles: any = []
const otherLevel = const otherLevel =
permLevel === permissions.PermissionLevel.READ permLevel === PermissionLevel.READ
? permissions.PermissionLevel.WRITE ? PermissionLevel.WRITE
: permissions.PermissionLevel.READ : PermissionLevel.READ
const appId = context.getAppId() const appId = context.getAppId()
if (appId && hasResource(ctx)) { if (appId && hasResource(ctx)) {
resourceRoles = await roles.getRequiredResourceRole(permLevel, ctx) resourceRoles = await roles.getRequiredResourceRole(permLevel!, ctx)
if (opts && opts.schema) { if (opts && opts.schema) {
otherLevelRoles = await roles.getRequiredResourceRole(otherLevel, ctx) otherLevelRoles = await roles.getRequiredResourceRole(otherLevel, ctx)
} }
@ -110,13 +121,16 @@ export default (
// check general builder stuff, this middleware is a good way // check general builder stuff, this middleware is a good way
// to find API endpoints which are builder focused // to find API endpoints which are builder focused
if (permType === permissions.PermissionType.BUILDER) { if (
permType === PermissionType.BUILDER ||
permType === PermissionType.GLOBAL_BUILDER
) {
await builderMiddleware(ctx) await builderMiddleware(ctx)
} }
try { try {
// check authorized // check authorized
await checkAuthorized(ctx, resourceRoles, permType, permLevel) await checkAuthorized(ctx, resourceRoles, permType, permLevel!)
} catch (err) { } catch (err) {
// this is a schema, check if // this is a schema, check if
if (opts && opts.schema && permLevel) { if (opts && opts.schema && permLevel) {

View File

@ -10,7 +10,7 @@ import {
setDebounce, setDebounce,
} from "../utilities/redis" } from "../utilities/redis"
import { db as dbCore, cache } from "@budibase/backend-core" 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 const DEBOUNCE_TIME_SEC = 30

View File

@ -4,12 +4,13 @@ import {
roles, roles,
tenancy, tenancy,
context, context,
users,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { generateUserMetadataID, isDevAppID } from "../db/utils" import { generateUserMetadataID, isDevAppID } from "../db/utils"
import { getCachedSelf } from "../utilities/global" import { getCachedSelf } from "../utilities/global"
import env from "../environment" import env from "../environment"
import { isWebhookEndpoint } from "./utils" import { isWebhookEndpoint } from "./utils"
import { UserCtx } from "@budibase/types" import { UserCtx, ContextUser } from "@budibase/types"
export default async (ctx: UserCtx, next: any) => { export default async (ctx: UserCtx, next: any) => {
// try to get the appID from the request // try to get the appID from the request
@ -23,7 +24,7 @@ export default async (ctx: UserCtx, next: any) => {
if ( if (
isDevAppID(requestAppId) && isDevAppID(requestAppId) &&
!isWebhookEndpoint(ctx) && !isWebhookEndpoint(ctx) &&
(!ctx.user || !ctx.user.builder || !ctx.user.builder.global) !users.isBuilder(ctx.user, requestAppId)
) { ) {
return ctx.redirect("/") return ctx.redirect("/")
} }
@ -42,8 +43,7 @@ export default async (ctx: UserCtx, next: any) => {
roleId = globalUser.roleId || roleId roleId = globalUser.roleId || roleId
// Allow builders to specify their role via a header // Allow builders to specify their role via a header
const isBuilder = const isBuilder = users.isBuilder(globalUser, appId)
globalUser && globalUser.builder && globalUser.builder.global
const isDevApp = appId && isDevAppID(appId) const isDevApp = appId && isDevAppID(appId)
const roleHeader = const roleHeader =
ctx.request && ctx.request &&
@ -56,8 +56,7 @@ export default async (ctx: UserCtx, next: any) => {
roleId = roleHeader roleId = roleHeader
// Delete admin and builder flags so that the specified role is honoured // Delete admin and builder flags so that the specified role is honoured
delete ctx.user.builder ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser
delete ctx.user.admin
} }
} catch (error) { } catch (error) {
// Swallow error and do nothing // Swallow error and do nothing
@ -71,7 +70,6 @@ export default async (ctx: UserCtx, next: any) => {
} }
return context.doInAppContext(appId, async () => { return context.doInAppContext(appId, async () => {
let skipCookie = false
// if the user not in the right tenant then make sure they have no permissions // 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, // need to judge this only based on the request app ID,
if ( if (
@ -81,12 +79,9 @@ export default async (ctx: UserCtx, next: any) => {
!tenancy.isUserInAppTenant(requestAppId, ctx.user) !tenancy.isUserInAppTenant(requestAppId, ctx.user)
) { ) {
// don't error, simply remove the users rights (they are a public user) // don't error, simply remove the users rights (they are a public user)
delete ctx.user.builder ctx.user = users.cleanseUserObject(ctx.user) as ContextUser
delete ctx.user.admin
delete ctx.user.roles
ctx.isAuthenticated = false ctx.isAuthenticated = false
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
skipCookie = true
} }
ctx.appId = appId ctx.appId = appId

View File

@ -9,7 +9,7 @@ jest.mock("../../environment", () => ({
) )
const authorizedMiddleware = require("../authorized").default const authorizedMiddleware = require("../authorized").default
const env = require("../../environment") const env = require("../../environment")
const { permissions } = require("@budibase/backend-core") const { PermissionType, PermissionLevel } = require("@budibase/types")
const APP_ID = "" const APP_ID = ""
@ -112,7 +112,7 @@ describe("Authorization middleware", () => {
it("throws if the user does not have builder permissions", async () => { it("throws if the user does not have builder permissions", async () => {
config.setEnvironment(false) config.setEnvironment(false)
config.setMiddlewareRequiredPermission(permissions.PermissionType.BUILDER) config.setMiddlewareRequiredPermission(PermissionType.BUILDER)
config.setUser({ config.setUser({
role: { role: {
_id: "" _id: ""
@ -124,13 +124,13 @@ describe("Authorization middleware", () => {
}) })
it("passes on to next() middleware if the user has resource permission", async () => { it("passes on to next() middleware if the user has resource permission", async () => {
config.setResourceId(permissions.PermissionType.QUERY) config.setResourceId(PermissionType.QUERY)
config.setUser({ config.setUser({
role: { role: {
_id: "" _id: ""
} }
}) })
config.setMiddlewareRequiredPermission(permissions.PermissionType.QUERY) config.setMiddlewareRequiredPermission(PermissionType.QUERY)
await config.executeMiddleware() await config.executeMiddleware()
expect(config.next).toHaveBeenCalled() expect(config.next).toHaveBeenCalled()
@ -154,7 +154,7 @@ describe("Authorization middleware", () => {
_id: "" _id: ""
}, },
}) })
config.setMiddlewareRequiredPermission(permissions.PermissionType.ADMIN, permissions.PermissionLevel.BASIC) config.setMiddlewareRequiredPermission(PermissionType.ADMIN, PermissionLevel.BASIC)
await config.executeMiddleware() await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission") expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission")

View File

@ -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 { User, CloudAccount } from "@budibase/types"
import { DEFAULT_TIMESTAMP } from ".." import { DEFAULT_TIMESTAMP } from ".."
@ -30,11 +34,11 @@ export const backfill = async (
await events.identification.identifyUser(user, account, timestamp) await events.identification.identifyUser(user, account, timestamp)
await events.user.created(user, timestamp) await events.user.created(user, timestamp)
if (user.admin?.global) { if (usersCore.hasAdminPermissions(user)) {
await events.user.permissionAdminAssigned(user, timestamp) await events.user.permissionAdminAssigned(user, timestamp)
} }
if (user.builder?.global) { if (usersCore.hasBuilderPermissions(user)) {
await events.user.permissionBuilderAssigned(user, timestamp) await events.user.permissionBuilderAssigned(user, timestamp)
} }

View File

@ -0,0 +1,50 @@
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[] = []
const roleApps = Object.keys(user.roles || {})
if (users.hasAppBuilderPermissions(user)) {
appList = user.builder?.apps || []
appList = appList.concat(roleApps)
} else if (!users.isAdminOrBuilder(user)) {
appList = roleApps
} else {
return apps
}
return apps.filter(app => appList.includes(dbCore.getProdAppID(app.appId)))
}
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)
}

View File

@ -1,7 +1,9 @@
import * as sync from "./sync" import * as sync from "./sync"
import * as utils from "./utils" import * as utils from "./utils"
import * as applications from "./applications"
export default { export default {
...sync, ...sync,
...utils, ...utils,
...applications,
} }

View File

@ -5,6 +5,7 @@ import {
cache, cache,
tenancy, tenancy,
context, context,
users,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import env from "../environment" import env from "../environment"
import { groups } from "@budibase/pro" 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 in an multi-tenancy environment make sure roles are never updated
if (env.MULTI_TENANCY && appId && !tenancy.isUserInAppTenant(appId, user)) { if (env.MULTI_TENANCY && appId && !tenancy.isUserInAppTenant(appId, user)) {
delete user.builder user = users.removePortalUserPermissions(user)
delete user.admin
user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
return user return user
} }
@ -32,7 +32,7 @@ export function updateAppRole(
user.roleId = user.roles[dbCore.getProdAppID(appId)] user.roleId = user.roles[dbCore.getProdAppID(appId)]
} }
// if a role wasn't found then either set as admin (builder) or public (everyone else) // 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 user.roleId = roles.BUILTIN_ROLE_IDS.ADMIN
} else if (!user.roleId && !user?.userGroups?.length) { } else if (!user.roleId && !user?.userGroups?.length) {
user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC user.roleId = roles.BUILTIN_ROLE_IDS.PUBLIC

View File

@ -2,3 +2,4 @@ export * from "./constants"
export * as dataFilters from "./filters" export * as dataFilters from "./filters"
export * as helpers from "./helpers" export * as helpers from "./helpers"
export * as utils from "./utils" export * as utils from "./utils"
export * as sdk from "./sdk"

View File

@ -0,0 +1,35 @@
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) {
throw new Error("No app ID provided")
}
if (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) {
throw new Error("No app ID provided")
}
if (!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}`
}

View File

@ -0,0 +1,2 @@
export * as applications from "./applications"
export * as users from "./users"

View File

@ -0,0 +1,62 @@
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): boolean {
if (!user) {
return false
}
if (user.builder?.global) {
return true
} else if (appId && user.builder?.apps?.includes(getProdAppID(appId))) {
return true
}
return false
}
export function isGlobalBuilder(user: User | ContextUser): boolean {
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
export function isAdmin(user: User | ContextUser): boolean {
if (!user) {
return false
}
return hasAdminPermissions(user)
}
export function isAdminOrBuilder(
user: User | ContextUser,
appId?: string
): boolean {
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): boolean {
if (!user) {
return false
}
const appLength = user.builder?.apps?.length
const isGlobalBuilder = !!user.builder?.global
return !isGlobalBuilder && appLength != null && appLength > 0
}
// checks if a user is capable of building any app
export function hasBuilderPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return user.builder?.global || hasAppBuilderPermissions(user)
}
// checks if a user is capable of being an admin
export function hasAdminPermissions(user?: User | ContextUser): boolean {
if (!user) {
return false
}
return !!user.admin?.global
}

View File

@ -0,0 +1 @@
export * from "./documents"

View File

@ -0,0 +1 @@
export * from "./installation"

View File

@ -0,0 +1,4 @@
export enum ServiceType {
WORKER = "worker",
APPS = "apps",
}

View File

@ -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 { export interface Document {
_id?: string _id?: string
_rev?: string _rev?: string

View File

@ -42,7 +42,8 @@ export interface User extends Document {
forceResetPassword?: boolean forceResetPassword?: boolean
roles: UserRoles roles: UserRoles
builder?: { builder?: {
global: boolean global?: boolean
apps?: string[]
} }
admin?: { admin?: {
global: boolean global: boolean
@ -69,7 +70,8 @@ export interface UserRoles {
export interface BuilderUser extends User { export interface BuilderUser extends User {
builder: { builder: {
global: boolean global?: boolean
apps?: string[]
} }
} }
@ -82,6 +84,12 @@ export interface AdminUser extends User {
} }
} }
export interface AdminOnlyUser extends User {
admin: {
global: boolean
}
}
export function isUser(user: object): user is User { export function isUser(user: object): user is User {
return !!(user as User).roles return !!(user as User).roles
} }

View File

@ -1,4 +1,5 @@
export * from "./documents" export * from "./documents"
export * from "./sdk" export * from "./sdk"
export * from "./api" export * from "./api"
export * from "./core"
export * from "./shared" export * from "./shared"

View File

@ -18,3 +18,4 @@ export * from "./sso"
export * from "./user" export * from "./user"
export * from "./cli" export * from "./cli"
export * from "./websocket" export * from "./websocket"
export * from "./permissions"

View File

@ -38,6 +38,7 @@ export interface Ctx<RequestBody = any, ResponseBody = any> extends Context {
export interface UserCtx<RequestBody = any, ResponseBody = any> export interface UserCtx<RequestBody = any, ResponseBody = any>
extends Ctx<RequestBody, ResponseBody> { extends Ctx<RequestBody, ResponseBody> {
user: ContextUser user: ContextUser
roleId?: string
} }
/** /**

View File

@ -9,6 +9,7 @@ export enum Feature {
BRANDING = "branding", BRANDING = "branding",
SCIM = "scim", SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations", SYNC_AUTOMATIONS = "syncAutomations",
APP_BUILDERS = "appBuilders",
OFFLINE = "offline", OFFLINE = "offline",
} }

View File

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

View File

@ -55,8 +55,8 @@ async function passportCallback(
export const login = async (ctx: Ctx<LoginRequest>, next: any) => { export const login = async (ctx: Ctx<LoginRequest>, next: any) => {
const email = ctx.request.body.username const email = ctx.request.body.username
const user = await userSdk.getUserByEmail(email) const user = await userSdk.db.getUserByEmail(email)
if (user && (await userSdk.isPreventPasswordActions(user))) { if (user && (await userSdk.db.isPreventPasswordActions(user))) {
ctx.throw(403, "Invalid credentials") ctx.throw(403, "Invalid credentials")
} }
@ -174,7 +174,7 @@ export const googlePreAuth = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
config, config,
callbackUrl, callbackUrl,
userSdk.save userSdk.db.save
) )
return passport.authenticate(strategy, { return passport.authenticate(strategy, {
@ -193,7 +193,7 @@ export const googleCallback = async (ctx: any, next: any) => {
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
config, config,
callbackUrl, callbackUrl,
userSdk.save userSdk.db.save
) )
return passport.authenticate( return passport.authenticate(
@ -228,7 +228,7 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
//Remote Config //Remote Config
const enrichedConfig = await oidc.fetchStrategyConfig(config, callbackUrl) const enrichedConfig = await oidc.fetchStrategyConfig(config, callbackUrl)
return oidc.strategyFactory(enrichedConfig, userSdk.save) return oidc.strategyFactory(enrichedConfig, userSdk.db.save)
} }
/** /**

View File

@ -5,10 +5,10 @@ import {
cache, cache,
tenancy, tenancy,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { BBContext, App } from "@budibase/types" import sdk from "../../../sdk"
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 const tenantId = ctx.user!.tenantId
// always use the dev apps as they'll be most up to date (true) // always use the dev apps as they'll be most up to date (true)
const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[] const apps = (await dbCore.getAllApps({ tenantId, all: true })) as App[]
@ -31,7 +31,7 @@ export async function fetch(ctx: BBContext) {
ctx.body = response ctx.body = response
} }
export async function find(ctx: BBContext) { export async function find(ctx: Ctx) {
const appId = ctx.params.appId const appId = ctx.params.appId
await context.doInAppContext(dbCore.getDevAppID(appId), async () => { await context.doInAppContext(dbCore.getDevAppID(appId), async () => {
const db = context.getAppDB() const db = context.getAppDB()
@ -45,10 +45,10 @@ export async function find(ctx: BBContext) {
}) })
} }
export async function removeAppRole(ctx: BBContext) { export async function removeAppRole(ctx: Ctx) {
const { appId } = ctx.params const { appId } = ctx.params
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const users = await allUsers() const users = await sdk.users.db.allUsers()
const bulk = [] const bulk = []
const cacheInvalidations = [] const cacheInvalidations = []
for (let user of users) { for (let user of users) {

View File

@ -91,7 +91,7 @@ export async function getSelf(ctx: any) {
} }
// get the main body of the user // get the main body of the user
const user = await userSdk.getUser(userId) const user = await userSdk.db.getUser(userId)
ctx.body = await groups.enrichUserRolesFromGroups(user) ctx.body = await groups.enrichUserRolesFromGroups(user)
// add the feature flags for this tenant // add the feature flags for this tenant
@ -106,12 +106,12 @@ export async function updateSelf(
) { ) {
const update = ctx.request.body const update = ctx.request.body
let user = await userSdk.getUser(ctx.user._id!) let user = await userSdk.db.getUser(ctx.user._id!)
user = { user = {
...user, ...user,
...update, ...update,
} }
user = await userSdk.save(user, { requirePassword: false }) user = await userSdk.db.save(user, { requirePassword: false })
if (update.password) { if (update.password) {
// Log all other sessions out apart from the current one // Log all other sessions out apart from the current one

View File

@ -41,7 +41,7 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
const currentUserId = ctx.user?._id const currentUserId = ctx.user?._id
const requestUser = ctx.request.body const requestUser = ctx.request.body
const user = await userSdk.save(requestUser, { currentUserId }) const user = await userSdk.db.save(requestUser, { currentUserId })
ctx.body = { ctx.body = {
_id: user._id!, _id: user._id!,
@ -57,7 +57,7 @@ const bulkDelete = async (userIds: string[], currentUserId: string) => {
if (userIds?.indexOf(currentUserId) !== -1) { if (userIds?.indexOf(currentUserId) !== -1) {
throw new Error("Unable to delete self.") throw new Error("Unable to delete self.")
} }
return await userSdk.bulkDelete(userIds) return await userSdk.db.bulkDelete(userIds)
} }
const bulkCreate = async (users: User[], groupIds: string[]) => { const bulkCreate = async (users: User[], groupIds: string[]) => {
@ -66,7 +66,7 @@ const bulkCreate = async (users: User[], groupIds: string[]) => {
"Max limit for upload is 1000 users. Please reduce file size and try again." "Max limit for upload is 1000 users. Please reduce file size and try again."
) )
} }
return await userSdk.bulkCreate(users, groupIds) return await userSdk.db.bulkCreate(users, groupIds)
} }
export const bulkUpdate = async ( export const bulkUpdate = async (
@ -141,7 +141,7 @@ export const adminUser = async (
// always bust checklist beforehand, if an error occurs but can proceed, don't get // always bust checklist beforehand, if an error occurs but can proceed, don't get
// stuck in a cycle // stuck in a cycle
await cache.bustCache(cache.CacheKey.CHECKLIST) await cache.bustCache(cache.CacheKey.CHECKLIST)
const finalUser = await userSdk.save(user, { const finalUser = await userSdk.db.save(user, {
hashPassword, hashPassword,
requirePassword, requirePassword,
}) })
@ -167,7 +167,7 @@ export const adminUser = async (
export const countByApp = async (ctx: any) => { export const countByApp = async (ctx: any) => {
const appId = ctx.params.appId const appId = ctx.params.appId
try { try {
ctx.body = await userSdk.countUsersByApp(appId) ctx.body = await userSdk.db.countUsersByApp(appId)
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status || 400, err) ctx.throw(err.status || 400, err)
} }
@ -179,7 +179,7 @@ export const destroy = async (ctx: any) => {
ctx.throw(400, "Unable to delete self.") ctx.throw(400, "Unable to delete self.")
} }
await userSdk.destroy(id) await userSdk.db.destroy(id)
ctx.body = { ctx.body = {
message: `User ${id} deleted.`, message: `User ${id} deleted.`,
@ -188,7 +188,7 @@ export const destroy = async (ctx: any) => {
export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => { export const getAppUsers = async (ctx: Ctx<SearchUsersRequest>) => {
const body = ctx.request.body const body = ctx.request.body
const users = await userSdk.getUsersByAppAccess(body?.appId) const users = await userSdk.db.getUsersByAppAccess(body?.appId)
ctx.body = { data: users } ctx.body = { data: users }
} }
@ -212,7 +212,7 @@ export const search = async (ctx: Ctx<SearchUsersRequest>) => {
// called internally by app server user fetch // called internally by app server user fetch
export const fetch = async (ctx: any) => { export const fetch = async (ctx: any) => {
const all = await userSdk.allUsers() const all = await userSdk.db.allUsers()
// user hashed password shouldn't ever be returned // user hashed password shouldn't ever be returned
for (let user of all) { for (let user of all) {
if (user) { if (user) {
@ -224,12 +224,12 @@ export const fetch = async (ctx: any) => {
// called internally by app server user find // called internally by app server user find
export const find = async (ctx: any) => { export const find = async (ctx: any) => {
ctx.body = await userSdk.getUser(ctx.params.id) ctx.body = await userSdk.db.getUser(ctx.params.id)
} }
export const tenantUserLookup = async (ctx: any) => { export const tenantUserLookup = async (ctx: any) => {
const id = ctx.params.id const id = ctx.params.id
const user = await userSdk.getPlatformUser(id) const user = await userSdk.core.getPlatformUser(id)
if (user) { if (user) {
ctx.body = user ctx.body = user
} else { } else {
@ -252,7 +252,7 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
// @ts-ignore // @ts-ignore
const { users, groups, roles } = request.create const { users, groups, roles } = request.create
const assignUsers = users.map((user: User) => (user.roles = roles)) const assignUsers = users.map((user: User) => (user.roles = roles))
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups) onboardingResponse = await userSdk.db.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse ctx.body = onboardingResponse
} else if (emailConfigured) { } else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx) onboardingResponse = await inviteMultiple(ctx)
@ -277,7 +277,7 @@ export const onboardUsers = async (ctx: Ctx<InviteUsersRequest>) => {
tenantId: tenancy.getTenantId(), tenantId: tenancy.getTenantId(),
} }
}) })
let bulkCreateReponse = await userSdk.bulkCreate(users, []) let bulkCreateReponse = await userSdk.db.bulkCreate(users, [])
// Apply temporary credentials // Apply temporary credentials
let createWithCredentials = { let createWithCredentials = {
@ -410,7 +410,7 @@ export const inviteAccept = async (
...info, ...info,
} }
const saved = await userSdk.save(request) const saved = await userSdk.db.save(request)
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const user = await db.get<User>(saved._id) const user = await db.get<User>(saved._id)
await events.user.inviteAccepted(user) await events.user.inviteAccepted(user)

View File

@ -0,0 +1,62 @@
import { TestConfiguration, structures } from "../../../../tests"
import { mocks } from "@budibase/backend-core/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
}
describe("Confirm pro license", () => {
it("should 400 with licensing", async () => {
const user = await newUser()
const resp = await config.api.users.grantBuilderToApp(
user._id!,
MOCK_APP_ID,
400
)
expect(resp.body.message).toContain("Feature not enabled")
})
})
describe("PATCH /api/global/users/:userId/app/:appId/builder", () => {
it("should be able to grant a user access to a particular app", async () => {
mocks.licenses.useAppBuilders()
const user = await newUser()
await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
const updated = await getUser(user._id!)
expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID)
})
})
describe("DELETE /api/global/users/:userId/app/:appId/builder", () => {
it("should allow revoking access", async () => {
mocks.licenses.useAppBuilders()
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)
await config.api.users.revokeBuilderFromApp(user._id!, MOCK_APP_ID)
updated = await getUser(user._id!)
expect(updated.builder?.apps!.length).toBe(0)
})
})
})

View File

@ -41,7 +41,7 @@ describe("/api/global/auth", () => {
async function createSSOUser() { async function createSSOUser() {
return config.doInTenant(async () => { return config.doInTenant(async () => {
return userSdk.save(structures.users.ssoUser(), { return userSdk.db.save(structures.users.ssoUser(), {
requirePassword: false, requirePassword: false,
}) })
}) })
@ -245,7 +245,7 @@ describe("/api/global/auth", () => {
const ssoUser = user as SSOUser const ssoUser = user as SSOUser
ssoUser.providerType = structures.sso.providerType() ssoUser.providerType = structures.sso.providerType()
delete ssoUser.password delete ssoUser.password
await config.doInTenant(() => userSdk.save(ssoUser)) await config.doInTenant(() => userSdk.db.save(ssoUser))
await testSSOUser(code!) await testSSOUser(code!)
}) })

View File

@ -480,7 +480,7 @@ describe("/api/global/users", () => {
function createSSOUser() { function createSSOUser() {
return config.doInTenant(() => { return config.doInTenant(() => {
const user = structures.users.ssoUser() const user = structures.users.ssoUser()
return userSdk.save(user, { requirePassword: false }) return userSdk.db.save(user, { requirePassword: false })
}) })
} }

View File

@ -23,6 +23,7 @@ import env from "../../environment"
export const routes: Router[] = [ export const routes: Router[] = [
configRoutes, configRoutes,
userRoutes, userRoutes,
pro.users,
workspaceRoutes, workspaceRoutes,
authRoutes, authRoutes,
templateRoutes, templateRoutes,

View File

@ -1,4 +1,8 @@
const { join } = require("path") import { env as coreEnv } from "@budibase/backend-core"
import { ServiceType } from "@budibase/types"
import { join } from "path"
coreEnv._set("SERVICE_TYPE", ServiceType.WORKER)
function isDev() { function isDev() {
return process.env.NODE_ENV !== "production" return process.env.NODE_ENV !== "production"

View File

@ -2,12 +2,5 @@ import { sdk as proSdk } from "@budibase/pro"
import * as userSdk from "./sdk/users" import * as userSdk from "./sdk/users"
export const initPro = async () => { export const initPro = async () => {
await proSdk.init({ await proSdk.init({})
scimUserServiceConfig: {
functions: {
saveUser: userSdk.save,
removeUser: (id: string) => userSdk.destroy(id),
},
},
})
} }

View File

@ -10,7 +10,7 @@ import { platform } from "@budibase/backend-core"
* Re-sync the global-db users to the global-info db users * Re-sync the global-db users to the global-info db users
*/ */
export const run = async (globalDb: any) => { export const run = async (globalDb: any) => {
const users = (await usersSdk.allUsers()) as User[] const users = (await usersSdk.db.allUsers()) as User[]
const promises = [] const promises = []
for (let user of users) { for (let user of users) {
promises.push( promises.push(

View File

@ -1,11 +1,11 @@
import { import {
auth as authCore, auth as authCore,
tenancy, env as coreEnv,
utils as coreUtils,
sessions,
events, events,
HTTPError, HTTPError,
env as coreEnv, sessions,
tenancy,
utils as coreUtils,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { PlatformLogoutOpts, User } from "@budibase/types" import { PlatformLogoutOpts, User } from "@budibase/types"
import jwt from "jsonwebtoken" import jwt from "jsonwebtoken"
@ -20,7 +20,7 @@ export async function loginUser(user: User) {
const sessionId = coreUtils.newid() const sessionId = coreUtils.newid()
const tenantId = tenancy.getTenantId() const tenantId = tenancy.getTenantId()
await sessions.createASession(user._id!, { sessionId, tenantId }) await sessions.createASession(user._id!, { sessionId, tenantId })
const token = jwt.sign( return jwt.sign(
{ {
userId: user._id, userId: user._id,
sessionId, sessionId,
@ -28,7 +28,6 @@ export async function loginUser(user: User) {
}, },
coreEnv.JWT_SECRET! coreEnv.JWT_SECRET!
) )
return token
} }
export async function logout(opts: PlatformLogoutOpts) { export async function logout(opts: PlatformLogoutOpts) {
@ -58,7 +57,7 @@ export const reset = async (email: string) => {
} }
// exit if user has sso // exit if user has sso
if (await userSdk.isPreventPasswordActions(user)) { if (await userSdk.db.isPreventPasswordActions(user)) {
return return
} }
@ -76,9 +75,9 @@ export const reset = async (email: string) => {
export const resetUpdate = async (resetCode: string, password: string) => { export const resetUpdate = async (resetCode: string, password: string) => {
const { userId } = await redis.checkResetPasswordCode(resetCode) const { userId } = await redis.checkResetPasswordCode(resetCode)
let user = await userSdk.getUser(userId) let user = await userSdk.db.getUser(userId)
user.password = password user.password = password
user = await userSdk.save(user) user = await userSdk.db.save(user)
// remove password from the user before sending events // remove password from the user before sending events
delete user.password delete user.password

View File

@ -1,2 +1,7 @@
export * from "./users" export * from "./users"
import { users } from "@budibase/backend-core"
import * as pro from "@budibase/pro"
// pass in the components which are specific to the worker/the parts of pro which backend-core cannot access
users.UserDB.init(pro.quotas, pro.groups, pro.features)
export const db = users.UserDB
export { users as core } from "@budibase/backend-core" export { users as core } from "@budibase/backend-core"

View File

@ -1,8 +1,8 @@
import { structures, mocks } from "../../../tests" import { structures, mocks } from "../../../tests"
import { env, context } from "@budibase/backend-core" import { env, context } from "@budibase/backend-core"
import * as users from "../users" import * as users from "../users"
import { db as userDb } from "../"
import { CloudAccount } from "@budibase/types" import { CloudAccount } from "@budibase/types"
import { isPreventPasswordActions } from "../users"
describe("users", () => { describe("users", () => {
beforeEach(() => { beforeEach(() => {
@ -13,7 +13,7 @@ describe("users", () => {
it("returns false for non sso user", async () => { it("returns false for non sso user", async () => {
await context.doInTenant(structures.tenant.id(), async () => { await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.user() const user = structures.users.user()
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(false) expect(result).toBe(false)
}) })
}) })
@ -24,7 +24,7 @@ describe("users", () => {
const account = structures.accounts.ssoAccount() as CloudAccount const account = structures.accounts.ssoAccount() as CloudAccount
account.email = user.email account.email = user.email
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
@ -34,7 +34,7 @@ describe("users", () => {
const user = structures.users.user() const user = structures.users.user()
const account = structures.accounts.ssoAccount() as CloudAccount const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account) mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(false) expect(result).toBe(false)
}) })
}) })
@ -42,7 +42,7 @@ describe("users", () => {
it("returns true for sso user", async () => { it("returns true for sso user", async () => {
await context.doInTenant(structures.tenant.id(), async () => { await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.ssoUser() const user = structures.users.ssoUser()
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
@ -52,7 +52,7 @@ describe("users", () => {
await context.doInTenant(structures.tenant.id(), async () => { await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.user() const user = structures.users.user()
mocks.pro.features.isSSOEnforced.mockResolvedValueOnce(true) mocks.pro.features.isSSOEnforced.mockResolvedValueOnce(true)
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
@ -70,7 +70,7 @@ describe("users", () => {
describe("non-admin user", () => { describe("non-admin user", () => {
it("returns true", async () => { it("returns true", async () => {
const user = structures.users.ssoUser() const user = structures.users.ssoUser()
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
@ -80,7 +80,7 @@ describe("users", () => {
const user = structures.users.ssoUser({ const user = structures.users.ssoUser({
user: structures.users.adminUser(), user: structures.users.adminUser(),
}) })
const result = await users.isPreventPasswordActions(user) const result = await userDb.isPreventPasswordActions(user)
expect(result).toBe(false) expect(result).toBe(false)
}) })
}) })

View File

@ -1,602 +1,19 @@
import env from "../../environment" import { events, tenancy, users as usersCore } from "@budibase/backend-core"
import * as eventHelpers from "./events" import { InviteUsersRequest, InviteUsersResponse } from "@budibase/types"
import {
accounts,
cache,
constants,
db as dbUtils,
events,
HTTPError,
sessions,
tenancy,
platform,
users as usersCore,
utils,
ViewName,
env as coreEnv,
context,
EmailUnavailableError,
} from "@budibase/backend-core"
import {
AccountMetadata,
AllDocsResponse,
CloudAccount,
InviteUsersRequest,
InviteUsersResponse,
isSSOAccount,
isSSOUser,
PlatformUser,
PlatformUserByEmail,
RowResponse,
User,
SaveUserOpts,
BulkUserCreated,
BulkUserDeleted,
Account,
} from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
import * as pro from "@budibase/pro"
import * as accountSdk from "../accounts"
export const allUsers = async () => { export async function invite(
const db = tenancy.getGlobalDB()
const response = await db.allDocs(
dbUtils.getGlobalUserParams(null, {
include_docs: true,
})
)
return response.rows.map((row: any) => row.doc)
}
export const countUsersByApp = async (appId: string) => {
let response: any = await usersCore.searchGlobalUsersByApp(appId, {})
return {
userCount: response.length,
}
}
export const getUsersByAppAccess = async (appId?: string) => {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
export async function getUserByEmail(email: string) {
return usersCore.getGlobalUserByEmail(email)
}
/**
* Gets a user by ID from the global database, based on the current tenancy.
*/
export const getUser = async (userId: string) => {
const user = await usersCore.getById(userId)
if (user) {
delete user.password
}
return user
}
const buildUser = async (
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
},
tenantId: string,
dbUser?: any,
account?: Account
): Promise<User> => {
let { password, _id } = user
// don't require a password if the db user doesn't already have one
if (dbUser && !dbUser.password) {
opts.requirePassword = false
}
let hashedPassword
if (password) {
if (await isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
} else if (dbUser) {
hashedPassword = dbUser.password
}
// passwords are never required if sso is enforced
const requirePasswords =
opts.requirePassword && !(await pro.features.isSSOEnforced())
if (!hashedPassword && requirePasswords) {
throw "Password must be specified."
}
_id = _id || dbUtils.generateGlobalUserID()
const fullUser = {
createdAt: Date.now(),
...dbUser,
...user,
_id,
password: hashedPassword,
tenantId,
}
// make sure the roles object is always present
if (!fullUser.roles) {
fullUser.roles = {}
}
// add the active status to a user if its not provided
if (fullUser.status == null) {
fullUser.status = constants.UserStatus.ACTIVE
}
return fullUser
}
// lookup, could be email or userId, either will return a doc
export const getPlatformUser = async (
identifier: string
): Promise<PlatformUser | null> => {
// use the view here and allow to find anyone regardless of casing
// Use lowercase to ensure email login is case insensitive
const response = dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
{
keys: [identifier.toLowerCase()],
include_docs: true,
}
) as Promise<PlatformUser>
return response
}
const validateUniqueUser = async (email: string, tenantId: string) => {
// check budibase users in other tenants
if (env.MULTI_TENANCY) {
const tenantUser = await getPlatformUser(email)
if (tenantUser != null && tenantUser.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
// check root account users in account portal
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(email)
if (account && account.verified && account.tenantId !== tenantId) {
throw new EmailUnavailableError(email)
}
}
}
export async function isPreventPasswordActions(user: User, account?: Account) {
// when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout
if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) {
return false
}
// SSO is enforced for all users
if (await pro.features.isSSOEnforced()) {
return true
}
// Check local sso
if (isSSOUser(user)) {
return true
}
// Check account sso
if (!account) {
account = await accountSdk.api.getAccountByTenantId(tenancy.getTenantId())
}
return !!(account && account.email === user.email && isSSOAccount(account))
}
// TODO: The single save should re-use the bulk insert with a single
// user so that we don't need to duplicate logic
export const save = async (
user: User,
opts: SaveUserOpts = {}
): Promise<User> => {
// default booleans to true
if (opts.hashPassword == null) {
opts.hashPassword = true
}
if (opts.requirePassword == null) {
opts.requirePassword = true
}
const tenantId = tenancy.getTenantId()
const db = tenancy.getGlobalDB()
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
}
let dbUser: User | undefined
if (_id) {
// try to get existing user from db
try {
dbUser = (await db.get(_id)) as User
if (email && dbUser.email !== email) {
throw "Email address cannot be changed"
}
email = dbUser.email
} catch (e: any) {
if (e.status === 404) {
// do nothing, save this new user with the id specified - required for SSO auth
} else {
throw e
}
}
}
if (!dbUser && email) {
// no id was specified - load from email instead
dbUser = await usersCore.getGlobalUserByEmail(email)
if (dbUser && dbUser._id !== _id) {
throw new EmailUnavailableError(email)
}
}
const change = dbUser ? 0 : 1 // no change if there is existing user
return pro.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await buildUser(user, opts, tenantId, dbUser)
// don't allow a user to update its own roles/perms
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
builtUser.builder = dbUser.builder
builtUser.admin = dbUser.admin
builtUser.roles = dbUser.roles
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []
if (!_id) {
_id = builtUser._id!
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
}
}
}
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises)
// finally returned the saved user from the db
return db.get(builtUser._id!)
} catch (err: any) {
if (err.status === 409) {
throw "User exists already"
} else {
throw err
}
}
})
}
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return dbUtils.queryGlobalView(
ViewName.USER_BY_EMAIL,
params,
undefined,
opts
) as Promise<User[]>
}
const getExistingPlatformUsers = async (
emails: string[]
): Promise<PlatformUserByEmail[]> => {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return dbUtils.queryPlatformView(
ViewName.PLATFORM_USERS_LOWERCASE,
params,
opts
) as Promise<PlatformUserByEmail[]>
}
const getExistingAccounts = async (
emails: string[]
): Promise<AccountMetadata[]> => {
const lcEmails = emails.map(email => email.toLowerCase())
const params = {
keys: lcEmails,
include_docs: true,
}
const opts = {
arrayResponse: true,
}
return dbUtils.queryPlatformView(
ViewName.ACCOUNT_BY_EMAIL,
params,
opts
) as Promise<AccountMetadata[]>
}
/**
* Apply a system-wide search on emails:
* - in tenant
* - cross tenant
* - accounts
* return an array of emails that match the supplied emails.
*/
const searchExistingEmails = async (emails: string[]) => {
let matchedEmails: string[] = []
const existingTenantUsers = await getExistingTenantUsers(emails)
matchedEmails.push(...existingTenantUsers.map(user => user.email))
const existingPlatformUsers = await getExistingPlatformUsers(emails)
matchedEmails.push(...existingPlatformUsers.map(user => user._id!))
const existingAccounts = await getExistingAccounts(emails)
matchedEmails.push(...existingAccounts.map(account => account.email))
return [...new Set(matchedEmails.map(email => email.toLowerCase()))]
}
export const bulkCreate = async (
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserCreated> => {
const tenantId = tenancy.getTenantId()
let usersToSave: any[] = []
let newUsers: any[] = []
const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails)
const unsuccessful: { email: string; reason: string }[] = []
for (const newUser of newUsersRequested) {
if (
newUsers.find(
(x: User) => x.email.toLowerCase() === newUser.email.toLowerCase()
) ||
existingEmails.includes(newUser.email.toLowerCase())
) {
unsuccessful.push({
email: newUser.email,
reason: `Unavailable`,
})
continue
}
newUser.userGroups = groups
newUsers.push(newUser)
}
const account = await accountSdk.api.getAccountByTenantId(tenantId)
return pro.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
buildUser(
user,
{
hashPassword: true,
requirePassword: user.requirePassword,
},
tenantId,
undefined, // no dbUser
account
)
)
})
const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
})
}
/**
* For the given user id's, return the account holder if it is in the ids.
*/
const getAccountHolderFromUserIds = async (
userIds: string[]
): Promise<CloudAccount | undefined> => {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = tenancy.getTenantId()
const account = await accounts.getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
}
}
export const bulkDelete = async (
userIds: string[]
): Promise<BulkUserDeleted> => {
const db = tenancy.getGlobalDB()
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
reason: "Account holder cannot be deleted",
})
}
// Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
keys: userIds,
})
const usersToDelete: User[] = allDocsResponse.rows.map(
(user: RowResponse<User>) => {
return user.doc
}
)
// Delete from DB
const toDelete = usersToDelete.map(user => ({
...user,
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await pro.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
// Build Response
// index users by id
const userIndex: { [key: string]: User } = {}
usersToDelete.reduce((prev, current) => {
prev[current._id!] = current
return prev
}, userIndex)
// add the successful and unsuccessful users to response
dbResponse.forEach(item => {
const email = userIndex[item.id].email
if (item.ok) {
response.successful.push({ _id: item.id, email })
} else {
response.unsuccessful.push({
_id: item.id,
email,
reason: "Database error",
})
}
})
return response
}
// TODO: The single delete should re-use the bulk delete with a single
// user so that we don't need to duplicate logic
export const destroy = async (id: string) => {
const db = tenancy.getGlobalDB()
const dbUser = (await db.get(id)) as User
const userId = dbUser._id as string
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
// root account holder can't be deleted from inside budibase
const email = dbUser.email
const account = await accounts.getAccount(email)
if (account) {
if (dbUser.userId === context.getIdentity()!._id) {
throw new HTTPError('Please visit "Account" to delete this user', 400)
} else {
throw new HTTPError("Account holder cannot be deleted", 400)
}
}
}
await platform.users.removeUser(dbUser)
await db.remove(userId, dbUser._rev)
await pro.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
}
const bulkDeleteProcessing = async (dbUser: User) => {
const userId = dbUser._id as string
await platform.users.removeUser(dbUser)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
}
export const invite = async (
users: InviteUsersRequest users: InviteUsersRequest
): Promise<InviteUsersResponse> => { ): Promise<InviteUsersResponse> {
const response: InviteUsersResponse = { const response: InviteUsersResponse = {
successful: [], successful: [],
unsuccessful: [], unsuccessful: [],
} }
const matchedEmails = await searchExistingEmails(users.map(u => u.email)) const matchedEmails = await usersCore.searchExistingEmails(
users.map(u => u.email)
)
const newUsers = [] const newUsers = []
// separate duplicates from new users // separate duplicates from new users

View File

@ -251,9 +251,9 @@ class TestConfiguration {
}) })
} }
async getUser(email: string): Promise<User | undefined> { async getUser(email: string): Promise<User> {
return context.doInTenant(this.getTenantId(), () => { return context.doInTenant(this.getTenantId(), async () => {
return users.getGlobalUserByEmail(email) return (await users.getGlobalUserByEmail(email)) as User
}) })
} }
@ -263,7 +263,7 @@ class TestConfiguration {
} }
const response = await this._req(user, null, controllers.users.save) const response = await this._req(user, null, controllers.users.save)
const body = response as SaveUserResponse const body = response as SaveUserResponse
return this.getUser(body.email) as Promise<User> return (await this.getUser(body.email)) as User
} }
// CONFIGS // CONFIGS

View File

@ -140,4 +140,24 @@ export class UserAPI extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(opts?.status ? opts.status : 200) .expect(opts?.status ? opts.status : 200)
} }
grantBuilderToApp = (
userId: string,
appId: string,
statusCode: number = 200
) => {
return this.request
.post(`/api/global/users/${userId}/app/${appId}/builder`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(statusCode)
}
revokeBuilderFromApp = (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)
}
} }