Update tenancy detection to honour any subdomain pattern according to platform url

This commit is contained in:
Rory Powell 2022-11-09 16:35:16 +00:00
parent e63afd560a
commit ada0eb79bc
18 changed files with 337 additions and 124 deletions

View File

@ -58,12 +58,15 @@ http {
}
location ~ ^/api/(system|admin|global)/ {
proxy_pass http://worker-service;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://worker-service;
}
location /api/backups/ {
@ -78,60 +81,78 @@ http {
location /api/ {
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://app-service;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://app-service;
}
location = / {
proxy_pass http://app-service;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://app-service;
}
location /app_ {
proxy_pass http://app-service;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://app-service;
}
location /app {
proxy_pass http://app-service;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://app-service;
}
location /builder {
proxy_pass http://builder;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_pass http://builder;
rewrite ^/builder(.*)$ /builder/$1 break;
}
location /builder/ {
proxy_pass http://builder;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s;
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_pass http://builder;
}
location /vite/ {

View File

@ -100,18 +100,25 @@ http {
location ~ ^/(builder|app_) {
proxy_http_version 1.1;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://$apps:4002;
}
location ~ ^/api/(system|admin|global)/ {
proxy_set_header Host $host;
proxy_pass http://$worker:4003;
}
location /worker/ {
proxy_set_header Host $host;
proxy_pass http://$worker:4003;
rewrite ^/worker/(.*)$ /$1 break;
}
@ -139,6 +146,7 @@ http {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://$apps:4002;
}
@ -158,6 +166,7 @@ http {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://$apps:4002;
}

View File

@ -75,8 +75,8 @@
"env:multi:disable": "lerna run env:multi:disable",
"env:selfhost:enable": "lerna run env:selfhost:enable",
"env:selfhost:disable": "lerna run env:selfhost:disable",
"env:localdomain:enable": "lerna run env:localdomain:enable",
"env:localdomain:disable": "lerna run env:localdomain:disable",
"env:localdomain:enable": "./scripts/localdomain.sh enable",
"env:localdomain:disable": "./scripts/localdomain.sh disable",
"env:account:enable": "lerna run env:account:enable",
"env:account:disable": "lerna run env:account:disable",
"mode:self": "yarn env:selfhost:enable && yarn env:multi:disable && yarn env:account:disable",

View File

@ -15,6 +15,7 @@ import { getAppMetadata } from "../cache/appMetadata"
import { isDevApp, isDevAppID, getProdAppID } from "./conversions"
import { APP_PREFIX } from "./constants"
import * as events from "../events"
import { App } from "@budibase/types"
export * from "./constants"
export * from "./conversions"
@ -301,7 +302,12 @@ export async function getAllDbs(opts = { efficient: false }) {
*
* @return {Promise<object[]>} returns the app information document stored in each app database.
*/
export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) {
export async function getAllApps({
dev,
all,
idsOnly,
efficient,
}: any = {}): Promise<App[] | string[]> {
let tenantId = getTenantId()
if (!env.MULTI_TENANCY && !tenantId) {
tenantId = DEFAULT_TENANT_ID
@ -373,18 +379,16 @@ export async function getAllApps({ dev, all, idsOnly, efficient }: any = {}) {
* Utility function for getAllApps but filters to production apps only.
*/
export async function getProdAppIDs() {
return (await getAllApps({ idsOnly: true })).filter(
(id: any) => !isDevAppID(id)
)
const apps = (await getAllApps({ idsOnly: true })) as string[]
return apps.filter((id: any) => !isDevAppID(id))
}
/**
* Utility function for the inverse of above.
*/
export async function getDevAppIDs() {
return (await getAllApps({ idsOnly: true })).filter((id: any) =>
isDevAppID(id)
)
const apps = (await getAllApps({ idsOnly: true })) as string[]
return apps.filter((id: any) => isDevAppID(id))
}
export async function dbExists(dbName: any) {

View File

@ -1,52 +0,0 @@
const { doInTenant, isMultiTenant, DEFAULT_TENANT_ID } = require("../tenancy")
const { buildMatcherRegex, matches } = require("./matchers")
const { Headers } = require("../constants")
const getTenantID = (ctx, opts = { allowQs: false, allowNoTenant: false }) => {
// exit early if not multi-tenant
if (!isMultiTenant()) {
return DEFAULT_TENANT_ID
}
let tenantId
const allowQs = opts && opts.allowQs
const allowNoTenant = opts && opts.allowNoTenant
const header = ctx.request.headers[Headers.TENANT_ID]
const user = ctx.user || {}
if (allowQs) {
const query = ctx.request.query || {}
tenantId = query.tenantId
}
// override query string (if allowed) by user, or header
// URL params cannot be used in a middleware, as they are
// processed later in the chain
tenantId = user.tenantId || header || tenantId
// Set the tenantId from the subdomain
if (!tenantId) {
tenantId = ctx.subdomains && ctx.subdomains[0]
}
if (!tenantId && !allowNoTenant) {
ctx.throw(403, "Tenant id not set")
}
return tenantId
}
module.exports = (
allowQueryStringPatterns,
noTenancyPatterns,
opts = { noTenancyRequired: false }
) => {
const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns)
const noTenancyOptions = buildMatcherRegex(noTenancyPatterns)
return async function (ctx, next) {
const allowNoTenant =
opts.noTenancyRequired || !!matches(ctx, noTenancyOptions)
const allowQs = !!matches(ctx, allowQsOptions)
const tenantId = getTenantID(ctx, { allowQs, allowNoTenant })
return doInTenant(tenantId, next)
}
}

View File

@ -0,0 +1,35 @@
import { doInTenant, getTenantIDFromCtx } from "../tenancy"
import { buildMatcherRegex, matches } from "./matchers"
import {
BBContext,
EndpointMatcher,
GetTenantIdOptions,
TenantResolutionStrategy,
} from "@budibase/types"
const tenancy = (
allowQueryStringPatterns: EndpointMatcher[],
noTenancyPatterns: EndpointMatcher,
opts = { noTenancyRequired: false }
) => {
const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns)
const noTenancyOptions = buildMatcherRegex(noTenancyPatterns)
return async function (ctx: BBContext, next: any) {
const allowNoTenant =
opts.noTenancyRequired || !!matches(ctx, noTenancyOptions)
const tenantOpts: GetTenantIdOptions = {
allowNoTenant,
}
const allowQs = !!matches(ctx, allowQsOptions)
if (!allowQs) {
tenantOpts.excludeStrategies = [TenantResolutionStrategy.QUERY]
}
const tenantId = getTenantIDFromCtx(ctx, tenantOpts)
return doInTenant(tenantId, next)
}
}
export = tenancy

View File

@ -12,6 +12,7 @@ import {
MigrationOptions,
MigrationType,
MigrationNoOpOptions,
App,
} from "@budibase/types"
export const getMigrationsDoc = async (db: any) => {
@ -55,14 +56,17 @@ export const runMigration = async (
}
// get the db to store the migration in
let dbNames
let dbNames: string[]
if (migrationType === MigrationType.GLOBAL) {
dbNames = [getGlobalDBName()]
} else if (migrationType === MigrationType.APP) {
if (options.noOp) {
if (!options.noOp.appId) {
throw new Error("appId is required for noOp app migration")
}
dbNames = [options.noOp.appId]
} else {
const apps = await getAllApps(migration.appOpts)
const apps = (await getAllApps(migration.appOpts)) as App[]
dbNames = apps.map(app => app.appId)
}
} else if (migrationType === MigrationType.INSTALLATION) {

View File

@ -9,7 +9,13 @@ import {
getTenantIDFromAppID,
} from "../context"
import env from "../environment"
import { PlatformUser } from "@budibase/types"
import {
BBContext,
PlatformUser,
TenantResolutionStrategy,
GetTenantIdOptions,
} from "@budibase/types"
import { Headers } from "../constants"
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
@ -144,3 +150,82 @@ export const getTenantIds = async () => {
return (tenants && tenants.tenantIds) || []
})
}
const ALL_STRATEGIES = Object.values(TenantResolutionStrategy)
export const getTenantIDFromCtx = (
ctx: BBContext,
opts: GetTenantIdOptions
): string | null => {
// exit early if not multi-tenant
if (!isMultiTenant()) {
return DEFAULT_TENANT_ID
}
// opt defaults
if (opts.allowNoTenant === undefined) {
opts.allowNoTenant = false
}
if (!opts.includeStrategies) {
opts.includeStrategies = ALL_STRATEGIES
}
if (!opts.excludeStrategies) {
opts.excludeStrategies = []
}
const isAllowed = (strategy: TenantResolutionStrategy) => {
// excluded takes precedence
if (opts.excludeStrategies?.includes(strategy)) {
return false
}
if (opts.includeStrategies?.includes(strategy)) {
return true
}
}
// always use user first
if (isAllowed(TenantResolutionStrategy.USER)) {
const userTenantId = ctx.user?.tenantId
if (userTenantId) {
return userTenantId
}
}
// header
if (isAllowed(TenantResolutionStrategy.HEADER)) {
const headerTenantId = ctx.request.headers[Headers.TENANT_ID]
if (headerTenantId) {
return headerTenantId as string
}
}
// query param
if (isAllowed(TenantResolutionStrategy.QUERY)) {
const queryTenantId = ctx.request.query.tenantId
if (queryTenantId) {
return queryTenantId as string
}
}
// subdomain
if (isAllowed(TenantResolutionStrategy.SUBDOMAIN)) {
// e.g. budibase.app or local.com:10000
const platformHost = new URL(env.PLATFORM_URL).host.split(":")[0]
// e.g. tenant.budibase.app or tenant.local.com
const requestHost = ctx.host
// parse the tenant id from the difference
const tenantId = requestHost.substring(
0,
requestHost.indexOf(`.${platformHost}`)
)
if (tenantId) {
return tenantId
}
}
if (!opts.allowNoTenant) {
ctx.throw(403, "Tenant id not set")
}
return null
}

View File

@ -1,38 +1,41 @@
const { DocumentType, SEPARATOR, ViewName, getAllApps } = require("./db/utils")
import { DocumentType, SEPARATOR, ViewName, getAllApps } from "./db/utils"
const jwt = require("jsonwebtoken")
const { options } = require("./middleware/passport/jwt")
const { queryGlobalView } = require("./db/views")
const { Headers, Cookies, MAX_VALID_DATE } = require("./constants")
const env = require("./environment")
const userCache = require("./cache/user")
const {
getSessionsForUser,
invalidateSessions,
} = require("./security/sessions")
const events = require("./events")
const tenancy = require("./tenancy")
import { options } from "./middleware/passport/jwt"
import { queryGlobalView } from "./db/views"
import { Headers, Cookies, MAX_VALID_DATE } from "./constants"
import env from "./environment"
import userCache from "./cache/user"
import { getSessionsForUser, invalidateSessions } from "./security/sessions"
import * as events from "./events"
import tenancy from "./tenancy"
import { App, BBContext, TenantResolutionStrategy } from "@budibase/types"
import { SetOption } from "cookies"
const APP_PREFIX = DocumentType.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/"
function confirmAppId(possibleAppId) {
function confirmAppId(possibleAppId: string | undefined) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
? possibleAppId
: undefined
}
async function resolveAppUrl(ctx) {
async function resolveAppUrl(ctx: BBContext) {
const appUrl = ctx.path.split("/")[2]
let possibleAppUrl = `/${appUrl.toLowerCase()}`
let tenantId = tenancy.getTenantId()
if (!env.SELF_HOSTED && ctx.subdomains.length) {
// always use the tenant id from the url in cloud
tenantId = ctx.subdomains[0]
if (!env.SELF_HOSTED) {
// always use the tenant id from the subdomain in cloud
// this ensures the logged-in user tenant id doesn't overwrite
// e.g. in the case of viewing a public app while already logged-in to another tenant
tenantId = tenancy.getTenantIDFromCtx(ctx, {
includeStrategies: [TenantResolutionStrategy.SUBDOMAIN],
})
}
// search prod apps for a url that matches
const apps = await tenancy.doInTenant(tenantId, () =>
const apps: App[] = await tenancy.doInTenant(tenantId, () =>
getAllApps({ dev: false })
)
const app = apps.filter(
@ -42,7 +45,7 @@ async function resolveAppUrl(ctx) {
return app && app.appId ? app.appId : undefined
}
exports.isServingApp = ctx => {
export const isServingApp = (ctx: BBContext) => {
// dev app
if (ctx.path.startsWith(`/${APP_PREFIX}`)) {
return true
@ -59,12 +62,12 @@ exports.isServingApp = ctx => {
* @param {object} ctx The main request body to look through.
* @returns {string|undefined} If an appId was found it will be returned.
*/
exports.getAppIdFromCtx = async ctx => {
export const getAppIdFromCtx = async (ctx: BBContext) => {
// look in headers
const options = [ctx.headers[Headers.APP_ID]]
let appId
for (let option of options) {
appId = confirmAppId(option)
appId = confirmAppId(option as string)
if (appId) {
break
}
@ -95,7 +98,7 @@ exports.getAppIdFromCtx = async ctx => {
* opens the contents of the specified encrypted JWT.
* @return {object} the contents of the token.
*/
exports.openJwt = token => {
export const openJwt = (token: string) => {
if (!token) {
return token
}
@ -107,14 +110,14 @@ exports.openJwt = token => {
* @param {object} ctx The request which is to be manipulated.
* @param {string} name The name of the cookie to get.
*/
exports.getCookie = (ctx, name) => {
export const getCookie = (ctx: BBContext, name: string) => {
const cookie = ctx.cookies.get(name)
if (!cookie) {
return cookie
}
return exports.openJwt(cookie)
return openJwt(cookie)
}
/**
@ -124,12 +127,17 @@ exports.getCookie = (ctx, name) => {
* @param {string|object} value The value of cookie which will be set.
* @param {object} opts options like whether to sign.
*/
exports.setCookie = (ctx, value, name = "builder", opts = { sign: true }) => {
export const setCookie = (
ctx: BBContext,
value: any,
name = "builder",
opts = { sign: true }
) => {
if (value && opts && opts.sign) {
value = jwt.sign(value, options.secretOrKey)
}
const config = {
const config: SetOption = {
expires: MAX_VALID_DATE,
path: "/",
httpOnly: false,
@ -146,8 +154,8 @@ exports.setCookie = (ctx, value, name = "builder", opts = { sign: true }) => {
/**
* Utility function, simply calls setCookie with an empty string for value
*/
exports.clearCookie = (ctx, name) => {
exports.setCookie(ctx, null, name)
export const clearCookie = (ctx: BBContext, name: string) => {
setCookie(ctx, null, name)
}
/**
@ -156,7 +164,7 @@ exports.clearCookie = (ctx, name) => {
* @param {object} ctx The koa context object to be tested.
* @return {boolean} returns true if the call is from the client lib (a built app rather than the builder).
*/
exports.isClient = ctx => {
export const isClient = (ctx: BBContext) => {
return ctx.headers[Headers.TYPE] === "client"
}
@ -176,18 +184,28 @@ const getBuilders = async () => {
}
}
exports.getBuildersCount = async () => {
export const getBuildersCount = async () => {
const builders = await getBuilders()
return builders.length
}
interface PlatformLogoutOpts {
ctx: BBContext
userId: string
keepActiveSession: boolean
}
/**
* Logs a user out from budibase. Re-used across account portal and builder.
*/
exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
export const platformLogout = async (opts: PlatformLogoutOpts) => {
const ctx = opts.ctx
const userId = opts.userId
const keepActiveSession = opts.keepActiveSession
if (!ctx) throw new Error("Koa context must be supplied to logout.")
const currentSession = exports.getCookie(ctx, Cookies.Auth)
const currentSession = getCookie(ctx, Cookies.Auth)
let sessions = await getSessionsForUser(userId)
if (keepActiveSession) {
@ -196,8 +214,8 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
)
} else {
// clear cookies
exports.clearCookie(ctx, Cookies.Auth)
exports.clearCookie(ctx, Cookies.CurrentApp)
clearCookie(ctx, Cookies.Auth)
clearCookie(ctx, Cookies.CurrentApp)
}
const sessionIds = sessions.map(({ sessionId }) => sessionId)
@ -206,6 +224,6 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
await userCache.invalidateUser(userId)
}
exports.timeout = timeMs => {
export const timeout = (timeMs: number) => {
return new Promise(resolve => setTimeout(resolve, timeMs))
}

View File

@ -2,6 +2,36 @@
const updateDotEnv = require("update-dotenv")
const arg = process.argv.slice(2)[0]
const isEnable = arg === "enable"
let domain = process.argv.slice(2)[1]
if (!domain) {
domain = "local.com"
}
const getAccountPortalUrl = () => {
if (isEnable) {
return `http://account.${domain}:10001`
} else {
return `http://localhost:10001`
}
}
const getBudibaseUrl = () => {
if (isEnable) {
return `http://${domain}:10000`
} else {
return `http://localhost:10000`
}
}
const getCookieDomain = () => {
if (isEnable) {
return `.${domain}`
} else {
return ""
}
}
/**
* For testing multi tenancy sub domains locally.
@ -16,9 +46,7 @@ const arg = process.argv.slice(2)[0]
* 127.0.0.1 t2.local.com
*/
updateDotEnv({
ACCOUNT_PORTAL_URL:
arg === "enable"
? "http://account.local.com:10001"
: "http://localhost:10001",
COOKIE_DOMAIN: arg === "enable" ? ".local.com" : "",
}).then(() => console.log("Updated worker!"))
ACCOUNT_PORTAL_URL: getAccountPortalUrl(),
COOKIE_DOMAIN: getCookieDomain(),
PLATFORM_URL: getBudibaseUrl(),
}).then(() => console.log("Updated server!"))

View File

@ -149,7 +149,7 @@ export const run = async (db: any) => {
}
try {
const allApps: App[] = await dbUtils.getAllApps({ dev: true })
const allApps = (await dbUtils.getAllApps({ dev: true })) as App[]
totals.apps = allApps.length
totals.usage = await quotas.backfill(allApps)

View File

@ -2,11 +2,11 @@ import { getTenantId } from "@budibase/backend-core/tenancy"
import { getAllApps } from "@budibase/backend-core/db"
import { getUniqueRows } from "../../../utilities/usageQuota/rows"
import { quotas } from "@budibase/pro"
import { StaticQuotaName, QuotaUsageType } from "@budibase/types"
import { StaticQuotaName, QuotaUsageType, App } from "@budibase/types"
export const run = async () => {
// get all rows in all apps
const allApps = await getAllApps({ all: true })
const allApps = (await getAllApps({ all: true })) as App[]
const appIds = allApps ? allApps.map((app: { appId: any }) => app.appId) : []
const { appRows } = await getUniqueRows(appIds)

View File

@ -9,3 +9,4 @@ export * from "./koa"
export * from "./auth"
export * from "./locks"
export * from "./db"
export * from "./middleware"

View File

@ -0,0 +1,2 @@
export * from "./matchers"
export * from "./tenancy"

View File

@ -0,0 +1,4 @@
export interface EndpointMatcher {
route: string
method: string
}

View File

@ -0,0 +1,12 @@
export interface GetTenantIdOptions {
allowNoTenant?: boolean
excludeStrategies?: TenantResolutionStrategy[]
includeStrategies?: TenantResolutionStrategy[]
}
export enum TenantResolutionStrategy {
USER = "user",
HEADER = "header",
QUERY = "query",
SUBDOMAIN = "subdomain",
}

View File

@ -2,6 +2,36 @@
const updateDotEnv = require("update-dotenv")
const arg = process.argv.slice(2)[0]
const isEnable = arg === "enable"
let domain = process.argv.slice(2)[1]
if (!domain) {
domain = "local.com"
}
const getAccountPortalUrl = () => {
if (isEnable) {
return `http://account.${domain}:10001`
} else {
return `http://localhost:10001`
}
}
const getBudibaseUrl = () => {
if (isEnable) {
return `http://${domain}:10000`
} else {
return `http://localhost:10000`
}
}
const getCookieDomain = () => {
if (isEnable) {
return `.${domain}`
} else {
return ""
}
}
/**
* For testing multi tenancy sub domains locally.
@ -16,11 +46,7 @@ const arg = process.argv.slice(2)[0]
* 127.0.0.1 t2.local.com
*/
updateDotEnv({
ACCOUNT_PORTAL_URL:
arg === "enable"
? "http://account.local.com:10001"
: "http://localhost:10001",
COOKIE_DOMAIN: arg === "enable" ? ".local.com" : "",
PLATFORM_URL:
arg === "enable" ? "http://local.com:10000" : "http://localhost:10000",
ACCOUNT_PORTAL_URL: getAccountPortalUrl(),
COOKIE_DOMAIN: getCookieDomain(),
PLATFORM_URL: getBudibaseUrl(),
}).then(() => console.log("Updated worker!"))

16
scripts/localdomain.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
enable=$1
domain=$2
if [ "$enable" = "enable" ]; then
lerna run env:localdomain:enable -- "$domain"
cd ../account-portal
yarn env:localdomain:enable "$domain"
cd -
else
lerna run env:localdomain:disable
cd ../account-portal
yarn env:localdomain:disable
cd -
fi