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:
parent
3d12607b98
commit
2c46109e7d
|
@ -10,7 +10,8 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- develop
|
- develop
|
||||||
|
- configs-refactor-and-server-test-fixes
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {
|
||||||
GoogleInnerConfig,
|
GoogleInnerConfig,
|
||||||
OIDCInnerConfig,
|
OIDCInnerConfig,
|
||||||
PlatformLogoutOpts,
|
PlatformLogoutOpts,
|
||||||
|
SSOProviderType,
|
||||||
User,
|
User,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { logAlert } from "../logging"
|
import { logAlert } from "../logging"
|
||||||
|
@ -149,26 +150,26 @@ interface RefreshResponse {
|
||||||
|
|
||||||
export async function refreshOAuthToken(
|
export async function refreshOAuthToken(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
configType: ConfigType,
|
providerType: SSOProviderType,
|
||||||
configId?: string
|
configId?: string
|
||||||
): Promise<RefreshResponse> {
|
): Promise<RefreshResponse> {
|
||||||
if (configType === ConfigType.OIDC && configId) {
|
switch (providerType) {
|
||||||
const config = await configs.getOIDCConfigById(configId)
|
case SSOProviderType.OIDC:
|
||||||
if (!config) {
|
if (!configId) {
|
||||||
return { err: { data: "OIDC configuration not found" } }
|
return { err: { data: "OIDC config id not provided" } }
|
||||||
}
|
}
|
||||||
return refreshOIDCAccessToken(config, refreshToken)
|
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
|
// TODO: Refactor to use user save function instead to prevent the need for
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -91,6 +93,14 @@ const environment = {
|
||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
|
SMTP_PORT: parseInt(process.env.SMTP_PORT || ""),
|
||||||
SMTP_FROM_ADDRESS: process.env.SMTP_FROM_ADDRESS,
|
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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
return {
|
return {
|
||||||
|
@ -72,6 +75,7 @@ export const createLicensingStore = () => {
|
||||||
groupsEnabled,
|
groupsEnabled,
|
||||||
backupsEnabled,
|
backupsEnabled,
|
||||||
environmentVariablesEnabled,
|
environmentVariablesEnabled,
|
||||||
|
enforceableSSO,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -115,6 +115,7 @@ export const Features = {
|
||||||
USER_GROUPS: "userGroups",
|
USER_GROUPS: "userGroups",
|
||||||
BACKUPS: "appBackups",
|
BACKUPS: "appBackups",
|
||||||
ENVIRONMENT_VARIABLES: "environmentVariables",
|
ENVIRONMENT_VARIABLES: "environmentVariables",
|
||||||
|
ENFORCEABLE_SSO: "enforceableSSO",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role IDs
|
// Role IDs
|
||||||
|
|
|
@ -29,6 +29,7 @@ export interface SettingsInnerConfig {
|
||||||
logoUrlEtag?: string
|
logoUrlEtag?: string
|
||||||
uniqueTenantId?: string
|
uniqueTenantId?: string
|
||||||
analyticsEnabled?: boolean
|
analyticsEnabled?: boolean
|
||||||
|
isSSOEnforced?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsConfig extends Config {
|
export interface SettingsConfig extends Config {
|
||||||
|
|
|
@ -2,4 +2,5 @@ export enum Feature {
|
||||||
USER_GROUPS = "userGroups",
|
USER_GROUPS = "userGroups",
|
||||||
APP_BACKUPS = "appBackups",
|
APP_BACKUPS = "appBackups",
|
||||||
ENVIRONMENT_VARIABLES = "environmentVariables",
|
ENVIRONMENT_VARIABLES = "environmentVariables",
|
||||||
|
ENFORCEABLE_SSO = "enforceableSSO",
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {
|
||||||
isSMTPConfig,
|
isSMTPConfig,
|
||||||
UserCtx,
|
UserCtx,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
import * as pro from "@budibase/pro"
|
||||||
|
|
||||||
const getEventFns = async (config: Config, existing?: Config) => {
|
const getEventFns = async (config: Config, existing?: Config) => {
|
||||||
const fns = []
|
const fns = []
|
||||||
|
@ -126,10 +127,10 @@ export async function save(ctx: UserCtx<Config>) {
|
||||||
const existingConfig = await configs.getConfig(type)
|
const existingConfig = await configs.getConfig(type)
|
||||||
let eventFns = await getEventFns(ctx.request.body, existingConfig)
|
let eventFns = await getEventFns(ctx.request.body, existingConfig)
|
||||||
|
|
||||||
// Config does not exist yet
|
if (existingConfig) {
|
||||||
if (!existingConfig) {
|
body._rev = existingConfig._rev
|
||||||
body._id = configs.generateConfigID(type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// verify the configuration
|
// verify the configuration
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
|
@ -142,6 +143,7 @@ export async function save(ctx: UserCtx<Config>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
body._id = configs.generateConfigID(type)
|
||||||
const response = await configs.save(body)
|
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)
|
||||||
|
@ -203,7 +205,8 @@ export async function publicSettings(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// settings
|
// settings
|
||||||
const config = await configs.getSettingsConfig()
|
const configDoc = await configs.getSettingsConfigDoc()
|
||||||
|
const config = configDoc.config
|
||||||
// enrich the logo url - empty url means deleted
|
// enrich the logo url - empty url means deleted
|
||||||
if (config.logoUrl && config.logoUrl !== "") {
|
if (config.logoUrl && config.logoUrl !== "") {
|
||||||
config.logoUrl = objectStore.getGlobalFileUrl(
|
config.logoUrl = objectStore.getGlobalFileUrl(
|
||||||
|
@ -224,13 +227,18 @@ export async function publicSettings(
|
||||||
const oidc = oidcConfig?.activated || false
|
const oidc = oidcConfig?.activated || false
|
||||||
const _oidcCallbackUrl = await oidcCallbackUrl()
|
const _oidcCallbackUrl = await oidcCallbackUrl()
|
||||||
|
|
||||||
|
// sso enforced
|
||||||
|
const isSSOEnforced = await pro.features.isSSOEnforced({ config })
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
type: ConfigType.SETTINGS,
|
type: ConfigType.SETTINGS,
|
||||||
_id: configs.generateConfigID(ConfigType.SETTINGS),
|
_id: configDoc._id,
|
||||||
|
_rev: configDoc._rev,
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
google,
|
google,
|
||||||
oidc,
|
oidc,
|
||||||
|
isSSOEnforced,
|
||||||
oidcCallbackUrl: _oidcCallbackUrl,
|
oidcCallbackUrl: _oidcCallbackUrl,
|
||||||
googleCallbackUrl: _googleCallbackUrl,
|
googleCallbackUrl: _googleCallbackUrl,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -13,7 +13,13 @@ 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 } from "@budibase/backend-core"
|
import {
|
||||||
|
auth,
|
||||||
|
logging,
|
||||||
|
events,
|
||||||
|
middleware,
|
||||||
|
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"
|
||||||
|
@ -25,7 +31,7 @@ const koaSession = require("koa-session")
|
||||||
const logger = require("koa-pino-logger")
|
const logger = require("koa-pino-logger")
|
||||||
import destroyable from "server-destroy"
|
import destroyable from "server-destroy"
|
||||||
|
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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`)
|
||||||
|
|
|
@ -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,12 +53,13 @@ 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: "wyatt.zulauf29@ethereal.email",
|
user: "wyatt.zulauf29@ethereal.email",
|
||||||
|
@ -63,9 +70,9 @@ export function smtpEthereal() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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: "",
|
||||||
|
|
Loading…
Reference in New Issue