Enforceable SSO (#9787)

* Add ENFORCEABLE_SSO feature flag

* First draft of enforce sso configuration / show single sign on url

* Reading and writing isSSOEnforced + integration with login page

* Enable CI + lint

* Set correct base branch for CI

* Test fix for expected string changed

* Use tenant aware platform url as SSO link

* Bring in latest pro changes

* Lint

* Add useEnforceableSSO mock helper function

* Update configs.spec.ts with coverage for public settings

* Update users.spec.ts with additional tests for isPreventPasswordActions

* Lint

* Update refresh OAuthToken to use correct enum and add case statement
This commit is contained in:
Rory Powell 2023-02-27 13:42:51 +00:00 committed by GitHub
parent 3d12607b98
commit 2c46109e7d
23 changed files with 365 additions and 191 deletions

View File

@ -10,7 +10,8 @@ on:
pull_request:
branches:
- master
- develop
- develop
- configs-refactor-and-server-test-fixes
workflow_dispatch:
env:

View File

@ -20,6 +20,7 @@ import {
GoogleInnerConfig,
OIDCInnerConfig,
PlatformLogoutOpts,
SSOProviderType,
User,
} from "@budibase/types"
import { logAlert } from "../logging"
@ -149,26 +150,26 @@ interface RefreshResponse {
export async function refreshOAuthToken(
refreshToken: string,
configType: ConfigType,
providerType: SSOProviderType,
configId?: string
): Promise<RefreshResponse> {
if (configType === ConfigType.OIDC && configId) {
const config = await configs.getOIDCConfigById(configId)
if (!config) {
return { err: { data: "OIDC configuration not found" } }
}
return refreshOIDCAccessToken(config, refreshToken)
switch (providerType) {
case SSOProviderType.OIDC:
if (!configId) {
return { err: { data: "OIDC config id not provided" } }
}
const oidcConfig = await configs.getOIDCConfigById(configId)
if (!oidcConfig) {
return { err: { data: "OIDC configuration not found" } }
}
return refreshOIDCAccessToken(oidcConfig, refreshToken)
case SSOProviderType.GOOGLE:
let googleConfig = await configs.getGoogleConfig()
if (!googleConfig) {
return { err: { data: "Google configuration not found" } }
}
return refreshGoogleAccessToken(googleConfig, refreshToken)
}
if (configType === ConfigType.GOOGLE) {
const config = await configs.getGoogleConfig()
if (!config) {
return { err: { data: "Google configuration not found" } }
}
return refreshGoogleAccessToken(config, refreshToken)
}
throw new Error(`Unsupported configType=${configType}`)
}
// TODO: Refactor to use user save function instead to prevent the need for

View File

@ -28,6 +28,8 @@ const DefaultBucketName = {
PLUGINS: "plugins",
}
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = {
isTest,
isJest,
@ -58,7 +60,7 @@ const environment = {
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "",
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
SELF_HOSTED: selfHosted,
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL || "",
POSTHOG_TOKEN: process.env.POSTHOG_TOKEN,
@ -91,6 +93,14 @@ const environment = {
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) {
process.env[key] = value
// @ts-ignore

View File

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

View File

@ -79,67 +79,71 @@
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
<GoogleButton />
</FancyForm>
<Divider />
{/if}
<FancyForm bind:this={form}>
<FancyInput
label="Your work email"
value={formData.username}
on:change={e => {
formData = {
...formData,
username: e.detail,
}
}}
validate={() => {
let fieldError = {
username: !formData.username
? "Please enter a valid email"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.username}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {
password: !formData.password
? "Please enter your password"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
/>
</FancyForm>
</Layout>
<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>
{#if !$organisation.isSSOEnforced}
<Divider />
<FancyForm bind:this={form}>
<FancyInput
label="Your work email"
value={formData.username}
on:change={e => {
formData = {
...formData,
username: e.detail,
}
}}
validate={() => {
let fieldError = {
username: !formData.username
? "Please enter a valid email"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.username}
/>
<FancyInput
label="Password"
value={formData.password}
type="password"
on:change={e => {
formData = {
...formData,
password: e.detail,
}
}}
validate={() => {
let fieldError = {
password: !formData.password
? "Please enter your password"
: undefined,
}
errors = handleError({ ...errors, ...fieldError })
}}
error={errors.password}
/>
</FancyForm>
{/if}
</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}
<Body size="xs" textAlign="center">

View File

@ -22,10 +22,11 @@
Tags,
Icon,
Helpers,
Link,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import { organisation, admin } from "stores/portal"
import { organisation, admin, licensing } from "stores/portal"
const ConfigTypes = {
Google: "google",
@ -34,6 +35,8 @@
const HasSpacesRegex = /[\\"\s]/
$: enforcedSSO = $organisation.isSSOEnforced
// 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
$: googleCallbackUrl = undefined
@ -154,6 +157,11 @@
iconDropdownOptions.unshift({ label: fileName, value: fileName })
}
async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced
await organisation.save({ isSSOEnforced: !value })
}
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
@ -316,6 +324,49 @@
<Heading size="M">Authentication</Heading>
<Body>Add additional authentication methods from the options below</Body>
</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}
<Divider />
<Layout gap="XS" noPadding>
@ -546,7 +597,24 @@
input[type="file"] {
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 {
display: flex;
flex-direction: row;

View File

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

View File

@ -63,6 +63,9 @@ export const createLicensingStore = () => {
const environmentVariablesEnabled = license.features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = license.features.includes(
Constants.Features.ENFORCEABLE_SSO
)
store.update(state => {
return {
@ -72,6 +75,7 @@ export const createLicensingStore = () => {
groupsEnabled,
backupsEnabled,
environmentVariablesEnabled,
enforceableSSO,
}
})
},

View File

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

View File

@ -115,6 +115,7 @@ export const Features = {
USER_GROUPS: "userGroups",
BACKUPS: "appBackups",
ENVIRONMENT_VARIABLES: "environmentVariables",
ENFORCEABLE_SSO: "enforceableSSO",
}
// Role IDs

View File

@ -29,6 +29,7 @@ export interface SettingsInnerConfig {
logoUrlEtag?: string
uniqueTenantId?: string
analyticsEnabled?: boolean
isSSOEnforced?: boolean
}
export interface SettingsConfig extends Config {

View File

@ -2,4 +2,5 @@ export enum Feature {
USER_GROUPS = "userGroups",
APP_BACKUPS = "appBackups",
ENVIRONMENT_VARIABLES = "environmentVariables",
ENFORCEABLE_SSO = "enforceableSSO",
}

View File

@ -61,8 +61,8 @@ export const login = async (ctx: Ctx<LoginRequest>, next: any) => {
const email = ctx.request.body.username
const user = await userSdk.getUserByEmail(email)
if (user && (await userSdk.isPreventSSOPasswords(user))) {
ctx.throw(400, "SSO user cannot login using password")
if (user && (await userSdk.isPreventPasswordActions(user))) {
ctx.throw(400, "Password login is disabled for this user")
}
return passport.authenticate(

View File

@ -23,6 +23,7 @@ import {
isSMTPConfig,
UserCtx,
} from "@budibase/types"
import * as pro from "@budibase/pro"
const getEventFns = async (config: Config, existing?: Config) => {
const fns = []
@ -126,10 +127,10 @@ export async function save(ctx: UserCtx<Config>) {
const existingConfig = await configs.getConfig(type)
let eventFns = await getEventFns(ctx.request.body, existingConfig)
// Config does not exist yet
if (!existingConfig) {
body._id = configs.generateConfigID(type)
if (existingConfig) {
body._rev = existingConfig._rev
}
try {
// verify the configuration
switch (config.type) {
@ -142,6 +143,7 @@ export async function save(ctx: UserCtx<Config>) {
}
try {
body._id = configs.generateConfigID(type)
const response = await configs.save(body)
await cache.bustCache(cache.CacheKey.CHECKLIST)
await cache.bustCache(cache.CacheKey.ANALYTICS_ENABLED)
@ -203,7 +205,8 @@ export async function publicSettings(
) {
try {
// settings
const config = await configs.getSettingsConfig()
const configDoc = await configs.getSettingsConfigDoc()
const config = configDoc.config
// enrich the logo url - empty url means deleted
if (config.logoUrl && config.logoUrl !== "") {
config.logoUrl = objectStore.getGlobalFileUrl(
@ -224,13 +227,18 @@ export async function publicSettings(
const oidc = oidcConfig?.activated || false
const _oidcCallbackUrl = await oidcCallbackUrl()
// sso enforced
const isSSOEnforced = await pro.features.isSSOEnforced({ config })
ctx.body = {
type: ConfigType.SETTINGS,
_id: configs.generateConfigID(ConfigType.SETTINGS),
_id: configDoc._id,
_rev: configDoc._rev,
config: {
...config,
google,
oidc,
isSSOEnforced,
oidcCallbackUrl: _oidcCallbackUrl,
googleCallbackUrl: _googleCallbackUrl,
},

View File

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

View File

@ -2,7 +2,8 @@
jest.mock("nodemailer")
import { TestConfiguration, structures, mocks } from "../../../../tests"
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", () => {
const config = new TestConfiguration()
@ -19,22 +20,29 @@ describe("configs", () => {
await config.afterAll()
})
describe("post /api/global/configs", () => {
const saveConfig = async (conf: any, _id?: string, _rev?: string) => {
const data = {
...conf,
_id,
_rev,
}
const res = await config.api.configs.saveConfig(data)
return {
...data,
...res.body,
}
const saveConfig = async (conf: Config, _id?: string, _rev?: string) => {
const data = {
...conf,
_id,
_rev,
}
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", () => {
const saveGoogleConfig = async (
conf?: any,
@ -49,20 +57,20 @@ describe("configs", () => {
it("should create activated google config", async () => {
await saveGoogleConfig()
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.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.GOOGLE)
await config.deleteConfig(Config.GOOGLE)
expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(ConfigType.GOOGLE)
})
it("should create deactivated google config", async () => {
await saveGoogleConfig({ activated: false })
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.SSODeactivated).not.toBeCalled()
await config.deleteConfig(Config.GOOGLE)
await config.deleteConfig(ConfigType.GOOGLE)
})
})
@ -76,11 +84,11 @@ describe("configs", () => {
googleConf._rev
)
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.SSODeactivated).toBeCalledTimes(1)
expect(events.auth.SSODeactivated).toBeCalledWith(Config.GOOGLE)
await config.deleteConfig(Config.GOOGLE)
expect(events.auth.SSODeactivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(ConfigType.GOOGLE)
})
it("should update google config to activated", async () => {
@ -92,11 +100,11 @@ describe("configs", () => {
googleConf._rev
)
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.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.GOOGLE)
await config.deleteConfig(Config.GOOGLE)
expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.GOOGLE)
await config.deleteConfig(ConfigType.GOOGLE)
})
})
})
@ -115,20 +123,20 @@ describe("configs", () => {
it("should create activated OIDC config", async () => {
await saveOIDCConfig()
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.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.OIDC)
await config.deleteConfig(Config.OIDC)
expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(ConfigType.OIDC)
})
it("should create deactivated OIDC config", async () => {
await saveOIDCConfig({ activated: false })
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.SSODeactivated).not.toBeCalled()
await config.deleteConfig(Config.OIDC)
await config.deleteConfig(ConfigType.OIDC)
})
})
@ -142,11 +150,11 @@ describe("configs", () => {
oidcConf._rev
)
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.SSODeactivated).toBeCalledTimes(1)
expect(events.auth.SSODeactivated).toBeCalledWith(Config.OIDC)
await config.deleteConfig(Config.OIDC)
expect(events.auth.SSODeactivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(ConfigType.OIDC)
})
it("should update OIDC config to activated", async () => {
@ -158,11 +166,11 @@ describe("configs", () => {
oidcConf._rev
)
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.SSOActivated).toBeCalledTimes(1)
expect(events.auth.SSOActivated).toBeCalledWith(Config.OIDC)
await config.deleteConfig(Config.OIDC)
expect(events.auth.SSOActivated).toBeCalledWith(ConfigType.OIDC)
await config.deleteConfig(ConfigType.OIDC)
})
})
})
@ -179,11 +187,11 @@ describe("configs", () => {
describe("create", () => {
it("should create SMTP config", async () => {
await config.deleteConfig(Config.SMTP)
await config.deleteConfig(ConfigType.SMTP)
await saveSMTPConfig()
expect(events.email.SMTPUpdated).not.toBeCalled()
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)
expect(events.email.SMTPCreated).not.toBeCalled()
expect(events.email.SMTPUpdated).toBeCalledTimes(1)
await config.deleteConfig(Config.SMTP)
await config.deleteConfig(ConfigType.SMTP)
})
})
})
describe("settings", () => {
const saveSettingsConfig = async (
conf?: any,
_id?: string,
_rev?: string
) => {
const settingsConfig = structures.configs.settings(conf)
return saveConfig(settingsConfig, _id, _rev)
}
describe("create", () => {
it("should create settings config with default settings", async () => {
await config.deleteConfig(Config.SETTINGS)
await config.deleteConfig(ConfigType.SETTINGS)
await saveSettingsConfig()
@ -222,7 +221,7 @@ describe("configs", () => {
it("should create settings config with non-default settings", async () => {
config.selfHosted()
await config.deleteConfig(Config.SETTINGS)
await config.deleteConfig(ConfigType.SETTINGS)
const conf = {
company: "acme",
logoUrl: "http://example.com",
@ -241,7 +240,7 @@ describe("configs", () => {
describe("update", () => {
it("should update settings config", async () => {
config.selfHosted()
await config.deleteConfig(Config.SETTINGS)
await config.deleteConfig(ConfigType.SETTINGS)
const settingsConfig = await saveSettingsConfig()
settingsConfig.config.company = "acme"
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 () => {
await config.saveSmtpConfig()
describe("GET /api/global/configs/checklist", () => {
it("should return the correct checklist", async () => {
await config.saveSmtpConfig()
const res = await config.api.configs.getConfigChecklist()
const checklist = res.body
const res = await config.api.configs.getConfigChecklist()
const checklist = res.body
expect(checklist.apps.checked).toBeFalsy()
expect(checklist.smtp.checked).toBeTruthy()
expect(checklist.adminUser.checked).toBeTruthy()
expect(checklist.apps.checked).toBeFalsy()
expect(checklist.smtp.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

@ -26,8 +26,6 @@ function parseIntSafe(number: any) {
}
}
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
const environment = {
// auth
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
@ -51,7 +49,7 @@ const environment = {
CLUSTER_PORT: process.env.CLUSTER_PORT,
// flags
NODE_ENV: process.env.NODE_ENV,
SELF_HOSTED: selfHosted,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
LOG_LEVEL: process.env.LOG_LEVEL,
MULTI_TENANCY: process.env.MULTI_TENANCY,
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.
*/
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) {
process.env[key] = value
// @ts-ignore

View File

@ -13,7 +13,13 @@ import { Event } from "@sentry/types/dist/event"
import Application from "koa"
import { bootstrap } from "global-agent"
import * as db from "./db"
import { auth, logging, events, middleware } from "@budibase/backend-core"
import {
auth,
logging,
events,
middleware,
env as coreEnv,
} from "@budibase/backend-core"
db.init()
import Koa from "koa"
import koaBody from "koa-body"
@ -25,7 +31,7 @@ const koaSession = require("koa-session")
const logger = require("koa-pino-logger")
import destroyable from "server-destroy"
if (env.ENABLE_SSO_MAINTENANCE_MODE) {
if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE) {
console.warn(
"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
if (await userSdk.isPreventSSOPasswords(user)) {
throw new HTTPError("SSO user cannot reset password", 400)
if (await userSdk.isPreventPasswordActions(user)) {
throw new HTTPError("Password reset is disabled for this user", 400)
}
// send password reset

View File

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

View File

@ -14,6 +14,7 @@ import {
users as usersCore,
utils,
ViewName,
env as coreEnv,
} from "@budibase/backend-core"
import {
AccountMetadata,
@ -34,7 +35,7 @@ import {
} from "@budibase/types"
import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants"
import { groups as groupsSdk } from "@budibase/pro"
import * as pro from "@budibase/pro"
import * as accountSdk from "../accounts"
const PAGE_LIMIT = 8
@ -122,8 +123,8 @@ const buildUser = async (
let hashedPassword
if (password) {
if (await isPreventSSOPasswords(user)) {
throw new HTTPError("SSO user cannot set password", 400)
if (await isPreventPasswordActions(user)) {
throw new HTTPError("Password change is disabled for this user", 400)
}
hashedPassword = opts.hashPassword ? await utils.hash(password) : password
} 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
// 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
}
// SSO is enforced for all users
if (await pro.features.isSSOEnforced()) {
return true
}
// Check local sso
if (isSSOUser(user)) {
return true
@ -278,7 +284,7 @@ export const save = async (
if (userGroups.length > 0) {
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 createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(groupsSdk.addUsers(groupId, createdUserIds))
groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}

View File

@ -14,6 +14,14 @@ export class ConfigAPI extends TestAPI {
.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) => {
return this.request
.post(`/api/global/configs`)

View File

@ -1,9 +1,15 @@
import { Config } from "../../constants"
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 {
type: Config.OIDC,
type: ConfigType.OIDC,
config: {
configs: [
{
@ -21,9 +27,9 @@ export function oidc(conf?: any) {
}
}
export function google(conf?: any) {
export function google(conf?: any): GoogleConfig {
return {
type: Config.GOOGLE,
type: ConfigType.GOOGLE,
config: {
clientID: "clientId",
clientSecret: "clientSecret",
@ -33,9 +39,9 @@ export function google(conf?: any) {
}
}
export function smtp(conf?: any) {
export function smtp(conf?: any): SMTPConfig {
return {
type: Config.SMTP,
type: ConfigType.SMTP,
config: {
port: 12345,
host: "smtptesthost.com",
@ -47,12 +53,13 @@ export function smtp(conf?: any) {
}
}
export function smtpEthereal() {
export function smtpEthereal(): SMTPConfig {
return {
type: Config.SMTP,
type: ConfigType.SMTP,
config: {
port: 587,
host: "smtp.ethereal.email",
from: "testfrom@test.com",
secure: false,
auth: {
user: "wyatt.zulauf29@ethereal.email",
@ -63,9 +70,9 @@ export function smtpEthereal() {
}
}
export function settings(conf?: any) {
export function settings(conf?: any): SettingsConfig {
return {
type: Config.SETTINGS,
type: ConfigType.SETTINGS,
config: {
platformUrl: "http://localhost:10000",
logoUrl: "",