1
0
Fork 0
mirror of synced 2024-07-04 22:11:23 +12:00

Merge branch 'develop' of github.com:Budibase/budibase into feature/audit-logs

This commit is contained in:
mike12345567 2023-02-27 14:51:33 +00:00
commit 2fb33e2cf3
70 changed files with 1115 additions and 1019 deletions

View file

@ -10,7 +10,7 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
- develop - develop
workflow_dispatch: workflow_dispatch:
env: env:

View file

@ -1,5 +1,5 @@
{ {
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.1", "@budibase/nano": "10.1.1",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.3.18-alpha.12", "@budibase/types": "2.3.18-alpha.13",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@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

@ -2,25 +2,34 @@ const _passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
const refresh = require("passport-oauth2-refresh") import { Cookie } from "../constants"
import { Config, Cookie } from "../constants"
import { getScopedConfig } from "../db"
import { getSessionsForUser, invalidateSessions } from "../security/sessions" import { getSessionsForUser, invalidateSessions } from "../security/sessions"
import { import {
authenticated,
csrf,
google,
jwt as jwtPassport, jwt as jwtPassport,
local, local,
authenticated,
tenancy,
csrf,
oidc, oidc,
google, tenancy,
} from "../middleware" } from "../middleware"
import * as userCache from "../cache/user"
import { invalidateUser } from "../cache/user" import { invalidateUser } from "../cache/user"
import { PlatformLogoutOpts, User } from "@budibase/types" import {
ConfigType,
GoogleInnerConfig,
OIDCInnerConfig,
PlatformLogoutOpts,
SSOProviderType,
User,
} from "@budibase/types"
import { logAlert } from "../logging" import { logAlert } from "../logging"
import * as events from "../events" import * as events from "../events"
import * as userCache from "../cache/user" import * as configs from "../configs"
import { clearCookie, getCookie } from "../utils" import { clearCookie, getCookie } from "../utils"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
const refresh = require("passport-oauth2-refresh")
export { export {
auditLog, auditLog,
authError, authError,
@ -33,7 +42,6 @@ export {
google, google,
oidc, oidc,
} from "../middleware" } from "../middleware"
import { ssoSaveUserNoOp } from "../middleware/passport/sso/sso"
export const buildAuthMiddleware = authenticated export const buildAuthMiddleware = authenticated
export const buildTenancyMiddleware = tenancy export const buildTenancyMiddleware = tenancy
export const buildCsrfMiddleware = csrf export const buildCsrfMiddleware = csrf
@ -63,11 +71,10 @@ _passport.deserializeUser(async (user: User, done: any) => {
}) })
async function refreshOIDCAccessToken( async function refreshOIDCAccessToken(
db: any, chosenConfig: OIDCInnerConfig,
chosenConfig: any,
refreshToken: string refreshToken: string
) { ): Promise<RefreshResponse> {
const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) const callbackUrl = await oidc.getCallbackUrl()
let enrichedConfig: any let enrichedConfig: any
let strategy: any let strategy: any
@ -90,7 +97,7 @@ async function refreshOIDCAccessToken(
return new Promise(resolve => { return new Promise(resolve => {
refresh.requestNewAccessToken( refresh.requestNewAccessToken(
Config.OIDC, ConfigType.OIDC,
refreshToken, refreshToken,
(err: any, accessToken: string, refreshToken: any, params: any) => { (err: any, accessToken: string, refreshToken: any, params: any) => {
resolve({ err, accessToken, refreshToken, params }) resolve({ err, accessToken, refreshToken, params })
@ -100,11 +107,10 @@ async function refreshOIDCAccessToken(
} }
async function refreshGoogleAccessToken( async function refreshGoogleAccessToken(
db: any, config: GoogleInnerConfig,
config: any,
refreshToken: any refreshToken: any
) { ): Promise<RefreshResponse> {
let callbackUrl = await google.getCallbackUrl(db, config) let callbackUrl = await google.getCallbackUrl(config)
let strategy let strategy
try { try {
@ -124,7 +130,7 @@ async function refreshGoogleAccessToken(
return new Promise(resolve => { return new Promise(resolve => {
refresh.requestNewAccessToken( refresh.requestNewAccessToken(
Config.GOOGLE, ConfigType.GOOGLE,
refreshToken, refreshToken,
(err: any, accessToken: string, refreshToken: string, params: any) => { (err: any, accessToken: string, refreshToken: string, params: any) => {
resolve({ err, accessToken, refreshToken, params }) resolve({ err, accessToken, refreshToken, params })
@ -133,41 +139,37 @@ async function refreshGoogleAccessToken(
}) })
} }
interface RefreshResponse {
err?: {
data?: string
}
accessToken?: string
refreshToken?: string
params?: any
}
export async function refreshOAuthToken( export async function refreshOAuthToken(
refreshToken: string, refreshToken: string,
configType: string, providerType: SSOProviderType,
configId: string configId?: string
) { ): Promise<RefreshResponse> {
const db = getGlobalDB() switch (providerType) {
case SSOProviderType.OIDC:
const config = await getScopedConfig(db, { if (!configId) {
type: configType, return { err: { data: "OIDC config id not provided" } }
group: {}, }
}) const oidcConfig = await configs.getOIDCConfigById(configId)
if (!oidcConfig) {
let chosenConfig = {} return { err: { data: "OIDC configuration not found" } }
let refreshResponse }
if (configType === Config.OIDC) { return refreshOIDCAccessToken(oidcConfig, refreshToken)
// configId - retrieved from cookie. case SSOProviderType.GOOGLE:
chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0] let googleConfig = await configs.getGoogleConfig()
if (!chosenConfig) { if (!googleConfig) {
throw new Error("Invalid OIDC configuration") return { err: { data: "Google configuration not found" } }
} }
refreshResponse = await refreshOIDCAccessToken( return refreshGoogleAccessToken(googleConfig, refreshToken)
db,
chosenConfig,
refreshToken
)
} else {
chosenConfig = config
refreshResponse = await refreshGoogleAccessToken(
db,
chosenConfig,
refreshToken
)
} }
return refreshResponse
} }
// TODO: Refactor to use user save function instead to prevent the need for // TODO: Refactor to use user save function instead to prevent the need for

View file

@ -0,0 +1,224 @@
import {
Config,
ConfigType,
GoogleConfig,
GoogleInnerConfig,
OIDCConfig,
OIDCInnerConfig,
SettingsConfig,
SettingsInnerConfig,
SMTPConfig,
SMTPInnerConfig,
} from "@budibase/types"
import { DocumentType, SEPARATOR } from "../constants"
import { CacheKey, TTL, withCache } from "../cache"
import * as context from "../context"
import env from "../environment"
import environment from "../environment"
// UTILS
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
export function generateConfigID(type: ConfigType) {
return `${DocumentType.CONFIG}${SEPARATOR}${type}`
}
export async function getConfig<T extends Config>(
type: ConfigType
): Promise<T | undefined> {
const db = context.getGlobalDB()
try {
// await to catch error
const config = (await db.get(generateConfigID(type))) as T
return config
} catch (e: any) {
if (e.status === 404) {
return
}
throw e
}
}
export async function save(config: Config) {
const db = context.getGlobalDB()
return db.put(config)
}
// SETTINGS
export async function getSettingsConfigDoc(): Promise<SettingsConfig> {
let config = await getConfig<SettingsConfig>(ConfigType.SETTINGS)
if (!config) {
config = {
_id: generateConfigID(ConfigType.GOOGLE),
type: ConfigType.SETTINGS,
config: {},
}
}
// overridden fields
config.config.platformUrl = await getPlatformUrl({
tenantAware: true,
config: config.config,
})
config.config.analyticsEnabled = await analyticsEnabled({
config: config.config,
})
return config
}
export async function getSettingsConfig(): Promise<SettingsInnerConfig> {
return (await getSettingsConfigDoc()).config
}
export async function getPlatformUrl(
opts: { tenantAware: boolean; config?: SettingsInnerConfig } = {
tenantAware: true,
}
) {
let platformUrl = env.PLATFORM_URL || "http://localhost:10000"
if (!env.SELF_HOSTED && env.MULTI_TENANCY && opts.tenantAware) {
// cloud and multi tenant - add the tenant to the default platform url
const tenantId = context.getTenantId()
if (!platformUrl.includes("localhost:")) {
platformUrl = platformUrl.replace("://", `://${tenantId}.`)
}
} else if (env.SELF_HOSTED) {
const config = opts?.config
? opts.config
: // direct to db to prevent infinite loop
(await getConfig<SettingsConfig>(ConfigType.SETTINGS))?.config
if (config?.platformUrl) {
platformUrl = config.platformUrl
}
}
return platformUrl
}
export const analyticsEnabled = async (opts?: {
config?: SettingsInnerConfig
}) => {
// cloud - always use the environment variable
if (!env.SELF_HOSTED) {
return !!env.ENABLE_ANALYTICS
}
// self host - prefer the settings doc
// use cache as events have high throughput
const enabledInDB = await withCache(
CacheKey.ANALYTICS_ENABLED,
TTL.ONE_DAY,
async () => {
const config = opts?.config
? opts.config
: // direct to db to prevent infinite loop
(await getConfig<SettingsConfig>(ConfigType.SETTINGS))?.config
// need to do explicit checks in case the field is not set
if (config?.analyticsEnabled === false) {
return false
} else if (config?.analyticsEnabled === true) {
return true
}
}
)
if (enabledInDB !== undefined) {
return enabledInDB
}
// fallback to the environment variable
// explicitly check for 0 or false here, undefined or otherwise is treated as true
const envEnabled: any = env.ENABLE_ANALYTICS
if (envEnabled === 0 || envEnabled === false) {
return false
} else {
return true
}
}
// GOOGLE
async function getGoogleConfigDoc(): Promise<GoogleConfig | undefined> {
return await getConfig<GoogleConfig>(ConfigType.GOOGLE)
}
export async function getGoogleConfig(): Promise<
GoogleInnerConfig | undefined
> {
const config = await getGoogleConfigDoc()
if (config) {
return config.config
}
// Use google fallback configuration from env variables
if (environment.GOOGLE_CLIENT_ID && environment.GOOGLE_CLIENT_SECRET) {
return {
clientID: environment.GOOGLE_CLIENT_ID!,
clientSecret: environment.GOOGLE_CLIENT_SECRET!,
activated: true,
}
}
}
// OIDC
async function getOIDCConfigDoc(): Promise<OIDCConfig | undefined> {
return getConfig<OIDCConfig>(ConfigType.OIDC)
}
export async function getOIDCConfig(): Promise<OIDCInnerConfig | undefined> {
const config = (await getOIDCConfigDoc())?.config
// default to the 0th config
return config?.configs && config.configs[0]
}
/**
* @param configId The config id of the inner config to retrieve
*/
export async function getOIDCConfigById(
configId: string
): Promise<OIDCInnerConfig | undefined> {
const config = (await getConfig<OIDCConfig>(ConfigType.OIDC))?.config
return config && config.configs.filter((c: any) => c.uuid === configId)[0]
}
// SMTP
export async function getSMTPConfigDoc(): Promise<SMTPConfig | undefined> {
return getConfig<SMTPConfig>(ConfigType.SMTP)
}
export async function getSMTPConfig(
isAutomation?: boolean
): Promise<SMTPInnerConfig | undefined> {
const config = await getSMTPConfigDoc()
if (config) {
return config.config
}
// always allow fallback in self host
// in cloud don't allow for automations
const allowFallback = env.SELF_HOSTED || !isAutomation
// Use an SMTP fallback configuration from env variables
if (env.SMTP_FALLBACK_ENABLED && allowFallback) {
return {
port: env.SMTP_PORT,
host: env.SMTP_HOST!,
secure: false,
from: env.SMTP_FROM_ADDRESS!,
auth: {
user: env.SMTP_USER!,
pass: env.SMTP_PASSWORD!,
},
}
}
}

View file

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

View file

@ -0,0 +1,116 @@
import { DBTestConfiguration, generator, testEnv } from "../../../tests"
import { ConfigType } from "@budibase/types"
import env from "../../environment"
import * as configs from "../configs"
const DEFAULT_URL = "http://localhost:10000"
const ENV_URL = "http://env.com"
describe("configs", () => {
const config = new DBTestConfiguration()
const setDbPlatformUrl = async (dbUrl: string) => {
const settingsConfig = {
_id: configs.generateConfigID(ConfigType.SETTINGS),
type: ConfigType.SETTINGS,
config: {
platformUrl: dbUrl,
},
}
await configs.save(settingsConfig)
}
beforeEach(async () => {
config.newTenant()
})
describe("getPlatformUrl", () => {
describe("self host", () => {
beforeEach(async () => {
testEnv.selfHosted()
})
it("gets the default url", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl()
expect(url).toBe(DEFAULT_URL)
})
})
it("gets the platform url from the environment", async () => {
await config.doInTenant(async () => {
env._set("PLATFORM_URL", ENV_URL)
const url = await configs.getPlatformUrl()
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the database", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const url = await configs.getPlatformUrl()
expect(url).toBe(dbUrl)
})
})
})
describe("cloud", () => {
function getTenantAwareUrl() {
return `http://${config.tenantId}.env.com`
}
beforeEach(async () => {
testEnv.cloudHosted()
testEnv.multiTenant()
env._set("PLATFORM_URL", ENV_URL)
})
it("gets the platform url from the environment without tenancy", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl({ tenantAware: false })
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the environment with tenancy", async () => {
await config.doInTenant(async () => {
const url = await configs.getPlatformUrl()
expect(url).toBe(getTenantAwareUrl())
})
})
it("never gets the platform url from the database", async () => {
await config.doInTenant(async () => {
await setDbPlatformUrl(generator.url())
const url = await configs.getPlatformUrl()
expect(url).toBe(getTenantAwareUrl())
})
})
})
})
describe("getSettingsConfig", () => {
beforeAll(async () => {
testEnv.selfHosted()
env._set("PLATFORM_URL", "")
})
it("returns the platform url with an existing config", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const config = await configs.getSettingsConfig()
expect(config.platformUrl).toBe(dbUrl)
})
})
it("returns the platform url without an existing config", async () => {
await config.doInTenant(async () => {
const config = await configs.getSettingsConfig()
expect(config.platformUrl).toBe(DEFAULT_URL)
})
})
})
})

View file

@ -1,19 +1,13 @@
import { generator, DBTestConfiguration, testEnv } from "../../../tests"
import { import {
getDevelopmentAppID, getDevelopmentAppID,
getProdAppID, getProdAppID,
isDevAppID, isDevAppID,
isProdAppID, isProdAppID,
} from "../conversions" } from "../conversions"
import { generateAppID, getPlatformUrl, getScopedConfig } from "../utils" import { generateAppID } from "../utils"
import * as context from "../../context"
import { Config } from "../../constants"
import env from "../../environment"
describe("utils", () => { describe("utils", () => {
const config = new DBTestConfiguration() describe("generateAppID", () => {
describe("app ID manipulation", () => {
function getID() { function getID() {
const appId = generateAppID() const appId = generateAppID()
const split = appId.split("_") const split = appId.split("_")
@ -66,127 +60,4 @@ describe("utils", () => {
expect(isProdAppID(devAppId)).toEqual(false) expect(isProdAppID(devAppId)).toEqual(false)
}) })
}) })
const DEFAULT_URL = "http://localhost:10000"
const ENV_URL = "http://env.com"
const setDbPlatformUrl = async (dbUrl: string) => {
const db = context.getGlobalDB()
await db.put({
_id: "config_settings",
type: Config.SETTINGS,
config: {
platformUrl: dbUrl,
},
})
}
const clearSettingsConfig = async () => {
await config.doInTenant(async () => {
const db = context.getGlobalDB()
try {
const config = await db.get("config_settings")
await db.remove("config_settings", config._rev)
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
})
}
describe("getPlatformUrl", () => {
describe("self host", () => {
beforeEach(async () => {
testEnv.selfHosted()
await clearSettingsConfig()
})
it("gets the default url", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl()
expect(url).toBe(DEFAULT_URL)
})
})
it("gets the platform url from the environment", async () => {
await config.doInTenant(async () => {
env._set("PLATFORM_URL", ENV_URL)
const url = await getPlatformUrl()
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the database", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const url = await getPlatformUrl()
expect(url).toBe(dbUrl)
})
})
})
describe("cloud", () => {
const TENANT_AWARE_URL = `http://${config.tenantId}.env.com`
beforeEach(async () => {
testEnv.cloudHosted()
testEnv.multiTenant()
env._set("PLATFORM_URL", ENV_URL)
await clearSettingsConfig()
})
it("gets the platform url from the environment without tenancy", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl({ tenantAware: false })
expect(url).toBe(ENV_URL)
})
})
it("gets the platform url from the environment with tenancy", async () => {
await config.doInTenant(async () => {
const url = await getPlatformUrl()
expect(url).toBe(TENANT_AWARE_URL)
})
})
it("never gets the platform url from the database", async () => {
await config.doInTenant(async () => {
await setDbPlatformUrl(generator.url())
const url = await getPlatformUrl()
expect(url).toBe(TENANT_AWARE_URL)
})
})
})
})
describe("getScopedConfig", () => {
describe("settings config", () => {
beforeEach(async () => {
env._set("SELF_HOSTED", 1)
env._set("PLATFORM_URL", "")
await clearSettingsConfig()
})
it("returns the platform url with an existing config", async () => {
await config.doInTenant(async () => {
const dbUrl = generator.url()
await setDbPlatformUrl(dbUrl)
const db = context.getGlobalDB()
const config = await getScopedConfig(db, { type: Config.SETTINGS })
expect(config.platformUrl).toBe(dbUrl)
})
})
it("returns the platform url without an existing config", async () => {
await config.doInTenant(async () => {
const db = context.getGlobalDB()
const config = await getScopedConfig(db, { type: Config.SETTINGS })
expect(config.platformUrl).toBe(DEFAULT_URL)
})
})
})
})
}) })

View file

@ -9,12 +9,11 @@ import {
InternalTable, InternalTable,
APP_PREFIX, APP_PREFIX,
} from "../constants" } from "../constants"
import { getTenantId, getGlobalDB, getGlobalDBName } from "../context" import { getTenantId, getGlobalDBName } from "../context"
import { doWithDB, directCouchAllDbs } from "./db" import { doWithDB, directCouchAllDbs } from "./db"
import { getAppMetadata } from "../cache/appMetadata" import { getAppMetadata } from "../cache/appMetadata"
import { isDevApp, isDevAppID, getProdAppID } from "./conversions" import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import * as events from "../events" import { App, Database } from "@budibase/types"
import { App, Database, ConfigType, isSettingsConfig } from "@budibase/types"
/** /**
* Generates a new app ID. * Generates a new app ID.
@ -412,32 +411,6 @@ export async function dbExists(dbName: any) {
) )
} }
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
export const generateConfigID = ({ type, workspace, user }: any) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return `${DocumentType.CONFIG}${SEPARATOR}${scope}`
}
/**
* Gets parameters for retrieving configurations.
*/
export const getConfigParams = (
{ type, workspace, user }: any,
otherProps = {}
) => {
const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return {
...otherProps,
startkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}`,
endkey: `${DocumentType.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`,
}
}
/** /**
* Generates a new dev info document ID - this is scoped to a user. * Generates a new dev info document ID - this is scoped to a user.
* @returns {string} The new dev info ID which info for dev (like api key) can be stored under. * @returns {string} The new dev info ID which info for dev (like api key) can be stored under.
@ -461,109 +434,6 @@ export const getPluginParams = (pluginId?: string | null, otherProps = {}) => {
return getDocParams(DocumentType.PLUGIN, pluginId, otherProps) return getDocParams(DocumentType.PLUGIN, pluginId, otherProps)
} }
/**
* Returns the most granular configuration document from the DB based on the type, workspace and userID passed.
* @param {Object} db - db instance to query
* @param {Object} scopes - the type, workspace and userID scopes of the configuration.
* @returns The most granular configuration document based on the scope.
*/
export const getScopedFullConfig = async function (
db: any,
{ type, user, workspace }: any
) {
const response = await db.allDocs(
getConfigParams(
{ type, user, workspace },
{
include_docs: true,
}
)
)
function determineScore(row: any) {
const config = row.doc
// Config is specific to a user and a workspace
if (config._id.includes(generateConfigID({ type, user, workspace }))) {
return 4
} else if (config._id.includes(generateConfigID({ type, user }))) {
// Config is specific to a user only
return 3
} else if (config._id.includes(generateConfigID({ type, workspace }))) {
// Config is specific to a workspace only
return 2
} else if (config._id.includes(generateConfigID({ type }))) {
// Config is specific to a type only
return 1
}
return 0
}
// Find the config with the most granular scope based on context
let scopedConfig = response.rows.sort(
(a: any, b: any) => determineScore(a) - determineScore(b)
)[0]
// custom logic for settings doc
if (type === ConfigType.SETTINGS) {
if (!scopedConfig || !scopedConfig.doc) {
// defaults
scopedConfig = {
doc: {
_id: generateConfigID({ type, user, workspace }),
type: ConfigType.SETTINGS,
config: {
platformUrl: await getPlatformUrl({ tenantAware: true }),
analyticsEnabled: await events.analytics.enabled(),
},
},
}
}
// will always be true - use assertion function to get type access
if (isSettingsConfig(scopedConfig.doc)) {
// overrides affected by environment
scopedConfig.doc.config.platformUrl = await getPlatformUrl({
tenantAware: true,
})
scopedConfig.doc.config.analyticsEnabled =
await events.analytics.enabled()
}
}
return scopedConfig && scopedConfig.doc
}
export const getPlatformUrl = async (opts = { tenantAware: true }) => {
let platformUrl = env.PLATFORM_URL || "http://localhost:10000"
if (!env.SELF_HOSTED && env.MULTI_TENANCY && opts.tenantAware) {
// cloud and multi tenant - add the tenant to the default platform url
const tenantId = getTenantId()
if (!platformUrl.includes("localhost:")) {
platformUrl = platformUrl.replace("://", `://${tenantId}.`)
}
} else if (env.SELF_HOSTED) {
const db = getGlobalDB()
// get the doc directly instead of with getScopedConfig to prevent loop
let settings
try {
settings = await db.get(generateConfigID({ type: ConfigType.SETTINGS }))
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
// self hosted - check for platform url override
if (settings && settings.config && settings.config.platformUrl) {
platformUrl = settings.config.platformUrl
}
}
return platformUrl
}
export function pagination( export function pagination(
data: any[], data: any[],
pageSize: number, pageSize: number,
@ -597,8 +467,3 @@ export function pagination(
nextPage, nextPage,
} }
} }
export async function getScopedConfig(db: any, params: any) {
const configDoc = await getScopedFullConfig(db, params)
return configDoc && configDoc.config ? configDoc.config : configDoc
}

View file

@ -28,6 +28,8 @@ const DefaultBucketName = {
PLUGINS: "plugins", PLUGINS: "plugins",
} }
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = { const environment = {
isTest, isTest,
isJest, isJest,
@ -58,7 +60,7 @@ const environment = {
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "", ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "",
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""), SELF_HOSTED: selfHosted,
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL || "", PLATFORM_URL: process.env.PLATFORM_URL || "",
POSTHOG_TOKEN: process.env.POSTHOG_TOKEN, POSTHOG_TOKEN: process.env.POSTHOG_TOKEN,
@ -85,6 +87,21 @@ const environment = {
process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose", process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose",
ENABLE_4XX_HTTP_LOGGING: process.env.ENABLE_4XX_HTTP_LOGGING || true, ENABLE_4XX_HTTP_LOGGING: process.env.ENABLE_4XX_HTTP_LOGGING || true,
ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR, ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR,
// smtp
SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
/**
* Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO.
* However, this should be turned OFF by default for security purposes.
*/
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false,
_set(key: any, value: any) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View file

@ -1,55 +1,6 @@
import env from "../environment" import * as configs from "../configs"
import * as context from "../context"
import * as dbUtils from "../db/utils"
import { Config } from "../constants"
import { withCache, TTL, CacheKey } from "../cache"
// wrapper utility function
export const enabled = async () => { export const enabled = async () => {
// cloud - always use the environment variable return configs.analyticsEnabled()
if (!env.SELF_HOSTED) {
return !!env.ENABLE_ANALYTICS
}
// self host - prefer the settings doc
// use cache as events have high throughput
const enabledInDB = await withCache(
CacheKey.ANALYTICS_ENABLED,
TTL.ONE_DAY,
async () => {
const settings = await getSettingsDoc()
// need to do explicit checks in case the field is not set
if (settings?.config?.analyticsEnabled === false) {
return false
} else if (settings?.config?.analyticsEnabled === true) {
return true
}
}
)
if (enabledInDB !== undefined) {
return enabledInDB
}
// fallback to the environment variable
// explicitly check for 0 or false here, undefined or otherwise is treated as true
const envEnabled: any = env.ENABLE_ANALYTICS
if (envEnabled === 0 || envEnabled === false) {
return false
} else {
return true
}
}
const getSettingsDoc = async () => {
const db = context.getGlobalDB()
let settings
try {
settings = await db.get(dbUtils.generateConfigID({ type: Config.SETTINGS }))
} catch (e: any) {
if (e.status !== 404) {
throw e
}
}
return settings
} }

View file

@ -10,7 +10,6 @@ import {
isCloudAccount, isCloudAccount,
isSSOAccount, isSSOAccount,
TenantGroup, TenantGroup,
SettingsConfig,
CloudAccount, CloudAccount,
UserIdentity, UserIdentity,
InstallationGroup, InstallationGroup,
@ -19,10 +18,9 @@ import {
isSSOUser, isSSOUser,
} from "@budibase/types" } from "@budibase/types"
import { processors } from "./processors" import { processors } from "./processors"
import * as dbUtils from "../db/utils"
import { Config } from "../constants"
import { newid } from "../utils" import { newid } from "../utils"
import * as installation from "../installation" import * as installation from "../installation"
import * as configs from "../configs"
import { withCache, TTL, CacheKey } from "../cache/generic" import { withCache, TTL, CacheKey } from "../cache/generic"
const pkg = require("../../package.json") const pkg = require("../../package.json")
@ -271,9 +269,7 @@ const getUniqueTenantId = async (tenantId: string): Promise<string> => {
return context.doInTenant(tenantId, () => { return context.doInTenant(tenantId, () => {
return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => { return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => {
const db = context.getGlobalDB() const db = context.getGlobalDB()
const config: SettingsConfig = await dbUtils.getScopedFullConfig(db, { const config = await configs.getSettingsConfigDoc()
type: Config.SETTINGS,
})
let uniqueTenantId: string let uniqueTenantId: string
if (config.config.uniqueTenantId) { if (config.config.uniqueTenantId) {

View file

@ -1,3 +1,4 @@
export * as configs from "./configs"
export * as events from "./events" export * as events from "./events"
export * as migrations from "./migrations" export * as migrations from "./migrations"
export * as users from "./users" export * as users from "./users"

View file

@ -115,7 +115,8 @@ export default function (
authenticated = true authenticated = true
} catch (err: any) { } catch (err: any) {
authenticated = false authenticated = false
console.error("Auth Error", err?.message || err) console.error(`Auth Error: ${err.message}`)
console.error(err)
// remove the cookie as the user does not exist anymore // remove the cookie as the user does not exist anymore
clearCookie(ctx, Cookie.Auth) clearCookie(ctx, Cookie.Auth)
} }

View file

@ -11,6 +11,7 @@ export async function errorHandling(ctx: any, next: any) {
if (status > 499 || env.ENABLE_4XX_HTTP_LOGGING) { if (status > 499 || env.ENABLE_4XX_HTTP_LOGGING) {
ctx.log.error(err) ctx.log.error(err)
console.trace(err)
} }
const error = errors.getPublicError(err) const error = errors.getPublicError(err)

View file

@ -1,9 +1,8 @@
import * as google from "../sso/google" import * as google from "../sso/google"
import { Cookie, Config } from "../../../constants" import { Cookie } from "../../../constants"
import { clearCookie, getCookie } from "../../../utils" import { clearCookie, getCookie } from "../../../utils"
import { getScopedConfig, getPlatformUrl, doWithDB } from "../../../db" import { doWithDB } from "../../../db"
import environment from "../../../environment" import * as configs from "../../../configs"
import { getGlobalDB } from "../../../context"
import { BBContext, Database, SSOProfile } from "@budibase/types" import { BBContext, Database, SSOProfile } from "@budibase/types"
import { ssoSaveUserNoOp } from "../sso/sso" import { ssoSaveUserNoOp } from "../sso/sso"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
@ -13,18 +12,11 @@ type Passport = {
} }
async function fetchGoogleCreds() { async function fetchGoogleCreds() {
// try and get the config from the tenant const config = await configs.getGoogleConfig()
const db = getGlobalDB() if (!config) {
const googleConfig = await getScopedConfig(db, { throw new Error("No google configuration found")
type: Config.GOOGLE, }
}) return config
// or fall back to env variables
return (
googleConfig || {
clientID: environment.GOOGLE_CLIENT_ID,
clientSecret: environment.GOOGLE_CLIENT_SECRET,
}
)
} }
export async function preAuth( export async function preAuth(
@ -34,7 +26,7 @@ export async function preAuth(
) { ) {
// get the relevant config // get the relevant config
const googleConfig = await fetchGoogleCreds() const googleConfig = await fetchGoogleCreds()
const platformUrl = await getPlatformUrl({ tenantAware: false }) const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
@ -61,7 +53,7 @@ export async function postAuth(
) { ) {
// get the relevant config // get the relevant config
const config = await fetchGoogleCreds() const config = await fetchGoogleCreds()
const platformUrl = await getPlatformUrl({ tenantAware: false }) const platformUrl = await configs.getPlatformUrl({ tenantAware: false })
let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback` let callbackUrl = `${platformUrl}/api/global/auth/datasource/google/callback`
const authStateCookie = getCookie(ctx, Cookie.DatasourceAuth) const authStateCookie = getCookie(ctx, Cookie.DatasourceAuth)

View file

@ -2,12 +2,11 @@ import { ssoCallbackUrl } from "../utils"
import * as sso from "./sso" import * as sso from "./sso"
import { import {
ConfigType, ConfigType,
GoogleConfig,
Database,
SSOProfile, SSOProfile,
SSOAuthDetails, SSOAuthDetails,
SSOProviderType, SSOProviderType,
SaveSSOUserFunction, SaveSSOUserFunction,
GoogleInnerConfig,
} from "@budibase/types" } from "@budibase/types"
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
@ -45,7 +44,7 @@ export function buildVerifyFn(saveUserFn: SaveSSOUserFunction) {
* @returns Dynamically configured Passport Google Strategy * @returns Dynamically configured Passport Google Strategy
*/ */
export async function strategyFactory( export async function strategyFactory(
config: GoogleConfig["config"], config: GoogleInnerConfig,
callbackUrl: string, callbackUrl: string,
saveUserFn: SaveSSOUserFunction saveUserFn: SaveSSOUserFunction
) { ) {
@ -73,9 +72,6 @@ export async function strategyFactory(
} }
} }
export async function getCallbackUrl( export async function getCallbackUrl(config: GoogleInnerConfig) {
db: Database, return ssoCallbackUrl(ConfigType.GOOGLE, config)
config: { callbackURL?: string }
) {
return ssoCallbackUrl(db, config, ConfigType.GOOGLE)
} }

View file

@ -4,7 +4,6 @@ import { ssoCallbackUrl } from "../utils"
import { import {
ConfigType, ConfigType,
OIDCInnerConfig, OIDCInnerConfig,
Database,
SSOProfile, SSOProfile,
OIDCStrategyConfiguration, OIDCStrategyConfiguration,
SSOAuthDetails, SSOAuthDetails,
@ -157,9 +156,6 @@ export async function fetchStrategyConfig(
} }
} }
export async function getCallbackUrl( export async function getCallbackUrl() {
db: Database, return ssoCallbackUrl(ConfigType.OIDC)
config: { callbackURL?: string }
) {
return ssoCallbackUrl(db, config, ConfigType.OIDC)
} }

View file

@ -1,6 +1,6 @@
import { isMultiTenant, getTenantId } from "../../context" import { getTenantId, isMultiTenant } from "../../context"
import { getScopedConfig } from "../../db" import * as configs from "../../configs"
import { ConfigType, Database } from "@budibase/types" import { ConfigType, GoogleInnerConfig } from "@budibase/types"
/** /**
* Utility to handle authentication errors. * Utility to handle authentication errors.
@ -19,17 +19,14 @@ export function authError(done: Function, message: string, err?: any) {
} }
export async function ssoCallbackUrl( export async function ssoCallbackUrl(
db: Database, type: ConfigType,
config?: { callbackURL?: string }, config?: GoogleInnerConfig
type?: ConfigType
) { ) {
// incase there is a callback URL from before // incase there is a callback URL from before
if (config && config.callbackURL) { if (config && (config as GoogleInnerConfig).callbackURL) {
return config.callbackURL return (config as GoogleInnerConfig).callbackURL as string
} }
const publicConfig = await getScopedConfig(db, { const settingsConfig = await configs.getSettingsConfig()
type: ConfigType.SETTINGS,
})
let callbackUrl = `/api/global/auth` let callbackUrl = `/api/global/auth`
if (isMultiTenant()) { if (isMultiTenant()) {
@ -37,5 +34,5 @@ export async function ssoCallbackUrl(
} }
callbackUrl += `/${type}/callback` callbackUrl += `/${type}/callback`
return `${publicConfig.platformUrl}${callbackUrl}` return `${settingsConfig.platformUrl}${callbackUrl}`
} }

View file

@ -70,6 +70,10 @@ export const useBackups = () => {
return useFeature(Feature.APP_BACKUPS) return useFeature(Feature.APP_BACKUPS)
} }
export const useEnforceableSSO = () => {
return useFeature(Feature.ENFORCEABLE_SSO)
}
export const useGroups = () => { export const useGroups = () => {
return useFeature(Feature.USER_GROUPS) return useFeature(Feature.USER_GROUPS)
} }

View file

@ -72,6 +72,7 @@ export function oidcConfig(): OIDCInnerConfig {
configUrl: "http://someconfigurl", configUrl: "http://someconfigurl",
clientID: generator.string(), clientID: generator.string(),
clientSecret: generator.string(), clientSecret: generator.string(),
scopes: [],
} }
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1", "@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@spectrum-css/accordion": "3.0.24", "@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1", "@spectrum-css/actiongroup": "1.0.1",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -58,10 +58,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.12", "@budibase/bbui": "2.3.18-alpha.13",
"@budibase/client": "2.3.18-alpha.12", "@budibase/client": "2.3.18-alpha.13",
"@budibase/frontend-core": "2.3.18-alpha.12", "@budibase/frontend-core": "2.3.18-alpha.13",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",

View file

@ -79,67 +79,71 @@
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} /> <OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
<GoogleButton /> <GoogleButton />
</FancyForm> </FancyForm>
<Divider />
{/if} {/if}
<FancyForm bind:this={form}> {#if !$organisation.isSSOEnforced}
<FancyInput <Divider />
label="Your work email" <FancyForm bind:this={form}>
value={formData.username} <FancyInput
on:change={e => { label="Your work email"
formData = { value={formData.username}
...formData, on:change={e => {
username: e.detail, formData = {
} ...formData,
}} username: e.detail,
validate={() => { }
let fieldError = { }}
username: !formData.username validate={() => {
? "Please enter a valid email" let fieldError = {
: undefined, username: !formData.username
} ? "Please enter a valid email"
errors = handleError({ ...errors, ...fieldError }) : undefined,
}} }
error={errors.username} errors = handleError({ ...errors, ...fieldError })
/> }}
<FancyInput error={errors.username}
label="Password" />
value={formData.password} <FancyInput
type="password" label="Password"
on:change={e => { value={formData.password}
formData = { type="password"
...formData, on:change={e => {
password: e.detail, formData = {
} ...formData,
}} password: e.detail,
validate={() => { }
let fieldError = { }}
password: !formData.password validate={() => {
? "Please enter your password" let fieldError = {
: undefined, password: !formData.password
} ? "Please enter your password"
errors = handleError({ ...errors, ...fieldError }) : undefined,
}} }
error={errors.password} errors = handleError({ ...errors, ...fieldError })
/> }}
</FancyForm> error={errors.password}
</Layout> />
<Layout gap="XS" noPadding justifyItems="center"> </FancyForm>
<Button {/if}
size="L"
cta
disabled={Object.keys(errors).length > 0}
on:click={login}
>
Log in to {company}
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
</div>
</Layout> </Layout>
{#if !$organisation.isSSOEnforced}
<Layout gap="XS" noPadding justifyItems="center">
<Button
size="L"
cta
disabled={Object.keys(errors).length > 0}
on:click={login}
>
Log in to {company}
</Button>
</Layout>
<Layout gap="XS" noPadding justifyItems="center">
<div class="user-actions">
<ActionButton size="L" quiet on:click={() => $goto("./forgot")}>
Forgot password?
</ActionButton>
</div>
</Layout>
{/if}
{#if cloud} {#if cloud}
<Body size="xs" textAlign="center"> <Body size="xs" textAlign="center">

View file

@ -22,10 +22,11 @@
Tags, Tags,
Icon, Icon,
Helpers, Helpers,
Link,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { API } from "api" import { API } from "api"
import { organisation, admin } from "stores/portal" import { organisation, admin, licensing } from "stores/portal"
const ConfigTypes = { const ConfigTypes = {
Google: "google", Google: "google",
@ -34,6 +35,8 @@
const HasSpacesRegex = /[\\"\s]/ const HasSpacesRegex = /[\\"\s]/
$: enforcedSSO = $organisation.isSSOEnforced
// Some older google configs contain a manually specified value - retain the functionality to edit the field // Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change // When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined $: googleCallbackUrl = undefined
@ -154,6 +157,11 @@
iconDropdownOptions.unshift({ label: fileName, value: fileName }) iconDropdownOptions.unshift({ label: fileName, value: fileName })
} }
async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced
await organisation.save({ isSSOEnforced: !value })
}
async function save(docs) { async function save(docs) {
let calls = [] let calls = []
// Only if the user has provided an image, upload it // Only if the user has provided an image, upload it
@ -316,6 +324,49 @@
<Heading size="M">Authentication</Heading> <Heading size="M">Authentication</Heading>
<Body>Add additional authentication methods from the options below</Body> <Body>Add additional authentication methods from the options below</Body>
</Layout> </Layout>
<Divider />
<Layout noPadding gap="XS">
<Heading size="S">Single Sign-On URL</Heading>
<Body size="S">
Use the following link to access your configured identity provider.
</Body>
<Body size="S">
<div class="sso-link">
<Link href={$organisation.platformUrl} target="_blank"
>{$organisation.platformUrl}</Link
>
<div class="sso-link-icon">
<Icon size="XS" name="LinkOutLight" />
</div>
</div>
</Body>
</Layout>
<Divider />
<Layout noPadding gap="XS">
<div class="provider-title">
<div class="enforce-sso-heading-container">
<div class="enforce-sso-title">
<Heading size="S">Enforce Single Sign-On</Heading>
</div>
{#if !$licensing.enforceableSSO}
<Tags>
<Tag icon="LockClosed">Business plan</Tag>
</Tags>
{/if}
</div>
{#if $licensing.enforceableSSO}
<Toggle on:change={toggleIsSSOEnforced} bind:value={enforcedSSO} />
{/if}
</div>
<Body size="S">
Require SSO authentication for all users. It is recommended to read the
help <Link
size="M"
href={"https://docs.budibase.com/docs/authentication-and-sso"}
>documentation</Link
> before enabling this feature.
</Body>
</Layout>
{#if providers.google} {#if providers.google}
<Divider /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
@ -546,7 +597,24 @@
input[type="file"] { input[type="file"] {
display: none; display: none;
} }
.sso-link-icon {
padding-top: 4px;
margin-left: 3px;
}
.sso-link {
margin-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
}
.enforce-sso-title {
margin-right: 10px;
}
.enforce-sso-heading-container {
display: flex;
flex-direction: row;
align-items: start;
}
.provider-title { .provider-title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View file

@ -24,6 +24,7 @@
} }
const values = writable({ const values = writable({
isSSOEnforced: $organisation.isSSOEnforced,
company: $organisation.company, company: $organisation.company,
platformUrl: $organisation.platformUrl, platformUrl: $organisation.platformUrl,
analyticsEnabled: $organisation.analyticsEnabled, analyticsEnabled: $organisation.analyticsEnabled,
@ -54,6 +55,7 @@
} }
const config = { const config = {
isSSOEnforced: $values.isSSOEnforced,
company: $values.company ?? "", company: $values.company ?? "",
platformUrl: $values.platformUrl ?? "", platformUrl: $values.platformUrl ?? "",
analyticsEnabled: $values.analyticsEnabled, analyticsEnabled: $values.analyticsEnabled,

View file

@ -63,6 +63,9 @@ export const createLicensingStore = () => {
const environmentVariablesEnabled = license.features.includes( const environmentVariablesEnabled = license.features.includes(
Constants.Features.ENVIRONMENT_VARIABLES Constants.Features.ENVIRONMENT_VARIABLES
) )
const enforceableSSO = license.features.includes(
Constants.Features.ENFORCEABLE_SSO
)
const auditLogsEnabled = license.features.includes( const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS Constants.Features.AUDIT_LOGS
@ -76,6 +79,7 @@ export const createLicensingStore = () => {
backupsEnabled, backupsEnabled,
environmentVariablesEnabled, environmentVariablesEnabled,
auditLogsEnabled, auditLogsEnabled,
enforceableSSO,
} }
}) })
}, },

View file

@ -11,6 +11,7 @@ const DEFAULT_CONFIG = {
google: undefined, google: undefined,
oidcCallbackUrl: "", oidcCallbackUrl: "",
googleCallbackUrl: "", googleCallbackUrl: "",
isSSOEnforced: false,
} }
export function createOrganisationStore() { export function createOrganisationStore() {
@ -19,8 +20,8 @@ export function createOrganisationStore() {
async function init() { async function init() {
const tenantId = get(auth).tenantId const tenantId = get(auth).tenantId
const tenant = await API.getTenantConfig(tenantId) const settingsConfigDoc = await API.getTenantConfig(tenantId)
set({ ...DEFAULT_CONFIG, ...tenant.config, _rev: tenant._rev }) set({ ...DEFAULT_CONFIG, ...settingsConfigDoc.config })
} }
async function save(config) { async function save(config) {
@ -33,7 +34,6 @@ export function createOrganisationStore() {
await API.saveConfig({ await API.saveConfig({
type: "settings", type: "settings",
config: { ...get(store), ...config }, config: { ...get(store), ...config },
_rev: get(store)._rev,
}) })
await init() await init()
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {
@ -26,9 +26,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.3.18-alpha.12", "@budibase/backend-core": "2.3.18-alpha.13",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@budibase/types": "2.3.18-alpha.12", "@budibase/types": "2.3.18-alpha.13",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.12", "@budibase/bbui": "2.3.18-alpha.13",
"@budibase/frontend-core": "2.3.18-alpha.12", "@budibase/frontend-core": "2.3.18-alpha.13",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View file

@ -1,12 +1,12 @@
{ {
"name": "@budibase/frontend-core", "name": "@budibase/frontend-core",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase frontend core libraries used in builder and client", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"dependencies": { "dependencies": {
"@budibase/bbui": "2.3.18-alpha.12", "@budibase/bbui": "2.3.18-alpha.13",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"svelte": "^3.46.2" "svelte": "^3.46.2"
} }

View file

@ -116,6 +116,7 @@ export const Features = {
BACKUPS: "appBackups", BACKUPS: "appBackups",
ENVIRONMENT_VARIABLES: "environmentVariables", ENVIRONMENT_VARIABLES: "environmentVariables",
AUDIT_LOGS: "auditLogs", AUDIT_LOGS: "auditLogs",
ENFORCEABLE_SSO: "enforceableSSO",
} }
// Role IDs // Role IDs

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/sdk", "name": "@budibase/sdk",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase Public API SDK", "description": "Budibase Public API SDK",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -43,11 +43,11 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.3.18-alpha.12", "@budibase/backend-core": "2.3.18-alpha.13",
"@budibase/client": "2.3.18-alpha.12", "@budibase/client": "2.3.18-alpha.13",
"@budibase/pro": "2.3.18-alpha.12", "@budibase/pro": "2.3.18-alpha.13",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@budibase/types": "2.3.18-alpha.12", "@budibase/types": "2.3.18-alpha.13",
"@bull-board/api": "3.7.0", "@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4", "@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View file

@ -58,7 +58,7 @@ export async function exportApps(ctx: Ctx) {
} }
async function checkHasBeenImported() { async function checkHasBeenImported() {
if (!env.SELF_HOSTED || env.MULTI_TENANCY) { if (!env.SELF_HOSTED) {
return true return true
} }
const apps = await dbCore.getAllApps({ all: true }) const apps = await dbCore.getAllApps({ all: true })
@ -72,7 +72,7 @@ export async function hasBeenImported(ctx: Ctx) {
} }
export async function importApps(ctx: Ctx) { export async function importApps(ctx: Ctx) {
if (!env.SELF_HOSTED || env.MULTI_TENANCY) { if (!env.SELF_HOSTED) {
ctx.throw(400, "Importing only allowed in self hosted environments.") ctx.throw(400, "Importing only allowed in self hosted environments.")
} }
const beenImported = await checkHasBeenImported() const beenImported = await checkHasBeenImported()

View file

@ -1,3 +1,5 @@
import { App } from "@budibase/types"
jest.setTimeout(30000) jest.setTimeout(30000)
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
@ -5,6 +7,7 @@ import { AppStatus } from "../../../db/utils"
import * as setup from "./utilities" import * as setup from "./utilities"
import { wipeDb } from "./utilities/TestFunctions" import { wipeDb } from "./utilities/TestFunctions"
import { tenancy } from "@budibase/backend-core"
describe("/cloud", () => { describe("/cloud", () => {
let request = setup.getRequest()! let request = setup.getRequest()!
@ -12,18 +15,10 @@ describe("/cloud", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeAll(() => { beforeAll(async () => {
// Importing is only allowed in self hosted environments // Importing is only allowed in self hosted environments
config.modeSelf()
})
beforeEach(async () => {
await config.init() await config.init()
}) config.modeSelf()
afterEach(async () => {
// clear all mocks
jest.clearAllMocks()
}) })
describe("import", () => { describe("import", () => {
@ -32,30 +27,28 @@ describe("/cloud", () => {
// import will not run // import will not run
await wipeDb() await wipeDb()
// get a count of apps before the import
const preImportApps = await request
.get(`/api/applications?status=${AppStatus.ALL}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
// Perform the import // Perform the import
const res = await request const res = await request
.post(`/api/cloud/import`) .post(`/api/cloud/import`)
.set(config.publicHeaders())
.attach("importFile", "src/api/routes/tests/data/export-test.tar.gz") .attach("importFile", "src/api/routes/tests/data/export-test.tar.gz")
.set(config.defaultHeaders())
.expect(200) .expect(200)
expect(res.body.message).toEqual("Apps successfully imported.") expect(res.body.message).toEqual("Apps successfully imported.")
// get a count of apps after the import // get a count of apps after the import
const postImportApps = await request const postImportApps = await request
.get(`/api/applications?status=${AppStatus.ALL}`) .get(`/api/applications?status=${AppStatus.ALL}`)
.set(config.defaultHeaders()) .set(config.publicHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
const apps = postImportApps.body as App[]
// There are two apps in the file that was imported so check for this // There are two apps in the file that was imported so check for this
expect(postImportApps.body.length).toEqual(2) expect(apps.length).toEqual(2)
// The new tenant id was assigned to the imported apps
expect(tenancy.getTenantIDFromAppID(apps[0].appId)).toBe(
config.getTenantId()
)
}) })
}) })
}) })

View file

@ -2,7 +2,6 @@ import * as rowController from "../../../controllers/row"
import * as appController from "../../../controllers/application" import * as appController from "../../../controllers/application"
import { AppStatus } from "../../../../db/utils" import { AppStatus } from "../../../../db/utils"
import { roles, tenancy, context } from "@budibase/backend-core" import { roles, tenancy, context } from "@budibase/backend-core"
import { TENANT_ID } from "../../../../tests/utilities/structures"
import env from "../../../../environment" import env from "../../../../environment"
import { db } from "@budibase/backend-core" import { db } from "@budibase/backend-core"
import Nano from "@budibase/nano" import Nano from "@budibase/nano"
@ -33,7 +32,7 @@ export const getAllTableRows = async (config: any) => {
} }
export const clearAllApps = async ( export const clearAllApps = async (
tenantId = TENANT_ID, tenantId: string,
exceptions: Array<string> = [] exceptions: Array<string> = []
) => { ) => {
await tenancy.doInTenant(tenantId, async () => { await tenancy.doInTenant(tenantId, async () => {

View file

@ -11,8 +11,7 @@ import { OAuth2Client } from "google-auth-library"
import { buildExternalTableId } from "./utils" import { buildExternalTableId } from "./utils"
import { DataSourceOperation, FieldTypes } from "../constants" import { DataSourceOperation, FieldTypes } from "../constants"
import { GoogleSpreadsheet } from "google-spreadsheet" import { GoogleSpreadsheet } from "google-spreadsheet"
import env from "../environment" import { configs, HTTPError } from "@budibase/backend-core"
import { tenancy, db as dbCore, constants } from "@budibase/backend-core"
const fetch = require("node-fetch") const fetch = require("node-fetch")
interface GoogleSheetsConfig { interface GoogleSheetsConfig {
@ -173,16 +172,9 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async connect() { async connect() {
try { try {
// Initialise oAuth client // Initialise oAuth client
const db = tenancy.getGlobalDB() let googleConfig = await configs.getGoogleConfig()
let googleConfig = await dbCore.getScopedConfig(db, {
type: constants.Config.GOOGLE,
})
if (!googleConfig) { if (!googleConfig) {
googleConfig = { throw new HTTPError("Google config not found", 400)
clientID: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
}
} }
const oauthClient = new OAuth2Client({ const oauthClient = new OAuth2Client({

View file

@ -1,4 +1,9 @@
import { events, db as dbUtils } from "@budibase/backend-core" import {
events,
DocumentType,
SEPARATOR,
UNICODE_MAX,
} from "@budibase/backend-core"
import { import {
Config, Config,
isSMTPConfig, isSMTPConfig,
@ -9,15 +14,16 @@ import {
} from "@budibase/types" } from "@budibase/types"
import env from "./../../../../environment" import env from "./../../../../environment"
export const getConfigParams = () => {
return {
include_docs: true,
startkey: `${DocumentType.CONFIG}${SEPARATOR}`,
endkey: `${DocumentType.CONFIG}${SEPARATOR}${UNICODE_MAX}`,
}
}
const getConfigs = async (globalDb: any): Promise<Config[]> => { const getConfigs = async (globalDb: any): Promise<Config[]> => {
const response = await globalDb.allDocs( const response = await globalDb.allDocs(getConfigParams())
dbUtils.getConfigParams(
{},
{
include_docs: true,
}
)
)
return response.rows.map((row: any) => row.doc) return response.rows.map((row: any) => row.doc)
} }

View file

@ -8,9 +8,8 @@ jest.mock("@budibase/backend-core", () => {
} }
} }
}) })
const { tenancy, db: dbCore } = require("@budibase/backend-core") const { context, db: dbCore } = require("@budibase/backend-core")
const TestConfig = require("../../../tests/utilities/TestConfiguration") const TestConfig = require("../../../tests/utilities/TestConfiguration")
const { TENANT_ID } = require("../../../tests/utilities/structures")
// mock email view creation // mock email view creation
@ -26,8 +25,8 @@ describe("run", () => {
afterAll(config.end) afterAll(config.end)
it("runs successfully", async () => { it("runs successfully", async () => {
await tenancy.doInTenant(TENANT_ID, async () => { await config.doInTenant(async () => {
const globalDb = tenancy.getGlobalDB() const globalDb = context.getGlobalDB()
await migration.run(globalDb) await migration.run(globalDb)
expect(dbCore.createNewUserEmailView).toHaveBeenCalledTimes(1) expect(dbCore.createNewUserEmailView).toHaveBeenCalledTimes(1)
}) })

View file

@ -1,7 +1,7 @@
// Mimic configs test configuration from worker, creation configs directly in database // Mimic configs test configuration from worker, creation configs directly in database
import * as structures from "./structures" import * as structures from "./structures"
import { db } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
import { Config } from "@budibase/types" import { Config } from "@budibase/types"
export const saveSettingsConfig = async (globalDb: any) => { export const saveSettingsConfig = async (globalDb: any) => {
@ -25,7 +25,7 @@ export const saveSmtpConfig = async (globalDb: any) => {
} }
const saveConfig = async (config: Config, globalDb: any) => { const saveConfig = async (config: Config, globalDb: any) => {
config._id = db.generateConfigID({ type: config.type }) config._id = configs.generateConfigID(config.type)
let response let response
try { try {

View file

@ -20,6 +20,7 @@ export const oidc = (conf?: OIDCConfig): OIDCConfig => {
name: "Active Directory", name: "Active Directory",
uuid: utils.newid(), uuid: utils.newid(),
activated: true, activated: true,
scopes: [],
...conf, ...conf,
}, },
], ],

View file

@ -8,3 +8,4 @@ process.env.BUDIBASE_DIR = tmpdir("budibase-unittests")
process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error" process.env.LOG_LEVEL = process.env.LOG_LEVEL || "error"
process.env.ENABLE_4XX_HTTP_LOGGING = "0" process.env.ENABLE_4XX_HTTP_LOGGING = "0"
process.env.MOCK_REDIS = "1" process.env.MOCK_REDIS = "1"
process.env.PLATFORM_URL = "http://localhost:10000"

View file

@ -21,7 +21,6 @@ import {
basicScreen, basicScreen,
basicLayout, basicLayout,
basicWebhook, basicWebhook,
TENANT_ID,
} from "./structures" } from "./structures"
import { import {
constants, constants,
@ -41,8 +40,8 @@ import { generateUserMetadataID } from "../../db/utils"
import { startup } from "../../startup" import { startup } from "../../startup"
import supertest from "supertest" import supertest from "supertest"
import { import {
App,
AuthToken, AuthToken,
Database,
Datasource, Datasource,
Row, Row,
SourceName, SourceName,
@ -63,7 +62,7 @@ class TestConfiguration {
started: boolean started: boolean
appId: string | null appId: string | null
allApps: any[] allApps: any[]
app: any app?: App
prodApp: any prodApp: any
prodAppId: any prodAppId: any
user: any user: any
@ -73,7 +72,7 @@ class TestConfiguration {
linkedTable: any linkedTable: any
automation: any automation: any
datasource: any datasource: any
tenantId: string | null tenantId?: string
defaultUserValues: DefaultUserValues defaultUserValues: DefaultUserValues
constructor(openServer = true) { constructor(openServer = true) {
@ -89,7 +88,6 @@ class TestConfiguration {
} }
this.appId = null this.appId = null
this.allApps = [] this.allApps = []
this.tenantId = null
this.defaultUserValues = this.populateDefaultUserValues() this.defaultUserValues = this.populateDefaultUserValues()
} }
@ -154,19 +152,10 @@ class TestConfiguration {
// use a new id as the name to avoid name collisions // use a new id as the name to avoid name collisions
async init(appName = newid()) { async init(appName = newid()) {
this.defaultUserValues = this.populateDefaultUserValues()
if (context.isMultiTenant()) {
this.tenantId = structures.tenant.id()
}
if (!this.started) { if (!this.started) {
await startup() await startup()
} }
this.user = await this.globalUser() return this.newTenant(appName)
this.globalUserId = this.user._id
this.userMetadataId = generateUserMetadataID(this.globalUserId)
return this.createApp(appName)
} }
end() { end() {
@ -182,24 +171,22 @@ class TestConfiguration {
} }
// MODES // MODES
#setMultiTenancy = (value: boolean) => { setMultiTenancy = (value: boolean) => {
env._set("MULTI_TENANCY", value) env._set("MULTI_TENANCY", value)
coreEnv._set("MULTI_TENANCY", value) coreEnv._set("MULTI_TENANCY", value)
} }
#setSelfHosted = (value: boolean) => { setSelfHosted = (value: boolean) => {
env._set("SELF_HOSTED", value) env._set("SELF_HOSTED", value)
coreEnv._set("SELF_HOSTED", value) coreEnv._set("SELF_HOSTED", value)
} }
modeCloud = () => { modeCloud = () => {
this.#setSelfHosted(false) this.setSelfHosted(false)
this.#setMultiTenancy(true)
} }
modeSelf = () => { modeSelf = () => {
this.#setSelfHosted(true) this.setSelfHosted(true)
this.#setMultiTenancy(false)
} }
// UTILS // UTILS
@ -354,6 +341,8 @@ class TestConfiguration {
}) })
} }
// HEADERS
defaultHeaders(extras = {}) { defaultHeaders(extras = {}) {
const tenantId = this.getTenantId() const tenantId = this.getTenantId()
const authObj: AuthToken = { const authObj: AuthToken = {
@ -374,6 +363,7 @@ class TestConfiguration {
`${constants.Cookie.CurrentApp}=${appToken}`, `${constants.Cookie.CurrentApp}=${appToken}`,
], ],
[constants.Header.CSRF_TOKEN]: this.defaultUserValues.csrfToken, [constants.Header.CSRF_TOKEN]: this.defaultUserValues.csrfToken,
Host: this.tenantHost(),
...extras, ...extras,
} }
@ -383,10 +373,6 @@ class TestConfiguration {
return headers return headers
} }
getTenantId() {
return this.tenantId || TENANT_ID
}
publicHeaders({ prodApp = true } = {}) { publicHeaders({ prodApp = true } = {}) {
const appId = prodApp ? this.prodAppId : this.appId const appId = prodApp ? this.prodAppId : this.appId
@ -397,9 +383,7 @@ class TestConfiguration {
headers[constants.Header.APP_ID] = appId headers[constants.Header.APP_ID] = appId
} }
if (this.tenantId) { headers[constants.Header.TENANT_ID] = this.getTenantId()
headers[constants.Header.TENANT_ID] = this.tenantId
}
return headers return headers
} }
@ -413,6 +397,34 @@ class TestConfiguration {
return this.login({ email, roleId, builder, prodApp }) return this.login({ email, roleId, builder, prodApp })
} }
// TENANCY
tenantHost() {
const tenantId = this.getTenantId()
const platformHost = new URL(coreEnv.PLATFORM_URL).host.split(":")[0]
return `${tenantId}.${platformHost}`
}
getTenantId() {
if (!this.tenantId) {
throw new Error("no test tenant id - init has not been called")
}
return this.tenantId
}
async newTenant(appName = newid()): Promise<App> {
this.defaultUserValues = this.populateDefaultUserValues()
this.tenantId = structures.tenant.id()
this.user = await this.globalUser()
this.globalUserId = this.user._id
this.userMetadataId = generateUserMetadataID(this.globalUserId)
return this.createApp(appName)
}
doInTenant(task: any) {
return context.doInTenant(this.getTenantId(), task)
}
// API // API
async generateApiKey(userId = this.defaultUserValues.globalUserId) { async generateApiKey(userId = this.defaultUserValues.globalUserId) {
@ -432,7 +444,7 @@ class TestConfiguration {
} }
// APP // APP
async createApp(appName: string) { async createApp(appName: string): Promise<App> {
// create dev app // create dev app
// clear any old app // clear any old app
this.appId = null this.appId = null
@ -442,7 +454,7 @@ class TestConfiguration {
null, null,
controllers.app.create controllers.app.create
) )
this.appId = this.app.appId this.appId = this.app?.appId!
}) })
return await context.doInAppContext(this.appId, async () => { return await context.doInAppContext(this.appId, async () => {
// create production app // create production app

View file

@ -13,8 +13,6 @@ import {
const { v4: uuidv4 } = require("uuid") const { v4: uuidv4 } = require("uuid")
export const TENANT_ID = "default"
export function basicTable() { export function basicTable() {
return { return {
name: "TestTable", name: "TestTable",

View file

@ -1278,14 +1278,14 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.3.18-alpha.12": "@budibase/backend-core@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.12.tgz#ad1b16be64b78b596af2b5f75647c32e8f6f101a" resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.13.tgz#b797d7a4d30ff7f21e473334edb4086f5818830e"
integrity sha512-E1NEO+/sNkkRqn/xk9XQmFBO9/dl27w9EB0QGztti/16JV9NgxyDQCJIdGwlD08s1y/lUwOKk0TkSZJs+CTYDw== integrity sha512-c6d0xCRgLlPeX1euAoQuoDOkMkDGQy/miBx/Z8xyU9bzDDTNqBTogxpxsNf8DdZG7EMJhsJlCUvb26Onz7/50A==
dependencies: dependencies:
"@budibase/nano" "10.1.1" "@budibase/nano" "10.1.1"
"@budibase/pouchdb-replication-stream" "1.2.10" "@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.3.18-alpha.12" "@budibase/types" "2.3.18-alpha.13"
"@shopify/jest-koa-mocks" "5.0.1" "@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2" "@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0" aws-cloudfront-sign "2.2.0"
@ -1392,13 +1392,13 @@
pouchdb-promise "^6.0.4" pouchdb-promise "^6.0.4"
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@2.3.18-alpha.12": "@budibase/pro@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.12.tgz#be552b3a9f5850e746081540d6586aae69147bec" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.13.tgz#9f3dd0339184a58609c726f3f2b4580cf802dc78"
integrity sha512-M3b0njzSi47KH6uaQfYPoA2KWrjPiwcU3ONyaVWXHIktVrIKtYaFwOLBr/dmWGfMrL2297SSqg7V4DTaLyAhnw== integrity sha512-pRroVVFGITFsFtzzH6LgzuaUBKldvFQxTgO7K6dYjjE7xwTCnZCg0E4L2Ew/JIY39UN2WEb5bwdt3+pqPH7Dmg==
dependencies: dependencies:
"@budibase/backend-core" "2.3.18-alpha.12" "@budibase/backend-core" "2.3.18-alpha.13"
"@budibase/types" "2.3.18-alpha.12" "@budibase/types" "2.3.18-alpha.13"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
joi "17.6.0" joi "17.6.0"
@ -1424,10 +1424,10 @@
svelte-apexcharts "^1.0.2" svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0" svelte-flatpickr "^3.1.0"
"@budibase/types@2.3.18-alpha.12": "@budibase/types@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.12.tgz#a63eb978ccc7e55c209b3e9d71f9aecf7facc0d1" resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.13.tgz#e80cbf79249ed5cf8fe86e294d0b1e25e0a839ee"
integrity sha512-27o2BmI/HXIR3frZ8FtqHgAe1hd8jPIzgPaEhKrQiYJ/opUVccqupx9ld75Hyk9E6cdXu0UF0/+LxPpUmMugag== integrity sha512-fmgpwMMGkbPOObmFnZZH8iKelycmhvBydGQuPEmIk1c449ysfiKF2Strnca6MaY1XtskfRz/nNdWGdGS1HhmFw==
"@bull-board/api@3.7.0": "@bull-board/api@3.7.0":
version "3.7.0" version "3.7.0"

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",

View file

@ -1,6 +1,6 @@
{ {
"name": "@budibase/types", "name": "@budibase/types",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase types", "description": "Budibase types",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View file

@ -0,0 +1,23 @@
import { SettingsConfig, SettingsInnerConfig } from "../../../documents"
/**
* Settings that aren't stored in the database - enriched at runtime.
*/
export interface PublicSettingsInnerConfig extends SettingsInnerConfig {
google: boolean
oidc: boolean
oidcCallbackUrl: string
googleCallbackUrl: string
}
export interface GetPublicSettingsResponse extends SettingsConfig {
config: PublicSettingsInnerConfig
}
export interface PublicOIDCConfig {
logo?: string
name?: string
uuid?: string
}
export type GetPublicOIDCConfigResponse = PublicOIDCConfig[]

View file

@ -1,3 +1,4 @@
export * from "./environmentVariables" export * from "./environmentVariables"
export * from "./auditLogs" export * from "./auditLogs"
export * from "./events" export * from "./events"
export * from "./configs"

View file

@ -5,32 +5,45 @@ export interface Config extends Document {
config: any config: any
} }
export interface SMTPConfig extends Config { export interface SMTPInnerConfig {
config: { port: number
port: number host: string
host: string from: string
from: string subject?: string
subject: string secure: boolean
secure: boolean auth?: {
user: string
pass: string
} }
connectionTimeout?: any
}
export interface SMTPConfig extends Config {
config: SMTPInnerConfig
}
export interface SettingsInnerConfig {
platformUrl?: string
company?: string
logoUrl?: string // Populated on read
logoUrlEtag?: string
uniqueTenantId?: string
analyticsEnabled?: boolean
isSSOEnforced?: boolean
} }
export interface SettingsConfig extends Config { export interface SettingsConfig extends Config {
config: { config: SettingsInnerConfig
company: string
// Populated on read
logoUrl?: string
logoUrlEtag?: boolean
platformUrl: string
uniqueTenantId?: string
analyticsEnabled?: boolean
}
} }
export interface GoogleInnerConfig { export interface GoogleInnerConfig {
clientID: string clientID: string
clientSecret: string clientSecret: string
activated: boolean activated: boolean
/**
* @deprecated read only
*/
callbackURL?: string
} }
export interface GoogleConfig extends Config { export interface GoogleConfig extends Config {
@ -55,6 +68,7 @@ export interface OIDCInnerConfig {
name: string name: string
uuid: string uuid: string
activated: boolean activated: boolean
scopes: string[]
} }
export interface OIDCConfig extends Config { export interface OIDCConfig extends Config {

View file

@ -3,4 +3,5 @@ export enum Feature {
APP_BACKUPS = "appBackups", APP_BACKUPS = "appBackups",
ENVIRONMENT_VARIABLES = "environmentVariables", ENVIRONMENT_VARIABLES = "environmentVariables",
AUDIT_LOGS = "auditLogs", AUDIT_LOGS = "auditLogs",
ENFORCEABLE_SSO = "enforceableSSO",
} }

View file

@ -1,7 +1,7 @@
{ {
"name": "@budibase/worker", "name": "@budibase/worker",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.3.18-alpha.12", "version": "2.3.18-alpha.13",
"description": "Budibase background service", "description": "Budibase background service",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -36,10 +36,10 @@
"author": "Budibase", "author": "Budibase",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@budibase/backend-core": "2.3.18-alpha.12", "@budibase/backend-core": "2.3.18-alpha.13",
"@budibase/pro": "2.3.18-alpha.12", "@budibase/pro": "2.3.18-alpha.13",
"@budibase/string-templates": "2.3.18-alpha.12", "@budibase/string-templates": "2.3.18-alpha.13",
"@budibase/types": "2.3.18-alpha.12", "@budibase/types": "2.3.18-alpha.13",
"@koa/router": "8.0.8", "@koa/router": "8.0.8",
"@sentry/node": "6.17.7", "@sentry/node": "6.17.7",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",

View file

@ -2,10 +2,9 @@ import {
auth as authCore, auth as authCore,
constants, constants,
context, context,
db as dbCore,
events, events,
tenancy,
utils as utilsCore, utils as utilsCore,
configs,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { import {
ConfigType, ConfigType,
@ -15,6 +14,7 @@ import {
SSOUser, SSOUser,
PasswordResetRequest, PasswordResetRequest,
PasswordResetUpdateRequest, PasswordResetUpdateRequest,
GoogleInnerConfig,
} from "@budibase/types" } from "@budibase/types"
import env from "../../../environment" import env from "../../../environment"
@ -61,8 +61,8 @@ 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.getUserByEmail(email)
if (user && (await userSdk.isPreventSSOPasswords(user))) { if (user && (await userSdk.isPreventPasswordActions(user))) {
ctx.throw(400, "SSO user cannot login using password") ctx.throw(400, "Password login is disabled for this user")
} }
return passport.authenticate( return passport.authenticate(
@ -163,8 +163,8 @@ export const datasourceAuth = async (ctx: any, next: any) => {
// GOOGLE SSO // GOOGLE SSO
export async function googleCallbackUrl(config?: { callbackURL?: string }) { export async function googleCallbackUrl(config?: GoogleInnerConfig) {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.GOOGLE) return ssoCallbackUrl(ConfigType.GOOGLE, config)
} }
/** /**
@ -172,12 +172,10 @@ export async function googleCallbackUrl(config?: { callbackURL?: string }) {
* On a successful login, you will be redirected to the googleAuth callback route. * On a successful login, you will be redirected to the googleAuth callback route.
*/ */
export const googlePreAuth = async (ctx: any, next: any) => { export const googlePreAuth = async (ctx: any, next: any) => {
const db = tenancy.getGlobalDB() const config = await configs.getGoogleConfig()
if (!config) {
const config = await dbCore.getScopedConfig(db, { return ctx.throw(400, "Google config not found")
type: ConfigType.GOOGLE, }
workspace: ctx.query.workspace,
})
let callbackUrl = await googleCallbackUrl(config) let callbackUrl = await googleCallbackUrl(config)
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
config, config,
@ -193,12 +191,10 @@ export const googlePreAuth = async (ctx: any, next: any) => {
} }
export const googleCallback = async (ctx: any, next: any) => { export const googleCallback = async (ctx: any, next: any) => {
const db = tenancy.getGlobalDB() const config = await configs.getGoogleConfig()
if (!config) {
const config = await dbCore.getScopedConfig(db, { return ctx.throw(400, "Google config not found")
type: ConfigType.GOOGLE, }
workspace: ctx.query.workspace,
})
const callbackUrl = await googleCallbackUrl(config) const callbackUrl = await googleCallbackUrl(config)
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(
config, config,
@ -221,25 +217,20 @@ export const googleCallback = async (ctx: any, next: any) => {
// OIDC SSO // OIDC SSO
export async function oidcCallbackUrl(config?: { callbackURL?: string }) { export async function oidcCallbackUrl() {
return ssoCallbackUrl(tenancy.getGlobalDB(), config, ConfigType.OIDC) return ssoCallbackUrl(ConfigType.OIDC)
} }
export const oidcStrategyFactory = async (ctx: any, configId: any) => { export const oidcStrategyFactory = async (ctx: any, configId: any) => {
const db = tenancy.getGlobalDB() const config = await configs.getOIDCConfig()
const config = await dbCore.getScopedConfig(db, { if (!config) {
type: ConfigType.OIDC, return ctx.throw(400, "OIDC config not found")
group: ctx.query.group, }
})
const chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0] let callbackUrl = await oidcCallbackUrl()
let callbackUrl = await oidcCallbackUrl(chosenConfig)
//Remote Config //Remote Config
const enrichedConfig = await oidc.fetchStrategyConfig( const enrichedConfig = await oidc.fetchStrategyConfig(config, callbackUrl)
chosenConfig,
callbackUrl
)
return oidc.strategyFactory(enrichedConfig, userSdk.save) return oidc.strategyFactory(enrichedConfig, userSdk.save)
} }
@ -247,23 +238,23 @@ export const oidcStrategyFactory = async (ctx: any, configId: any) => {
* The initial call that OIDC authentication makes to take you to the configured OIDC login screen. * The initial call that OIDC authentication makes to take you to the configured OIDC login screen.
* On a successful login, you will be redirected to the oidcAuth callback route. * On a successful login, you will be redirected to the oidcAuth callback route.
*/ */
export const oidcPreAuth = async (ctx: any, next: any) => { export const oidcPreAuth = async (ctx: Ctx, next: any) => {
const { configId } = ctx.params const { configId } = ctx.params
if (!configId) {
ctx.throw(400, "OIDC config id is required")
}
const strategy = await oidcStrategyFactory(ctx, configId) const strategy = await oidcStrategyFactory(ctx, configId)
setCookie(ctx, configId, Cookie.OIDC_CONFIG) setCookie(ctx, configId, Cookie.OIDC_CONFIG)
const db = tenancy.getGlobalDB() const config = await configs.getOIDCConfigById(configId)
const config = await dbCore.getScopedConfig(db, { if (!config) {
type: ConfigType.OIDC, return ctx.throw(400, "OIDC config not found")
group: ctx.query.group, }
})
const chosenConfig = config.configs.filter((c: any) => c.uuid === configId)[0]
let authScopes = let authScopes =
chosenConfig.scopes?.length > 0 config.scopes?.length > 0
? chosenConfig.scopes ? config.scopes
: ["profile", "email", "offline_access"] : ["profile", "email", "offline_access"]
return passport.authenticate(strategy, { return passport.authenticate(strategy, {

View file

@ -2,38 +2,32 @@ import * as email from "../../../utilities/email"
import env from "../../../environment" import env from "../../../environment"
import { googleCallbackUrl, oidcCallbackUrl } from "./auth" import { googleCallbackUrl, oidcCallbackUrl } from "./auth"
import { import {
events,
cache, cache,
objectStore, configs,
tenancy,
db as dbCore, db as dbCore,
env as coreEnv, env as coreEnv,
events,
objectStore,
tenancy,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users" import { checkAnyUserExists } from "../../../utilities/users"
import { import {
Database, Config,
Config as ConfigDoc,
ConfigType, ConfigType,
SSOType, Ctx,
GoogleConfig, GetPublicOIDCConfigResponse,
OIDCConfig, GetPublicSettingsResponse,
SettingsConfig,
isGoogleConfig, isGoogleConfig,
isOIDCConfig, isOIDCConfig,
isSettingsConfig, isSettingsConfig,
isSMTPConfig, isSMTPConfig,
Ctx,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import * as pro from "@budibase/pro"
const getEventFns = async (db: Database, config: ConfigDoc) => { const getEventFns = async (config: Config, existing?: Config) => {
const fns = [] const fns = []
let existing
if (config._id) {
existing = await db.get(config._id)
}
if (!existing) { if (!existing) {
if (isSMTPConfig(config)) { if (isSMTPConfig(config)) {
fns.push(events.email.SMTPCreated) fns.push(events.email.SMTPCreated)
@ -125,21 +119,21 @@ const getEventFns = async (db: Database, config: ConfigDoc) => {
return fns return fns
} }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx<Config>) {
const db = tenancy.getGlobalDB() const body = ctx.request.body
const { type, workspace, user, config } = ctx.request.body const type = body.type
let eventFns = await getEventFns(db, ctx.request.body) const config = body.config
// Config does not exist yet
if (!ctx.request.body._id) { const existingConfig = await configs.getConfig(type)
ctx.request.body._id = dbCore.generateConfigID({ let eventFns = await getEventFns(ctx.request.body, existingConfig)
type,
workspace, if (existingConfig) {
user, body._rev = existingConfig._rev
})
} }
try { try {
// verify the configuration // verify the configuration
switch (type) { switch (config.type) {
case ConfigType.SMTP: case ConfigType.SMTP:
await email.verifyConfig(config) await email.verifyConfig(config)
break break
@ -149,7 +143,8 @@ export async function save(ctx: UserCtx) {
} }
try { try {
const response = await db.put(ctx.request.body) body._id = configs.generateConfigID(type)
const response = await configs.save(body)
await cache.bustCache(cache.CacheKey.CHECKLIST) await cache.bustCache(cache.CacheKey.CHECKLIST)
await cache.bustCache(cache.CacheKey.ANALYTICS_ENABLED) await cache.bustCache(cache.CacheKey.ANALYTICS_ENABLED)
@ -167,44 +162,11 @@ export async function save(ctx: UserCtx) {
} }
} }
export async function fetch(ctx: UserCtx) {
const db = tenancy.getGlobalDB()
const response = await db.allDocs(
dbCore.getConfigParams(
{ type: ctx.params.type },
{
include_docs: true,
}
)
)
ctx.body = response.rows.map(row => row.doc)
}
/**
* Gets the most granular config for a particular configuration type.
* The hierarchy is type -> workspace -> user.
*/
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx) {
const db = tenancy.getGlobalDB()
const { userId, workspaceId } = ctx.query
if (workspaceId && userId) {
const workspace = await db.get(workspaceId as string)
const userInWorkspace = workspace.users.some(
(workspaceUser: any) => workspaceUser === userId
)
if (!ctx.user!.admin && !userInWorkspace) {
ctx.throw(400, `User is not in specified workspace: ${workspace}.`)
}
}
try { try {
// Find the config with the most granular scope based on context // Find the config with the most granular scope based on context
const scopedConfig = await dbCore.getScopedFullConfig(db, { const type = ctx.params.type
type: ctx.params.type, const scopedConfig = await configs.getConfig(type)
user: userId,
workspace: workspaceId,
})
if (scopedConfig) { if (scopedConfig) {
ctx.body = scopedConfig ctx.body = scopedConfig
@ -217,85 +179,70 @@ export async function find(ctx: UserCtx) {
} }
} }
export async function publicOidc(ctx: Ctx) { export async function publicOidc(ctx: Ctx<void, GetPublicOIDCConfigResponse>) {
const db = tenancy.getGlobalDB()
try { try {
// Find the config with the most granular scope based on context // Find the config with the most granular scope based on context
const oidcConfig: OIDCConfig = await dbCore.getScopedFullConfig(db, { const config = await configs.getOIDCConfig()
type: ConfigType.OIDC,
})
if (!oidcConfig) { if (!config) {
ctx.body = {} ctx.body = []
} else { } else {
ctx.body = oidcConfig.config.configs.map(config => ({ ctx.body = [
logo: config.logo, {
name: config.name, logo: config.logo,
uuid: config.uuid, name: config.name,
})) uuid: config.uuid,
},
]
} }
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status, err) ctx.throw(err.status, err)
} }
} }
export async function publicSettings(ctx: Ctx) { export async function publicSettings(
const db = tenancy.getGlobalDB() ctx: Ctx<void, GetPublicSettingsResponse>
) {
try { try {
// Find the config with the most granular scope based on context // settings
const publicConfig = await dbCore.getScopedFullConfig(db, { const configDoc = await configs.getSettingsConfigDoc()
type: ConfigType.SETTINGS, const config = configDoc.config
}) // enrich the logo url - empty url means deleted
if (config.logoUrl && config.logoUrl !== "") {
const googleConfig = await dbCore.getScopedFullConfig(db, { config.logoUrl = objectStore.getGlobalFileUrl(
type: ConfigType.GOOGLE,
})
const oidcConfig = await dbCore.getScopedFullConfig(db, {
type: ConfigType.OIDC,
})
let config
if (!publicConfig) {
config = {
config: {},
}
} else {
config = publicConfig
}
// enrich the logo url
// empty url means deleted
if (config.config.logoUrl && config.config.logoUrl !== "") {
config.config.logoUrl = objectStore.getGlobalFileUrl(
"settings", "settings",
"logoUrl", "logoUrl",
config.config.logoUrlEtag config.logoUrlEtag
) )
} }
// google button flag // google
if (googleConfig && googleConfig.config) { const googleConfig = await configs.getGoogleConfig()
// activated by default for configs pre-activated flag const preActivated = googleConfig?.activated == null
config.config.google = const google = preActivated || !!googleConfig?.activated
googleConfig.config.activated == null || googleConfig.config.activated const _googleCallbackUrl = await googleCallbackUrl(googleConfig)
} else {
config.config.google = false // oidc
const oidcConfig = await configs.getOIDCConfig()
const oidc = oidcConfig?.activated || false
const _oidcCallbackUrl = await oidcCallbackUrl()
// sso enforced
const isSSOEnforced = await pro.features.isSSOEnforced({ config })
ctx.body = {
type: ConfigType.SETTINGS,
_id: configDoc._id,
_rev: configDoc._rev,
config: {
...config,
google,
oidc,
isSSOEnforced,
oidcCallbackUrl: _oidcCallbackUrl,
googleCallbackUrl: _googleCallbackUrl,
},
} }
// callback urls
config.config.oidcCallbackUrl = await oidcCallbackUrl()
config.config.googleCallbackUrl = await googleCallbackUrl()
// oidc button flag
if (oidcConfig && oidcConfig.config) {
config.config.oidc = oidcConfig.config.configs[0].activated
} else {
config.config.oidc = false
}
ctx.body = config
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status, err) ctx.throw(err.status, err)
} }
@ -319,12 +266,11 @@ export async function upload(ctx: UserCtx) {
}) })
// add to configuration structure // add to configuration structure
// TODO: right now this only does a global level let config = await configs.getConfig(type)
const db = tenancy.getGlobalDB() if (!config) {
let cfgStructure = await dbCore.getScopedFullConfig(db, { type }) config = {
if (!cfgStructure) { _id: configs.generateConfigID(type),
cfgStructure = { type,
_id: dbCore.generateConfigID({ type }),
config: {}, config: {},
} }
} }
@ -332,14 +278,14 @@ export async function upload(ctx: UserCtx) {
// save the Etag for cache bursting // save the Etag for cache bursting
const etag = result.ETag const etag = result.ETag
if (etag) { if (etag) {
cfgStructure.config[`${name}Etag`] = etag.replace(/"/g, "") config.config[`${name}Etag`] = etag.replace(/"/g, "")
} }
// save the file key // save the file key
cfgStructure.config[`${name}`] = key config.config[`${name}`] = key
// write back to db // write back to db
await db.put(cfgStructure) await configs.save(config)
ctx.body = { ctx.body = {
message: "File has been uploaded and url stored to config.", message: "File has been uploaded and url stored to config.",
@ -360,7 +306,6 @@ export async function destroy(ctx: UserCtx) {
} }
export async function configChecklist(ctx: Ctx) { export async function configChecklist(ctx: Ctx) {
const db = tenancy.getGlobalDB()
const tenantId = tenancy.getTenantId() const tenantId = tenancy.getTenantId()
try { try {
@ -375,19 +320,13 @@ export async function configChecklist(ctx: Ctx) {
} }
// They have set up SMTP // They have set up SMTP
const smtpConfig = await dbCore.getScopedFullConfig(db, { const smtpConfig = await configs.getSMTPConfig()
type: ConfigType.SMTP,
})
// They have set up Google Auth // They have set up Google Auth
const googleConfig = await dbCore.getScopedFullConfig(db, { const googleConfig = await configs.getGoogleConfig()
type: ConfigType.GOOGLE,
})
// They have set up OIDC // They have set up OIDC
const oidcConfig = await dbCore.getScopedFullConfig(db, { const oidcConfig = await configs.getOIDCConfig()
type: ConfigType.OIDC,
})
// They have set up a global user // They have set up a global user
const userExists = await checkAnyUserExists() const userExists = await checkAnyUserExists()

View file

@ -104,13 +104,7 @@ router
controller.save controller.save
) )
.delete("/api/global/configs/:id/:rev", auth.adminOnly, controller.destroy) .delete("/api/global/configs/:id/:rev", auth.adminOnly, controller.destroy)
.get("/api/global/configs", controller.fetch)
.get("/api/global/configs/checklist", controller.configChecklist) .get("/api/global/configs/checklist", controller.configChecklist)
.get(
"/api/global/configs/all/:type",
buildConfigGetValidation(),
controller.fetch
)
.get("/api/global/configs/public", controller.publicSettings) .get("/api/global/configs/public", controller.publicSettings)
.get("/api/global/configs/public/oidc", controller.publicOidc) .get("/api/global/configs/public/oidc", controller.publicOidc)
.get("/api/global/configs/:type", buildConfigGetValidation(), controller.find) .get("/api/global/configs/:type", buildConfigGetValidation(), controller.find)

View file

@ -110,7 +110,7 @@ describe("/api/global/auth", () => {
) )
expect(response.body).toEqual({ expect(response.body).toEqual({
message: "SSO user cannot login using password", message: "Password login is disabled for this user",
status: 400, status: 400,
}) })
} }
@ -175,7 +175,7 @@ describe("/api/global/auth", () => {
) )
expect(res.body).toEqual({ expect(res.body).toEqual({
message: "SSO user cannot reset password", message: "Password reset is disabled for this user",
status: 400, status: 400,
error: { error: {
code: "http", code: "http",

View file

@ -2,7 +2,8 @@
jest.mock("nodemailer") jest.mock("nodemailer")
import { TestConfiguration, structures, mocks } from "../../../../tests" import { TestConfiguration, structures, mocks } from "../../../../tests"
mocks.email.mock() mocks.email.mock()
import { Config, events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import { GetPublicSettingsResponse, Config, ConfigType } from "@budibase/types"
describe("configs", () => { describe("configs", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
@ -19,22 +20,29 @@ describe("configs", () => {
await config.afterAll() await config.afterAll()
}) })
describe("post /api/global/configs", () => { const saveConfig = async (conf: Config, _id?: string, _rev?: string) => {
const saveConfig = async (conf: any, _id?: string, _rev?: string) => { const data = {
const data = { ...conf,
...conf, _id,
_id, _rev,
_rev,
}
const res = await config.api.configs.saveConfig(data)
return {
...data,
...res.body,
}
} }
const res = await config.api.configs.saveConfig(data)
return {
...data,
...res.body,
}
}
const saveSettingsConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const settingsConfig = structures.configs.settings(conf)
return saveConfig(settingsConfig, _id, _rev)
}
describe("POST /api/global/configs", () => {
describe("google", () => { describe("google", () => {
const saveGoogleConfig = async ( const saveGoogleConfig = async (
conf?: any, conf?: any,
@ -49,20 +57,20 @@ describe("configs", () => {
it("should create activated google config", async () => { it("should create activated google config", async () => {
await saveGoogleConfig() await saveGoogleConfig()
expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOCreated).toBeCalledWith(ConfigType.GOOGLE)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
expect(events.auth.SSOActivated).toBeCalledTimes(1) expect(events.auth.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(Config.GOOGLE) await config.deleteConfig(ConfigType.GOOGLE)
}) })
it("should create deactivated google config", async () => { it("should create deactivated google config", async () => {
await saveGoogleConfig({ activated: false }) await saveGoogleConfig({ activated: false })
expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOCreated).toBeCalledWith(ConfigType.GOOGLE)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
await config.deleteConfig(Config.GOOGLE) await config.deleteConfig(ConfigType.GOOGLE)
}) })
}) })
@ -76,11 +84,11 @@ describe("configs", () => {
googleConf._rev googleConf._rev
) )
expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOUpdated).toBeCalledWith(ConfigType.GOOGLE)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
expect(events.auth.SSODeactivated).toBeCalledTimes(1) expect(events.auth.SSODeactivated).toBeCalledTimes(1)
expect(events.auth.SSODeactivated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSODeactivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(Config.GOOGLE) await config.deleteConfig(ConfigType.GOOGLE)
}) })
it("should update google config to activated", async () => { it("should update google config to activated", async () => {
@ -92,11 +100,11 @@ describe("configs", () => {
googleConf._rev googleConf._rev
) )
expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOUpdated).toBeCalledWith(ConfigType.GOOGLE)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
expect(events.auth.SSOActivated).toBeCalledTimes(1) expect(events.auth.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.GOOGLE) expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(Config.GOOGLE) await config.deleteConfig(ConfigType.GOOGLE)
}) })
}) })
}) })
@ -115,20 +123,20 @@ describe("configs", () => {
it("should create activated OIDC config", async () => { it("should create activated OIDC config", async () => {
await saveOIDCConfig() await saveOIDCConfig()
expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOCreated).toBeCalledWith(ConfigType.OIDC)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
expect(events.auth.SSOActivated).toBeCalledTimes(1) expect(events.auth.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(Config.OIDC) await config.deleteConfig(ConfigType.OIDC)
}) })
it("should create deactivated OIDC config", async () => { it("should create deactivated OIDC config", async () => {
await saveOIDCConfig({ activated: false }) await saveOIDCConfig({ activated: false })
expect(events.auth.SSOCreated).toBeCalledTimes(1) expect(events.auth.SSOCreated).toBeCalledTimes(1)
expect(events.auth.SSOCreated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOCreated).toBeCalledWith(ConfigType.OIDC)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
await config.deleteConfig(Config.OIDC) await config.deleteConfig(ConfigType.OIDC)
}) })
}) })
@ -142,11 +150,11 @@ describe("configs", () => {
oidcConf._rev oidcConf._rev
) )
expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOUpdated).toBeCalledWith(ConfigType.OIDC)
expect(events.auth.SSOActivated).not.toBeCalled() expect(events.auth.SSOActivated).not.toBeCalled()
expect(events.auth.SSODeactivated).toBeCalledTimes(1) expect(events.auth.SSODeactivated).toBeCalledTimes(1)
expect(events.auth.SSODeactivated).toBeCalledWith(Config.OIDC) expect(events.auth.SSODeactivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(Config.OIDC) await config.deleteConfig(ConfigType.OIDC)
}) })
it("should update OIDC config to activated", async () => { it("should update OIDC config to activated", async () => {
@ -158,11 +166,11 @@ describe("configs", () => {
oidcConf._rev oidcConf._rev
) )
expect(events.auth.SSOUpdated).toBeCalledTimes(1) expect(events.auth.SSOUpdated).toBeCalledTimes(1)
expect(events.auth.SSOUpdated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOUpdated).toBeCalledWith(ConfigType.OIDC)
expect(events.auth.SSODeactivated).not.toBeCalled() expect(events.auth.SSODeactivated).not.toBeCalled()
expect(events.auth.SSOActivated).toBeCalledTimes(1) expect(events.auth.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.OIDC) expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(Config.OIDC) await config.deleteConfig(ConfigType.OIDC)
}) })
}) })
}) })
@ -179,11 +187,11 @@ describe("configs", () => {
describe("create", () => { describe("create", () => {
it("should create SMTP config", async () => { it("should create SMTP config", async () => {
await config.deleteConfig(Config.SMTP) await config.deleteConfig(ConfigType.SMTP)
await saveSMTPConfig() await saveSMTPConfig()
expect(events.email.SMTPUpdated).not.toBeCalled() expect(events.email.SMTPUpdated).not.toBeCalled()
expect(events.email.SMTPCreated).toBeCalledTimes(1) expect(events.email.SMTPCreated).toBeCalledTimes(1)
await config.deleteConfig(Config.SMTP) await config.deleteConfig(ConfigType.SMTP)
}) })
}) })
@ -194,24 +202,15 @@ describe("configs", () => {
await saveSMTPConfig(smtpConf.config, smtpConf._id, smtpConf._rev) await saveSMTPConfig(smtpConf.config, smtpConf._id, smtpConf._rev)
expect(events.email.SMTPCreated).not.toBeCalled() expect(events.email.SMTPCreated).not.toBeCalled()
expect(events.email.SMTPUpdated).toBeCalledTimes(1) expect(events.email.SMTPUpdated).toBeCalledTimes(1)
await config.deleteConfig(Config.SMTP) await config.deleteConfig(ConfigType.SMTP)
}) })
}) })
}) })
describe("settings", () => { describe("settings", () => {
const saveSettingsConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const settingsConfig = structures.configs.settings(conf)
return saveConfig(settingsConfig, _id, _rev)
}
describe("create", () => { describe("create", () => {
it("should create settings config with default settings", async () => { it("should create settings config with default settings", async () => {
await config.deleteConfig(Config.SETTINGS) await config.deleteConfig(ConfigType.SETTINGS)
await saveSettingsConfig() await saveSettingsConfig()
@ -222,7 +221,7 @@ describe("configs", () => {
it("should create settings config with non-default settings", async () => { it("should create settings config with non-default settings", async () => {
config.selfHosted() config.selfHosted()
await config.deleteConfig(Config.SETTINGS) await config.deleteConfig(ConfigType.SETTINGS)
const conf = { const conf = {
company: "acme", company: "acme",
logoUrl: "http://example.com", logoUrl: "http://example.com",
@ -241,7 +240,7 @@ describe("configs", () => {
describe("update", () => { describe("update", () => {
it("should update settings config", async () => { it("should update settings config", async () => {
config.selfHosted() config.selfHosted()
await config.deleteConfig(Config.SETTINGS) await config.deleteConfig(ConfigType.SETTINGS)
const settingsConfig = await saveSettingsConfig() const settingsConfig = await saveSettingsConfig()
settingsConfig.config.company = "acme" settingsConfig.config.company = "acme"
settingsConfig.config.logoUrl = "http://example.com" settingsConfig.config.logoUrl = "http://example.com"
@ -262,14 +261,43 @@ describe("configs", () => {
}) })
}) })
it("should return the correct checklist status based on the state of the budibase installation", async () => { describe("GET /api/global/configs/checklist", () => {
await config.saveSmtpConfig() it("should return the correct checklist", async () => {
await config.saveSmtpConfig()
const res = await config.api.configs.getConfigChecklist() const res = await config.api.configs.getConfigChecklist()
const checklist = res.body const checklist = res.body
expect(checklist.apps.checked).toBeFalsy() expect(checklist.apps.checked).toBeFalsy()
expect(checklist.smtp.checked).toBeTruthy() expect(checklist.smtp.checked).toBeTruthy()
expect(checklist.adminUser.checked).toBeTruthy() expect(checklist.adminUser.checked).toBeTruthy()
})
})
describe("GET /api/global/configs/public", () => {
it("should return the expected public settings", async () => {
await saveSettingsConfig()
const res = await config.api.configs.getPublicSettings()
const body = res.body as GetPublicSettingsResponse
const expected = {
_id: "config_settings",
type: "settings",
config: {
company: "Budibase",
logoUrl: "",
analyticsEnabled: false,
google: true,
googleCallbackUrl: `http://localhost:10000/api/global/auth/${config.tenantId}/google/callback`,
isSSOEnforced: false,
oidc: false,
oidcCallbackUrl: `http://localhost:10000/api/global/auth/${config.tenantId}/oidc/callback`,
platformUrl: "http://localhost:10000",
},
}
delete body._rev
expect(body).toEqual(expected)
})
}) })
}) })

View file

@ -1,3 +1,4 @@
jest.unmock("node-fetch")
import { TestConfiguration } from "../../../../tests" import { TestConfiguration } from "../../../../tests"
import { EmailTemplatePurpose } from "../../../../constants" import { EmailTemplatePurpose } from "../../../../constants"
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")

View file

@ -26,8 +26,6 @@ function parseIntSafe(number: any) {
} }
} }
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = { const environment = {
// auth // auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
@ -51,7 +49,7 @@ const environment = {
CLUSTER_PORT: process.env.CLUSTER_PORT, CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags // flags
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: selfHosted, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
@ -71,14 +69,6 @@ const environment = {
* Mock the email service in use - links to ethereal hosted emails are logged instead. * Mock the email service in use - links to ethereal hosted emails are logged instead.
*/ */
ENABLE_EMAIL_TEST_MODE: process.env.ENABLE_EMAIL_TEST_MODE, ENABLE_EMAIL_TEST_MODE: process.env.ENABLE_EMAIL_TEST_MODE,
/**
* Enable to allow an admin user to login using a password.
* This can be useful to prevent lockout when configuring SSO.
* However, this should be turned OFF by default for security purposes.
*/
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false,
_set(key: any, value: any) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View file

@ -13,8 +13,15 @@ import { Event } from "@sentry/types/dist/event"
import Application from "koa" import Application from "koa"
import { bootstrap } from "global-agent" import { bootstrap } from "global-agent"
import * as db from "./db" import * as db from "./db"
import { auth, logging, events, middleware, queue } from "@budibase/backend-core"
import { sdk as proSdk } from "@budibase/pro" import { sdk as proSdk } from "@budibase/pro"
import {
auth,
logging,
events,
middleware,
queue,
env as coreEnv,
} from "@budibase/backend-core"
db.init() db.init()
import Koa from "koa" import Koa from "koa"
import koaBody from "koa-body" import koaBody from "koa-body"
@ -32,7 +39,7 @@ import destroyable from "server-destroy"
// can't integrate directly into backend-core due to cyclic issues // can't integrate directly into backend-core due to cyclic issues
events.processors.init(proSdk.auditLogs.write) events.processors.init(proSdk.auditLogs.write)
if (env.ENABLE_SSO_MAINTENANCE_MODE) { if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE) {
console.warn( console.warn(
"Warning: ENABLE_SSO_MAINTENANCE_MODE is set. It is recommended this flag is disabled if maintenance is not in progress" "Warning: ENABLE_SSO_MAINTENANCE_MODE is set. It is recommended this flag is disabled if maintenance is not in progress"
) )

View file

@ -58,8 +58,8 @@ export const reset = async (email: string) => {
} }
// exit if user has sso // exit if user has sso
if (await userSdk.isPreventSSOPasswords(user)) { if (await userSdk.isPreventPasswordActions(user)) {
throw new HTTPError("SSO user cannot reset password", 400) throw new HTTPError("Password reset is disabled for this user", 400)
} }
// send password reset // send password reset

View file

@ -1,26 +1,50 @@
import { structures } from "../../../tests" import { structures } from "../../../tests"
import * as users from "../users"
import env from "../../../environment"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { env } from "@budibase/backend-core"
import * as users from "../users"
import { CloudAccount } from "@budibase/types" import { CloudAccount } from "@budibase/types"
import { isPreventPasswordActions } from "../users"
jest.mock("@budibase/pro")
import * as _pro from "@budibase/pro"
const pro = jest.mocked(_pro, true)
describe("users", () => { describe("users", () => {
describe("isPreventSSOPasswords", () => { beforeEach(() => {
jest.clearAllMocks()
})
describe("isPreventPasswordActions", () => {
it("returns false for non sso user", async () => {
const user = structures.users.user()
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
it("returns true for sso account user", async () => { it("returns true for sso account user", async () => {
const user = structures.users.user() const user = structures.users.user()
mocks.accounts.getAccount.mockReturnValue( mocks.accounts.getAccount.mockReturnValue(
Promise.resolve(structures.accounts.ssoAccount() as CloudAccount) Promise.resolve(structures.accounts.ssoAccount() as CloudAccount)
) )
const result = await users.isPreventSSOPasswords(user) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
it("returns true for sso user", async () => { it("returns true for sso user", async () => {
const user = structures.users.ssoUser() const user = structures.users.ssoUser()
const result = await users.isPreventSSOPasswords(user) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
describe("enforced sso", () => {
it("returns true for all users when sso is enforced", async () => {
const user = structures.users.user()
pro.features.isSSOEnforced.mockReturnValue(Promise.resolve(true))
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
describe("sso maintenance mode", () => { describe("sso maintenance mode", () => {
beforeEach(() => { beforeEach(() => {
env._set("ENABLE_SSO_MAINTENANCE_MODE", true) env._set("ENABLE_SSO_MAINTENANCE_MODE", true)
@ -33,7 +57,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.isPreventSSOPasswords(user) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true) expect(result).toBe(true)
}) })
}) })
@ -43,7 +67,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.isPreventSSOPasswords(user) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false) expect(result).toBe(false)
}) })
}) })

View file

@ -14,6 +14,7 @@ import {
users as usersCore, users as usersCore,
utils, utils,
ViewName, ViewName,
env as coreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { import {
AccountMetadata, AccountMetadata,
@ -34,7 +35,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
import { groups as groupsSdk } from "@budibase/pro" import * as pro from "@budibase/pro"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
const PAGE_LIMIT = 8 const PAGE_LIMIT = 8
@ -122,8 +123,8 @@ const buildUser = async (
let hashedPassword let hashedPassword
if (password) { if (password) {
if (await isPreventSSOPasswords(user)) { if (await isPreventPasswordActions(user)) {
throw new HTTPError("SSO user cannot set password", 400) throw new HTTPError("Password change is disabled for this user", 400)
} }
hashedPassword = opts.hashPassword ? await utils.hash(password) : password hashedPassword = opts.hashPassword ? await utils.hash(password) : password
} else if (dbUser) { } else if (dbUser) {
@ -188,13 +189,18 @@ const validateUniqueUser = async (email: string, tenantId: string) => {
} }
} }
export async function isPreventSSOPasswords(user: User) { export async function isPreventPasswordActions(user: User) {
// when in maintenance mode we allow sso users with the admin role // when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout // to perform any password action - this prevents lockout
if (env.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) { if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) {
return false return false
} }
// SSO is enforced for all users
if (await pro.features.isSSOEnforced()) {
return true
}
// Check local sso // Check local sso
if (isSSOUser(user)) { if (isSSOUser(user)) {
return true return true
@ -278,7 +284,7 @@ export const save = async (
if (userGroups.length > 0) { if (userGroups.length > 0) {
for (let groupId of userGroups) { for (let groupId of userGroups) {
groupPromises.push(groupsSdk.addUsers(groupId, [_id])) groupPromises.push(pro.groups.addUsers(groupId, [_id]))
} }
} }
} }
@ -456,7 +462,7 @@ export const bulkCreate = async (
const groupPromises = [] const groupPromises = []
const createdUserIds = saved.map(user => user._id) const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) { for (let groupId of groups) {
groupPromises.push(groupsSdk.addUsers(groupId, createdUserIds)) groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
} }
await Promise.all(groupPromises) await Promise.all(groupPromises)
} }

View file

@ -14,6 +14,14 @@ export class ConfigAPI extends TestAPI {
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
} }
getPublicSettings = () => {
return this.request
.get(`/api/global/configs/public`)
.set(this.config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
}
saveConfig = (data: any) => { saveConfig = (data: any) => {
return this.request return this.request
.post(`/api/global/configs`) .post(`/api/global/configs`)

View file

@ -13,6 +13,7 @@ export class EmailAPI extends TestAPI {
email: "test@test.com", email: "test@test.com",
purpose, purpose,
tenantId: this.config.getTenantId(), tenantId: this.config.getTenantId(),
userId: this.config.user?._id!,
}) })
.set(this.config.defaultHeaders()) .set(this.config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)

View file

@ -1,9 +1,15 @@
import { Config } from "../../constants"
import { utils } from "@budibase/backend-core" import { utils } from "@budibase/backend-core"
import {
SettingsConfig,
ConfigType,
SMTPConfig,
GoogleConfig,
OIDCConfig,
} from "@budibase/types"
export function oidc(conf?: any) { export function oidc(conf?: any): OIDCConfig {
return { return {
type: Config.OIDC, type: ConfigType.OIDC,
config: { config: {
configs: [ configs: [
{ {
@ -21,9 +27,9 @@ export function oidc(conf?: any) {
} }
} }
export function google(conf?: any) { export function google(conf?: any): GoogleConfig {
return { return {
type: Config.GOOGLE, type: ConfigType.GOOGLE,
config: { config: {
clientID: "clientId", clientID: "clientId",
clientSecret: "clientSecret", clientSecret: "clientSecret",
@ -33,9 +39,9 @@ export function google(conf?: any) {
} }
} }
export function smtp(conf?: any) { export function smtp(conf?: any): SMTPConfig {
return { return {
type: Config.SMTP, type: ConfigType.SMTP,
config: { config: {
port: 12345, port: 12345,
host: "smtptesthost.com", host: "smtptesthost.com",
@ -47,25 +53,26 @@ export function smtp(conf?: any) {
} }
} }
export function smtpEthereal() { export function smtpEthereal(): SMTPConfig {
return { return {
type: Config.SMTP, type: ConfigType.SMTP,
config: { config: {
port: 587, port: 587,
host: "smtp.ethereal.email", host: "smtp.ethereal.email",
from: "testfrom@test.com",
secure: false, secure: false,
auth: { auth: {
user: "don.bahringer@ethereal.email", user: "wyatt.zulauf29@ethereal.email",
pass: "yCKSH8rWyUPbnhGYk9", pass: "tEwDtHBWWxusVWAPfa",
}, },
connectionTimeout: 1000, // must be less than the jest default of 5000 connectionTimeout: 1000, // must be less than the jest default of 5000
}, },
} }
} }
export function settings(conf?: any) { export function settings(conf?: any): SettingsConfig {
return { return {
type: Config.SETTINGS, type: ConfigType.SETTINGS,
config: { config: {
platformUrl: "http://localhost:10000", platformUrl: "http://localhost:10000",
logoUrl: "", logoUrl: "",

View file

@ -1,11 +1,11 @@
import env from "../environment" import env from "../environment"
import { EmailTemplatePurpose, TemplateType, Config } from "../constants" import { EmailTemplatePurpose, TemplateType } from "../constants"
import { getTemplateByPurpose } from "../constants/templates" import { getTemplateByPurpose } from "../constants/templates"
import { getSettingsTemplateContext } from "./templates" import { getSettingsTemplateContext } from "./templates"
import { processString } from "@budibase/string-templates" import { processString } from "@budibase/string-templates"
import { getResetPasswordCode, getInviteCode } from "./redis" import { getResetPasswordCode, getInviteCode } from "./redis"
import { User, Database } from "@budibase/types" import { User, SMTPInnerConfig } from "@budibase/types"
import { tenancy, db as dbCore } from "@budibase/backend-core" import { configs } from "@budibase/backend-core"
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
type SendEmailOpts = { type SendEmailOpts = {
@ -36,24 +36,24 @@ const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.CUSTOM, EmailTemplatePurpose.CUSTOM,
] ]
function createSMTPTransport(config: any) { function createSMTPTransport(config?: SMTPInnerConfig) {
let options: any let options: any
let secure = config.secure let secure = config?.secure
// default it if not specified // default it if not specified
if (secure == null) { if (secure == null) {
secure = config.port === 465 secure = config?.port === 465
} }
if (!TEST_MODE) { if (!TEST_MODE) {
options = { options = {
port: config.port, port: config?.port,
host: config.host, host: config?.host,
secure: secure, secure: secure,
auth: config.auth, auth: config?.auth,
} }
options.tls = { options.tls = {
rejectUnauthorized: false, rejectUnauthorized: false,
} }
if (config.connectionTimeout) { if (config?.connectionTimeout) {
options.connectionTimeout = config.connectionTimeout options.connectionTimeout = config.connectionTimeout
} }
} else { } else {
@ -134,57 +134,16 @@ async function buildEmail(
}) })
} }
/**
* Utility function for finding most valid SMTP configuration.
* @param {object} db The CouchDB database which is to be looked up within.
* @param {string|null} workspaceId If using finer grain control of configs a workspace can be used.
* @param {boolean|null} automation Whether or not the configuration is being fetched for an email automation.
* @return {Promise<object|null>} returns the SMTP configuration if it exists
*/
async function getSmtpConfiguration(
db: Database,
workspaceId?: string,
automation?: boolean
) {
const params: any = {
type: Config.SMTP,
}
if (workspaceId) {
params.workspace = workspaceId
}
const customConfig = await dbCore.getScopedConfig(db, params)
if (customConfig) {
return customConfig
}
// Use an SMTP fallback configuration from env variables
if (!automation && env.SMTP_FALLBACK_ENABLED) {
return {
port: env.SMTP_PORT,
host: env.SMTP_HOST,
secure: false,
from: env.SMTP_FROM_ADDRESS,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
},
}
}
}
/** /**
* Checks if a SMTP config exists based on passed in parameters. * Checks if a SMTP config exists based on passed in parameters.
* @return {Promise<boolean>} returns true if there is a configuration that can be used. * @return {Promise<boolean>} returns true if there is a configuration that can be used.
*/ */
export async function isEmailConfigured(workspaceId?: string) { export async function isEmailConfigured() {
// when "testing" or smtp fallback is enabled simply return true // when "testing" or smtp fallback is enabled simply return true
if (TEST_MODE || env.SMTP_FALLBACK_ENABLED) { if (TEST_MODE || env.SMTP_FALLBACK_ENABLED) {
return true return true
} }
const db = tenancy.getGlobalDB() const config = await configs.getSMTPConfig()
const config = await getSmtpConfiguration(db, workspaceId)
return config != null return config != null
} }
@ -202,22 +161,17 @@ export async function sendEmail(
purpose: EmailTemplatePurpose, purpose: EmailTemplatePurpose,
opts: SendEmailOpts opts: SendEmailOpts
) { ) {
const db = tenancy.getGlobalDB() const config = await configs.getSMTPConfig(opts?.automation)
let config = if (!config && !TEST_MODE) {
(await getSmtpConfiguration(db, opts?.workspaceId, opts?.automation)) || {}
if (Object.keys(config).length === 0 && !TEST_MODE) {
throw "Unable to find SMTP configuration." throw "Unable to find SMTP configuration."
} }
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
// if there is a link code needed this will retrieve it // if there is a link code needed this will retrieve it
const code = await getLinkCode(purpose, email, opts.user, opts?.info) const code = await getLinkCode(purpose, email, opts.user, opts?.info)
let context let context = await getSettingsTemplateContext(purpose, code)
if (code) {
context = await getSettingsTemplateContext(purpose, code)
}
let message: any = { let message: any = {
from: opts?.from || config.from, from: opts?.from || config?.from,
html: await buildEmail(purpose, email, context, { html: await buildEmail(purpose, email, context, {
user: opts?.user, user: opts?.user,
contents: opts?.contents, contents: opts?.contents,
@ -231,9 +185,9 @@ export async function sendEmail(
bcc: opts?.bcc, bcc: opts?.bcc,
} }
if (opts?.subject || config.subject) { if (opts?.subject || config?.subject) {
message.subject = await processString( message.subject = await processString(
opts?.subject || config.subject, (opts?.subject || config?.subject) as string,
context context
) )
} }

View file

@ -1,6 +1,5 @@
import { db as dbCore, tenancy } from "@budibase/backend-core" import { tenancy, configs } from "@budibase/backend-core"
import { import {
Config,
InternalTemplateBinding, InternalTemplateBinding,
LOGO_URL, LOGO_URL,
EmailTemplatePurpose, EmailTemplatePurpose,
@ -10,20 +9,16 @@ const BASE_COMPANY = "Budibase"
export async function getSettingsTemplateContext( export async function getSettingsTemplateContext(
purpose: EmailTemplatePurpose, purpose: EmailTemplatePurpose,
code?: string code?: string | null
) { ) {
const db = tenancy.getGlobalDB() let settings = await configs.getSettingsConfig()
// TODO: use more granular settings in the future if required
let settings =
(await dbCore.getScopedConfig(db, { type: Config.SETTINGS })) || {}
const URL = settings.platformUrl const URL = settings.platformUrl
const context: any = { const context: any = {
[InternalTemplateBinding.LOGO_URL]: [InternalTemplateBinding.LOGO_URL]:
checkSlashesInUrl(`${URL}/${settings.logoUrl}`) || LOGO_URL, checkSlashesInUrl(`${URL}/${settings.logoUrl}`) || LOGO_URL,
[InternalTemplateBinding.PLATFORM_URL]: URL, [InternalTemplateBinding.PLATFORM_URL]: URL,
[InternalTemplateBinding.COMPANY]: settings.company || BASE_COMPANY, [InternalTemplateBinding.COMPANY]: settings.company || BASE_COMPANY,
[InternalTemplateBinding.DOCS_URL]: [InternalTemplateBinding.DOCS_URL]: "https://docs.budibase.com/",
settings.docsUrl || "https://docs.budibase.com/",
[InternalTemplateBinding.LOGIN_URL]: checkSlashesInUrl( [InternalTemplateBinding.LOGIN_URL]: checkSlashesInUrl(
tenancy.addTenantToUrl(`${URL}/login`) tenancy.addTenantToUrl(`${URL}/login`)
), ),

View file

@ -475,14 +475,14 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.3.18-alpha.12": "@budibase/backend-core@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.12.tgz#ad1b16be64b78b596af2b5f75647c32e8f6f101a" resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.13.tgz#b797d7a4d30ff7f21e473334edb4086f5818830e"
integrity sha512-E1NEO+/sNkkRqn/xk9XQmFBO9/dl27w9EB0QGztti/16JV9NgxyDQCJIdGwlD08s1y/lUwOKk0TkSZJs+CTYDw== integrity sha512-c6d0xCRgLlPeX1euAoQuoDOkMkDGQy/miBx/Z8xyU9bzDDTNqBTogxpxsNf8DdZG7EMJhsJlCUvb26Onz7/50A==
dependencies: dependencies:
"@budibase/nano" "10.1.1" "@budibase/nano" "10.1.1"
"@budibase/pouchdb-replication-stream" "1.2.10" "@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.3.18-alpha.12" "@budibase/types" "2.3.18-alpha.13"
"@shopify/jest-koa-mocks" "5.0.1" "@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2" "@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0" aws-cloudfront-sign "2.2.0"
@ -539,13 +539,13 @@
pouchdb-promise "^6.0.4" pouchdb-promise "^6.0.4"
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@2.3.18-alpha.12": "@budibase/pro@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.12.tgz#be552b3a9f5850e746081540d6586aae69147bec" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.13.tgz#9f3dd0339184a58609c726f3f2b4580cf802dc78"
integrity sha512-M3b0njzSi47KH6uaQfYPoA2KWrjPiwcU3ONyaVWXHIktVrIKtYaFwOLBr/dmWGfMrL2297SSqg7V4DTaLyAhnw== integrity sha512-pRroVVFGITFsFtzzH6LgzuaUBKldvFQxTgO7K6dYjjE7xwTCnZCg0E4L2Ew/JIY39UN2WEb5bwdt3+pqPH7Dmg==
dependencies: dependencies:
"@budibase/backend-core" "2.3.18-alpha.12" "@budibase/backend-core" "2.3.18-alpha.13"
"@budibase/types" "2.3.18-alpha.12" "@budibase/types" "2.3.18-alpha.13"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
joi "17.6.0" joi "17.6.0"
@ -553,10 +553,10 @@
lru-cache "^7.14.1" lru-cache "^7.14.1"
node-fetch "^2.6.1" node-fetch "^2.6.1"
"@budibase/types@2.3.18-alpha.12": "@budibase/types@2.3.18-alpha.13":
version "2.3.18-alpha.12" version "2.3.18-alpha.13"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.12.tgz#a63eb978ccc7e55c209b3e9d71f9aecf7facc0d1" resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.13.tgz#e80cbf79249ed5cf8fe86e294d0b1e25e0a839ee"
integrity sha512-27o2BmI/HXIR3frZ8FtqHgAe1hd8jPIzgPaEhKrQiYJ/opUVccqupx9ld75Hyk9E6cdXu0UF0/+LxPpUmMugag== integrity sha512-fmgpwMMGkbPOObmFnZZH8iKelycmhvBydGQuPEmIk1c449ysfiKF2Strnca6MaY1XtskfRz/nNdWGdGS1HhmFw==
"@cspotcode/source-map-support@^0.8.0": "@cspotcode/source-map-support@^0.8.0":
version "0.8.1" version "0.8.1"