Merge branch 'develop' of github.com:Budibase/budibase into chore/esbuild

This commit is contained in:
mike12345567 2023-04-17 18:10:29 +01:00
commit 9a36377d6e
70 changed files with 1054 additions and 449 deletions

View File

@ -62,7 +62,6 @@ jobs:
- name: Build/release Docker images - name: Build/release Docker images
run: | run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build
yarn build:docker:develop yarn build:docker:develop
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}

View File

@ -1,5 +1,5 @@
{ {
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"packages": ["packages/*"], "packages": ["packages/*"],

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.2", "@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.4.44-alpha.22", "@budibase/types": "2.5.6-alpha.2",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",
@ -63,7 +63,7 @@
"@trendyol/jest-testcontainers": "^2.1.1", "@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/ioredis": "4.28.0", "@types/ioredis": "4.28.0",
"@types/jest": "28.1.1", "@types/jest": "29.5.0",
"@types/koa": "2.13.4", "@types/koa": "2.13.4",
"@types/lodash": "4.14.180", "@types/lodash": "4.14.180",
"@types/node": "14.18.20", "@types/node": "14.18.20",

View File

@ -42,7 +42,11 @@ async function removeDeprecated(db: Database, viewName: ViewName) {
} }
} }
export async function createView(db: any, viewJs: string, viewName: string) { export async function createView(
db: any,
viewJs: string,
viewName: string
): Promise<void> {
let designDoc let designDoc
try { try {
designDoc = (await db.get(DESIGN_DB)) as DesignDocument designDoc = (await db.get(DESIGN_DB)) as DesignDocument
@ -57,7 +61,15 @@ export async function createView(db: any, viewJs: string, viewName: string) {
...designDoc.views, ...designDoc.views,
[viewName]: view, [viewName]: view,
} }
await db.put(designDoc) try {
await db.put(designDoc)
} catch (err: any) {
if (err.status === 409) {
return await createView(db, viewJs, viewName)
} else {
throw err
}
}
} }
export const createNewUserEmailView = async () => { export const createNewUserEmailView = async () => {
@ -135,6 +147,10 @@ export const queryView = async <T>(
await removeDeprecated(db, viewName) await removeDeprecated(db, viewName)
await createFunc() await createFunc()
return queryView(viewName, params, db, createFunc, opts) return queryView(viewName, params, db, createFunc, opts)
} else if (err.status === 409) {
// can happen when multiple queries occur at once, view couldn't be created
// other design docs being updated, re-run
return queryView(viewName, params, db, createFunc, opts)
} else { } else {
throw err throw err
} }

View File

@ -0,0 +1,29 @@
import { asyncEventQueue, init as initQueue } from "../events/asyncEvents"
import {
ProcessorMap,
default as DocumentUpdateProcessor,
} from "../events/processors/async/DocumentUpdateProcessor"
let processingPromise: Promise<void>
let documentProcessor: DocumentUpdateProcessor
export function init(processors: ProcessorMap) {
if (!asyncEventQueue) {
initQueue()
}
if (!documentProcessor) {
documentProcessor = new DocumentUpdateProcessor(processors)
}
// if not processing in this instance, kick it off
if (!processingPromise) {
processingPromise = asyncEventQueue.process(async job => {
const { event, identity, properties, timestamp } = job.data
await documentProcessor.processEvent(
event,
identity,
properties,
timestamp
)
})
}
}

View File

@ -0,0 +1,2 @@
export * from "./queue"
export * from "./publisher"

View File

@ -0,0 +1,12 @@
import { AsyncEvents } from "@budibase/types"
import { EventPayload, asyncEventQueue, init } from "./queue"
export async function publishAsyncEvent(payload: EventPayload) {
if (!asyncEventQueue) {
init()
}
const { event, identity } = payload
if (AsyncEvents.indexOf(event) !== -1 && identity.tenantId) {
await asyncEventQueue.add(payload)
}
}

View File

@ -0,0 +1,22 @@
import BullQueue from "bull"
import { createQueue, JobQueue } from "../../queue"
import { Event, Identity } from "@budibase/types"
export interface EventPayload {
event: Event
identity: Identity
properties: any
timestamp?: string | number
}
export let asyncEventQueue: BullQueue.Queue
export function init() {
asyncEventQueue = createQueue<EventPayload>(JobQueue.SYSTEM_EVENT_QUEUE)
}
export async function shutdown() {
if (asyncEventQueue) {
await asyncEventQueue.close()
}
}

View File

@ -0,0 +1,56 @@
import {
Event,
UserCreatedEvent,
UserUpdatedEvent,
UserDeletedEvent,
UserPermissionAssignedEvent,
UserPermissionRemovedEvent,
GroupCreatedEvent,
GroupUpdatedEvent,
GroupDeletedEvent,
GroupUsersAddedEvent,
GroupUsersDeletedEvent,
GroupPermissionsEditedEvent,
} from "@budibase/types"
const getEventProperties: Record<
string,
(properties: any) => string | undefined
> = {
[Event.USER_CREATED]: (properties: UserCreatedEvent) => properties.userId,
[Event.USER_UPDATED]: (properties: UserUpdatedEvent) => properties.userId,
[Event.USER_DELETED]: (properties: UserDeletedEvent) => properties.userId,
[Event.USER_PERMISSION_ADMIN_ASSIGNED]: (
properties: UserPermissionAssignedEvent
) => properties.userId,
[Event.USER_PERMISSION_ADMIN_REMOVED]: (
properties: UserPermissionRemovedEvent
) => properties.userId,
[Event.USER_PERMISSION_BUILDER_ASSIGNED]: (
properties: UserPermissionAssignedEvent
) => properties.userId,
[Event.USER_PERMISSION_BUILDER_REMOVED]: (
properties: UserPermissionRemovedEvent
) => properties.userId,
[Event.USER_GROUP_CREATED]: (properties: GroupCreatedEvent) =>
properties.groupId,
[Event.USER_GROUP_UPDATED]: (properties: GroupUpdatedEvent) =>
properties.groupId,
[Event.USER_GROUP_DELETED]: (properties: GroupDeletedEvent) =>
properties.groupId,
[Event.USER_GROUP_USERS_ADDED]: (properties: GroupUsersAddedEvent) =>
properties.groupId,
[Event.USER_GROUP_USERS_REMOVED]: (properties: GroupUsersDeletedEvent) =>
properties.groupId,
[Event.USER_GROUP_PERMISSIONS_EDITED]: (
properties: GroupPermissionsEditedEvent
) => properties.groupId,
}
export function getDocumentId(event: Event, properties: any) {
const extractor = getEventProperties[event]
if (!extractor) {
throw new Error("Event does not have a method of document ID extraction")
}
return extractor(properties)
}

View File

@ -1,7 +1,8 @@
import { Event, AuditedEventFriendlyName } from "@budibase/types" import { Event } from "@budibase/types"
import { processors } from "./processors" import { processors } from "./processors"
import identification from "./identification" import identification from "./identification"
import * as backfill from "./backfill" import * as backfill from "./backfill"
import { publishAsyncEvent } from "./asyncEvents"
export const publishEvent = async ( export const publishEvent = async (
event: Event, event: Event,
@ -14,6 +15,14 @@ export const publishEvent = async (
const backfilling = await backfill.isBackfillingEvent(event) const backfilling = await backfill.isBackfillingEvent(event)
// no backfill - send the event and exit // no backfill - send the event and exit
if (!backfilling) { if (!backfilling) {
// send off async events if required
await publishAsyncEvent({
event,
identity,
properties,
timestamp,
})
// now handle the main sync event processing pipeline
await processors.processEvent(event, identity, properties, timestamp) await processors.processEvent(event, identity, properties, timestamp)
return return
} }

View File

@ -65,6 +65,7 @@ const getCurrentIdentity = async (): Promise<Identity> => {
hosting, hosting,
installationId, installationId,
tenantId, tenantId,
realTenantId: context.getTenantId(),
environment, environment,
} }
} else if (identityType === IdentityType.USER) { } else if (identityType === IdentityType.USER) {

View File

@ -6,6 +6,8 @@ export * as backfillCache from "./backfill"
import { processors } from "./processors" import { processors } from "./processors"
export function initAsyncEvents() {}
export const shutdown = () => { export const shutdown = () => {
processors.shutdown() processors.shutdown()
console.log("Events shutdown") console.log("Events shutdown")

View File

@ -25,7 +25,9 @@ export default class Processor implements EventProcessor {
timestamp?: string | number timestamp?: string | number
): Promise<void> { ): Promise<void> {
for (const eventProcessor of this.processors) { for (const eventProcessor of this.processors) {
await eventProcessor.identify(identity, timestamp) if (eventProcessor.identify) {
await eventProcessor.identify(identity, timestamp)
}
} }
} }
@ -34,13 +36,17 @@ export default class Processor implements EventProcessor {
timestamp?: string | number timestamp?: string | number
): Promise<void> { ): Promise<void> {
for (const eventProcessor of this.processors) { for (const eventProcessor of this.processors) {
await eventProcessor.identifyGroup(identity, timestamp) if (eventProcessor.identifyGroup) {
await eventProcessor.identifyGroup(identity, timestamp)
}
} }
} }
shutdown() { shutdown() {
for (const eventProcessor of this.processors) { for (const eventProcessor of this.processors) {
eventProcessor.shutdown() if (eventProcessor.shutdown) {
eventProcessor.shutdown()
}
} }
} }
} }

View File

@ -0,0 +1,43 @@
import { EventProcessor } from "../types"
import { Event, Identity, DocUpdateEvent } from "@budibase/types"
import { doInTenant } from "../../../context"
import { getDocumentId } from "../../documentId"
import { shutdown } from "../../asyncEvents"
export type Processor = (update: DocUpdateEvent) => Promise<void>
export type ProcessorMap = { events: Event[]; processor: Processor }[]
export default class DocumentUpdateProcessor implements EventProcessor {
processors: ProcessorMap = []
constructor(processors: ProcessorMap) {
this.processors = processors
}
async processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
) {
const tenantId = identity.realTenantId
const docId = getDocumentId(event, properties)
if (!tenantId || !docId) {
return
}
for (let { events, processor } of this.processors) {
if (events.includes(event)) {
await doInTenant(tenantId, async () => {
await processor({
id: docId,
tenantId,
})
})
}
}
}
shutdown() {
return shutdown()
}
}

View File

@ -1,18 +1 @@
import { Event, Identity, Group } from "@budibase/types" export { EventProcessor } from "@budibase/types"
export enum EventProcessorType {
POSTHOG = "posthog",
LOGGING = "logging",
}
export interface EventProcessor {
processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
): Promise<void>
identify(identity: Identity, timestamp?: string | number): Promise<void>
identifyGroup(group: Group, timestamp?: string | number): Promise<void>
shutdown(): void
}

View File

@ -27,6 +27,7 @@ export * as errors from "./errors"
export * as timers from "./timers" export * as timers from "./timers"
export { default as env } from "./environment" export { default as env } from "./environment"
export * as blacklist from "./blacklist" export * as blacklist from "./blacklist"
export * as docUpdates from "./docUpdates"
export { SearchParams } from "./db" export { SearchParams } from "./db"
// Add context to tenancy for backwards compatibility // Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal // only do this for external usages to prevent internal

View File

@ -2,4 +2,5 @@ export enum JobQueue {
AUTOMATION = "automationQueue", AUTOMATION = "automationQueue",
APP_BACKUP = "appBackupQueue", APP_BACKUP = "appBackupQueue",
AUDIT_LOG = "auditLogQueue", AUDIT_LOG = "auditLogQueue",
SYSTEM_EVENT_QUEUE = "systemEventQueue",
} }

View File

@ -24,7 +24,7 @@ export enum PermissionType {
QUERY = "query", QUERY = "query",
} }
class Permission { export class Permission {
type: PermissionType type: PermissionType
level: PermissionLevel level: PermissionLevel
@ -34,7 +34,7 @@ class Permission {
} }
} }
function levelToNumber(perm: PermissionLevel) { export function levelToNumber(perm: PermissionLevel) {
switch (perm) { switch (perm) {
// not everything has execute privileges // not everything has execute privileges
case PermissionLevel.EXECUTE: case PermissionLevel.EXECUTE:
@ -55,7 +55,7 @@ function levelToNumber(perm: PermissionLevel) {
* @param {string} userPermLevel The permission level of the user. * @param {string} userPermLevel The permission level of the user.
* @return {string[]} All the permission levels this user is allowed to carry out. * @return {string[]} All the permission levels this user is allowed to carry out.
*/ */
function getAllowedLevels(userPermLevel: PermissionLevel) { export function getAllowedLevels(userPermLevel: PermissionLevel): string[] {
switch (userPermLevel) { switch (userPermLevel) {
case PermissionLevel.EXECUTE: case PermissionLevel.EXECUTE:
return [PermissionLevel.EXECUTE] return [PermissionLevel.EXECUTE]
@ -64,9 +64,9 @@ function getAllowedLevels(userPermLevel: PermissionLevel) {
case PermissionLevel.WRITE: case PermissionLevel.WRITE:
case PermissionLevel.ADMIN: case PermissionLevel.ADMIN:
return [ return [
PermissionLevel.EXECUTE,
PermissionLevel.READ, PermissionLevel.READ,
PermissionLevel.WRITE, PermissionLevel.WRITE,
PermissionLevel.EXECUTE,
] ]
default: default:
return [] return []
@ -81,7 +81,7 @@ export enum BuiltinPermissionID {
POWER = "power", POWER = "power",
} }
const BUILTIN_PERMISSIONS = { export const BUILTIN_PERMISSIONS = {
PUBLIC: { PUBLIC: {
_id: BuiltinPermissionID.PUBLIC, _id: BuiltinPermissionID.PUBLIC,
name: "Public", name: "Public",

View File

@ -0,0 +1,145 @@
import { cloneDeep } from "lodash"
import * as permissions from "../permissions"
import { BUILTIN_ROLE_IDS } from "../roles"
describe("levelToNumber", () => {
it("should return 0 for EXECUTE", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.EXECUTE)).toBe(
0
)
})
it("should return 1 for READ", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.READ)).toBe(1)
})
it("should return 2 for WRITE", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.WRITE)).toBe(2)
})
it("should return 3 for ADMIN", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.ADMIN)).toBe(3)
})
it("should return -1 for an unknown permission level", () => {
expect(
permissions.levelToNumber("unknown" as permissions.PermissionLevel)
).toBe(-1)
})
})
describe("getAllowedLevels", () => {
it('should return ["execute"] for EXECUTE', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.EXECUTE)
).toEqual([permissions.PermissionLevel.EXECUTE])
})
it('should return ["execute", "read"] for READ', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.READ)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
])
})
it('should return ["execute", "read", "write"] for WRITE', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.WRITE)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
permissions.PermissionLevel.WRITE,
])
})
it('should return ["execute", "read", "write"] for ADMIN', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.ADMIN)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
permissions.PermissionLevel.WRITE,
])
})
it("should return [] for an unknown permission level", () => {
expect(
permissions.getAllowedLevels("unknown" as permissions.PermissionLevel)
).toEqual([])
})
})
describe("doesHaveBasePermission", () => {
it("should return true if base permission has the required level", () => {
const permType = permissions.PermissionType.USER
const permLevel = permissions.PermissionLevel.READ
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.ADMIN,
permissionId: permissions.BuiltinPermissionID.ADMIN,
},
]
expect(
permissions.doesHaveBasePermission(permType, permLevel, rolesHierarchy)
).toBe(true)
})
it("should return false if base permission does not have the required level", () => {
const permType = permissions.PermissionType.APP
const permLevel = permissions.PermissionLevel.READ
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.PUBLIC,
permissionId: permissions.BuiltinPermissionID.PUBLIC,
},
]
expect(
permissions.doesHaveBasePermission(permType, permLevel, rolesHierarchy)
).toBe(false)
})
})
describe("isPermissionLevelHigherThanRead", () => {
it("should return true if level is higher than read", () => {
expect(
permissions.isPermissionLevelHigherThanRead(
permissions.PermissionLevel.WRITE
)
).toBe(true)
})
it("should return false if level is read or lower", () => {
expect(
permissions.isPermissionLevelHigherThanRead(
permissions.PermissionLevel.READ
)
).toBe(false)
})
})
describe("getBuiltinPermissions", () => {
it("returns a clone of the builtin permissions", () => {
const builtins = permissions.getBuiltinPermissions()
expect(builtins).toEqual(cloneDeep(permissions.BUILTIN_PERMISSIONS))
expect(builtins).not.toBe(permissions.BUILTIN_PERMISSIONS)
})
})
describe("getBuiltinPermissionByID", () => {
it("returns correct permission object for valid ID", () => {
const expectedPermission = {
_id: permissions.BuiltinPermissionID.PUBLIC,
name: "Public",
permissions: [
new permissions.Permission(
permissions.PermissionType.WEBHOOK,
permissions.PermissionLevel.EXECUTE
),
],
}
expect(permissions.getBuiltinPermissionByID("public")).toEqual(
expectedPermission
)
})
})

View File

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

View File

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

View File

@ -120,7 +120,7 @@ export const toBindingsArray = (valueMap, prefix, category) => {
return [] return []
} }
return Object.keys(valueMap).reduce((acc, binding) => { return Object.keys(valueMap).reduce((acc, binding) => {
if (!binding || !valueMap[binding]) { if (!binding) {
return acc return acc
} }

View File

@ -42,7 +42,13 @@
</script> </script>
{#if type === "options" && meta.constraints.inclusion.length !== 0} {#if type === "options" && meta.constraints.inclusion.length !== 0}
<Select {label} bind:value options={meta.constraints.inclusion} sort /> <Select
{label}
bind:value
options={meta.constraints.inclusion}
sort
{error}
/>
{:else if type === "datetime"} {:else if type === "datetime"}
<DatePicker <DatePicker
{error} {error}

View File

@ -27,21 +27,19 @@
notifications.success("Row saved successfully") notifications.success("Row saved successfully")
dispatch("updaterows") dispatch("updaterows")
} catch (error) { } catch (error) {
if (error.handled) { const response = error.json
const response = error.json if (error.handled && response?.errors) {
if (response?.errors) { errors = response.errors
errors = response.errors } else if (error.handled && response?.validationErrors) {
} else if (response?.validationErrors) { const mappedErrors = {}
const mappedErrors = {} for (let field in response.validationErrors) {
for (let field in response.validationErrors) { mappedErrors[
mappedErrors[ field
field ] = `${field} ${response.validationErrors[field][0]}`
] = `${field} ${response.validationErrors[field][0]}`
}
errors = mappedErrors
} }
errors = mappedErrors
} else { } else {
notifications.error("Failed to save row") notifications.error(`Failed to save row - ${error.message}`)
} }
// Prevent modal closing if there were errors // Prevent modal closing if there were errors
return false return false

View File

@ -75,8 +75,7 @@
let loaded = false let loaded = false
let editModal, deleteModal let editModal, deleteModal
const scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !$auth.isAdmin || scimEnabled
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchUsers(page, searchTerm) $: fetchUsers(page, searchTerm)

View File

@ -86,8 +86,7 @@
let user let user
let loaded = false let loaded = false
const scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = user?.admin?.global || user?.builder?.global

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,11 +19,11 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "2.4.44-alpha.22", "@budibase/bbui": "2.5.6-alpha.2",
"@budibase/frontend-core": "2.4.44-alpha.22", "@budibase/frontend-core": "2.5.6-alpha.2",
"@budibase/shared-core": "2.4.44-alpha.22", "@budibase/shared-core": "2.5.6-alpha.2",
"@budibase/string-templates": "2.4.44-alpha.22", "@budibase/string-templates": "2.5.6-alpha.2",
"@budibase/types": "2.4.44-alpha.22", "@budibase/types": "2.5.6-alpha.2",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/sdk", "name": "@budibase/sdk",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Budibase Public API SDK", "description": "Budibase Public API SDK",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -45,12 +45,12 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.4.44-alpha.22", "@budibase/backend-core": "2.5.6-alpha.2",
"@budibase/client": "2.4.44-alpha.22", "@budibase/client": "2.5.6-alpha.2",
"@budibase/pro": "2.4.44-alpha.22", "@budibase/pro": "2.5.6-alpha.2",
"@budibase/shared-core": "2.4.44-alpha.22", "@budibase/shared-core": "2.5.6-alpha.2",
"@budibase/string-templates": "2.4.44-alpha.22", "@budibase/string-templates": "2.5.6-alpha.2",
"@budibase/types": "2.4.44-alpha.22", "@budibase/types": "2.5.6-alpha.2",
"@bull-board/api": "3.7.0", "@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4", "@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
@ -118,8 +118,7 @@
"vm2": "3.9.16", "vm2": "3.9.16",
"worker-farm": "1.7.0", "worker-farm": "1.7.0",
"yargs": "13.2.4", "yargs": "13.2.4",
"xml2js": "0.5.0", "xml2js": "0.5.0"
"zlib": "1.0.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.17.4", "@babel/core": "7.17.4",

View File

@ -11,6 +11,7 @@ if [ "$1" = '/opt/mssql/bin/sqlservr' ]; then
echo "RUNNING BUDIBASE SETUP" echo "RUNNING BUDIBASE SETUP"
cat setup.sql
#run the setup script to create the DB and the schema in the DB #run the setup script to create the DB and the schema in the DB
/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Passw0rd -i setup.sql /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Passw0rd -i setup.sql

View File

@ -34,7 +34,7 @@ GO
CREATE TABLE people CREATE TABLE people
( (
name varchar(30) NOT NULL, name varchar(30) NOT NULL,
age varchar(20), age int default 20 NOT NULL,
CONSTRAINT pk_people PRIMARY KEY NONCLUSTERED (name, age) CONSTRAINT pk_people PRIMARY KEY NONCLUSTERED (name, age)
); );
@ -50,22 +50,22 @@ VALUES
('Processing', 1); ('Processing', 1);
INSERT INTO people (name, age) INSERT INTO people (name, age)
VALUES ('Bob', '30'), VALUES ('Bob', 30),
('Bert', '10'), ('Bert', 10),
('Jack', '12'), ('Jack', 12),
('Mike', '31'), ('Mike', 31),
('Dave', '44'), ('Dave', 44),
('Jim', '43'), ('Jim', 43),
('Kerry', '32'), ('Kerry', 32),
('Julie', '12'), ('Julie', 12),
('Kim', '55'), ('Kim', 55),
('Andy', '33'), ('Andy', 33),
('John', '22'), ('John', 22),
('Ruth', '66'), ('Ruth', 66),
('Robert', '88'), ('Robert', 88),
('Bobert', '99'), ('Bobert', 99),
('Jan', '22'), ('Jan', 22),
('Megan', '11'); ('Megan', 11);
IF OBJECT_ID ('Chains.sizes', 'U') IS NOT NULL IF OBJECT_ID ('Chains.sizes', 'U') IS NOT NULL

View File

@ -3,7 +3,7 @@ USE main;
CREATE TABLE Persons ( CREATE TABLE Persons (
PersonID int NOT NULL AUTO_INCREMENT, PersonID int NOT NULL AUTO_INCREMENT,
CreatedAt datetime, CreatedAt datetime,
Age float, Age float DEFAULT 20 NOT NULL,
LastName varchar(255), LastName varchar(255),
FirstName varchar(255), FirstName varchar(255),
Address varchar(255), Address varchar(255),

View File

@ -8,6 +8,7 @@ CREATE TABLE Persons (
FirstName varchar(255), FirstName varchar(255),
Address varchar(255), Address varchar(255),
City varchar(255) DEFAULT 'Belfast', City varchar(255) DEFAULT 'Belfast',
Age INTEGER DEFAULT 20 NOT NULL,
Type person_job Type person_job
); );
CREATE TABLE Tasks ( CREATE TABLE Tasks (

View File

@ -30,7 +30,6 @@ import { finaliseRow, updateRelatedFormula } from "./staticFormula"
import { csv, json, jsonWithSchema, Format } from "../view/exporters" import { csv, json, jsonWithSchema, Format } from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem" import { apiFileReturn } from "../../../utilities/fileSystem"
import { import {
Ctx,
UserCtx, UserCtx,
Database, Database,
LinkDocumentValue, LinkDocumentValue,
@ -72,7 +71,7 @@ async function getView(db: Database, viewName: string) {
return viewInfo return viewInfo
} }
async function getRawTableData(ctx: Ctx, db: Database, tableId: string) { async function getRawTableData(ctx: UserCtx, db: Database, tableId: string) {
let rows let rows
if (tableId === InternalTables.USER_METADATA) { if (tableId === InternalTables.USER_METADATA) {
await userController.fetchMetadata(ctx) await userController.fetchMetadata(ctx)
@ -188,7 +187,7 @@ export async function save(ctx: UserCtx) {
}) })
} }
export async function fetchView(ctx: Ctx) { export async function fetchView(ctx: UserCtx) {
const viewName = decodeURIComponent(ctx.params.viewName) const viewName = decodeURIComponent(ctx.params.viewName)
// if this is a table view being looked for just transfer to that // if this is a table view being looked for just transfer to that
@ -255,7 +254,7 @@ export async function fetchView(ctx: Ctx) {
return rows return rows
} }
export async function fetch(ctx: Ctx) { export async function fetch(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
@ -264,7 +263,7 @@ export async function fetch(ctx: Ctx) {
return outputProcessing(table, rows) return outputProcessing(table, rows)
} }
export async function find(ctx: Ctx) { export async function find(ctx: UserCtx) {
const db = dbCore.getDB(ctx.appId) const db = dbCore.getDB(ctx.appId)
const table = await db.get(ctx.params.tableId) const table = await db.get(ctx.params.tableId)
let row = await utils.findRow(ctx, ctx.params.tableId, ctx.params.rowId) let row = await utils.findRow(ctx, ctx.params.tableId, ctx.params.rowId)
@ -272,7 +271,7 @@ export async function find(ctx: Ctx) {
return row return row
} }
export async function destroy(ctx: Ctx) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const { _id } = ctx.request.body const { _id } = ctx.request.body
let row = await db.get(_id) let row = await db.get(_id)
@ -308,7 +307,7 @@ export async function destroy(ctx: Ctx) {
return { response, row } return { response, row }
} }
export async function bulkDestroy(ctx: Ctx) { export async function bulkDestroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
const table = await db.get(tableId) const table = await db.get(tableId)
@ -347,7 +346,7 @@ export async function bulkDestroy(ctx: Ctx) {
return { response: { ok: true }, rows: processedRows } return { response: { ok: true }, rows: processedRows }
} }
export async function search(ctx: Ctx) { export async function search(ctx: UserCtx) {
// Fetch the whole table when running in cypress, as search doesn't work // Fetch the whole table when running in cypress, as search doesn't work
if (!env.COUCH_DB_URL && env.isCypress()) { if (!env.COUCH_DB_URL && env.isCypress()) {
return { rows: await fetch(ctx) } return { rows: await fetch(ctx) }
@ -387,7 +386,7 @@ export async function search(ctx: Ctx) {
return response return response
} }
export async function exportRows(ctx: Ctx) { export async function exportRows(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const table = await db.get(ctx.params.tableId) const table = await db.get(ctx.params.tableId)
const rowIds = ctx.request.body.rows const rowIds = ctx.request.body.rows
@ -439,7 +438,7 @@ export async function exportRows(ctx: Ctx) {
} }
} }
export async function fetchEnrichedRow(ctx: Ctx) { export async function fetchEnrichedRow(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const tableId = ctx.params.tableId const tableId = ctx.params.tableId
const rowId = ctx.params.rowId const rowId = ctx.params.rowId

View File

@ -5,7 +5,7 @@ import { context } from "@budibase/backend-core"
import { makeExternalQuery } from "../../../integrations/base/query" import { makeExternalQuery } from "../../../integrations/base/query"
import { Row, Table } from "@budibase/types" import { Row, Table } from "@budibase/types"
import { Format } from "../view/exporters" import { Format } from "../view/exporters"
import { Ctx } from "@budibase/types" import { UserCtx } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
const validateJs = require("validate.js") const validateJs = require("validate.js")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
@ -26,7 +26,7 @@ export async function getDatasourceAndQuery(json: any) {
return makeExternalQuery(datasource, json) return makeExternalQuery(datasource, json)
} }
export async function findRow(ctx: Ctx, tableId: string, rowId: string) { export async function findRow(ctx: UserCtx, tableId: string, rowId: string) {
const db = context.getAppDB() const db = context.getAppDB()
let row let row
// TODO remove special user case in future // TODO remove special user case in future

View File

@ -1,98 +1,12 @@
import { generateUserMetadataID, generateUserFlagID } from "../../db/utils" import { generateUserMetadataID, generateUserFlagID } from "../../db/utils"
import { InternalTables } from "../../db/utils" import { InternalTables } from "../../db/utils"
import { getGlobalUsers, getRawGlobalUser } from "../../utilities/global" import { getGlobalUsers } from "../../utilities/global"
import { getFullUser } from "../../utilities/users" import { getFullUser } from "../../utilities/users"
import { import { context } from "@budibase/backend-core"
context, import { UserCtx } from "@budibase/types"
roles as rolesCore,
db as dbCore,
} from "@budibase/backend-core"
import { BBContext, Ctx, SyncUserRequest, User } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
export async function syncUser(ctx: Ctx<SyncUserRequest>) { export async function fetchMetadata(ctx: UserCtx) {
let deleting = false,
user: User | any
const userId = ctx.params.id
const previousUser = ctx.request.body?.previousUser
try {
user = (await getRawGlobalUser(userId)) as User
} catch (err: any) {
if (err && err.status === 404) {
user = {}
deleting = true
} else {
throw err
}
}
let previousApps = previousUser
? Object.keys(previousUser.roles).map(appId => appId)
: []
const roles = deleting ? {} : user.roles
// remove props which aren't useful to metadata
delete user.password
delete user.forceResetPassword
delete user.roles
// run through all production appIDs in the users roles
let prodAppIds
// if they are a builder then get all production app IDs
if ((user.builder && user.builder.global) || deleting) {
prodAppIds = await dbCore.getProdAppIDs()
} else {
prodAppIds = Object.entries(roles)
.filter(entry => entry[1] !== rolesCore.BUILTIN_ROLE_IDS.PUBLIC)
.map(([appId]) => appId)
}
for (let prodAppId of new Set([...prodAppIds, ...previousApps])) {
const roleId = roles[prodAppId]
const deleteFromApp = !roleId
const devAppId = dbCore.getDevelopmentAppID(prodAppId)
for (let appId of [prodAppId, devAppId]) {
if (!(await dbCore.dbExists(appId))) {
continue
}
await context.doInAppContext(appId, async () => {
const db = context.getAppDB()
const metadataId = generateUserMetadataID(userId)
let metadata
try {
metadata = await db.get(metadataId)
} catch (err) {
if (deleteFromApp) {
return
}
metadata = {
tableId: InternalTables.USER_METADATA,
}
}
if (deleteFromApp) {
await db.remove(metadata)
return
}
// assign the roleId for the metadata doc
if (roleId) {
metadata.roleId = roleId
}
let combined = sdk.users.combineMetadataAndUser(user, metadata)
// if its null then there was no updates required
if (combined) {
await db.put(combined)
}
})
}
}
ctx.body = {
message: "User synced.",
}
}
export async function fetchMetadata(ctx: BBContext) {
const global = await getGlobalUsers() const global = await getGlobalUsers()
const metadata = await sdk.users.rawUserMetadata() const metadata = await sdk.users.rawUserMetadata()
const users = [] const users = []
@ -111,7 +25,7 @@ export async function fetchMetadata(ctx: BBContext) {
ctx.body = users ctx.body = users
} }
export async function updateSelfMetadata(ctx: BBContext) { export async function updateSelfMetadata(ctx: UserCtx) {
// overwrite the ID with current users // overwrite the ID with current users
ctx.request.body._id = ctx.user?._id ctx.request.body._id = ctx.user?._id
// make sure no stale rev // make sure no stale rev
@ -121,7 +35,7 @@ export async function updateSelfMetadata(ctx: BBContext) {
await updateMetadata(ctx) await updateMetadata(ctx)
} }
export async function updateMetadata(ctx: BBContext) { export async function updateMetadata(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const user = ctx.request.body const user = ctx.request.body
// this isn't applicable to the user // this isn't applicable to the user
@ -133,7 +47,7 @@ export async function updateMetadata(ctx: BBContext) {
ctx.body = await db.put(metadata) ctx.body = await db.put(metadata)
} }
export async function destroyMetadata(ctx: BBContext) { export async function destroyMetadata(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
try { try {
const dbUser = await db.get(ctx.params.id) const dbUser = await db.get(ctx.params.id)
@ -146,11 +60,11 @@ export async function destroyMetadata(ctx: BBContext) {
} }
} }
export async function findMetadata(ctx: BBContext) { export async function findMetadata(ctx: UserCtx) {
ctx.body = await getFullUser(ctx, ctx.params.id) ctx.body = await getFullUser(ctx, ctx.params.id)
} }
export async function setFlag(ctx: BBContext) { export async function setFlag(ctx: UserCtx) {
const userId = ctx.user?._id const userId = ctx.user?._id
const { flag, value } = ctx.request.body const { flag, value } = ctx.request.body
if (!flag) { if (!flag) {
@ -169,7 +83,7 @@ export async function setFlag(ctx: BBContext) {
ctx.body = { message: "Flag set successfully" } ctx.body = { message: "Flag set successfully" }
} }
export async function getFlags(ctx: BBContext) { export async function getFlags(ctx: UserCtx) {
const userId = ctx.user?._id const userId = ctx.user?._id
const docId = generateUserFlagID(userId!) const docId = generateUserFlagID(userId!)
const db = context.getAppDB() const db = context.getAppDB()

View File

@ -205,41 +205,4 @@ describe("/users", () => {
expect(res.body.message).toEqual("Flag set successfully") expect(res.body.message).toEqual("Flag set successfully")
}) })
}) })
describe("syncUser", () => {
it("should sync the user", async () => {
let user = await config.createUser()
await config.createApp("New App")
let res = await request
.post(`/api/users/metadata/sync/${user._id}`)
.set(config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.message).toEqual("User synced.")
})
it("should sync the user when a previous user is specified", async () => {
const app1 = await config.createApp("App 1")
const app2 = await config.createApp("App 2")
let user = await config.createUser({
builder: false,
admin: true,
roles: { [app1.appId]: "ADMIN" },
})
let res = await request
.post(`/api/users/metadata/sync/${user._id}`)
.set(config.defaultHeaders())
.send({
previousUser: {
...user,
roles: { ...user.roles, [app2.appId]: "BASIC" },
},
})
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.message).toEqual("User synced.")
})
})
}) })

View File

@ -32,11 +32,6 @@ router
authorized(PermissionType.USER, PermissionLevel.WRITE), authorized(PermissionType.USER, PermissionLevel.WRITE),
controller.destroyMetadata controller.destroyMetadata
) )
.post(
"/api/users/metadata/sync/:id",
authorized(PermissionType.USER, PermissionLevel.WRITE),
controller.syncUser
)
.post( .post(
"/api/users/flags", "/api/users/flags",
authorized(PermissionType.USER, PermissionLevel.WRITE), authorized(PermissionType.USER, PermissionLevel.WRITE),

View File

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

View File

@ -0,0 +1,14 @@
import userGroupProcessor from "./syncUsers"
import { docUpdates } from "@budibase/backend-core"
export type UpdateCallback = (docId: string) => void
let started = false
export function init(updateCb?: UpdateCallback) {
if (started) {
return
}
const processors = [userGroupProcessor(updateCb)]
docUpdates.init(processors)
started = true
}

View File

@ -0,0 +1,35 @@
import { constants, logging } from "@budibase/backend-core"
import { sdk as proSdk } from "@budibase/pro"
import { DocUpdateEvent, UserGroupSyncEvents } from "@budibase/types"
import { syncUsersToAllApps } from "../../sdk/app/applications/sync"
import { UpdateCallback } from "./processors"
export default function process(updateCb?: UpdateCallback) {
const processor = async (update: DocUpdateEvent) => {
try {
const docId = update.id
const isGroup = docId.startsWith(constants.DocumentType.GROUP)
let userIds: string[]
if (isGroup) {
const group = await proSdk.groups.get(docId)
userIds = group.users?.map(user => user._id) || []
} else {
userIds = [docId]
}
if (userIds.length > 0) {
await syncUsersToAllApps(userIds)
}
if (updateCb) {
updateCb(docId)
}
} catch (err: any) {
// if something not found - no changes to perform
if (err?.status === 404) {
return
} else {
logging.logAlert("Failed to perform user/group app sync", err)
}
}
}
return { events: UserGroupSyncEvents, processor }
}

View File

@ -2,4 +2,5 @@ import BudibaseEmitter from "./BudibaseEmitter"
const emitter = new BudibaseEmitter() const emitter = new BudibaseEmitter()
export { init } from "./docUpdates"
export default emitter export default emitter

View File

@ -243,11 +243,14 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
if (typeof name !== "string") { if (typeof name !== "string") {
continue continue
} }
const hasDefault = def.COLUMN_DEFAULT
const isAuto = !!autoColumns.find(col => col === name)
const required = !!requiredColumns.find(col => col === name)
schema[name] = { schema[name] = {
autocolumn: !!autoColumns.find(col => col === name), autocolumn: isAuto,
name: name, name: name,
constraints: { constraints: {
presence: requiredColumns.find(col => col === name), presence: required && !isAuto && !hasDefault,
}, },
...convertSqlType(def.DATA_TYPE), ...convertSqlType(def.DATA_TYPE),
externalType: def.DATA_TYPE, externalType: def.DATA_TYPE,

View File

@ -229,13 +229,15 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
if (column.Key === "PRI" && primaryKeys.indexOf(column.Key) === -1) { if (column.Key === "PRI" && primaryKeys.indexOf(column.Key) === -1) {
primaryKeys.push(columnName) primaryKeys.push(columnName)
} }
const constraints = { const hasDefault = column.Default != null
presence: column.Null !== "YES",
}
const isAuto: boolean = const isAuto: boolean =
typeof column.Extra === "string" && typeof column.Extra === "string" &&
(column.Extra === "auto_increment" || (column.Extra === "auto_increment" ||
column.Extra.toLowerCase().includes("generated")) column.Extra.toLowerCase().includes("generated"))
const required = column.Null !== "YES"
const constraints = {
presence: required && !isAuto && !hasDefault,
}
schema[columnName] = { schema[columnName] = {
name: columnName, name: columnName,
autocolumn: isAuto, autocolumn: isAuto,

View File

@ -262,15 +262,17 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
column.identity_start || column.identity_start ||
column.identity_increment column.identity_increment
) )
const constraints = { const hasDefault = column.column_default != null
presence: column.is_nullable === "NO", const hasNextVal =
}
const hasDefault =
typeof column.column_default === "string" && typeof column.column_default === "string" &&
column.column_default.startsWith("nextval") column.column_default.startsWith("nextval")
const isGenerated = const isGenerated =
column.is_generated && column.is_generated !== "NEVER" column.is_generated && column.is_generated !== "NEVER"
const isAuto: boolean = hasDefault || identity || isGenerated const isAuto: boolean = hasNextVal || identity || isGenerated
const required = column.is_nullable === "NO"
const constraints = {
presence: required && !hasDefault && !isGenerated,
}
tables[tableName].schema[columnName] = { tables[tableName].schema[columnName] = {
autocolumn: isAuto, autocolumn: isAuto,
name: columnName, name: columnName,

View File

@ -1,6 +1,117 @@
import env from "../../../environment" import env from "../../../environment"
import { db as dbCore, context } from "@budibase/backend-core" import {
db as dbCore,
context,
docUpdates,
constants,
logging,
roles,
} from "@budibase/backend-core"
import { User, ContextUser, UserGroup } from "@budibase/types"
import { sdk as proSdk } from "@budibase/pro"
import sdk from "../../" import sdk from "../../"
import { getGlobalUsers, processUser } from "../../../utilities/global"
import { generateUserMetadataID, InternalTables } from "../../../db/utils"
type DeletedUser = { _id: string; deleted: boolean }
async function syncUsersToApp(
appId: string,
users: (User | DeletedUser)[],
groups: UserGroup[]
) {
if (!(await dbCore.dbExists(appId))) {
return
}
await context.doInAppContext(appId, async () => {
const db = context.getAppDB()
for (let user of users) {
let ctxUser = user as ContextUser
let deletedUser = false
const metadataId = generateUserMetadataID(user._id!)
if ((user as DeletedUser).deleted) {
deletedUser = true
}
// make sure role is correct
if (!deletedUser) {
ctxUser = await processUser(ctxUser, { appId, groups })
}
let roleId = ctxUser.roleId
if (roleId === roles.BUILTIN_ROLE_IDS.PUBLIC) {
roleId = undefined
}
let metadata
try {
metadata = await db.get(metadataId)
} catch (err: any) {
if (err.status !== 404) {
throw err
}
// no metadata and user is to be deleted, can skip
// no role - user isn't in app anyway
if (!roleId) {
continue
} else if (!deletedUser) {
// doesn't exist yet, creating it
metadata = {
tableId: InternalTables.USER_METADATA,
}
}
}
// the user doesn't exist, or doesn't have a role anymore
// get rid of their metadata
if (deletedUser || !roleId) {
await db.remove(metadata)
continue
}
// assign the roleId for the metadata doc
if (roleId) {
metadata.roleId = roleId
}
let combined = sdk.users.combineMetadataAndUser(ctxUser, metadata)
// if no combined returned, there are no updates to make
if (combined) {
await db.put(combined)
}
}
})
}
export async function syncUsersToAllApps(userIds: string[]) {
// list of users, if one has been deleted it will be undefined in array
const users = (await getGlobalUsers(userIds, {
noProcessing: true,
})) as User[]
const groups = await proSdk.groups.fetch()
const finalUsers: (User | DeletedUser)[] = []
for (let userId of userIds) {
const user = users.find(user => user._id === userId)
if (!user) {
finalUsers.push({ _id: userId, deleted: true })
} else {
finalUsers.push(user)
}
}
const devAppIds = await dbCore.getDevAppIDs()
let promises = []
for (let devAppId of devAppIds) {
const prodAppId = dbCore.getProdAppID(devAppId)
for (let appId of [prodAppId, devAppId]) {
promises.push(syncUsersToApp(appId, finalUsers, groups))
}
}
const resp = await Promise.allSettled(promises)
const failed = resp.filter(promise => promise.status === "rejected")
if (failed.length > 0) {
const reasons = failed.map(fail => (fail as PromiseRejectedResult).reason)
logging.logAlert("Failed to sync users to apps", reasons)
}
}
export async function syncApp( export async function syncApp(
appId: string, appId: string,
@ -23,32 +134,28 @@ export async function syncApp(
// specific case, want to make sure setup is skipped // specific case, want to make sure setup is skipped
const prodDb = context.getProdAppDB({ skip_setup: true }) const prodDb = context.getProdAppDB({ skip_setup: true })
const exists = await prodDb.exists() const exists = await prodDb.exists()
if (!exists) {
// the database doesn't exist. Don't replicate
return {
message: "App sync not required, app not deployed.",
}
}
const replication = new dbCore.Replication({
source: prodAppId,
target: appId,
})
let error let error
try { if (exists) {
const replOpts = replication.appReplicateOpts() const replication = new dbCore.Replication({
if (opts?.automationOnly) { source: prodAppId,
replOpts.filter = (doc: any) => target: appId,
doc._id.startsWith(dbCore.DocumentType.AUTOMATION) })
try {
const replOpts = replication.appReplicateOpts()
if (opts?.automationOnly) {
replOpts.filter = (doc: any) =>
doc._id.startsWith(dbCore.DocumentType.AUTOMATION)
}
await replication.replicate(replOpts)
} catch (err) {
error = err
} finally {
await replication.close()
} }
await replication.replicate(replOpts)
} catch (err) {
error = err
} finally {
await replication.close()
} }
// sync the users // sync the users - kept for safe keeping
await sdk.users.syncGlobalUsers() await sdk.users.syncGlobalUsers()
if (error) { if (error) {

View File

@ -0,0 +1,137 @@
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { events, context, roles, constants } from "@budibase/backend-core"
import { init } from "../../../../events"
import { rawUserMetadata } from "../../../users/utils"
import EventEmitter from "events"
import { UserGroup, UserMetadata, UserRoles, User } from "@budibase/types"
const config = new TestConfiguration()
let app, group: UserGroup, groupUser: User
const ROLE_ID = roles.BUILTIN_ROLE_IDS.BASIC
const emitter = new EventEmitter()
function updateCb(docId: string) {
const isGroup = docId.startsWith(constants.DocumentType.GROUP)
if (isGroup) {
emitter.emit("update-group")
} else {
emitter.emit("update-user")
}
}
init(updateCb)
function waitForUpdate(opts: { group?: boolean }) {
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject()
}, 5000)
const event = opts?.group ? "update-group" : "update-user"
emitter.on(event, () => {
clearTimeout(timeout)
resolve()
})
})
}
beforeAll(async () => {
app = await config.init("syncApp")
})
async function createUser(email: string, roles: UserRoles, builder?: boolean) {
const user = await config.createUser({
email,
roles,
builder: builder || false,
admin: false,
})
await context.doInContext(config.appId!, async () => {
await events.user.created(user)
})
return user
}
async function removeUserRole(user: User) {
const final = await config.globalUser({
...user,
id: user._id,
roles: {},
builder: false,
admin: false,
})
await context.doInContext(config.appId!, async () => {
await events.user.updated(final)
})
}
async function createGroupAndUser(email: string) {
groupUser = await config.createUser({
email,
roles: {},
builder: false,
admin: false,
})
group = await config.createGroup()
await config.addUserToGroup(group._id!, groupUser._id!)
}
async function removeUserFromGroup() {
await config.removeUserFromGroup(group._id!, groupUser._id!)
return context.doInContext(config.appId!, async () => {
await events.user.updated(groupUser)
})
}
async function getUserMetadata(): Promise<UserMetadata[]> {
return context.doInContext(config.appId!, async () => {
return await rawUserMetadata()
})
}
function buildRoles() {
return { [config.prodAppId!]: ROLE_ID }
}
describe("app user/group sync", () => {
const groupEmail = "test2@test.com",
normalEmail = "test@test.com"
async function checkEmail(
email: string,
opts?: { group?: boolean; notFound?: boolean }
) {
await waitForUpdate(opts || {})
const metadata = await getUserMetadata()
const found = metadata.find(data => data.email === email)
if (opts?.notFound) {
expect(found).toBeUndefined()
} else {
expect(found).toBeDefined()
}
}
it("should be able to sync a new user, add then remove", async () => {
const user = await createUser(normalEmail, buildRoles())
await checkEmail(normalEmail)
await removeUserRole(user)
await checkEmail(normalEmail, { notFound: true })
})
it("should be able to sync a group", async () => {
await createGroupAndUser(groupEmail)
await checkEmail(groupEmail, { group: true })
})
it("should be able to remove user from group", async () => {
if (!group) {
await createGroupAndUser(groupEmail)
}
await removeUserFromGroup()
await checkEmail(groupEmail, { notFound: true })
})
it("should be able to handle builder users", async () => {
await createUser("test3@test.com", {}, true)
await checkEmail("test3@test.com")
})
})

View File

@ -121,38 +121,7 @@ describe("syncGlobalUsers", () => {
await syncGlobalUsers() await syncGlobalUsers()
const metadata = await rawUserMetadata() const metadata = await rawUserMetadata()
expect(metadata).toHaveLength(1) expect(metadata).toHaveLength(0)
})
})
})
it("app users are removed when app is removed from user group", async () => {
await config.doInTenant(async () => {
const group = await proSdk.groups.save(structures.userGroups.userGroup())
const user1 = await config.createUser({ admin: false, builder: false })
const user2 = await config.createUser({ admin: false, builder: false })
await proSdk.groups.updateGroupApps(group.id, {
appsToAdd: [
{ appId: config.prodAppId!, roleId: roles.BUILTIN_ROLE_IDS.BASIC },
],
})
await proSdk.groups.addUsers(group.id, [user1._id, user2._id])
await config.doInContext(config.appId, async () => {
await syncGlobalUsers()
expect(await rawUserMetadata()).toHaveLength(3)
await proSdk.groups.removeUsers(group.id, [user1._id])
await syncGlobalUsers()
const metadata = await rawUserMetadata()
expect(metadata).toHaveLength(2)
expect(metadata).not.toContainEqual(
expect.objectContaining({
_id: db.generateUserMetadataID(user1._id),
})
)
}) })
}) })
}) })

View File

@ -1,12 +1,13 @@
import { getGlobalUsers } from "../../utilities/global" import { getGlobalUsers } from "../../utilities/global"
import { context, roles as rolesCore } from "@budibase/backend-core" import { context, roles as rolesCore } from "@budibase/backend-core"
import { import {
getGlobalIDFromUserMetadataID,
generateUserMetadataID, generateUserMetadataID,
getUserMetadataParams, getUserMetadataParams,
InternalTables, InternalTables,
} from "../../db/utils" } from "../../db/utils"
import { isEqual } from "lodash" import { isEqual } from "lodash"
import { ContextUser, UserMetadata } from "@budibase/types" import { ContextUser, UserMetadata, User } from "@budibase/types"
export function combineMetadataAndUser( export function combineMetadataAndUser(
user: ContextUser, user: ContextUser,
@ -37,6 +38,10 @@ export function combineMetadataAndUser(
if (found) { if (found) {
newDoc._rev = found._rev newDoc._rev = found._rev
} }
// clear fields that shouldn't be in metadata
delete newDoc.password
delete newDoc.forceResetPassword
delete newDoc.roles
if (found == null || !isEqual(newDoc, found)) { if (found == null || !isEqual(newDoc, found)) {
return { return {
...found, ...found,
@ -60,10 +65,9 @@ export async function rawUserMetadata() {
export async function syncGlobalUsers() { export async function syncGlobalUsers() {
// sync user metadata // sync user metadata
const db = context.getAppDB() const db = context.getAppDB()
const [users, metadata] = await Promise.all([ const resp = await Promise.all([getGlobalUsers(), rawUserMetadata()])
getGlobalUsers(), const users = resp[0] as User[]
rawUserMetadata(), const metadata = resp[1] as UserMetadata[]
])
const toWrite = [] const toWrite = []
for (let user of users) { for (let user of users) {
const combined = combineMetadataAndUser(user, metadata) const combined = combineMetadataAndUser(user, metadata)
@ -71,5 +75,19 @@ export async function syncGlobalUsers() {
toWrite.push(combined) toWrite.push(combined)
} }
} }
let foundEmails: string[] = []
for (let data of metadata) {
if (!data._id) {
continue
}
const alreadyExisting = data.email && foundEmails.indexOf(data.email) !== -1
const globalId = getGlobalIDFromUserMetadataID(data._id)
if (!users.find(user => user._id === globalId) || alreadyExisting) {
toWrite.push({ ...data, _deleted: true })
}
if (data.email) {
foundEmails.push(data.email)
}
}
await db.bulkDocs(toWrite) await db.bulkDocs(toWrite)
} }

View File

@ -10,7 +10,7 @@ import fs from "fs"
import { watch } from "./watch" import { watch } from "./watch"
import * as automations from "./automations" import * as automations from "./automations"
import * as fileSystem from "./utilities/fileSystem" import * as fileSystem from "./utilities/fileSystem"
import eventEmitter from "./events" import { default as eventEmitter, init as eventInit } from "./events"
import * as migrations from "./migrations" import * as migrations from "./migrations"
import * as bullboard from "./automations/bullboard" import * as bullboard from "./automations/bullboard"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
@ -63,6 +63,7 @@ export async function startup(app?: any, server?: any) {
eventEmitter.emitPort(env.PORT) eventEmitter.emitPort(env.PORT)
fileSystem.init() fileSystem.init()
await redis.init() await redis.init()
eventInit()
// run migrations on startup if not done via http // run migrations on startup if not done via http
// not recommended in a clustered environment // not recommended in a clustered environment

View File

@ -49,6 +49,7 @@ import {
SearchFilters, SearchFilters,
UserRoles, UserRoles,
} from "@budibase/types" } from "@budibase/types"
import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles"
type DefaultUserValues = { type DefaultUserValues = {
globalUserId: string globalUserId: string
@ -306,6 +307,33 @@ class TestConfiguration {
} }
} }
async createGroup(roleId: string = BUILTIN_ROLE_IDS.BASIC) {
return context.doInTenant(this.tenantId!, async () => {
const baseGroup = structures.userGroups.userGroup()
baseGroup.roles = {
[this.prodAppId]: roleId,
}
const { id, rev } = await pro.sdk.groups.save(baseGroup)
return {
_id: id,
_rev: rev,
...baseGroup,
}
})
}
async addUserToGroup(groupId: string, userId: string) {
return context.doInTenant(this.tenantId!, async () => {
await pro.sdk.groups.addUsers(groupId, [userId])
})
}
async removeUserFromGroup(groupId: string, userId: string) {
return context.doInTenant(this.tenantId!, async () => {
await pro.sdk.groups.removeUsers(groupId, [userId])
})
}
async login({ roleId, userId, builder, prodApp = false }: any = {}) { async login({ roleId, userId, builder, prodApp = false }: any = {}) {
const appId = prodApp ? this.prodAppId : this.appId const appId = prodApp ? this.prodAppId : this.appId
return context.doInAppContext(appId, async () => { return context.doInAppContext(appId, async () => {

View File

@ -9,6 +9,7 @@ import {
import env from "../environment" import env from "../environment"
import { groups } from "@budibase/pro" import { groups } from "@budibase/pro"
import { UserCtx, ContextUser, User, UserGroup } from "@budibase/types" import { UserCtx, ContextUser, User, UserGroup } from "@budibase/types"
import { global } from "yargs"
export function updateAppRole( export function updateAppRole(
user: ContextUser, user: ContextUser,
@ -16,7 +17,7 @@ export function updateAppRole(
) { ) {
appId = appId || context.getAppId() appId = appId || context.getAppId()
if (!user || !user.roles) { if (!user || (!user.roles && !user.userGroups)) {
return user return user
} }
// if in an multi-tenancy environment make sure roles are never updated // if in an multi-tenancy environment make sure roles are never updated
@ -27,7 +28,7 @@ export function updateAppRole(
return user return user
} }
// always use the deployed app // always use the deployed app
if (appId) { if (appId && user.roles) {
user.roleId = user.roles[dbCore.getProdAppID(appId)] user.roleId = user.roles[dbCore.getProdAppID(appId)]
} }
// if a role wasn't found then either set as admin (builder) or public (everyone else) // if a role wasn't found then either set as admin (builder) or public (everyone else)
@ -60,7 +61,7 @@ async function checkGroupRoles(
return user return user
} }
async function processUser( export async function processUser(
user: ContextUser, user: ContextUser,
opts: { appId?: string; groups?: UserGroup[] } = {} opts: { appId?: string; groups?: UserGroup[] } = {}
) { ) {
@ -94,16 +95,15 @@ export async function getGlobalUser(userId: string) {
return processUser(user, { appId }) return processUser(user, { appId })
} }
export async function getGlobalUsers(users?: ContextUser[]) { export async function getGlobalUsers(
userIds?: string[],
opts?: { noProcessing?: boolean }
) {
const appId = context.getAppId() const appId = context.getAppId()
const db = tenancy.getGlobalDB() const db = tenancy.getGlobalDB()
const allGroups = await groups.fetch()
let globalUsers let globalUsers
if (users) { if (userIds) {
const globalIds = users.map(user => globalUsers = (await db.allDocs(getMultiIDParams(userIds))).rows.map(
getGlobalIDFromUserMetadataID(user._id!)
)
globalUsers = (await db.allDocs(getMultiIDParams(globalIds))).rows.map(
row => row.doc row => row.doc
) )
} else { } else {
@ -126,15 +126,20 @@ export async function getGlobalUsers(users?: ContextUser[]) {
return globalUsers return globalUsers
} }
// pass in the groups, meaning we don't actually need to retrieve them for if (opts?.noProcessing) {
// each user individually return globalUsers
return Promise.all( } else {
globalUsers.map(user => processUser(user, { groups: allGroups })) // pass in the groups, meaning we don't actually need to retrieve them for
) // each user individually
const allGroups = await groups.fetch()
return Promise.all(
globalUsers.map(user => processUser(user, { groups: allGroups }))
)
}
} }
export async function getGlobalUsersFromMetadata(users: ContextUser[]) { export async function getGlobalUsersFromMetadata(users: ContextUser[]) {
const globalUsers = await getGlobalUsers(users) const globalUsers = await getGlobalUsers(users.map(user => user._id!))
return users.map(user => { return users.map(user => {
const globalUser = globalUsers.find( const globalUser = globalUsers.find(
globalUser => globalUser && user._id?.includes(globalUser._id) globalUser => globalUser && user._id?.includes(globalUser._id)

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/shared-core", "name": "@budibase/shared-core",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Shared data utils", "description": "Shared data utils",
"main": "dist/cjs/src/index.js", "main": "dist/cjs/src/index.js",
"types": "dist/mjs/src/index.d.ts", "types": "dist/mjs/src/index.d.ts",
@ -20,7 +20,7 @@
"dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\"" "dev:builder": "yarn prebuild && concurrently \"tsc -p tsconfig.build.json --watch\" \"tsc -p tsconfig-cjs.build.json --watch\""
}, },
"dependencies": { "dependencies": {
"@budibase/types": "2.4.44-alpha.22" "@budibase/types": "2.5.6-alpha.2"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^7.6.0", "concurrently": "^7.6.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",
@ -30,7 +30,7 @@
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"handlebars-utils": "^1.0.6", "handlebars-utils": "^1.0.6",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"vm2": "^3.9.4" "vm2": "^3.9.15"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-commonjs": "^17.1.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/types", "name": "@budibase/types",
"version": "2.4.44-alpha.22", "version": "2.5.6-alpha.2",
"description": "Budibase types", "description": "Budibase types",
"main": "dist/cjs/index.js", "main": "dist/cjs/index.js",
"types": "dist/mjs/index.d.ts", "types": "dist/mjs/index.d.ts",

View File

@ -2,4 +2,5 @@ import { Document } from "../document"
export interface UserMetadata extends Document { export interface UserMetadata extends Document {
roleId: string roleId: string
email?: string
} }

View File

@ -1,4 +1,5 @@
import { Hosting } from "../hosting" import { Hosting } from "../hosting"
import { Group, Identity } from "./identification"
export enum Event { export enum Event {
// USER // USER
@ -186,6 +187,24 @@ export enum Event {
AUDIT_LOGS_DOWNLOADED = "audit_log:downloaded", AUDIT_LOGS_DOWNLOADED = "audit_log:downloaded",
} }
export const UserGroupSyncEvents: Event[] = [
Event.USER_CREATED,
Event.USER_UPDATED,
Event.USER_DELETED,
Event.USER_PERMISSION_ADMIN_ASSIGNED,
Event.USER_PERMISSION_ADMIN_REMOVED,
Event.USER_PERMISSION_BUILDER_ASSIGNED,
Event.USER_PERMISSION_BUILDER_REMOVED,
Event.USER_GROUP_CREATED,
Event.USER_GROUP_UPDATED,
Event.USER_GROUP_DELETED,
Event.USER_GROUP_USERS_ADDED,
Event.USER_GROUP_USERS_REMOVED,
Event.USER_GROUP_PERMISSIONS_EDITED,
]
export const AsyncEvents: Event[] = [...UserGroupSyncEvents]
// all events that are not audited have been added to this record as undefined, this means // all events that are not audited have been added to this record as undefined, this means
// that Typescript can protect us against new events being added and auditing of those // that Typescript can protect us against new events being added and auditing of those
// events not being considered. This might be a little ugly, but provides a level of // events not being considered. This might be a little ugly, but provides a level of
@ -383,3 +402,21 @@ export interface BaseEvent {
} }
export type TableExportFormat = "json" | "csv" export type TableExportFormat = "json" | "csv"
export type DocUpdateEvent = {
id: string
tenantId: string
appId?: string
}
export interface EventProcessor {
processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
): Promise<void>
identify?(identity: Identity, timestamp?: string | number): Promise<void>
identifyGroup?(group: Group, timestamp?: string | number): Promise<void>
shutdown?(): void
}

View File

@ -46,6 +46,8 @@ export interface Identity {
environment: string environment: string
installationId?: string installationId?: string
tenantId?: string tenantId?: string
// usable - no unique format
realTenantId?: string
hostInfo?: HostInfo hostInfo?: HostInfo
} }

View File

@ -7,7 +7,8 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"incremental": true, "incremental": true,
"sourceMap": true, "sourceMap": true,
"declaration": true "declaration": true,
"skipLibCheck": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.spec.js"] "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.spec.js"]

View File

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

View File

@ -126,9 +126,8 @@ describe("/api/global/auth", () => {
it("should prevent user from logging in", async () => { it("should prevent user from logging in", async () => {
user = await config.createUser() user = await config.createUser()
const account = structures.accounts.ssoAccount() as CloudAccount const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce( account.email = user.email
Promise.resolve(account) mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
)
await testSSOUser() await testSSOUser()
}) })
@ -186,9 +185,8 @@ describe("/api/global/auth", () => {
it("should prevent user from generating password reset email", async () => { it("should prevent user from generating password reset email", async () => {
user = await config.createUser(structures.users.user()) user = await config.createUser(structures.users.user())
const account = structures.accounts.ssoAccount() as CloudAccount const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccount.mockReturnValueOnce( account.email = user.email
Promise.resolve(account) mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
)
await testSSOUser() await testSSOUser()
}) })

View File

@ -585,6 +585,59 @@ describe("scim", () => {
totalResults: groupCount, totalResults: groupCount,
}) })
}) })
it("can fetch groups using displayName filters", async () => {
const groupToFetch = _.sample(groups)
const response = await getScimGroups({
params: { filter: `displayName eq "${groupToFetch!.displayName}"` },
})
expect(response).toEqual({
Resources: [groupToFetch],
itemsPerPage: 1,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: 1,
totalResults: 1,
})
})
it("can fetch groups excluding members", async () => {
const response = await getScimGroups({
params: { excludedAttributes: "members" },
})
expect(response).toEqual({
Resources: expect.arrayContaining(
groups.map(g => {
const { members, ...groupData } = g
return groupData
})
),
itemsPerPage: 25,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: 1,
totalResults: groupCount,
})
})
it("can fetch groups excluding multiple fields", async () => {
const response = await getScimGroups({
params: { excludedAttributes: "members,displayName" },
})
expect(response).toEqual({
Resources: expect.arrayContaining(
groups.map(g => {
const { members, displayName, ...groupData } = g
return groupData
})
),
itemsPerPage: 25,
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
startIndex: 1,
totalResults: groupCount,
})
})
}) })
}) })
@ -662,6 +715,16 @@ describe("scim", () => {
status: 404, status: 404,
}) })
}) })
it("should allow excluding members", async () => {
const response = await findScimGroup(group.id, {
qs: "excludedAttributes=members",
})
const { members, ...expectedResponse } = group
expect(response).toEqual(expectedResponse)
})
}) })
describe("DELETE /api/global/scim/v2/groups/:id", () => { describe("DELETE /api/global/scim/v2/groups/:id", () => {

View File

@ -1,6 +1,6 @@
import { structures } from "../../../tests" import { structures } from "../../../tests"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { env } from "@budibase/backend-core" import { env, context } from "@budibase/backend-core"
import * as users from "../users" import * as users from "../users"
import { CloudAccount } from "@budibase/types" import { CloudAccount } from "@budibase/types"
import { isPreventPasswordActions } from "../users" import { isPreventPasswordActions } from "../users"
@ -16,32 +16,50 @@ describe("users", () => {
describe("isPreventPasswordActions", () => { describe("isPreventPasswordActions", () => {
it("returns false for non sso user", async () => { it("returns false for non sso user", async () => {
const user = structures.users.user() await context.doInTenant(structures.tenant.id(), async () => {
const result = await users.isPreventPasswordActions(user) const user = structures.users.user()
expect(result).toBe(false) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
}) })
it("returns true for sso account user", async () => { it("returns true for sso account user", async () => {
const user = structures.users.user() await context.doInTenant(structures.tenant.id(), async () => {
mocks.accounts.getAccount.mockReturnValue( const user = structures.users.user()
Promise.resolve(structures.accounts.ssoAccount() as CloudAccount) const account = structures.accounts.ssoAccount() as CloudAccount
) account.email = user.email
const result = await users.isPreventPasswordActions(user) mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
expect(result).toBe(true) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
it("returns false when account doesn't match user email", async () => {
await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.user()
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
}) })
it("returns true for sso user", async () => { it("returns true for sso user", async () => {
const user = structures.users.ssoUser() await context.doInTenant(structures.tenant.id(), async () => {
const result = await users.isPreventPasswordActions(user) const user = structures.users.ssoUser()
expect(result).toBe(true) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
}) })
describe("enforced sso", () => { describe("enforced sso", () => {
it("returns true for all users when sso is enforced", async () => { it("returns true for all users when sso is enforced", async () => {
const user = structures.users.user() await context.doInTenant(structures.tenant.id(), async () => {
pro.features.isSSOEnforced.mockReturnValue(Promise.resolve(true)) const user = structures.users.user()
const result = await users.isPreventPasswordActions(user) pro.features.isSSOEnforced.mockResolvedValueOnce(true)
expect(result).toBe(true) const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
}) })
}) })

View File

@ -1,5 +1,4 @@
import env from "../../environment" import env from "../../environment"
import * as apps from "../../utilities/appService"
import * as eventHelpers from "./events" import * as eventHelpers from "./events"
import { import {
accounts, accounts,
@ -30,9 +29,9 @@ import {
PlatformUser, PlatformUser,
PlatformUserByEmail, PlatformUserByEmail,
RowResponse, RowResponse,
SearchUsersRequest,
User, User,
SaveUserOpts, SaveUserOpts,
Account,
} from "@budibase/types" } from "@budibase/types"
import { sendEmail } from "../../utilities/email" import { sendEmail } from "../../utilities/email"
import { EmailTemplatePurpose } from "../../constants" import { EmailTemplatePurpose } from "../../constants"
@ -90,7 +89,8 @@ const buildUser = async (
requirePassword: true, requirePassword: true,
}, },
tenantId: string, tenantId: string,
dbUser?: any dbUser?: any,
account?: Account
): Promise<User> => { ): Promise<User> => {
let { password, _id } = user let { password, _id } = user
@ -101,7 +101,7 @@ const buildUser = async (
let hashedPassword let hashedPassword
if (password) { if (password) {
if (await isPreventPasswordActions(user)) { if (await isPreventPasswordActions(user, account)) {
throw new HTTPError("Password change is disabled for this user", 400) throw new HTTPError("Password change is disabled for this user", 400)
} }
hashedPassword = opts.hashPassword ? await utils.hash(password) : password hashedPassword = opts.hashPassword ? await utils.hash(password) : password
@ -172,7 +172,7 @@ const validateUniqueUser = async (email: string, tenantId: string) => {
} }
} }
export async function isPreventPasswordActions(user: User) { export async function isPreventPasswordActions(user: User, account?: Account) {
// when in maintenance mode we allow sso users with the admin role // when in maintenance mode we allow sso users with the admin role
// to perform any password action - this prevents lockout // to perform any password action - this prevents lockout
if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) { if (coreEnv.ENABLE_SSO_MAINTENANCE_MODE && user.admin?.global) {
@ -190,8 +190,10 @@ export async function isPreventPasswordActions(user: User) {
} }
// Check account sso // Check account sso
const account = await accountSdk.api.getAccount(user.email) if (!account) {
return !!(account && isSSOAccount(account)) account = await accountSdk.api.getAccountByTenantId(tenancy.getTenantId())
}
return !!(account && account.email === user.email && isSSOAccount(account))
} }
export const save = async ( export const save = async (
@ -276,9 +278,6 @@ export const save = async (
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id) await cache.user.invalidateUser(response.id)
// let server know to sync user
await apps.syncUserInApps(_id, dbUser)
await Promise.all(groupPromises) await Promise.all(groupPromises)
// finally returned the saved user from the db // finally returned the saved user from the db
@ -402,6 +401,7 @@ export const bulkCreate = async (
newUsers.push(newUser) newUsers.push(newUser)
} }
const account = await accountSdk.api.getAccountByTenantId(tenantId)
// create the promises array that will be called by bulkDocs // create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => { newUsers.forEach((user: any) => {
usersToSave.push( usersToSave.push(
@ -411,7 +411,9 @@ export const bulkCreate = async (
hashPassword: true, hashPassword: true,
requirePassword: user.requirePassword, requirePassword: user.requirePassword,
}, },
tenantId tenantId,
undefined, // no dbUser
account
) )
) )
}) })
@ -425,7 +427,6 @@ export const bulkCreate = async (
// instead of relying on looping tenant creation // instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email) await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined) await eventHelpers.handleSaveEvents(user, undefined)
await apps.syncUserInApps(user._id)
} }
const saved = usersToBulkSave.map(user => { const saved = usersToBulkSave.map(user => {
@ -564,8 +565,6 @@ export const destroy = async (id: string) => {
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })
// let server know to sync user
await apps.syncUserInApps(userId, dbUser)
} }
const bulkDeleteProcessing = async (dbUser: User) => { const bulkDeleteProcessing = async (dbUser: User) => {
@ -574,8 +573,6 @@ const bulkDeleteProcessing = async (dbUser: User) => {
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "bulk-deletion" }) await sessions.invalidateSessions(userId, { reason: "bulk-deletion" })
// let server know to sync user
await apps.syncUserInApps(userId, dbUser)
} }
export const invite = async ( export const invite = async (

View File

@ -18,6 +18,7 @@ export class ScimGroupsAPI extends ScimTestAPI {
startIndex?: number startIndex?: number
pageSize?: number pageSize?: number
filter?: string filter?: string
excludedAttributes?: string
} }
} }
) => { ) => {
@ -32,6 +33,9 @@ export class ScimGroupsAPI extends ScimTestAPI {
if (params?.filter) { if (params?.filter) {
url += `filter=${params.filter}&` url += `filter=${params.filter}&`
} }
if (params?.excludedAttributes) {
url += `excludedAttributes=${params.excludedAttributes}&`
}
const res = await this.call(url, "get", requestSettings) const res = await this.call(url, "get", requestSettings)
return res.body as ScimGroupListResponse return res.body as ScimGroupListResponse
} }
@ -54,9 +58,12 @@ export class ScimGroupsAPI extends ScimTestAPI {
return res.body as ScimGroupResponse return res.body as ScimGroupResponse
} }
find = async (id: string, requestSettings?: Partial<RequestSettings>) => { find = async (
id: string,
requestSettings?: Partial<RequestSettings> & { qs?: string }
) => {
const res = await this.call( const res = await this.call(
`/api/global/scim/v2/groups/${id}`, `/api/global/scim/v2/groups/${id}?${requestSettings?.qs}`,
"get", "get",
requestSettings requestSettings
) )

View File

@ -1,46 +0,0 @@
import fetch from "node-fetch"
import {
constants,
tenancy,
logging,
env as coreEnv,
} from "@budibase/backend-core"
import { checkSlashesInUrl } from "../utilities"
import env from "../environment"
import { SyncUserRequest, User } from "@budibase/types"
async function makeAppRequest(url: string, method: string, body: any) {
if (env.isTest()) {
return
}
const request: any = { headers: {} }
request.headers[constants.Header.API_KEY] = coreEnv.INTERNAL_API_KEY
if (tenancy.isTenantIdSet()) {
request.headers[constants.Header.TENANT_ID] = tenancy.getTenantId()
}
if (body) {
request.headers["Content-Type"] = "application/json"
request.body = JSON.stringify(body)
}
request.method = method
// add x-budibase-correlation-id header
logging.correlation.setHeader(request.headers)
return fetch(checkSlashesInUrl(env.APPS_URL + url), request)
}
export async function syncUserInApps(userId: string, previousUser?: User) {
const body: SyncUserRequest = {
previousUser,
}
const response = await makeAppRequest(
`/api/users/metadata/sync/${userId}`,
"POST",
body
)
if (response && response.status !== 200) {
throw "Unable to sync user."
}
}

View File

@ -36,7 +36,7 @@ describe("Internal API - Application creation, update, publish and delete", () =
const [syncResponse, sync] = await config.api.apps.sync(app.appId!) const [syncResponse, sync] = await config.api.apps.sync(app.appId!)
expect(sync).toEqual({ expect(sync).toEqual({
message: "App sync not required, app not deployed.", message: "App sync completed successfully.",
}) })
}) })

View File

@ -1485,15 +1485,15 @@
pouchdb-promise "^6.0.4" pouchdb-promise "^6.0.4"
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@2.4.44-alpha.22": "@budibase/pro@2.5.6-alpha.2":
version "2.4.44-alpha.22" version "2.5.6-alpha.2"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.4.44-alpha.22.tgz#5709ae5261b95cc0e53a19905a62bce6bdb6d54a" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.5.6-alpha.2.tgz#9029a9748a1beb5733bb76326d9c9bebf4e5f326"
integrity sha512-DWRufe9tROX/bNqWo2wJ/G5g6zdph1y3wmK1C6p7eVNvuNBgddQojsr2hPHqKxaBo1XbnLvRJeyfja0sAmNrKA== integrity sha512-55psh21TQmbPIV+7SZhNT3uogUsKhRmSrk2L6iQB31Tbywu8JDuXpbjaf8Zyi15QiZLslV+1Od47b9YfX97adg==
dependencies: dependencies:
"@budibase/backend-core" "2.4.44-alpha.22" "@budibase/backend-core" "2.5.6-alpha.2"
"@budibase/shared-core" "2.4.44-alpha.1" "@budibase/shared-core" "2.4.44-alpha.1"
"@budibase/string-templates" "2.4.44-alpha.1" "@budibase/string-templates" "2.4.44-alpha.1"
"@budibase/types" "2.4.44-alpha.22" "@budibase/types" "2.5.6-alpha.2"
"@koa/router" "8.0.8" "@koa/router" "8.0.8"
bull "4.10.1" bull "4.10.1"
joi "17.6.0" joi "17.6.0"
@ -24261,6 +24261,14 @@ vm2@3.9.16:
acorn "^8.7.0" acorn "^8.7.0"
acorn-walk "^8.2.0" acorn-walk "^8.2.0"
vm2@^3.9.15:
version "3.9.17"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.17.tgz#251b165ff8a0e034942b5181057305e39570aeab"
integrity sha512-AqwtCnZ/ERcX+AVj9vUsphY56YANXxRuqMb7GsDtAr0m0PcQX3u0Aj3KWiXM0YAHy7i6JEeHrwOnwXbGYgRpAw==
dependencies:
acorn "^8.7.0"
acorn-walk "^8.2.0"
vm2@^3.9.4, vm2@^3.9.8: vm2@^3.9.4, vm2@^3.9.8:
version "3.9.14" version "3.9.14"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.14.tgz#964042b474cf1e6e4f475a39144773cdb9deb734" resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.14.tgz#964042b474cf1e6e4f475a39144773cdb9deb734"
@ -25064,8 +25072,3 @@ z-schema@^5.0.1:
validator "^13.7.0" validator "^13.7.0"
optionalDependencies: optionalDependencies:
commander "^9.4.1" commander "^9.4.1"
zlib@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/zlib/-/zlib-1.0.5.tgz#6e7c972fc371c645a6afb03ab14769def114fcc0"
integrity sha512-40fpE2II+Cd3k8HWTWONfeKE2jL+P42iWJ1zzps5W51qcTsOUKM5Q5m2PFb0CLxlmFAaUuUdJGc3OfZy947v0w==