Merge remote-tracking branch 'origin/v3-ui' into feature/automation-branching-ux

This commit is contained in:
Dean 2024-10-10 09:04:52 +01:00
commit 2c31ddc177
58 changed files with 1342 additions and 694 deletions

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.32.12", "version": "2.32.15",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -253,6 +253,11 @@ export function getAppId(): string | undefined {
} }
} }
export function getIP(): string | undefined {
const context = Context.get()
return context?.ip
}
export const getProdAppId = () => { export const getProdAppId = () => {
const appId = getAppId() const appId = getAppId()
if (!appId) { if (!appId) {
@ -281,6 +286,10 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export function doInIPContext(ip: string, task: any) {
return newContext({ ip }, task)
}
export async function ensureSnippetContext(enabled = !env.isTest()) { export async function ensureSnippetContext(enabled = !env.isTest()) {
const ctx = getCurrentContext() const ctx = getCurrentContext()

View File

@ -9,6 +9,7 @@ export type ContextMap = {
identity?: IdentityContext identity?: IdentityContext
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>
isScim?: boolean isScim?: boolean
ip?: string
automationId?: string automationId?: string
isMigrating?: boolean isMigrating?: boolean
vm?: VM vm?: VM

View File

@ -213,17 +213,21 @@ export class DatabaseImpl implements Database {
async getMultiple<T extends Document>( async getMultiple<T extends Document>(
ids: string[], ids: string[],
opts?: { allowMissing?: boolean } opts?: { allowMissing?: boolean; excludeDocs?: boolean }
): Promise<T[]> { ): Promise<T[]> {
// get unique // get unique
ids = [...new Set(ids)] ids = [...new Set(ids)]
const includeDocs = !opts?.excludeDocs
const response = await this.allDocs<T>({ const response = await this.allDocs<T>({
keys: ids, keys: ids,
include_docs: true, include_docs: includeDocs,
}) })
const rowUnavailable = (row: RowResponse<T>) => { const rowUnavailable = (row: RowResponse<T>) => {
// row is deleted - key lookup can return this // row is deleted - key lookup can return this
if (row.doc == null || ("deleted" in row.value && row.value.deleted)) { if (
(includeDocs && row.doc == null) ||
(row.value && "deleted" in row.value && row.value.deleted)
) {
return true return true
} }
return row.error === "not_found" return row.error === "not_found"
@ -237,7 +241,7 @@ export class DatabaseImpl implements Database {
const missingIds = missing.map(row => row.key).join(", ") const missingIds = missing.map(row => row.key).join(", ")
throw new Error(`Unable to get documents: ${missingIds}`) throw new Error(`Unable to get documents: ${missingIds}`)
} }
return rows.map(row => row.doc!) return rows.map(row => (includeDocs ? row.doc! : row.value))
} }
async remove(idOrDoc: string | Document, rev?: string) { async remove(idOrDoc: string | Document, rev?: string) {

View File

@ -0,0 +1,278 @@
import env from "../environment"
import * as crypto from "crypto"
import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
if (
env.POSTHOG_TOKEN &&
env.POSTHOG_API_HOST &&
!env.SELF_HOSTED &&
env.POSTHOG_FEATURE_FLAGS_ENABLED
) {
console.log("initializing posthog client...")
posthog = new PostHog(env.POSTHOG_TOKEN, {
host: env.POSTHOG_API_HOST,
personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(),
...opts,
})
} else {
console.log("posthog disabled")
}
}
export function shutdown() {
posthog?.shutdown()
}
export abstract class Flag<T> {
static boolean(defaultValue: boolean): Flag<boolean> {
return new BooleanFlag(defaultValue)
}
static string(defaultValue: string): Flag<string> {
return new StringFlag(defaultValue)
}
static number(defaultValue: number): Flag<number> {
return new NumberFlag(defaultValue)
}
protected constructor(public defaultValue: T) {}
abstract parse(value: any): T
}
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
export type FlagValues<T> = {
[K in keyof T]: UnwrapFlag<T[K]>
}
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends Flag<U> ? K : never
}[keyof T]
class BooleanFlag extends Flag<boolean> {
parse(value: any) {
if (typeof value === "string") {
return ["true", "t", "1"].includes(value.toLowerCase())
}
if (typeof value === "boolean") {
return value
}
throw new Error(`could not parse value "${value}" as boolean`)
}
}
class StringFlag extends Flag<string> {
parse(value: any) {
if (typeof value === "string") {
return value
}
throw new Error(`could not parse value "${value}" as string`)
}
}
class NumberFlag extends Flag<number> {
parse(value: any) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
throw new Error(`could not parse value "${value}" as number`)
}
}
export interface EnvFlagEntry {
tenantId: string
key: string
value: boolean
}
export function parseEnvFlags(flags: string): EnvFlagEntry[] {
const split = flags.split(",").map(x => x.split(":"))
const result: EnvFlagEntry[] = []
for (const [tenantId, ...features] of split) {
for (let feature of features) {
let value = true
if (feature.startsWith("!")) {
feature = feature.slice(1)
value = false
}
result.push({ tenantId, key: feature, value })
}
}
return result
}
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// This is used to safely cache flags sets in the current request context.
// Because multiple sets could theoretically exist, we don't want the cache of
// one to leak into another.
private readonly setId: string
constructor(private readonly flagSchema: T) {
this.setId = crypto.randomUUID()
}
defaults(): FlagValues<T> {
return Object.keys(this.flagSchema).reduce((acc, key) => {
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
}
isFlagName(name: string | number | symbol): name is keyof T {
return this.flagSchema[name as keyof T] !== undefined
}
async get<K extends keyof T>(key: K): Promise<FlagValues<T>[K]> {
const flags = await this.fetch()
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> {
const flags = await this.fetch()
return flags[key]
}
async fetch(): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
}
const tags: Record<string, any> = {}
const flagValues = this.defaults()
const currentTenantId = context.getTenantId()
const specificallySetFalse = new Set<string>()
for (const { tenantId, key, value } of parseEnvFlags(
env.TENANT_FEATURE_FLAGS || ""
)) {
if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
continue
}
tags[`readFromEnvironmentVars`] = true
if (value === false) {
specificallySetFalse.add(key)
}
// ignore unknown flags
if (!this.isFlagName(key)) {
continue
}
if (typeof flagValues[key] !== "boolean") {
throw new Error(`Feature: ${key} is not a boolean`)
}
// @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object.
flagValues[key as keyof FlagValues] = value
tags[`flags.${key}.source`] = "environment"
}
const identity = context.getIdentity()
let userId = identity?._id
if (!userId) {
const ip = context.getIP()
if (ip) {
userId = crypto.createHash("sha512").update(ip).digest("hex")
}
}
let tenantId = identity?.tenantId
if (!tenantId) {
tenantId = currentTenantId
}
tags[`identity.type`] = identity?.type
tags[`identity._id`] = identity?._id
tags[`tenantId`] = tenantId
tags[`userId`] = userId
if (posthog && userId) {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = { tenantId }
const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, {
personProperties,
})
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
console.warn(`Unexpected posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try {
// @ts-expect-error - TS does not like you writing into a generic
// type, but we know that it's okay in this case because it's just
// an object.
flagValues[name] = flag.parse(payload || value)
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just
// log it and continue.
console.warn(`Error parsing posthog flag "${name}": ${value}`, err)
}
}
}
context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value
}
span?.addTags(tags)
return flagValues
})
}
}
// This is the primary source of truth for feature flags. If you want to add a
// new flag, add it here and use the `fetch` and `get` functions to access it.
// All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system.
export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
})
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>>

View File

@ -1,281 +1,2 @@
import env from "../environment" export * from "./features"
import * as context from "../context" export * as testutils from "./tests/utils"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
if (
env.POSTHOG_TOKEN &&
env.POSTHOG_API_HOST &&
!env.SELF_HOSTED &&
env.POSTHOG_FEATURE_FLAGS_ENABLED
) {
console.log("initializing posthog client...")
posthog = new PostHog(env.POSTHOG_TOKEN, {
host: env.POSTHOG_API_HOST,
personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
featureFlagsPollingInterval: Duration.fromMinutes(3).toMs(),
...opts,
})
} else {
console.log("posthog disabled")
}
}
export function shutdown() {
posthog?.shutdown()
}
export abstract class Flag<T> {
static boolean(defaultValue: boolean): Flag<boolean> {
return new BooleanFlag(defaultValue)
}
static string(defaultValue: string): Flag<string> {
return new StringFlag(defaultValue)
}
static number(defaultValue: number): Flag<number> {
return new NumberFlag(defaultValue)
}
protected constructor(public defaultValue: T) {}
abstract parse(value: any): T
}
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
export type FlagValues<T> = {
[K in keyof T]: UnwrapFlag<T[K]>
}
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends Flag<U> ? K : never
}[keyof T]
class BooleanFlag extends Flag<boolean> {
parse(value: any) {
if (typeof value === "string") {
return ["true", "t", "1"].includes(value.toLowerCase())
}
if (typeof value === "boolean") {
return value
}
throw new Error(`could not parse value "${value}" as boolean`)
}
}
class StringFlag extends Flag<string> {
parse(value: any) {
if (typeof value === "string") {
return value
}
throw new Error(`could not parse value "${value}" as string`)
}
}
class NumberFlag extends Flag<number> {
parse(value: any) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
throw new Error(`could not parse value "${value}" as number`)
}
}
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// This is used to safely cache flags sets in the current request context.
// Because multiple sets could theoretically exist, we don't want the cache of
// one to leak into another.
private readonly setId: string
constructor(private readonly flagSchema: T) {
this.setId = crypto.randomUUID()
}
defaults(): FlagValues<T> {
return Object.keys(this.flagSchema).reduce((acc, key) => {
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
}
isFlagName(name: string | number | symbol): name is keyof T {
return this.flagSchema[name as keyof T] !== undefined
}
async get<K extends keyof T>(
key: K,
ctx?: UserCtx
): Promise<FlagValues<T>[K]> {
const flags = await this.fetch(ctx)
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(
key: K,
ctx?: UserCtx
): Promise<boolean> {
const flags = await this.fetch(ctx)
return flags[key]
}
async fetch(ctx?: UserCtx): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
}
const tags: Record<string, any> = {}
const flagValues = this.defaults()
const currentTenantId = context.getTenantId()
const specificallySetFalse = new Set<string>()
const split = (env.TENANT_FEATURE_FLAGS || "")
.split(",")
.map(x => x.split(":"))
for (const [tenantId, ...features] of split) {
if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
continue
}
tags[`readFromEnvironmentVars`] = true
for (let feature of features) {
let value = true
if (feature.startsWith("!")) {
feature = feature.slice(1)
value = false
specificallySetFalse.add(feature)
}
// ignore unknown flags
if (!this.isFlagName(feature)) {
continue
}
if (typeof flagValues[feature] !== "boolean") {
throw new Error(`Feature: ${feature} is not a boolean`)
}
// @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object.
flagValues[feature as keyof FlagValues] = value
tags[`flags.${feature}.source`] = "environment"
}
}
const license = ctx?.user?.license
if (license) {
tags[`readFromLicense`] = true
for (const feature of license.features) {
if (!this.isFlagName(feature)) {
continue
}
if (
flagValues[feature] === true ||
specificallySetFalse.has(feature)
) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
// @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object.
flagValues[feature] = true
tags[`flags.${feature}.source`] = "license"
}
}
const identity = context.getIdentity()
tags[`identity.type`] = identity?.type
tags[`identity.tenantId`] = identity?.tenantId
tags[`identity._id`] = identity?._id
if (posthog && identity?.type === IdentityType.USER) {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = {}
if (identity.tenantId) {
personProperties.tenantId = identity.tenantId
}
const posthogFlags = await posthog.getAllFlagsAndPayloads(
identity._id,
{
personProperties,
}
)
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
console.warn(`Unexpected posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try {
// @ts-expect-error - TS does not like you writing into a generic
// type, but we know that it's okay in this case because it's just
// an object.
flagValues[name] = flag.parse(payload || value)
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just
// log it and continue.
console.warn(`Error parsing posthog flag "${name}": ${value}`, err)
}
}
}
context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value
}
span?.addTags(tags)
return flagValues
})
}
}
// This is the primary source of truth for feature flags. If you want to add a
// new flag, add it here and use the `fetch` and `get` functions to access it.
// All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system.
export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
})

View File

@ -1,9 +1,10 @@
import { IdentityContext, IdentityType, UserCtx } from "@budibase/types" import { IdentityContext, IdentityType } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../" import { Flag, FlagSet, FlagValues, init, shutdown } from "../"
import * as context from "../../context" import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
import nock from "nock" import nock from "nock"
import * as crypto from "crypto"
const schema = { const schema = {
TEST_BOOLEAN: Flag.boolean(false), TEST_BOOLEAN: Flag.boolean(false),
@ -17,7 +18,6 @@ interface TestCase {
identity?: Partial<IdentityContext> identity?: Partial<IdentityContext>
environmentFlags?: string environmentFlags?: string
posthogFlags?: PostHogFlags posthogFlags?: PostHogFlags
licenseFlags?: Array<string>
expected?: Partial<FlagValues<typeof schema>> expected?: Partial<FlagValues<typeof schema>>
errorMessage?: string | RegExp errorMessage?: string | RegExp
} }
@ -27,10 +27,14 @@ interface PostHogFlags {
featureFlagPayloads?: Record<string, string> featureFlagPayloads?: Record<string, string>
} }
function mockPosthogFlags(flags: PostHogFlags) { function mockPosthogFlags(
flags: PostHogFlags,
opts?: { token?: string; distinct_id?: string }
) {
const { token = "test", distinct_id = "us_1234" } = opts || {}
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .post("/decide/?v=3", body => {
return body.token === "test" && body.distinct_id === "us_1234" return body.token === token && body.distinct_id === distinct_id
}) })
.reply(200, flags) .reply(200, flags)
.persist() .persist()
@ -112,17 +116,6 @@ describe("feature flags", () => {
}, },
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{
it: "should be able to set boolean flags through the license",
licenseFlags: ["TEST_BOOLEAN"],
expected: { TEST_BOOLEAN: true },
},
{
it: "should not be able to override a negative environment flag from license",
environmentFlags: "default:!TEST_BOOLEAN",
licenseFlags: ["TEST_BOOLEAN"],
expected: { TEST_BOOLEAN: false },
},
{ {
it: "should not error on unrecognised PostHog flag", it: "should not error on unrecognised PostHog flag",
posthogFlags: { posthogFlags: {
@ -130,18 +123,12 @@ describe("feature flags", () => {
}, },
expected: flags.defaults(), expected: flags.defaults(),
}, },
{
it: "should not error on unrecognised license flag",
licenseFlags: ["UNDEFINED"],
expected: flags.defaults(),
},
])( ])(
"$it", "$it",
async ({ async ({
identity, identity,
environmentFlags, environmentFlags,
posthogFlags, posthogFlags,
licenseFlags,
expected, expected,
errorMessage, errorMessage,
}) => { }) => {
@ -157,8 +144,6 @@ describe("feature flags", () => {
env.POSTHOG_API_HOST = "https://us.i.posthog.com" env.POSTHOG_API_HOST = "https://us.i.posthog.com"
} }
const ctx = { user: { license: { features: licenseFlags || [] } } }
await withEnv(env, async () => { await withEnv(env, async () => {
// We need to pass in node-fetch here otherwise nock won't get used // We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood. // because posthog-node uses axios under the hood.
@ -180,18 +165,13 @@ describe("feature flags", () => {
await context.doInIdentityContext(fullIdentity, async () => { await context.doInIdentityContext(fullIdentity, async () => {
if (errorMessage) { if (errorMessage) {
await expect(flags.fetch(ctx as UserCtx)).rejects.toThrow( await expect(flags.fetch()).rejects.toThrow(errorMessage)
errorMessage
)
} else if (expected) { } else if (expected) {
const values = await flags.fetch(ctx as UserCtx) const values = await flags.fetch()
expect(values).toMatchObject(expected) expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) { for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.get( const value = await flags.get(key as keyof typeof schema)
key as keyof typeof schema,
ctx as UserCtx
)
expect(value).toBe(expectedValue) expect(value).toBe(expectedValue)
} }
} else { } else {
@ -214,6 +194,14 @@ describe("feature flags", () => {
lastName: "User", lastName: "User",
} }
// We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood.
init({
fetch: (url, opts) => {
return nodeFetch(url, opts)
},
})
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .post("/decide/?v=3", body => {
return body.token === "test" && body.distinct_id === "us_1234" return body.token === "test" && body.distinct_id === "us_1234"
@ -230,4 +218,44 @@ describe("feature flags", () => {
} }
) )
}) })
it("should still get flags when user is logged out", async () => {
const env: Partial<typeof environment> = {
SELF_HOSTED: false,
POSTHOG_FEATURE_FLAGS_ENABLED: "true",
POSTHOG_API_HOST: "https://us.i.posthog.com",
POSTHOG_TOKEN: "test",
}
const ip = "127.0.0.1"
const hashedIp = crypto.createHash("sha512").update(ip).digest("hex")
await withEnv(env, async () => {
mockPosthogFlags(
{
featureFlags: { TEST_BOOLEAN: true },
},
{
distinct_id: hashedIp,
}
)
// We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood.
init({
fetch: (url, opts) => {
return nodeFetch(url, opts)
},
})
await context.doInIPContext(ip, async () => {
await context.doInTenant("default", async () => {
const result = await flags.fetch()
expect(result.TEST_BOOLEAN).toBe(true)
})
})
shutdown()
})
})
}) })

View File

@ -0,0 +1,64 @@
import { FeatureFlags, parseEnvFlags } from ".."
import { setEnv } from "../../environment"
function getCurrentFlags(): Record<string, Record<string, boolean>> {
const result: Record<string, Record<string, boolean>> = {}
for (const { tenantId, key, value } of parseEnvFlags(
process.env.TENANT_FEATURE_FLAGS || ""
)) {
const tenantFlags = result[tenantId] || {}
// Don't allow overwriting specifically false flags, to match the beheaviour
// of FlagSet.
if (tenantFlags[key] === false) {
continue
}
tenantFlags[key] = value
result[tenantId] = tenantFlags
}
return result
}
function buildFlagString(
flags: Record<string, Record<string, boolean>>
): string {
const parts: string[] = []
for (const [tenantId, tenantFlags] of Object.entries(flags)) {
for (const [key, value] of Object.entries(tenantFlags)) {
if (value === false) {
parts.push(`${tenantId}:!${key}`)
} else {
parts.push(`${tenantId}:${key}`)
}
}
}
return parts.join(",")
}
export function setFeatureFlags(
tenantId: string,
flags: Partial<FeatureFlags>
): () => void {
const current = getCurrentFlags()
for (const [key, value] of Object.entries(flags)) {
const tenantFlags = current[tenantId] || {}
tenantFlags[key] = value
current[tenantId] = tenantFlags
}
const flagString = buildFlagString(current)
return setEnv({ TENANT_FEATURE_FLAGS: flagString })
}
export function withFeatureFlags<T>(
tenantId: string,
flags: Partial<FeatureFlags>,
f: () => T
) {
const cleanup = setFeatureFlags(tenantId, flags)
const result = f()
if (result instanceof Promise) {
return result.finally(cleanup)
} else {
cleanup()
return result
}
}

View File

@ -20,3 +20,4 @@ export { default as correlation } from "../logging/correlation/middleware"
export { default as errorHandling } from "./errorHandling" export { default as errorHandling } from "./errorHandling"
export { default as querystringToBody } from "./querystringToBody" export { default as querystringToBody } from "./querystringToBody"
export * as joiValidator from "./joi-validator" export * as joiValidator from "./joi-validator"
export { default as ip } from "./ip"

View File

@ -0,0 +1,12 @@
import { Ctx } from "@budibase/types"
import { doInIPContext } from "../context"
export default async (ctx: Ctx, next: any) => {
if (ctx.ip) {
return await doInIPContext(ctx.ip, () => {
return next()
})
} else {
return next()
}
}

View File

@ -65,7 +65,13 @@ export enum BuiltinPermissionID {
POWER = "power", POWER = "power",
} }
export const BUILTIN_PERMISSIONS = { export const BUILTIN_PERMISSIONS: {
[key in keyof typeof BuiltinPermissionID]: {
_id: (typeof BuiltinPermissionID)[key]
name: string
permissions: Permission[]
}
} = {
PUBLIC: { PUBLIC: {
_id: BuiltinPermissionID.PUBLIC, _id: BuiltinPermissionID.PUBLIC,
name: "Public", name: "Public",

View File

@ -325,7 +325,7 @@ class InternalBuilder {
return input return input
} }
private parseBody(body: any) { private parseBody(body: Record<string, any>) {
for (let [key, value] of Object.entries(body)) { for (let [key, value] of Object.entries(body)) {
const { column } = this.splitter.run(key) const { column } = this.splitter.run(key)
const schema = this.table.schema[column] const schema = this.table.schema[column]
@ -1259,6 +1259,10 @@ class InternalBuilder {
create(opts: QueryOptions): Knex.QueryBuilder { create(opts: QueryOptions): Knex.QueryBuilder {
const { body } = this.query const { body } = this.query
if (!body) {
throw new Error("Cannot create without row body")
}
let query = this.qualifiedKnex({ alias: false }) let query = this.qualifiedKnex({ alias: false })
const parsedBody = this.parseBody(body) const parsedBody = this.parseBody(body)
@ -1417,6 +1421,9 @@ class InternalBuilder {
update(opts: QueryOptions): Knex.QueryBuilder { update(opts: QueryOptions): Knex.QueryBuilder {
const { body, filters } = this.query const { body, filters } = this.query
if (!body) {
throw new Error("Cannot update without row body")
}
let query = this.qualifiedKnex() let query = this.qualifiedKnex()
const parsedBody = this.parseBody(body) const parsedBody = this.parseBody(body)
query = this.addFilters(query, filters) query = this.addFilters(query, filters)

View File

@ -24,6 +24,7 @@ import * as context from "../context"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"
import { isCreator } from "./utils" import { isCreator } from "./utils"
import { UserDB } from "./db" import { UserDB } from "./db"
import { dataFilters } from "@budibase/shared-core"
type GetOpts = { cleanup?: boolean } type GetOpts = { cleanup?: boolean }
@ -262,10 +263,17 @@ export async function paginatedUsers({
userList = await bulkGetGlobalUsersById(query?.oneOf?._id, { userList = await bulkGetGlobalUsersById(query?.oneOf?._id, {
cleanup: true, cleanup: true,
}) })
} else if (query) {
// TODO: this should use SQS search, but the logic is built in the 'server' package. Using the in-memory filtering to get this working meanwhile
const response = await db.allDocs<User>(
getGlobalUserParams(null, { ...opts, limit: undefined })
)
userList = response.rows.map(row => row.doc!)
userList = dataFilters.search(userList, { query, limit: opts.limit }).rows
} else { } else {
// no search, query allDocs // no search, query allDocs
const response = await db.allDocs(getGlobalUserParams(null, opts)) const response = await db.allDocs<User>(getGlobalUserParams(null, opts))
userList = response.rows.map((row: any) => row.doc) userList = response.rows.map(row => row.doc!)
} }
return pagination(userList, pageSize, { return pagination(userList, pageSize, {
paginate: true, paginate: true,

View File

@ -191,7 +191,7 @@ export const initialise = context => {
if ($view?.id !== $datasource.id) { if ($view?.id !== $datasource.id) {
return return
} }
if (JSON.stringify($filter) !== JSON.stringify($view.query)) { if (JSON.stringify($filter) !== JSON.stringify($view.queryUI)) {
await datasource.actions.saveDefinition({ await datasource.actions.saveDefinition({
...$view, ...$view,
query: $filter, query: $filter,

View File

@ -1,7 +1,6 @@
import { permissions, roles, context } from "@budibase/backend-core" import { permissions, roles, context } from "@budibase/backend-core"
import { import {
UserCtx, UserCtx,
Role,
GetResourcePermsResponse, GetResourcePermsResponse,
ResourcePermissionInfo, ResourcePermissionInfo,
GetDependantResourcesResponse, GetDependantResourcesResponse,
@ -9,6 +8,7 @@ import {
AddPermissionRequest, AddPermissionRequest,
RemovePermissionRequest, RemovePermissionRequest,
RemovePermissionResponse, RemovePermissionResponse,
FetchResourcePermissionInfoResponse,
} from "@budibase/types" } from "@budibase/types"
import { import {
CURRENTLY_SUPPORTED_LEVELS, CURRENTLY_SUPPORTED_LEVELS,
@ -28,10 +28,12 @@ export function fetchLevels(ctx: UserCtx) {
ctx.body = SUPPORTED_LEVELS ctx.body = SUPPORTED_LEVELS
} }
export async function fetch(ctx: UserCtx) { export async function fetch(
ctx: UserCtx<void, FetchResourcePermissionInfoResponse>
) {
const db = context.getAppDB() const db = context.getAppDB()
const dbRoles: Role[] = await sdk.permissions.getAllDBRoles(db) const dbRoles = await sdk.permissions.getAllDBRoles(db)
let permissions: any = {} let permissions: Record<string, Record<string, string>> = {}
// create an object with structure role ID -> resource ID -> level // create an object with structure role ID -> resource ID -> level
for (let role of dbRoles) { for (let role of dbRoles) {
if (!role.permissions) { if (!role.permissions) {
@ -43,13 +45,13 @@ export async function fetch(ctx: UserCtx) {
} }
for (let [resource, levelArr] of Object.entries(role.permissions)) { for (let [resource, levelArr] of Object.entries(role.permissions)) {
const levels: string[] = Array.isArray(levelArr) ? levelArr : [levelArr] const levels: string[] = Array.isArray(levelArr) ? levelArr : [levelArr]
const perms: Record<string, string> = {} const perms: Record<string, string> = permissions[resource] || {}
levels.forEach(level => (perms[level] = roleId!)) levels.forEach(level => (perms[level] = roleId!))
permissions[resource] = perms permissions[resource] = perms
} }
} }
// apply the base permissions // apply the base permissions
const finalPermissions: Record<string, Record<string, string>> = {} const finalPermissions: FetchResourcePermissionInfoResponse = {}
for (let [resource, permission] of Object.entries(permissions)) { for (let [resource, permission] of Object.entries(permissions)) {
const basePerms = getBasePermissions(resource) const basePerms = getBasePermissions(resource)
finalPermissions[resource] = Object.assign(basePerms, permission) finalPermissions[resource] = Object.assign(basePerms, permission)
@ -92,18 +94,17 @@ export async function getDependantResources(
export async function addPermission(ctx: UserCtx<void, AddPermissionResponse>) { export async function addPermission(ctx: UserCtx<void, AddPermissionResponse>) {
const params: AddPermissionRequest = ctx.params const params: AddPermissionRequest = ctx.params
ctx.body = await sdk.permissions.updatePermissionOnRole( await sdk.permissions.updatePermissionOnRole(params, PermissionUpdateType.ADD)
params, ctx.status = 200
PermissionUpdateType.ADD
)
} }
export async function removePermission( export async function removePermission(
ctx: UserCtx<void, RemovePermissionResponse> ctx: UserCtx<void, RemovePermissionResponse>
) { ) {
const params: RemovePermissionRequest = ctx.params const params: RemovePermissionRequest = ctx.params
ctx.body = await sdk.permissions.updatePermissionOnRole( await sdk.permissions.updatePermissionOnRole(
params, params,
PermissionUpdateType.REMOVE PermissionUpdateType.REMOVE
) )
ctx.status = 200
} }

View File

@ -4,6 +4,7 @@ import {
AutoFieldSubType, AutoFieldSubType,
AutoReason, AutoReason,
Datasource, Datasource,
DatasourcePlusQueryResponse,
FieldSchema, FieldSchema,
FieldType, FieldType,
FilterType, FilterType,
@ -269,18 +270,13 @@ export class ExternalRequest<T extends Operation> {
} }
} }
private async removeManyToManyRelationships( private async removeManyToManyRelationships(rowId: string, table: Table) {
rowId: string,
table: Table,
colName: string
) {
const tableId = table._id! const tableId = table._id!
const filters = this.prepareFilters(rowId, {}, table) const filters = this.prepareFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen // safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) { if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({ return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.DELETE), endpoint: getEndpoint(tableId, Operation.DELETE),
body: { [colName]: null },
filters, filters,
meta: { meta: {
table, table,
@ -291,13 +287,18 @@ export class ExternalRequest<T extends Operation> {
} }
} }
private async removeOneToManyRelationships(rowId: string, table: Table) { private async removeOneToManyRelationships(
rowId: string,
table: Table,
colName: string
) {
const tableId = table._id! const tableId = table._id!
const filters = this.prepareFilters(rowId, {}, table) const filters = this.prepareFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen // safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) { if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({ return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.UPDATE), endpoint: getEndpoint(tableId, Operation.UPDATE),
body: { [colName]: null },
filters, filters,
meta: { meta: {
table, table,
@ -557,8 +558,9 @@ export class ExternalRequest<T extends Operation> {
return matchesPrimaryLink return matchesPrimaryLink
} }
const matchesSecondayLink = row[linkSecondary] === body?.[linkSecondary] const matchesSecondaryLink =
return matchesPrimaryLink && matchesSecondayLink row[linkSecondary] === body?.[linkSecondary]
return matchesPrimaryLink && matchesSecondaryLink
} }
const existingRelationship = rows.find((row: { [key: string]: any }) => const existingRelationship = rows.find((row: { [key: string]: any }) =>
@ -595,8 +597,8 @@ export class ExternalRequest<T extends Operation> {
for (let row of rows) { for (let row of rows) {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
const promise: Promise<any> = isMany const promise: Promise<any> = isMany
? this.removeManyToManyRelationships(rowId, table, colName) ? this.removeManyToManyRelationships(rowId, table)
: this.removeOneToManyRelationships(rowId, table) : this.removeOneToManyRelationships(rowId, table, colName)
if (promise) { if (promise) {
promises.push(promise) promises.push(promise)
} }
@ -619,12 +621,12 @@ export class ExternalRequest<T extends Operation> {
rows.map(row => { rows.map(row => {
const rowId = generateIdForRow(row, table) const rowId = generateIdForRow(row, table)
return isMany return isMany
? this.removeManyToManyRelationships( ? this.removeManyToManyRelationships(rowId, table)
: this.removeOneToManyRelationships(
rowId, rowId,
table, table,
relationshipColumn.fieldName relationshipColumn.fieldName
) )
: this.removeOneToManyRelationships(rowId, table)
}) })
) )
} }
@ -669,6 +671,7 @@ export class ExternalRequest<T extends Operation> {
config.includeSqlRelationships === IncludeRelationship.INCLUDE config.includeSqlRelationships === IncludeRelationship.INCLUDE
// clean up row on ingress using schema // clean up row on ingress using schema
const unprocessedRow = config.row
const processed = this.inputProcessing(row, table) const processed = this.inputProcessing(row, table)
row = processed.row row = processed.row
let manyRelationships = processed.manyRelationships let manyRelationships = processed.manyRelationships
@ -743,9 +746,20 @@ export class ExternalRequest<T extends Operation> {
// aliasing can be disabled fully if desired // aliasing can be disabled fully if desired
const aliasing = new sdk.rows.AliasTables(Object.keys(this.tables)) const aliasing = new sdk.rows.AliasTables(Object.keys(this.tables))
let response = env.SQL_ALIASING_DISABLE let response: DatasourcePlusQueryResponse
? await getDatasourceAndQuery(json) // there's a chance after input processing nothing needs updated, so pass over the call
: await aliasing.queryWithAliasing(json, makeExternalQuery) // we might still need to perform other operations like updating the foreign keys on other rows
if (
this.operation === Operation.UPDATE &&
Object.keys(row || {}).length === 0 &&
unprocessedRow
) {
response = [unprocessedRow]
} else {
response = env.SQL_ALIASING_DISABLE
? await getDatasourceAndQuery(json)
: await aliasing.queryWithAliasing(json, makeExternalQuery)
}
// if it's a counting operation there will be no more processing, just return the number // if it's a counting operation there will be no more processing, just return the number
if (this.operation === Operation.COUNT) { if (this.operation === Operation.COUNT) {

View File

@ -27,6 +27,7 @@ import {
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import { generateIdForRow } from "./utils" import { generateIdForRow } from "./utils"
import { helpers } from "@budibase/shared-core"
export async function handleRequest<T extends Operation>( export async function handleRequest<T extends Operation>(
operation: T, operation: T,
@ -42,6 +43,11 @@ export async function handleRequest<T extends Operation>(
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const source = await utils.getSource(ctx) const source = await utils.getSource(ctx)
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
ctx.throw(400, "Cannot update rows through a calculation view")
}
const table = await utils.getTableFromSource(source) const table = await utils.getTableFromSource(source)
const { _id, ...rowData } = ctx.request.body const { _id, ...rowData } = ctx.request.body

View File

@ -22,13 +22,20 @@ import sdk from "../../../sdk"
import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils" import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils"
import { flatten } from "lodash" import { flatten } from "lodash"
import { findRow } from "../../../sdk/app/rows/internal" import { findRow } from "../../../sdk/app/rows/internal"
import { helpers } from "@budibase/shared-core"
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) { export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const { tableId } = utils.getSourceId(ctx) const { tableId } = utils.getSourceId(ctx)
const source = await utils.getSource(ctx) const source = await utils.getSource(ctx)
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
ctx.throw(400, "Cannot update rows through a calculation view")
}
const table = sdk.views.isView(source) const table = sdk.views.isView(source)
? await sdk.views.getTable(source.id) ? await sdk.views.getTable(source.id)
: source : source
const inputs = ctx.request.body const inputs = ctx.request.body
const isUserTable = tableId === InternalTables.USER_METADATA const isUserTable = tableId === InternalTables.USER_METADATA
let oldRow let oldRow

View File

@ -31,7 +31,7 @@ function getDatasourceId(table: Table) {
return breakExternalTableId(table._id).datasourceId return breakExternalTableId(table._id).datasourceId
} }
export async function save( export async function updateTable(
ctx: UserCtx<SaveTableRequest, SaveTableResponse>, ctx: UserCtx<SaveTableRequest, SaveTableResponse>,
renaming?: RenameColumn renaming?: RenameColumn
) { ) {

View File

@ -102,18 +102,22 @@ export async function find(ctx: UserCtx<void, TableResponse>) {
export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) { export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
const appId = ctx.appId const appId = ctx.appId
const table = ctx.request.body const { rows, ...table } = ctx.request.body
const isImport = table.rows const isImport = rows
const renaming = ctx.request.body._rename const renaming = ctx.request.body._rename
const isCreate = !table._id
checkDefaultFields(table) checkDefaultFields(table)
const api = pickApi({ table }) let savedTable: Table
let savedTable = await api.save(ctx, renaming) if (isCreate) {
if (!table._id) { savedTable = await sdk.tables.create(table, rows, ctx.user._id)
savedTable = await sdk.tables.enrichViewSchemas(savedTable) savedTable = await sdk.tables.enrichViewSchemas(savedTable)
await events.table.created(savedTable) await events.table.created(savedTable)
} else { } else {
const api = pickApi({ table })
savedTable = await api.updateTable(ctx, renaming)
await events.table.updated(savedTable) await events.table.updated(savedTable)
} }
if (renaming) { if (renaming) {

View File

@ -12,7 +12,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
export async function save( export async function updateTable(
ctx: UserCtx<SaveTableRequest, SaveTableResponse>, ctx: UserCtx<SaveTableRequest, SaveTableResponse>,
renaming?: RenameColumn renaming?: RenameColumn
) { ) {
@ -25,19 +25,16 @@ export async function save(
sourceType: rest.sourceType || TableSourceType.INTERNAL, sourceType: rest.sourceType || TableSourceType.INTERNAL,
} }
const isImport = !!rows
if (!tableToSave.views) { if (!tableToSave.views) {
tableToSave.views = {} tableToSave.views = {}
} }
try { try {
const { table } = await sdk.tables.internal.save(tableToSave, { const { table } = await sdk.tables.internal.save(tableToSave, {
user: ctx.user, userId: ctx.user._id,
rowsToImport: rows, rowsToImport: rows,
tableId: ctx.request.body._id, tableId: ctx.request.body._id,
renaming, renaming,
isImport,
}) })
return table return table
@ -72,7 +69,7 @@ export async function bulkImport(
await handleDataImport(table, { await handleDataImport(table, {
importRows: rows, importRows: rows,
identifierFields, identifierFields,
user: ctx.user, userId: ctx.user._id,
}) })
return table return table
} }

View File

@ -41,7 +41,7 @@ describe("utils", () => {
const data = [{ name: "Alice" }, { name: "Bob" }, { name: "Claire" }] const data = [{ name: "Alice" }, { name: "Bob" }, { name: "Claire" }]
const result = await importToRows(data, table, config.user) const result = await importToRows(data, table, config.user?._id)
expect(result).toEqual([ expect(result).toEqual([
expect.objectContaining({ expect.objectContaining({
autoId: 1, autoId: 1,

View File

@ -18,7 +18,6 @@ import { quotas } from "@budibase/pro"
import { events, context, features } from "@budibase/backend-core" import { events, context, features } from "@budibase/backend-core"
import { import {
AutoFieldSubType, AutoFieldSubType,
ContextUser,
Datasource, Datasource,
Row, Row,
SourceName, SourceName,
@ -122,7 +121,7 @@ export function makeSureTableUpToDate(table: Table, tableToSave: Table) {
export async function importToRows( export async function importToRows(
data: Row[], data: Row[],
table: Table, table: Table,
user?: ContextUser, userId?: string,
opts?: { keepCouchId: boolean } opts?: { keepCouchId: boolean }
) { ) {
const originalTable = table const originalTable = table
@ -136,7 +135,7 @@ export async function importToRows(
// We use a reference to table here and update it after input processing, // We use a reference to table here and update it after input processing,
// so that we can auto increment auto IDs in imported data properly // so that we can auto increment auto IDs in imported data properly
const processed = await inputProcessing(user?._id, table, row, { const processed = await inputProcessing(userId, table, row, {
noAutoRelationships: true, noAutoRelationships: true,
}) })
row = processed row = processed
@ -167,11 +166,10 @@ export async function importToRows(
export async function handleDataImport( export async function handleDataImport(
table: Table, table: Table,
opts?: { identifierFields?: string[]; user?: ContextUser; importRows?: Row[] } opts?: { identifierFields?: string[]; userId?: string; importRows?: Row[] }
) { ) {
const schema = table.schema const schema = table.schema
const identifierFields = opts?.identifierFields || [] const identifierFields = opts?.identifierFields || []
const user = opts?.user
const importRows = opts?.importRows const importRows = opts?.importRows
if (!importRows || !isRows(importRows) || !isSchema(schema)) { if (!importRows || !isRows(importRows) || !isSchema(schema)) {
@ -181,7 +179,7 @@ export async function handleDataImport(
const db = context.getAppDB() const db = context.getAppDB()
const data = parse(importRows, table) const data = parse(importRows, table)
const finalData = await importToRows(data, table, user, { const finalData = await importToRows(data, table, opts?.userId, {
keepCouchId: identifierFields.includes("_id"), keepCouchId: identifierFields.includes("_id"),
}) })
@ -282,22 +280,22 @@ export function checkStaticTables(table: Table) {
class TableSaveFunctions { class TableSaveFunctions {
db: Database db: Database
user?: ContextUser userId?: string
oldTable?: Table oldTable?: Table
importRows?: Row[] importRows?: Row[]
rows: Row[] rows: Row[]
constructor({ constructor({
user, userId,
oldTable, oldTable,
importRows, importRows,
}: { }: {
user?: ContextUser userId?: string
oldTable?: Table oldTable?: Table
importRows?: Row[] importRows?: Row[]
}) { }) {
this.db = context.getAppDB() this.db = context.getAppDB()
this.user = user this.userId = userId
this.oldTable = oldTable this.oldTable = oldTable
this.importRows = importRows this.importRows = importRows
// any rows that need updated // any rows that need updated
@ -329,7 +327,7 @@ class TableSaveFunctions {
table = await handleSearchIndexes(table) table = await handleSearchIndexes(table)
table = await handleDataImport(table, { table = await handleDataImport(table, {
importRows: this.importRows, importRows: this.importRows,
user: this.user, userId: this.userId,
}) })
if (await features.flags.isEnabled("SQS")) { if (await features.flags.isEnabled("SQS")) {
await sdk.tables.sqs.addTable(table) await sdk.tables.sqs.addTable(table)

View File

@ -14,12 +14,7 @@ jest.mock("../../../utilities/redis", () => ({
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
import { import { events, utils, context, features } from "@budibase/backend-core"
events,
utils,
context,
withEnv as withCoreEnv,
} from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
import { type App } from "@budibase/types" import { type App } from "@budibase/types"
import tk from "timekeeper" import tk from "timekeeper"
@ -358,9 +353,13 @@ describe("/applications", () => {
.delete(`/api/global/roles/${prodAppId}`) .delete(`/api/global/roles/${prodAppId}`)
.reply(200, {}) .reply(200, {})
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, async () => { await features.testutils.withFeatureFlags(
await config.api.application.delete(app.appId) "*",
}) { SQS: true },
async () => {
await config.api.application.delete(app.appId)
}
)
}) })
}) })

View File

@ -1,5 +1,5 @@
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { Document, PermissionLevel, Row, Table, ViewV2 } from "@budibase/types" import { Document, PermissionLevel, Row } from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
@ -9,13 +9,11 @@ const { BUILTIN_ROLE_IDS } = roles
const HIGHER_ROLE_ID = BUILTIN_ROLE_IDS.BASIC const HIGHER_ROLE_ID = BUILTIN_ROLE_IDS.BASIC
const STD_ROLE_ID = BUILTIN_ROLE_IDS.PUBLIC const STD_ROLE_ID = BUILTIN_ROLE_IDS.PUBLIC
const DEFAULT_TABLE_ROLE_ID = BUILTIN_ROLE_IDS.ADMIN
describe("/permission", () => { describe("/permission", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let table: Table & { _id: string }
let perms: Document[]
let row: Row
let view: ViewV2
afterAll(setup.afterAll) afterAll(setup.afterAll)
@ -25,18 +23,6 @@ describe("/permission", () => {
beforeEach(async () => { beforeEach(async () => {
mocks.licenses.useCloudFree() mocks.licenses.useCloudFree()
table = (await config.createTable()) as typeof table
row = await config.createRow()
view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
})
perms = await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.READ,
})
}) })
describe("levels", () => { describe("levels", () => {
@ -54,134 +40,251 @@ describe("/permission", () => {
}) })
}) })
describe("add", () => { describe("table permissions", () => {
it("should be able to add permission to a role for the table", async () => { let tableId: string
expect(perms.length).toEqual(1)
expect(perms[0]._id).toEqual(`${STD_ROLE_ID}`)
})
it("should get the resource permissions", async () => { beforeEach(async () => {
const res = await request const table = await config.createTable()
.get(`/api/permission/${table._id}`) tableId = table._id!
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "BASE", role: HIGHER_ROLE_ID },
},
})
})
it("should get resource permissions with multiple roles", async () => {
perms = await config.api.permission.add({
roleId: HIGHER_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.WRITE,
})
const res = await config.api.permission.get(table._id)
expect(res).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "EXPLICIT", role: HIGHER_ROLE_ID },
},
})
const allRes = await request
.get(`/api/permission`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID)
expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID)
})
})
describe("remove", () => {
it("should be able to remove the permission", async () => {
const res = await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.READ,
})
expect(res[0]._id).toEqual(STD_ROLE_ID)
const permsRes = await config.api.permission.get(table._id)
expect(permsRes.permissions[STD_ROLE_ID]).toBeUndefined()
})
})
describe("check public user allowed", () => {
it("should be able to read the row", async () => {
// replicate changes before checking permissions
await config.publish()
const res = await request
.get(`/api/${table._id}/rows`)
.set(config.publicHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]._id).toEqual(row._id)
})
it("should be able to access the view data when the table is set to public and with no view permissions overrides", async () => {
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
const res = await config.api.viewV2.publicSearch(view.id)
expect(res.rows[0]._id).toEqual(row._id)
})
it("should not be able to access the view data when the table is not public and there are no view permissions overrides", async () => {
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.READ,
})
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
await config.api.viewV2.publicSearch(view.id, undefined, { status: 401 })
})
it("should use the view permissions", async () => {
await config.api.permission.add({ await config.api.permission.add({
roleId: STD_ROLE_ID, roleId: STD_ROLE_ID,
resourceId: view.id, resourceId: tableId,
level: PermissionLevel.READ, level: PermissionLevel.READ,
}) })
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
const res = await config.api.viewV2.publicSearch(view.id)
expect(res.rows[0]._id).toEqual(row._id)
}) })
it("shouldn't allow writing from a public user", async () => { it("tables should be defaulted to admin", async () => {
const res = await request const table = await config.createTable()
.post(`/api/${table._id}/rows`) const { permissions } = await config.api.permission.get(table._id!)
.send(basicRow(table._id)) expect(permissions).toEqual({
.set(config.publicHeaders()) read: {
.expect("Content-Type", /json/) permissionType: "EXPLICIT",
.expect(401) role: DEFAULT_TABLE_ROLE_ID,
expect(res.status).toEqual(401) },
write: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
},
})
})
describe("add", () => {
it("should be able to add permission to a role for the table", async () => {
const res = await request
.get(`/api/permission/${tableId}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "EXPLICIT", role: DEFAULT_TABLE_ROLE_ID },
},
})
})
it("should get resource permissions with multiple roles", async () => {
await config.api.permission.add({
roleId: HIGHER_ROLE_ID,
resourceId: tableId,
level: PermissionLevel.WRITE,
})
const res = await config.api.permission.get(tableId)
expect(res).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "EXPLICIT", role: HIGHER_ROLE_ID },
},
})
const allRes = await request
.get(`/api/permission`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(allRes.body[tableId]["read"]).toEqual(STD_ROLE_ID)
expect(allRes.body[tableId]["write"]).toEqual(HIGHER_ROLE_ID)
})
})
describe("remove", () => {
it("should be able to remove the permission", async () => {
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: tableId,
level: PermissionLevel.READ,
})
const permsRes = await config.api.permission.get(tableId)
expect(permsRes.permissions[STD_ROLE_ID]).toBeUndefined()
})
})
describe("check public user allowed", () => {
let viewId: string
let row: Row
beforeEach(async () => {
const view = await config.api.viewV2.create({
tableId,
name: generator.guid(),
})
viewId = view.id
row = await config.createRow()
})
it("should be able to read the row", async () => {
// replicate changes before checking permissions
await config.publish()
const res = await request
.get(`/api/${tableId}/rows`)
.set(config.publicHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body[0]._id).toEqual(row._id)
})
it("should be able to access the view data when the table is set to public and with no view permissions overrides", async () => {
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: viewId,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
const res = await config.api.viewV2.publicSearch(viewId)
expect(res.rows[0]._id).toEqual(row._id)
})
it("should not be able to access the view data when the table is not public and there are no view permissions overrides", async () => {
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: tableId,
level: PermissionLevel.READ,
})
// Make view inherit table permissions. Needed for backwards compatibility with existing views.
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: viewId,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
await config.api.viewV2.publicSearch(viewId, undefined, {
status: 401,
})
})
it("should use the view permissions", async () => {
await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: viewId,
level: PermissionLevel.READ,
})
await config.api.permission.revoke({
roleId: STD_ROLE_ID,
resourceId: tableId,
level: PermissionLevel.READ,
})
// replicate changes before checking permissions
await config.publish()
const res = await config.api.viewV2.publicSearch(viewId)
expect(res.rows[0]._id).toEqual(row._id)
})
it("shouldn't allow writing from a public user", async () => {
const res = await request
.post(`/api/${tableId}/rows`)
.send(basicRow(tableId))
.set(config.publicHeaders())
.expect("Content-Type", /json/)
.expect(401)
expect(res.status).toEqual(401)
})
})
})
describe("view permissions", () => {
let tableId: string
let viewId: string
beforeEach(async () => {
const table = await config.createTable()
tableId = table._id!
const view = await config.api.viewV2.create({
tableId,
name: generator.guid(),
})
viewId = view.id
})
it("default permissions inherits and persists the table default value", async () => {
const { permissions } = await config.api.permission.get(viewId)
expect(permissions).toEqual({
read: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
inheritablePermission: DEFAULT_TABLE_ROLE_ID,
},
write: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
inheritablePermission: DEFAULT_TABLE_ROLE_ID,
},
})
})
it("does not update view permissions once persisted, even if table permissions change", async () => {
await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: tableId,
level: PermissionLevel.READ,
})
const { permissions } = await config.api.permission.get(viewId)
expect(permissions).toEqual({
read: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
inheritablePermission: STD_ROLE_ID,
},
write: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
inheritablePermission: DEFAULT_TABLE_ROLE_ID,
},
})
})
it("can sets permissions inherits explicit view permissions", async () => {
await config.api.permission.add({
roleId: HIGHER_ROLE_ID,
resourceId: viewId,
level: PermissionLevel.WRITE,
})
const { permissions } = await config.api.permission.get(viewId)
expect(permissions).toEqual({
read: {
permissionType: "EXPLICIT",
role: DEFAULT_TABLE_ROLE_ID,
inheritablePermission: DEFAULT_TABLE_ROLE_ID,
},
write: {
permissionType: "EXPLICIT",
role: HIGHER_ROLE_ID,
inheritablePermission: DEFAULT_TABLE_ROLE_ID,
},
})
}) })
}) })

View File

@ -28,6 +28,7 @@ describe.each(
const config = setup.getConfig() const config = setup.getConfig()
const isOracle = dbName === DatabaseName.ORACLE const isOracle = dbName === DatabaseName.ORACLE
const isMsSQL = dbName === DatabaseName.SQL_SERVER const isMsSQL = dbName === DatabaseName.SQL_SERVER
const isPostgres = dbName === DatabaseName.POSTGRES
let rawDatasource: Datasource let rawDatasource: Datasource
let datasource: Datasource let datasource: Datasource
@ -47,6 +48,9 @@ describe.each(
transformer: "return data", transformer: "return data",
readable: true, readable: true,
} }
if (query.fields?.sql && typeof query.fields.sql !== "string") {
throw new Error("Unable to create with knex structure in 'sql' field")
}
return await config.api.query.save( return await config.api.query.save(
{ ...defaultQuery, ...query }, { ...defaultQuery, ...query },
expectations expectations
@ -207,6 +211,31 @@ describe.each(
expect(prodQuery.parameters).toBeUndefined() expect(prodQuery.parameters).toBeUndefined()
expect(prodQuery.schema).toBeDefined() expect(prodQuery.schema).toBeDefined()
}) })
isPostgres &&
it("should be able to handle a JSON aggregate with newlines", async () => {
const jsonStatement = `COALESCE(json_build_object('name', name),'{"name":{}}'::json)`
const query = await createQuery({
fields: {
sql: client("test_table")
.select([
"*",
client.raw(
`${jsonStatement} as json,\n${jsonStatement} as json2`
),
])
.toString(),
},
})
const res = await config.api.query.execute(
query._id!,
{},
{
status: 200,
}
)
expect(res).toBeDefined()
})
}) })
}) })

View File

@ -13,8 +13,7 @@ import {
context, context,
InternalTable, InternalTable,
tenancy, tenancy,
withEnv as withCoreEnv, features,
setEnv as setCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import {
@ -40,7 +39,6 @@ import {
TableSchema, TableSchema,
JsonFieldSubType, JsonFieldSubType,
RowExportFormat, RowExportFormat,
FeatureFlag,
RelationSchemaField, RelationSchemaField,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
@ -98,12 +96,12 @@ describe.each([
let envCleanup: (() => void) | undefined let envCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) await features.testutils.withFeatureFlags("*", { SQS: true }, () =>
if (isLucene) { config.init()
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) )
} else if (isSqs) { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }) SQS: isSqs,
} })
if (dsProvider) { if (dsProvider) {
const rawDatasource = await dsProvider const rawDatasource = await dsProvider
@ -1116,6 +1114,33 @@ describe.each([
expect(getResp.user2[0]._id).toEqual(user2._id) expect(getResp.user2[0]._id).toEqual(user2._id)
}) })
it("should be able to remove a relationship from many side", async () => {
const row = await config.api.row.save(otherTable._id!, {
name: "test",
description: "test",
})
const row2 = await config.api.row.save(otherTable._id!, {
name: "test",
description: "test",
})
const { _id } = await config.api.row.save(table._id!, {
relationship: [{ _id: row._id }, { _id: row2._id }],
})
const relatedRow = await config.api.row.get(table._id!, _id!, {
status: 200,
})
expect(relatedRow.relationship.length).toEqual(2)
await config.api.row.save(table._id!, {
...relatedRow,
relationship: [{ _id: row._id }],
})
const afterRelatedRow = await config.api.row.get(table._id!, _id!, {
status: 200,
})
expect(afterRelatedRow.relationship.length).toEqual(1)
expect(afterRelatedRow.relationship[0]._id).toEqual(row._id)
})
it("should be able to update relationships when both columns are same name", async () => { it("should be able to update relationships when both columns are same name", async () => {
let row = await config.api.row.save(table._id!, { let row = await config.api.row.save(table._id!, {
name: "test", name: "test",
@ -1848,7 +1873,7 @@ describe.each([
}) })
describe("exportRows", () => { describe("exportRows", () => {
beforeAll(async () => { beforeEach(async () => {
table = await config.api.table.save(defaultTable()) table = await config.api.table.save(defaultTable())
}) })
@ -1885,6 +1910,16 @@ describe.each([
}) })
}) })
it("should allow exporting without filtering", async () => {
const existing = await config.api.row.save(table._id!, {})
const res = await config.api.row.exportRows(table._id!)
const results = JSON.parse(res)
expect(results.length).toEqual(1)
const row = results[0]
expect(row._id).toEqual(existing._id)
})
it("should allow exporting only certain columns", async () => { it("should allow exporting only certain columns", async () => {
const existing = await config.api.row.save(table._id!, {}) const existing = await config.api.row.save(table._id!, {})
const res = await config.api.row.exportRows(table._id!, { const res = await config.api.row.exportRows(table._id!, {
@ -2517,15 +2552,9 @@ describe.each([
let flagCleanup: (() => void) | undefined let flagCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
const env = { flagCleanup = features.testutils.setFeatureFlags("*", {
TENANT_FEATURE_FLAGS: `*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`, ENRICHED_RELATIONSHIPS: true,
} })
if (isSqs) {
env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:SQS`
} else {
env.TENANT_FEATURE_FLAGS = `${env.TENANT_FEATURE_FLAGS},*:!SQS`
}
flagCleanup = setCoreEnv(env)
const aux2Table = await config.api.table.save(saveTableRequest()) const aux2Table = await config.api.table.save(saveTableRequest())
const aux2Data = await config.api.row.save(aux2Table._id!, {}) const aux2Data = await config.api.row.save(aux2Table._id!, {})
@ -2752,9 +2781,10 @@ describe.each([
it.each(testScenarios)( it.each(testScenarios)(
"does not enrich relationships when not enabled (via %s)", "does not enrich relationships when not enabled (via %s)",
async (__, retrieveDelegate) => { async (__, retrieveDelegate) => {
await withCoreEnv( await features.testutils.withFeatureFlags(
"*",
{ {
TENANT_FEATURE_FLAGS: `*:!${FeatureFlag.ENRICHED_RELATIONSHIPS}`, ENRICHED_RELATIONSHIPS: false,
}, },
async () => { async () => {
const otherRows = _.sampleSize(auxData, 5) const otherRows = _.sampleSize(auxData, 5)

View File

@ -7,9 +7,9 @@ import {
import { import {
context, context,
db as dbCore, db as dbCore,
features,
MAX_VALID_DATE, MAX_VALID_DATE,
MIN_VALID_DATE, MIN_VALID_DATE,
setEnv as setCoreEnv,
SQLITE_DESIGN_DOC_ID, SQLITE_DESIGN_DOC_ID,
utils, utils,
withEnv as withCoreEnv, withEnv as withCoreEnv,
@ -94,16 +94,12 @@ describe.each([
} }
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, () => config.init()) await features.testutils.withFeatureFlags("*", { SQS: true }, () =>
if (isLucene) { config.init()
envCleanup = setCoreEnv({ )
TENANT_FEATURE_FLAGS: "*:!SQS", envCleanup = features.testutils.setFeatureFlags("*", {
}) SQS: isSqs,
} else if (isSqs) { })
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (config.app?.appId) { if (config.app?.appId) {
config.app = await config.api.application.update(config.app?.appId, { config.app = await config.api.application.update(config.app?.appId, {
@ -191,7 +187,6 @@ describe.each([
if (isInMemory) { if (isInMemory) {
return dataFilters.search(_.cloneDeep(rows), { return dataFilters.search(_.cloneDeep(rows), {
...this.query, ...this.query,
tableId: tableOrViewId,
}) })
} else { } else {
return config.api.row.search(tableOrViewId, this.query) return config.api.row.search(tableOrViewId, this.query)

View File

@ -2,7 +2,7 @@ import * as setup from "./utilities"
import path from "path" import path from "path"
import nock from "nock" import nock from "nock"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { withEnv as withCoreEnv, env as coreEnv } from "@budibase/backend-core" import { features } from "@budibase/backend-core"
interface App { interface App {
background: string background: string
@ -85,41 +85,44 @@ describe("/templates", () => {
it.each(["sqs", "lucene"])( it.each(["sqs", "lucene"])(
`should be able to create an app from a template (%s)`, `should be able to create an app from a template (%s)`,
async source => { async source => {
const env: Partial<typeof coreEnv> = { await features.testutils.withFeatureFlags(
TENANT_FEATURE_FLAGS: source === "sqs" ? "*:SQS" : "", "*",
} { SQS: source === "sqs" },
async () => {
const name = generator.guid().replaceAll("-", "")
const url = `/${name}`
await withCoreEnv(env, async () => { const app = await config.api.application.create({
const name = generator.guid().replaceAll("-", "") name,
const url = `/${name}` url,
useTemplate: "true",
const app = await config.api.application.create({ templateName: "Agency Client Portal",
name, templateKey: "app/agency-client-portal",
url,
useTemplate: "true",
templateName: "Agency Client Portal",
templateKey: "app/agency-client-portal",
})
expect(app.name).toBe(name)
expect(app.url).toBe(url)
await config.withApp(app, async () => {
const tables = await config.api.table.fetch()
expect(tables).toHaveLength(2)
tables.sort((a, b) => a.name.localeCompare(b.name))
const [agencyProjects, users] = tables
expect(agencyProjects.name).toBe("Agency Projects")
expect(users.name).toBe("Users")
const { rows } = await config.api.row.search(agencyProjects._id!, {
tableId: agencyProjects._id!,
query: {},
}) })
expect(app.name).toBe(name)
expect(app.url).toBe(url)
expect(rows).toHaveLength(3) await config.withApp(app, async () => {
}) const tables = await config.api.table.fetch()
}) expect(tables).toHaveLength(2)
tables.sort((a, b) => a.name.localeCompare(b.name))
const [agencyProjects, users] = tables
expect(agencyProjects.name).toBe("Agency Projects")
expect(users.name).toBe("Users")
const { rows } = await config.api.row.search(
agencyProjects._id!,
{
tableId: agencyProjects._id!,
query: {},
}
)
expect(rows).toHaveLength(3)
})
}
)
} }
) )
}) })

View File

@ -22,23 +22,17 @@ import {
RelationshipType, RelationshipType,
TableSchema, TableSchema,
RenameColumn, RenameColumn,
FeatureFlag,
BBReferenceFieldSubType, BBReferenceFieldSubType,
NumericCalculationFieldMetadata, NumericCalculationFieldMetadata,
ViewV2Schema, ViewV2Schema,
ViewV2Type, ViewV2Type,
JsonTypes,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import merge from "lodash/merge" import merge from "lodash/merge"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import { db, roles, features } from "@budibase/backend-core"
db,
roles,
withEnv as withCoreEnv,
setEnv as setCoreEnv,
env,
} from "@budibase/backend-core"
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -103,18 +97,13 @@ describe.each([
} }
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () =>
config.init() config.init()
) )
if (isLucene) {
envCleanup = setCoreEnv({ envCleanup = features.testutils.setFeatureFlags("*", {
TENANT_FEATURE_FLAGS: "*:!SQS", SQS: isSqs,
}) })
} else if (isSqs) {
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({
@ -748,6 +737,69 @@ describe.each([
}, },
}) })
}) })
!isLucene &&
it("does not get confused when a calculation field shadows a basic one", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
age: {
name: "age",
type: FieldType.NUMBER,
},
},
})
)
await config.api.row.bulkImport(table._id!, {
rows: [{ age: 1 }, { age: 2 }, { age: 3 }],
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
age: {
visible: true,
calculationType: CalculationType.SUM,
field: "age",
},
},
})
const { rows } = await config.api.row.search(view.id)
expect(rows).toHaveLength(1)
expect(rows[0].age).toEqual(6)
})
// We don't allow the creation of tables with most JsonTypes when using
// external datasources.
isInternal &&
it("cannot use complex types as group-by fields", async () => {
for (const type of JsonTypes) {
const field = { name: "field", type } as FieldSchema
const table = await config.api.table.save(
saveTableRequest({ schema: { field } })
)
await config.api.viewV2.create(
{
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
field: { visible: true },
},
},
{
status: 400,
body: {
message: `Grouping by fields of type "${type}" is not supported`,
},
}
)
}
})
}) })
describe("update", () => { describe("update", () => {
@ -1926,6 +1978,30 @@ describe.each([
expect(newRow.one).toBeUndefined() expect(newRow.one).toBeUndefined()
expect(newRow.two).toEqual("bar") expect(newRow.two).toEqual("bar")
}) })
it("should not be possible to create a row in a calculation view", async () => {
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
id: { visible: true },
one: { visible: true },
},
})
await config.api.row.save(
view.id,
{ one: "foo" },
{
status: 400,
body: {
message: "Cannot insert rows through a calculation view",
status: 400,
},
}
)
})
}) })
describe("patch", () => { describe("patch", () => {
@ -1990,6 +2066,40 @@ describe.each([
expect(row.one).toEqual("foo") expect(row.one).toEqual("foo")
expect(row.two).toEqual("newBar") expect(row.two).toEqual("newBar")
}) })
it("should not be possible to modify a row in a calculation view", async () => {
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
id: { visible: true },
one: { visible: true },
},
})
const newRow = await config.api.row.save(table._id!, {
one: "foo",
two: "bar",
})
await config.api.row.patch(
view.id,
{
tableId: table._id!,
_id: newRow._id!,
_rev: newRow._rev!,
one: "newFoo",
two: "newBar",
},
{
status: 400,
body: {
message: "Cannot update rows through a calculation view",
},
}
)
})
}) })
describe("destroy", () => { describe("destroy", () => {
@ -2666,12 +2776,8 @@ describe.each([
describe("foreign relationship columns", () => { describe("foreign relationship columns", () => {
let envCleanup: () => void let envCleanup: () => void
beforeAll(() => { beforeAll(() => {
const flags = [`*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`] envCleanup = features.testutils.setFeatureFlags("*", {
if (env.TENANT_FEATURE_FLAGS) { ENRICHED_RELATIONSHIPS: true,
flags.push(...env.TENANT_FEATURE_FLAGS.split(","))
}
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: flags.join(","),
}) })
}) })

View File

@ -2,8 +2,8 @@ import * as setup from "../../../api/routes/tests/utilities"
import { basicTable } from "../../../tests/utilities/structures" import { basicTable } from "../../../tests/utilities/structures"
import { import {
db as dbCore, db as dbCore,
features,
SQLITE_DESIGN_DOC_ID, SQLITE_DESIGN_DOC_ID,
withEnv as withCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { import {
LinkDocument, LinkDocument,
@ -71,11 +71,11 @@ function oldLinkDocument(): Omit<LinkDocument, "tableId"> {
} }
async function sqsDisabled(cb: () => Promise<void>) { async function sqsDisabled(cb: () => Promise<void>) {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }, cb) await features.testutils.withFeatureFlags("*", { SQS: false }, cb)
} }
async function sqsEnabled(cb: () => Promise<void>) { async function sqsEnabled(cb: () => Promise<void>) {
await withCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" }, cb) await features.testutils.withFeatureFlags("*", { SQS: true }, cb)
} }
describe("SQS migration", () => { describe("SQS migration", () => {

View File

@ -221,9 +221,15 @@ class LinkController {
link.id !== row._id && link.fieldName === linkedSchema.name link.id !== row._id && link.fieldName === linkedSchema.name
) )
// check all the related rows exist
const foundRecords = await this._db.getMultiple(
links.map(l => l.id),
{ allowMissing: true, excludeDocs: true }
)
// The 1 side of 1:N is already related to something else // The 1 side of 1:N is already related to something else
// You must remove the existing relationship // You must remove the existing relationship
if (links.length > 0) { if (foundRecords.length > 0) {
throw new Error( throw new Error(
`1:N Relationship Error: Record already linked to another.` `1:N Relationship Error: Record already linked to another.`
) )

View File

@ -41,7 +41,7 @@ if (types) {
types.setTypeParser(1184, (val: any) => val) // timestampz types.setTypeParser(1184, (val: any) => val) // timestampz
} }
const JSON_REGEX = /'{.*}'::json/s const JSON_REGEX = /'{\s*.*?\s*}'::json/gs
const Sql = sql.Sql const Sql = sql.Sql
interface PostgresConfig { interface PostgresConfig {

View File

@ -12,6 +12,7 @@ import { userAgent } from "koa-useragent"
export default function createKoaApp() { export default function createKoaApp() {
const app = new Koa() const app = new Koa()
app.proxy = true
let mbNumber = parseInt(env.HTTP_MB_LIMIT || "10") let mbNumber = parseInt(env.HTTP_MB_LIMIT || "10")
if (!mbNumber || isNaN(mbNumber)) { if (!mbNumber || isNaN(mbNumber)) {
@ -35,6 +36,7 @@ export default function createKoaApp() {
app.use(middleware.correlation) app.use(middleware.correlation)
app.use(middleware.pino) app.use(middleware.pino)
app.use(middleware.ip)
app.use(userAgent) app.use(userAgent)
const server = http.createServer(app.callback()) const server = http.createServer(app.callback())

View File

@ -71,7 +71,7 @@ describe("migrations", () => {
expect(events.datasource.created).toHaveBeenCalledTimes(2) expect(events.datasource.created).toHaveBeenCalledTimes(2)
expect(events.layout.created).toHaveBeenCalledTimes(1) expect(events.layout.created).toHaveBeenCalledTimes(1)
expect(events.query.created).toHaveBeenCalledTimes(2) expect(events.query.created).toHaveBeenCalledTimes(2)
expect(events.role.created).toHaveBeenCalledTimes(2) expect(events.role.created).toHaveBeenCalledTimes(3) // created roles + admin (created on table creation)
expect(events.table.created).toHaveBeenCalledTimes(3) expect(events.table.created).toHaveBeenCalledTimes(3)
expect(events.view.created).toHaveBeenCalledTimes(2) expect(events.view.created).toHaveBeenCalledTimes(2)
expect(events.view.calculationCreated).toHaveBeenCalledTimes(1) expect(events.view.calculationCreated).toHaveBeenCalledTimes(1)
@ -82,7 +82,7 @@ describe("migrations", () => {
// to make sure caching is working as expected // to make sure caching is working as expected
expect( expect(
events.processors.analyticsProcessor.processEvent events.processors.analyticsProcessor.processEvent
).toHaveBeenCalledTimes(23) ).toHaveBeenCalledTimes(24) // Addtion of of the events above
}) })
}) })
}) })

View File

@ -185,6 +185,26 @@ export async function updatePermissionOnRole(
}) })
} }
export async function setPermissions(
resourceId: string,
{
writeRole,
readRole,
}: {
writeRole: string
readRole: string
}
) {
await updatePermissionOnRole(
{ roleId: writeRole, resourceId, level: PermissionLevel.WRITE },
PermissionUpdateType.ADD
)
await updatePermissionOnRole(
{ roleId: readRole, resourceId, level: PermissionLevel.READ },
PermissionUpdateType.ADD
)
}
// utility function to stop this repetition - permissions always stored under roles // utility function to stop this repetition - permissions always stored under roles
export async function getAllDBRoles(db: Database) { export async function getAllDBRoles(db: Database) {
const body = await db.allDocs<Role>( const body = await db.allDocs<Role>(

View File

@ -15,6 +15,7 @@ import {
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
import { tryExtractingTableAndViewId } from "./utils" import { tryExtractingTableAndViewId } from "./utils"
import { helpers } from "@budibase/shared-core"
export async function getRow( export async function getRow(
sourceId: string | Table | ViewV2, sourceId: string | Table | ViewV2,
@ -54,6 +55,10 @@ export async function save(
source = await sdk.tables.getTable(tableId) source = await sdk.tables.getTable(tableId)
} }
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
throw new HTTPError("Cannot insert rows through a calculation view", 400)
}
const row = await inputProcessing(userId, cloneDeep(source), inputs) const row = await inputProcessing(userId, cloneDeep(source), inputs)
const validateResult = await sdk.rows.utils.validate({ const validateResult = await sdk.rows.utils.validate({

View File

@ -1,4 +1,4 @@
import { context, db } from "@budibase/backend-core" import { context, db, HTTPError } from "@budibase/backend-core"
import { Row, Table, ViewV2 } from "@budibase/types" import { Row, Table, ViewV2 } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { finaliseRow } from "../../../api/controllers/row/staticFormula" import { finaliseRow } from "../../../api/controllers/row/staticFormula"
@ -10,6 +10,7 @@ import * as linkRows from "../../../db/linkedRows"
import { InternalTables } from "../../../db/utils" import { InternalTables } from "../../../db/utils"
import { getFullUser } from "../../../utilities/users" import { getFullUser } from "../../../utilities/users"
import { getSource, tryExtractingTableAndViewId } from "./utils" import { getSource, tryExtractingTableAndViewId } from "./utils"
import { helpers } from "@budibase/shared-core"
export async function save( export async function save(
tableOrViewId: string, tableOrViewId: string,
@ -29,6 +30,10 @@ export async function save(
table = source table = source
} }
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
throw new HTTPError("Cannot insert rows through a calculation view", 400)
}
if (!inputs._rev && !inputs._id) { if (!inputs._rev && !inputs._id) {
inputs._id = db.generateRowID(inputs.tableId) inputs._id = db.generateRowID(inputs.tableId)
} }

View File

@ -62,10 +62,10 @@ export async function exportRows(
).rows.map(row => row.doc!) ).rows.map(row => row.doc!)
result = await outputProcessing(table, response) result = await outputProcessing(table, response)
} else if (query) { } else {
let searchResponse = await sdk.rows.search({ let searchResponse = await sdk.rows.search({
tableId, tableId,
query, query: query || {},
sort, sort,
sortOrder, sortOrder,
}) })

View File

@ -10,10 +10,7 @@ import {
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { search } from "../../../../../sdk/app/rows/search" import { search } from "../../../../../sdk/app/rows/search"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { import { features } from "@budibase/backend-core"
withEnv as withCoreEnv,
setEnv as setCoreEnv,
} from "@budibase/backend-core"
import { import {
DatabaseName, DatabaseName,
getDatasource, getDatasource,
@ -41,19 +38,13 @@ describe.each([
let table: Table let table: Table
beforeAll(async () => { beforeAll(async () => {
await withCoreEnv({ TENANT_FEATURE_FLAGS: isSqs ? "*:SQS" : "" }, () => await features.testutils.withFeatureFlags("*", { SQS: isSqs }, () =>
config.init() config.init()
) )
if (isLucene) { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ SQS: isSqs,
TENANT_FEATURE_FLAGS: "*:!SQS", })
})
} else if (isSqs) {
envCleanup = setCoreEnv({
TENANT_FEATURE_FLAGS: "*:SQS",
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({

View File

@ -0,0 +1,27 @@
import { Row, Table } from "@budibase/types"
import * as external from "./external"
import * as internal from "./internal"
import { isExternal } from "./utils"
import { setPermissions } from "../permissions"
import { roles } from "@budibase/backend-core"
export async function create(
table: Omit<Table, "_id" | "_rev">,
rows?: Row[],
userId?: string
): Promise<Table> {
let createdTable: Table
if (isExternal({ table })) {
createdTable = await external.create(table)
} else {
createdTable = await internal.create(table, rows, userId)
}
await setPermissions(createdTable._id!, {
writeRole: roles.BUILTIN_ROLE_IDS.ADMIN,
readRole: roles.BUILTIN_ROLE_IDS.ADMIN,
})
return createdTable
}

View File

@ -8,8 +8,11 @@ import {
ViewV2, ViewV2,
AutoFieldSubType, AutoFieldSubType,
} from "@budibase/types" } from "@budibase/types"
import { context } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import { buildExternalTableId } from "../../../../integrations/utils" import {
breakExternalTableId,
buildExternalTableId,
} from "../../../../integrations/utils"
import { import {
foreignKeyStructure, foreignKeyStructure,
hasTypeChanged, hasTypeChanged,
@ -86,6 +89,35 @@ function validate(table: Table, oldTable?: Table) {
} }
} }
function getDatasourceId(table: Table) {
if (!table) {
throw new Error("No table supplied")
}
if (table.sourceId) {
return table.sourceId
}
if (!table._id) {
throw new Error("No table ID supplied")
}
return breakExternalTableId(table._id).datasourceId
}
export async function create(table: Omit<Table, "_id" | "_rev">) {
const datasourceId = getDatasourceId(table)
const tableToCreate = { ...table, created: true }
try {
const result = await save(datasourceId!, tableToCreate)
return result.table
} catch (err: any) {
if (err instanceof Error) {
throw new HTTPError(err.message, 400)
} else {
throw new HTTPError(err?.message || err, err.status || 500)
}
}
}
export async function save( export async function save(
datasourceId: string, datasourceId: string,
update: Table, update: Table,

View File

@ -1,5 +1,6 @@
import { populateExternalTableSchemas } from "./validation" import { populateExternalTableSchemas } from "./validation"
import * as getters from "./getters" import * as getters from "./getters"
import * as create from "./create"
import * as updates from "./update" import * as updates from "./update"
import * as utils from "./utils" import * as utils from "./utils"
import { migrate } from "./migration" import { migrate } from "./migration"
@ -7,6 +8,7 @@ import * as sqs from "./internal/sqs"
export default { export default {
populateExternalTableSchemas, populateExternalTableSchemas,
...create,
...updates, ...updates,
...getters, ...getters,
...utils, ...utils,

View File

@ -5,7 +5,7 @@ import {
ViewStatisticsSchema, ViewStatisticsSchema,
ViewV2, ViewV2,
Row, Row,
ContextUser, TableSourceType,
} from "@budibase/types" } from "@budibase/types"
import { import {
hasTypeChanged, hasTypeChanged,
@ -16,18 +16,56 @@ import { EventType, updateLinks } from "../../../../db/linkedRows"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { runStaticFormulaChecks } from "../../../../api/controllers/table/bulkFormula" import { runStaticFormulaChecks } from "../../../../api/controllers/table/bulkFormula"
import { context } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import { findDuplicateInternalColumns } from "@budibase/shared-core" import { findDuplicateInternalColumns } from "@budibase/shared-core"
import { getTable } from "../getters" import { getTable } from "../getters"
import { checkAutoColumns } from "./utils" import { checkAutoColumns } from "./utils"
import * as viewsSdk from "../../views" import * as viewsSdk from "../../views"
import { getRowParams } from "../../../../db/utils" import { generateTableID, getRowParams } from "../../../../db/utils"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
export async function create(
table: Omit<Table, "_id" | "_rev">,
rows?: Row[],
userId?: string
) {
const tableId = generateTableID()
let tableToSave: Table = {
_id: tableId,
...table,
// Ensure these fields are populated, even if not sent in the request
type: table.type || "table",
sourceType: TableSourceType.INTERNAL,
}
const isImport = !!rows
if (!tableToSave.views) {
tableToSave.views = {}
}
try {
const { table } = await save(tableToSave, {
userId,
rowsToImport: rows,
isImport,
})
return table
} catch (err: any) {
if (err instanceof Error) {
throw new HTTPError(err.message, 400)
} else {
throw new HTTPError(err.message || err, err.status || 500)
}
}
}
export async function save( export async function save(
table: Table, table: Table,
opts?: { opts?: {
user?: ContextUser userId?: string
tableId?: string tableId?: string
rowsToImport?: Row[] rowsToImport?: Row[]
renaming?: RenameColumn renaming?: RenameColumn
@ -63,7 +101,7 @@ export async function save(
// saving a table is a complex operation, involving many different steps, this // saving a table is a complex operation, involving many different steps, this
// has been broken out into a utility to make it more obvious/easier to manipulate // has been broken out into a utility to make it more obvious/easier to manipulate
const tableSaveFunctions = new TableSaveFunctions({ const tableSaveFunctions = new TableSaveFunctions({
user: opts?.user, userId: opts?.userId,
oldTable, oldTable,
importRows: opts?.rowsToImport, importRows: opts?.rowsToImport,
}) })

View File

@ -1,6 +1,8 @@
import { import {
CalculationType, CalculationType,
canGroupBy,
FieldType, FieldType,
isNumeric,
PermissionLevel, PermissionLevel,
RelationSchemaField, RelationSchemaField,
RenameColumn, RenameColumn,
@ -11,7 +13,7 @@ import {
ViewV2ColumnEnriched, ViewV2ColumnEnriched,
ViewV2Enriched, ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { context, docIds, HTTPError, roles } from "@budibase/backend-core" import { context, docIds, HTTPError } from "@budibase/backend-core"
import { import {
helpers, helpers,
PROTECTED_EXTERNAL_COLUMNS, PROTECTED_EXTERNAL_COLUMNS,
@ -22,7 +24,6 @@ import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { PermissionUpdateType, updatePermissionOnRole } from "../permissions"
function pickApi(tableId: any) { function pickApi(tableId: any) {
if (isExternalTableID(tableId)) { if (isExternalTableID(tableId)) {
@ -101,7 +102,7 @@ async function guardCalculationViewSchema(
) )
} }
if (!isCount && !helpers.schema.isNumeric(targetSchema)) { if (!isCount && !isNumeric(targetSchema.type)) {
throw new HTTPError( throw new HTTPError(
`Calculation field "${name}" references field "${schema.field}" which is not a numeric field`, `Calculation field "${name}" references field "${schema.field}" which is not a numeric field`,
400 400
@ -118,6 +119,13 @@ async function guardCalculationViewSchema(
400 400
) )
} }
if (!canGroupBy(targetSchema.type)) {
throw new HTTPError(
`Grouping by fields of type "${targetSchema.type}" is not supported`,
400
)
}
} }
} }
@ -235,24 +243,10 @@ export async function create(
// Set permissions to be the same as the table // Set permissions to be the same as the table
const tablePerms = await sdk.permissions.getResourcePerms(tableId) const tablePerms = await sdk.permissions.getResourcePerms(tableId)
const readRole = tablePerms[PermissionLevel.READ]?.role await sdk.permissions.setPermissions(view.id, {
const writeRole = tablePerms[PermissionLevel.WRITE]?.role writeRole: tablePerms[PermissionLevel.WRITE].role,
await updatePermissionOnRole( readRole: tablePerms[PermissionLevel.READ].role,
{ })
roleId: readRole || roles.BUILTIN_ROLE_IDS.BASIC,
resourceId: view.id,
level: PermissionLevel.READ,
},
PermissionUpdateType.ADD
)
await updatePermissionOnRole(
{
roleId: writeRole || roles.BUILTIN_ROLE_IDS.BASIC,
resourceId: view.id,
level: PermissionLevel.WRITE,
},
PermissionUpdateType.ADD
)
return view return view
} }

View File

@ -1,6 +1,7 @@
import { import {
AddPermissionRequest, AddPermissionRequest,
AddPermissionResponse, AddPermissionResponse,
FetchResourcePermissionInfoResponse,
GetResourcePermsResponse, GetResourcePermsResponse,
RemovePermissionRequest, RemovePermissionRequest,
RemovePermissionResponse, RemovePermissionResponse,
@ -26,6 +27,15 @@ export class PermissionAPI extends TestAPI {
) )
} }
fetch = async (
expectations?: Expectations
): Promise<FetchResourcePermissionInfoResponse> => {
return await this._get<FetchResourcePermissionInfoResponse>(
`/api/permission`,
{ expectations }
)
}
revoke = async ( revoke = async (
request: RemovePermissionRequest, request: RemovePermissionRequest,
expectations?: Expectations expectations?: Expectations

View File

@ -105,7 +105,7 @@ export class RowAPI extends TestAPI {
exportRows = async ( exportRows = async (
tableId: string, tableId: string,
body: ExportRowsRequest, body?: ExportRowsRequest,
format: RowExportFormat = RowExportFormat.JSON, format: RowExportFormat = RowExportFormat.JSON,
expectations?: Expectations expectations?: Expectations
) => { ) => {

View File

@ -8,7 +8,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { outputProcessing } from ".." import { outputProcessing } from ".."
import { generator, structures } from "@budibase/backend-core/tests" import { generator, structures } from "@budibase/backend-core/tests"
import { setEnv as setCoreEnv } from "@budibase/backend-core" import { features } from "@budibase/backend-core"
import * as bbReferenceProcessor from "../bbReferenceProcessor" import * as bbReferenceProcessor from "../bbReferenceProcessor"
import TestConfiguration from "../../../tests/utilities/TestConfiguration" import TestConfiguration from "../../../tests/utilities/TestConfiguration"
@ -21,7 +21,7 @@ jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
describe("rowProcessor - outputProcessing", () => { describe("rowProcessor - outputProcessing", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
let cleanupEnv: () => void = () => {} let cleanupFlags: () => void = () => {}
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
@ -33,11 +33,11 @@ describe("rowProcessor - outputProcessing", () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
cleanupEnv = setCoreEnv({ TENANT_FEATURE_FLAGS: "*SQS" }) cleanupFlags = features.testutils.setFeatureFlags("*", { SQS: true })
}) })
afterEach(() => { afterEach(() => {
cleanupEnv() cleanupFlags()
}) })
const processOutputBBReferenceMock = const processOutputBBReferenceMock =

View File

@ -1,5 +1,6 @@
import { permissions, roles } from "@budibase/backend-core" import { permissions, roles } from "@budibase/backend-core"
import { DocumentType, VirtualDocumentType } from "../db/utils" import { DocumentType, VirtualDocumentType } from "../db/utils"
import { getDocumentType, getVirtualDocumentType } from "@budibase/types"
export const CURRENTLY_SUPPORTED_LEVELS: string[] = [ export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
permissions.PermissionLevel.WRITE, permissions.PermissionLevel.WRITE,
@ -8,13 +9,16 @@ export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
] ]
export function getPermissionType(resourceId: string) { export function getPermissionType(resourceId: string) {
const docType = Object.values(DocumentType).filter(docType => const virtualDocType = getVirtualDocumentType(resourceId)
resourceId.startsWith(docType) switch (virtualDocType) {
)[0] case VirtualDocumentType.VIEW:
switch (docType as DocumentType | VirtualDocumentType) { return permissions.PermissionType.TABLE
}
const docType = getDocumentType(resourceId)
switch (docType) {
case DocumentType.TABLE: case DocumentType.TABLE:
case DocumentType.ROW: case DocumentType.ROW:
case VirtualDocumentType.VIEW:
return permissions.PermissionType.TABLE return permissions.PermissionType.TABLE
case DocumentType.AUTOMATION: case DocumentType.AUTOMATION:
return permissions.PermissionType.AUTOMATION return permissions.PermissionType.AUTOMATION
@ -32,22 +36,25 @@ export function getPermissionType(resourceId: string) {
/** /**
* works out the basic permissions based on builtin roles for a resource, using its ID * works out the basic permissions based on builtin roles for a resource, using its ID
*/ */
export function getBasePermissions(resourceId: string) { export function getBasePermissions(resourceId: string): Record<string, string> {
const type = getPermissionType(resourceId) const type = getPermissionType(resourceId)
const basePermissions: { [key: string]: string } = {} const basePermissions: Record<string, string> = {}
for (let [roleId, role] of Object.entries(roles.getBuiltinRoles())) { for (let [roleId, role] of Object.entries(roles.getBuiltinRoles())) {
if (!role.permissionId) { if (!role.permissionId) {
continue continue
} }
const perms = permissions.getBuiltinPermissionByID(role.permissionId) const perms = permissions.getBuiltinPermissionByID(role.permissionId)
if (!perms) { if (!perms) {
continue continue
} }
const typedPermission = perms.permissions.find(perm => perm.type === type) const typedPermission = perms.permissions.find(perm => perm.type === type)
if ( if (!typedPermission) {
typedPermission && continue
CURRENTLY_SUPPORTED_LEVELS.indexOf(typedPermission.level) !== -1 }
) {
if (CURRENTLY_SUPPORTED_LEVELS.includes(typedPermission.level)) {
const level = typedPermission.level const level = typedPermission.level
basePermissions[level] = roles.lowerBuiltinRoleID( basePermissions[level] = roles.lowerBuiltinRoleID(
basePermissions[level], basePermissions[level],

View File

@ -642,19 +642,19 @@ export function fixupFilterArrays(filters: SearchFilters) {
return filters return filters
} }
export function search<T>( export function search<T extends Record<string, any>>(
docs: Record<string, T>[], docs: T[],
query: RowSearchParams query: Omit<RowSearchParams, "tableId">
): SearchResponse<Record<string, T>> { ): SearchResponse<T> {
let result = runQuery(docs, query.query) let result = runQuery(docs, query.query)
if (query.sort) { if (query.sort) {
result = sort(result, query.sort, query.sortOrder || SortOrder.ASCENDING) result = sort(result, query.sort, query.sortOrder || SortOrder.ASCENDING)
} }
let totalRows = result.length const totalRows = result.length
if (query.limit) { if (query.limit) {
result = limit(result, query.limit.toString()) result = limit(result, query.limit.toString())
} }
const response: SearchResponse<Record<string, any>> = { rows: result } const response: SearchResponse<T> = { rows: result }
if (query.countRows) { if (query.countRows) {
response.totalRows = totalRows response.totalRows = totalRows
} }

View File

@ -5,6 +5,7 @@ import {
SearchFilters, SearchFilters,
BasicOperator, BasicOperator,
ArrayOperator, ArrayOperator,
isLogicalSearchOperator,
} from "@budibase/types" } from "@budibase/types"
import * as Constants from "./constants" import * as Constants from "./constants"
import { removeKeyNumbering } from "./filters" import { removeKeyNumbering } from "./filters"
@ -97,10 +98,20 @@ export function isSupportedUserSearch(query: SearchFilters) {
{ op: BasicOperator.EQUAL, key: "_id" }, { op: BasicOperator.EQUAL, key: "_id" },
{ op: ArrayOperator.ONE_OF, key: "_id" }, { op: ArrayOperator.ONE_OF, key: "_id" },
] ]
for (let [key, operation] of Object.entries(query)) { for (const [key, operation] of Object.entries(query)) {
if (typeof operation !== "object") { if (typeof operation !== "object") {
return false return false
} }
if (isLogicalSearchOperator(key)) {
for (const condition of query[key]!.conditions) {
if (!isSupportedUserSearch(condition)) {
return false
}
}
return true
}
const fields = Object.keys(operation || {}) const fields = Object.keys(operation || {})
// this filter doesn't contain options - ignore // this filter doesn't contain options - ignore
if (fields.length === 0) { if (fields.length === 0) {

View File

@ -1,5 +1,9 @@
import { PermissionLevel } from "../../../sdk" import { PermissionLevel } from "../../../sdk"
export interface FetchResourcePermissionInfoResponse {
[key: string]: Record<string, string>
}
export interface ResourcePermissionInfo { export interface ResourcePermissionInfo {
role: string role: string
permissionType: string permissionType: string
@ -21,7 +25,7 @@ export interface AddedPermission {
reason?: string reason?: string
} }
export type AddPermissionResponse = AddedPermission[] export interface AddPermissionResponse {}
export interface AddPermissionRequest { export interface AddPermissionRequest {
roleId: string roleId: string
@ -30,4 +34,4 @@ export interface AddPermissionRequest {
} }
export interface RemovePermissionRequest extends AddPermissionRequest {} export interface RemovePermissionRequest extends AddPermissionRequest {}
export interface RemovePermissionResponse extends AddPermissionResponse {} export interface RemovePermissionResponse {}

View File

@ -127,6 +127,26 @@ export const JsonTypes = [
FieldType.ARRAY, FieldType.ARRAY,
] ]
export const NumericTypes = [FieldType.NUMBER, FieldType.BIGINT]
export function isNumeric(type: FieldType) {
return NumericTypes.includes(type)
}
export const GroupByTypes = [
FieldType.STRING,
FieldType.LONGFORM,
FieldType.OPTIONS,
FieldType.NUMBER,
FieldType.BOOLEAN,
FieldType.DATETIME,
FieldType.BIGINT,
]
export function canGroupBy(type: FieldType) {
return GroupByTypes.includes(type)
}
export interface RowAttachment { export interface RowAttachment {
size: number size: number
name: string name: string

View File

@ -42,6 +42,17 @@ export enum DocumentType {
ROW_ACTIONS = "ra", ROW_ACTIONS = "ra",
} }
// Because DocumentTypes can overlap, we need to make sure that we search
// longest first to ensure we get the correct type.
const sortedDocumentTypes = Object.values(DocumentType).sort(
(a, b) => b.length - a.length // descending
)
export function getDocumentType(id: string): DocumentType | undefined {
return sortedDocumentTypes.find(docType =>
id.startsWith(`${docType}${SEPARATOR}`)
)
}
// these are the core documents that make up the data, design // these are the core documents that make up the data, design
// and automation sections of an app. This excludes any internal // and automation sections of an app. This excludes any internal
// rows as we shouldn't import data. // rows as we shouldn't import data.
@ -72,6 +83,19 @@ export enum VirtualDocumentType {
ROW_ACTION = "row_action", ROW_ACTION = "row_action",
} }
// Because VirtualDocumentTypes can overlap, we need to make sure that we search
// longest first to ensure we get the correct type.
const sortedVirtualDocumentTypes = Object.values(VirtualDocumentType).sort(
(a, b) => b.length - a.length // descending
)
export function getVirtualDocumentType(
id: string
): VirtualDocumentType | undefined {
return sortedVirtualDocumentTypes.find(docType =>
id.startsWith(`${docType}${SEPARATOR}`)
)
}
export interface Document { export interface Document {
_id?: string _id?: string
_rev?: string _rev?: string

View File

@ -133,7 +133,7 @@ export interface Database {
exists(docId: string): Promise<boolean> exists(docId: string): Promise<boolean>
getMultiple<T extends Document>( getMultiple<T extends Document>(
ids: string[], ids: string[],
opts?: { allowMissing?: boolean } opts?: { allowMissing?: boolean; excludeDocs?: boolean }
): Promise<T[]> ): Promise<T[]>
remove(idOrDoc: Document): Promise<Nano.DocumentDestroyResponse> remove(idOrDoc: Document): Promise<Nano.DocumentDestroyResponse>
remove(idOrDoc: string, rev?: string): Promise<Nano.DocumentDestroyResponse> remove(idOrDoc: string, rev?: string): Promise<Nano.DocumentDestroyResponse>

View File

@ -1,5 +1,5 @@
import { mocks, structures } from "@budibase/backend-core/tests" import { mocks, structures } from "@budibase/backend-core/tests"
import { context, events, setEnv as setCoreEnv } from "@budibase/backend-core" import { context, events, features } from "@budibase/backend-core"
import { Event, IdentityType } from "@budibase/types" import { Event, IdentityType } from "@budibase/types"
import { TestConfiguration } from "../../../../tests" import { TestConfiguration } from "../../../../tests"
@ -17,11 +17,9 @@ describe.each(["lucene", "sql"])("/api/global/auditlogs (%s)", method => {
let envCleanup: (() => void) | undefined let envCleanup: (() => void) | undefined
beforeAll(async () => { beforeAll(async () => {
if (method === "lucene") { envCleanup = features.testutils.setFeatureFlags("*", {
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:!SQS" }) SQS: method === "sql",
} else if (method === "sql") { })
envCleanup = setCoreEnv({ TENANT_FEATURE_FLAGS: "*:SQS" })
}
await config.beforeAll() await config.beforeAll()
}) })

View File

@ -741,6 +741,25 @@ describe("/api/global/users", () => {
it("should throw an error if public query performed", async () => { it("should throw an error if public query performed", async () => {
await config.api.users.searchUsers({}, { status: 403, noHeaders: true }) await config.api.users.searchUsers({}, { status: 403, noHeaders: true })
}) })
it("should be able to search using logical conditions", async () => {
const user = await config.createUser()
const response = await config.api.users.searchUsers({
query: {
$and: {
conditions: [
{
$and: {
conditions: [{ string: { email: user.email } }],
},
},
],
},
},
})
expect(response.body.data.length).toBe(1)
expect(response.body.data[0].email).toBe(user.email)
})
}) })
describe("DELETE /api/global/users/:userId", () => { describe("DELETE /api/global/users/:userId", () => {

View File

@ -46,6 +46,7 @@ bootstrap()
const app: Application = new Application() const app: Application = new Application()
app.keys = ["secret", "key"] app.keys = ["secret", "key"]
app.proxy = true
// set up top level koa middleware // set up top level koa middleware
app.use(handleScimBody) app.use(handleScimBody)
@ -54,6 +55,7 @@ app.use(koaBody({ multipart: true }))
app.use(koaSession(app)) app.use(koaSession(app))
app.use(middleware.correlation) app.use(middleware.correlation)
app.use(middleware.pino) app.use(middleware.pino)
app.use(middleware.ip)
app.use(userAgent) app.use(userAgent)
// authentication // authentication