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

This commit is contained in:
Dean 2024-10-18 14:16:18 +01:00
commit 77fefcdbe4
41 changed files with 1517 additions and 609 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.33.1", "version": "2.33.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -54,30 +54,46 @@ function getPackageJsonFields(): {
VERSION: string VERSION: string
SERVICE_NAME: string SERVICE_NAME: string
} { } {
function findFileInAncestors( function getParentFile(file: string) {
fileName: string, function findFileInAncestors(
currentDir: string fileName: string,
): string | null { currentDir: string
const filePath = `${currentDir}/${fileName}` ): string | null {
if (existsSync(filePath)) { const filePath = `${currentDir}/${fileName}`
return filePath if (existsSync(filePath)) {
return filePath
}
const parentDir = `${currentDir}/..`
if (parentDir === currentDir) {
// reached root directory
return null
}
return findFileInAncestors(fileName, parentDir)
} }
const parentDir = `${currentDir}/..` const packageJsonFile = findFileInAncestors(file, process.cwd())
if (parentDir === currentDir) { const content = readFileSync(packageJsonFile!, "utf-8")
// reached root directory const parsedContent = JSON.parse(content)
return null return parsedContent
} }
return findFileInAncestors(fileName, parentDir) let localVersion: string | undefined
if (isDev() && !isTest()) {
try {
const lerna = getParentFile("lerna.json")
localVersion = lerna.version
} catch {
//
}
} }
try { try {
const packageJsonFile = findFileInAncestors("package.json", process.cwd()) const parsedContent = getParentFile("package.json")
const content = readFileSync(packageJsonFile!, "utf-8")
const parsedContent = JSON.parse(content)
return { return {
VERSION: process.env.BUDIBASE_VERSION || parsedContent.version, VERSION:
localVersion || process.env.BUDIBASE_VERSION || parsedContent.version,
SERVICE_NAME: parsedContent.name, SERVICE_NAME: parsedContent.name,
} }
} catch { } catch {

View File

@ -1,3 +1,4 @@
import semver from "semver"
import { BuiltinPermissionID, PermissionLevel } from "./permissions" import { BuiltinPermissionID, PermissionLevel } from "./permissions"
import { import {
prefixRoleID, prefixRoleID,
@ -7,9 +8,16 @@ import {
doWithDB, doWithDB,
} from "../db" } from "../db"
import { getAppDB } from "../context" import { getAppDB } from "../context"
import { Screen, Role as RoleDoc, RoleUIMetadata } from "@budibase/types" import {
Screen,
Role as RoleDoc,
RoleUIMetadata,
Database,
App,
} from "@budibase/types"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
import { RoleColor } from "@budibase/shared-core" import { RoleColor, helpers } from "@budibase/shared-core"
import { uniqBy } from "lodash"
export const BUILTIN_ROLE_IDS = { export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN", ADMIN: "ADMIN",
@ -23,14 +31,6 @@ const BUILTIN_IDS = {
BUILDER: "BUILDER", BUILDER: "BUILDER",
} }
// exclude internal roles like builder
const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_IDS.ADMIN,
BUILTIN_IDS.POWER,
BUILTIN_IDS.BASIC,
BUILTIN_IDS.PUBLIC,
]
export const RoleIDVersion = { export const RoleIDVersion = {
// original version, with a UUID based ID // original version, with a UUID based ID
UUID: undefined, UUID: undefined,
@ -38,12 +38,20 @@ export const RoleIDVersion = {
NAME: "name", NAME: "name",
} }
function rolesInList(roleIds: string[], ids: string | string[]) {
if (Array.isArray(ids)) {
return ids.filter(id => roleIds.includes(id)).length === ids.length
} else {
return roleIds.includes(ids)
}
}
export class Role implements RoleDoc { export class Role implements RoleDoc {
_id: string _id: string
_rev?: string _rev?: string
name: string name: string
permissionId: string permissionId: string
inherits?: string inherits?: string | string[]
version?: string version?: string
permissions: Record<string, PermissionLevel[]> = {} permissions: Record<string, PermissionLevel[]> = {}
uiMetadata?: RoleUIMetadata uiMetadata?: RoleUIMetadata
@ -62,12 +70,70 @@ export class Role implements RoleDoc {
this.version = RoleIDVersion.NAME this.version = RoleIDVersion.NAME
} }
addInheritance(inherits: string) { addInheritance(inherits?: string | string[]) {
// make sure IDs are correct format
if (inherits && typeof inherits === "string") {
inherits = prefixRoleIDNoBuiltin(inherits)
} else if (inherits && Array.isArray(inherits)) {
inherits = inherits.map(prefixRoleIDNoBuiltin)
}
this.inherits = inherits this.inherits = inherits
return this return this
} }
} }
export class RoleHierarchyTraversal {
allRoles: RoleDoc[]
opts?: { defaultPublic?: boolean }
constructor(allRoles: RoleDoc[], opts?: { defaultPublic?: boolean }) {
this.allRoles = allRoles
this.opts = opts
}
walk(role: RoleDoc): RoleDoc[] {
const opts = this.opts,
allRoles = this.allRoles
// this will be a full walked list of roles - which may contain duplicates
let roleList: RoleDoc[] = []
if (!role || !role._id) {
return roleList
}
roleList.push(role)
if (Array.isArray(role.inherits)) {
for (let roleId of role.inherits) {
const foundRole = findRole(roleId, allRoles, opts)
if (foundRole) {
roleList = roleList.concat(this.walk(foundRole))
}
}
} else {
const foundRoleIds: string[] = []
let currentRole: RoleDoc | undefined = role
while (
currentRole &&
currentRole.inherits &&
!rolesInList(foundRoleIds, currentRole.inherits)
) {
if (Array.isArray(currentRole.inherits)) {
return roleList.concat(this.walk(currentRole))
} else {
foundRoleIds.push(currentRole.inherits)
currentRole = findRole(currentRole.inherits, allRoles, opts)
if (currentRole) {
roleList.push(currentRole)
}
}
// loop now found - stop iterating
if (helpers.roles.checkForRoleInheritanceLoops(roleList)) {
break
}
}
}
return uniqBy(roleList, role => role._id)
}
}
const BUILTIN_ROLES = { const BUILTIN_ROLES = {
ADMIN: new Role( ADMIN: new Role(
BUILTIN_IDS.ADMIN, BUILTIN_IDS.ADMIN,
@ -126,7 +192,15 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
} }
export function isBuiltin(role: string) { export function isBuiltin(role: string) {
return getBuiltinRole(role) !== undefined return Object.values(BUILTIN_ROLE_IDS).includes(role)
}
export function prefixRoleIDNoBuiltin(roleId: string) {
if (isBuiltin(roleId)) {
return roleId
} else {
return prefixRoleID(roleId)
}
} }
export function getBuiltinRole(roleId: string): Role | undefined { export function getBuiltinRole(roleId: string): Role | undefined {
@ -154,7 +228,11 @@ export function builtinRoleToNumber(id: string) {
if (!role) { if (!role) {
break break
} }
role = builtins[role.inherits!] if (Array.isArray(role.inherits)) {
throw new Error("Built-in roles don't support multi-inheritance")
} else {
role = builtins[role.inherits!]
}
count++ count++
} while (role !== null) } while (role !== null)
return count return count
@ -170,12 +248,31 @@ export async function roleToNumber(id: string) {
const hierarchy = (await getUserRoleHierarchy(id, { const hierarchy = (await getUserRoleHierarchy(id, {
defaultPublic: true, defaultPublic: true,
})) as RoleDoc[] })) as RoleDoc[]
for (let role of hierarchy) { const findNumber = (role: RoleDoc): number => {
if (role?.inherits && isBuiltin(role.inherits)) { if (!role.inherits) {
return 0
}
if (Array.isArray(role.inherits)) {
// find the built-in roles, get their number, sort it, then get the last one
const highestBuiltin: number | undefined = role.inherits
.map(roleId => {
const foundRole = hierarchy.find(role => role._id === roleId)
if (foundRole) {
return findNumber(foundRole) + 1
}
})
.filter(number => number)
.sort()
.pop()
if (highestBuiltin != undefined) {
return highestBuiltin
}
} else if (isBuiltin(role.inherits)) {
return builtinRoleToNumber(role.inherits) + 1 return builtinRoleToNumber(role.inherits) + 1
} }
return 0
} }
return 0 return Math.max(...hierarchy.map(findNumber))
} }
/** /**
@ -193,6 +290,53 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
: roleId1 : roleId1
} }
export function compareRoleIds(roleId1: string, roleId2: string) {
// make sure both role IDs are prefixed correctly
return prefixRoleID(roleId1) === prefixRoleID(roleId2)
}
export function externalRole(role: RoleDoc): RoleDoc {
let _id: string | undefined
if (role._id) {
_id = getExternalRoleID(role._id)
}
return {
...role,
_id,
inherits: getExternalRoleIDs(role.inherits, role.version),
}
}
/**
* Given a list of roles, this will pick the role out, accounting for built ins.
*/
export function findRole(
roleId: string,
roles: RoleDoc[],
opts?: { defaultPublic?: boolean }
): RoleDoc | undefined {
// built in roles mostly come from the in-code implementation,
// but can be extended by a doc stored about them (e.g. permissions)
let role: RoleDoc | undefined = getBuiltinRole(roleId)
if (!role) {
// make sure has the prefix (if it has it then it won't be added)
roleId = prefixRoleID(roleId)
}
const dbRole = roles.find(
role => role._id && compareRoleIds(role._id, roleId)
)
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
// combine the roles
role = Object.assign(role || {}, dbRole)
// finalise the ID
if (role?._id) {
role._id = getExternalRoleID(role._id, role.version)
}
return Object.keys(role).length === 0 ? undefined : role
}
/** /**
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and * Gets the role object, this is mainly useful for two purposes, to check if the level exists and
* to check if the role inherits any others. * to check if the role inherits any others.
@ -203,30 +347,28 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
export async function getRole( export async function getRole(
roleId: string, roleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc> { ): Promise<RoleDoc | undefined> {
// built in roles mostly come from the in-code implementation, const db = getAppDB()
// but can be extended by a doc stored about them (e.g. permissions) const roleList = []
let role: RoleDoc | undefined = getBuiltinRole(roleId) if (!isBuiltin(roleId)) {
if (!role) { const role = await db.tryGet<RoleDoc>(getDBRoleID(roleId))
// make sure has the prefix (if it has it then it won't be added) if (role) {
roleId = prefixRoleID(roleId) roleList.push(role)
}
try {
const db = getAppDB()
const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
role = Object.assign(role || {}, dbRole)
// finalise the ID
role._id = getExternalRoleID(role._id!, role.version)
} catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
// only throw an error if there is no role at all
if (!role || Object.keys(role).length === 0) {
throw err
} }
} }
return role return findRole(roleId, roleList, opts)
}
export async function saveRoles(roles: RoleDoc[]) {
const db = getAppDB()
await db.bulkDocs(
roles
.filter(role => role._id)
.map(role => ({
...role,
_id: prefixRoleID(role._id!),
}))
)
} }
/** /**
@ -236,24 +378,18 @@ async function getAllUserRoles(
userRoleId: string, userRoleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc[]> { ): Promise<RoleDoc[]> {
const allRoles = await getAllRoles()
// admins have access to all roles // admins have access to all roles
if (userRoleId === BUILTIN_IDS.ADMIN) { if (userRoleId === BUILTIN_IDS.ADMIN) {
return getAllRoles() return allRoles
} }
let currentRole = await getRole(userRoleId, opts)
let roles = currentRole ? [currentRole] : []
let roleIds = [userRoleId]
// get all the inherited roles // get all the inherited roles
while ( const foundRole = findRole(userRoleId, allRoles, opts)
currentRole && let roles: RoleDoc[] = []
currentRole.inherits && if (foundRole) {
roleIds.indexOf(currentRole.inherits) === -1 const traversal = new RoleHierarchyTraversal(allRoles, opts)
) { roles = traversal.walk(foundRole)
roleIds.push(currentRole.inherits)
currentRole = await getRole(currentRole.inherits)
if (currentRole) {
roles.push(currentRole)
}
} }
return roles return roles
} }
@ -319,7 +455,7 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
} }
return internal(appDB) return internal(appDB)
} }
async function internal(db: any) { async function internal(db: Database | undefined) {
let roles: RoleDoc[] = [] let roles: RoleDoc[] = []
if (db) { if (db) {
const body = await db.allDocs( const body = await db.allDocs(
@ -334,8 +470,26 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
} }
const builtinRoles = getBuiltinRoles() const builtinRoles = getBuiltinRoles()
// exclude internal roles like builder
let externalBuiltinRoles = []
if (!db || (await shouldIncludePowerRole(db))) {
externalBuiltinRoles = [
BUILTIN_IDS.ADMIN,
BUILTIN_IDS.POWER,
BUILTIN_IDS.BASIC,
BUILTIN_IDS.PUBLIC,
]
} else {
externalBuiltinRoles = [
BUILTIN_IDS.ADMIN,
BUILTIN_IDS.BASIC,
BUILTIN_IDS.PUBLIC,
]
}
// need to combine builtin with any DB record of them (for sake of permissions) // need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { for (let builtinRoleId of externalBuiltinRoles) {
const builtinRole = builtinRoles[builtinRoleId] const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter( const dbBuiltin = roles.filter(
dbRole => dbRole =>
@ -366,6 +520,18 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
} }
} }
async function shouldIncludePowerRole(db: Database) {
const app = await db.tryGet<App>(DocumentType.APP_METADATA)
const creationVersion = app?.creationVersion
if (!creationVersion || !semver.valid(creationVersion)) {
// Old apps don't have creationVersion, so we should include it for backward compatibility
return true
}
const isGreaterThan3x = semver.gte(creationVersion, "3.0.0")
return !isGreaterThan3x
}
export class AccessController { export class AccessController {
userHierarchies: { [key: string]: string[] } userHierarchies: { [key: string]: string[] }
constructor() { constructor() {
@ -390,7 +556,10 @@ export class AccessController {
this.userHierarchies[userRoleId] = roleIds this.userHierarchies[userRoleId] = roleIds
} }
return roleIds?.indexOf(tryingRoleId) !== -1 return (
roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !==
undefined
)
} }
async checkScreensAccess(screens: Screen[], userRoleId: string) { async checkScreensAccess(screens: Screen[], userRoleId: string) {
@ -432,7 +601,7 @@ export function getDBRoleID(roleName: string) {
export function getExternalRoleID(roleId: string, version?: string) { export function getExternalRoleID(roleId: string, version?: string) {
// for built-in roles we want to remove the DB role ID element (role_) // for built-in roles we want to remove the DB role ID element (role_)
if ( if (
roleId.startsWith(DocumentType.ROLE) && roleId.startsWith(`${DocumentType.ROLE}${SEPARATOR}`) &&
(isBuiltin(roleId) || version === RoleIDVersion.NAME) (isBuiltin(roleId) || version === RoleIDVersion.NAME)
) { ) {
const parts = roleId.split(SEPARATOR) const parts = roleId.split(SEPARATOR)
@ -441,3 +610,16 @@ export function getExternalRoleID(roleId: string, version?: string) {
} }
return roleId return roleId
} }
export function getExternalRoleIDs(
roleIds: string | string[] | undefined,
version?: string
) {
if (!roleIds) {
return roleIds
} else if (typeof roleIds === "string") {
return getExternalRoleID(roleIds, version)
} else {
return roleIds.map(roleId => getExternalRoleID(roleId, version))
}
}

View File

@ -23,12 +23,14 @@ import {
InternalSearchFilterOperator, InternalSearchFilterOperator,
JsonFieldMetadata, JsonFieldMetadata,
JsonTypes, JsonTypes,
LogicalOperator,
Operation, Operation,
prefixed, prefixed,
QueryJson, QueryJson,
QueryOptions, QueryOptions,
RangeOperator, RangeOperator,
RelationshipsJson, RelationshipsJson,
SearchFilterKey,
SearchFilters, SearchFilters,
SortOrder, SortOrder,
SqlClient, SqlClient,
@ -96,6 +98,22 @@ function isSqs(table: Table): boolean {
) )
} }
const allowEmptyRelationships: Record<SearchFilterKey, boolean> = {
[BasicOperator.EQUAL]: false,
[BasicOperator.NOT_EQUAL]: true,
[BasicOperator.EMPTY]: false,
[BasicOperator.NOT_EMPTY]: true,
[BasicOperator.FUZZY]: false,
[BasicOperator.STRING]: false,
[RangeOperator.RANGE]: false,
[ArrayOperator.CONTAINS]: false,
[ArrayOperator.NOT_CONTAINS]: true,
[ArrayOperator.CONTAINS_ANY]: false,
[ArrayOperator.ONE_OF]: false,
[LogicalOperator.AND]: false,
[LogicalOperator.OR]: false,
}
class InternalBuilder { class InternalBuilder {
private readonly client: SqlClient private readonly client: SqlClient
private readonly query: QueryJson private readonly query: QueryJson
@ -405,31 +423,48 @@ class InternalBuilder {
addRelationshipForFilter( addRelationshipForFilter(
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
allowEmptyRelationships: boolean,
filterKey: string, filterKey: string,
whereCb: (query: Knex.QueryBuilder) => Knex.QueryBuilder whereCb: (filterKey: string, query: Knex.QueryBuilder) => Knex.QueryBuilder
): Knex.QueryBuilder { ): Knex.QueryBuilder {
const mainKnex = this.knex const mainKnex = this.knex
const { relationships, endpoint, tableAliases: aliases } = this.query const { relationships, endpoint, tableAliases: aliases } = this.query
const tableName = endpoint.entityId const tableName = endpoint.entityId
const fromAlias = aliases?.[tableName] || tableName const fromAlias = aliases?.[tableName] || tableName
const matches = (possibleTable: string) => const matches = (value: string) =>
filterKey.startsWith(`${possibleTable}`) filterKey.match(new RegExp(`^${value}\\.`))
if (!relationships) { if (!relationships) {
return query return query
} }
for (const relationship of relationships) { for (const relationship of relationships) {
const relatedTableName = relationship.tableName const relatedTableName = relationship.tableName
const toAlias = aliases?.[relatedTableName] || relatedTableName const toAlias = aliases?.[relatedTableName] || relatedTableName
const matchesTableName = matches(relatedTableName) || matches(toAlias)
const matchesRelationName = matches(relationship.column)
// this is the relationship which is being filtered // this is the relationship which is being filtered
if ( if (
(matches(relatedTableName) || matches(toAlias)) && (matchesTableName || matchesRelationName) &&
relationship.to && relationship.to &&
relationship.tableName relationship.tableName
) { ) {
let subQuery = mainKnex const joinTable = mainKnex
.select(mainKnex.raw(1)) .select(mainKnex.raw(1))
.from({ [toAlias]: relatedTableName }) .from({ [toAlias]: relatedTableName })
let subQuery = joinTable.clone()
const manyToMany = validateManyToMany(relationship) const manyToMany = validateManyToMany(relationship)
let updatedKey
if (!matchesTableName) {
updatedKey = filterKey.replace(
new RegExp(`^${relationship.column}.`),
`${aliases![relationship.tableName]}.`
)
} else {
updatedKey = filterKey
}
if (manyToMany) { if (manyToMany) {
const throughAlias = const throughAlias =
aliases?.[manyToMany.through] || relationship.through aliases?.[manyToMany.through] || relationship.through
@ -440,7 +475,6 @@ class InternalBuilder {
subQuery = subQuery subQuery = subQuery
// add a join through the junction table // add a join through the junction table
.innerJoin(throughTable, function () { .innerJoin(throughTable, function () {
// @ts-ignore
this.on( this.on(
`${toAlias}.${manyToMany.toPrimary}`, `${toAlias}.${manyToMany.toPrimary}`,
"=", "=",
@ -460,18 +494,38 @@ class InternalBuilder {
if (this.client === SqlClient.SQL_LITE) { if (this.client === SqlClient.SQL_LITE) {
subQuery = this.addJoinFieldCheck(subQuery, manyToMany) subQuery = this.addJoinFieldCheck(subQuery, manyToMany)
} }
query = query.where(q => {
q.whereExists(whereCb(updatedKey, subQuery))
if (allowEmptyRelationships) {
q.orWhereNotExists(
joinTable.clone().innerJoin(throughTable, function () {
this.on(
`${fromAlias}.${manyToMany.fromPrimary}`,
"=",
`${throughAlias}.${manyToMany.from}`
)
})
)
}
})
} else { } else {
const toKey = `${toAlias}.${relationship.to}`
const foreignKey = `${fromAlias}.${relationship.from}`
// "join" to the main table, making sure the ID matches that of the main // "join" to the main table, making sure the ID matches that of the main
subQuery = subQuery.where( subQuery = subQuery.where(
`${toAlias}.${relationship.to}`, toKey,
"=", "=",
mainKnex.raw( mainKnex.raw(this.quotedIdentifier(foreignKey))
this.quotedIdentifier(`${fromAlias}.${relationship.from}`)
)
) )
query = query.where(q => {
q.whereExists(whereCb(updatedKey, subQuery.clone()))
if (allowEmptyRelationships) {
q.orWhereNotExists(subQuery)
}
})
} }
query = query.whereExists(whereCb(subQuery))
break
} }
} }
return query return query
@ -502,6 +556,7 @@ class InternalBuilder {
} }
function iterate( function iterate(
structure: AnySearchFilter, structure: AnySearchFilter,
operation: SearchFilterKey,
fn: ( fn: (
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
key: string, key: string,
@ -558,9 +613,14 @@ class InternalBuilder {
if (allOr) { if (allOr) {
query = query.or query = query.or
} }
query = builder.addRelationshipForFilter(query, updatedKey, q => { query = builder.addRelationshipForFilter(
return handleRelationship(q, updatedKey, value) query,
}) allowEmptyRelationships[operation],
updatedKey,
(updatedKey, q) => {
return handleRelationship(q, updatedKey, value)
}
)
} }
} }
} }
@ -592,7 +652,7 @@ class InternalBuilder {
return `[${value.join(",")}]` return `[${value.join(",")}]`
} }
if (this.client === SqlClient.POSTGRES) { if (this.client === SqlClient.POSTGRES) {
iterate(mode, (q, key, value) => { iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
const wrap = any ? "" : "'" const wrap = any ? "" : "'"
const op = any ? "\\?| array" : "@>" const op = any ? "\\?| array" : "@>"
const fieldNames = key.split(/\./g) const fieldNames = key.split(/\./g)
@ -610,7 +670,7 @@ class InternalBuilder {
this.client === SqlClient.MARIADB this.client === SqlClient.MARIADB
) { ) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS" const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, (q, key, value) => { iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
return q[rawFnc]( return q[rawFnc](
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray( `${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value value
@ -619,7 +679,7 @@ class InternalBuilder {
}) })
} else { } else {
const andOr = mode === filters?.containsAny ? " OR " : " AND " const andOr = mode === filters?.containsAny ? " OR " : " AND "
iterate(mode, (q, key, value) => { iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
let statement = "" let statement = ""
const identifier = this.quotedIdentifier(key) const identifier = this.quotedIdentifier(key)
for (let i in value) { for (let i in value) {
@ -673,6 +733,7 @@ class InternalBuilder {
const fnc = allOr ? "orWhereIn" : "whereIn" const fnc = allOr ? "orWhereIn" : "whereIn"
iterate( iterate(
filters.oneOf, filters.oneOf,
ArrayOperator.ONE_OF,
(q, key: string, array) => { (q, key: string, array) => {
if (this.client === SqlClient.ORACLE) { if (this.client === SqlClient.ORACLE) {
key = this.convertClobs(key) key = this.convertClobs(key)
@ -697,7 +758,7 @@ class InternalBuilder {
) )
} }
if (filters.string) { if (filters.string) {
iterate(filters.string, (q, key, value) => { iterate(filters.string, BasicOperator.STRING, (q, key, value) => {
const fnc = allOr ? "orWhere" : "where" const fnc = allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does // postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) { if (this.client === SqlClient.POSTGRES) {
@ -712,10 +773,10 @@ class InternalBuilder {
}) })
} }
if (filters.fuzzy) { if (filters.fuzzy) {
iterate(filters.fuzzy, like) iterate(filters.fuzzy, BasicOperator.FUZZY, like)
} }
if (filters.range) { if (filters.range) {
iterate(filters.range, (q, key, value) => { iterate(filters.range, RangeOperator.RANGE, (q, key, value) => {
const isEmptyObject = (val: any) => { const isEmptyObject = (val: any) => {
return ( return (
val && val &&
@ -781,7 +842,7 @@ class InternalBuilder {
}) })
} }
if (filters.equal) { if (filters.equal) {
iterate(filters.equal, (q, key, value) => { iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => {
const fnc = allOr ? "orWhereRaw" : "whereRaw" const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) { if (this.client === SqlClient.MS_SQL) {
return q[fnc]( return q[fnc](
@ -801,7 +862,7 @@ class InternalBuilder {
}) })
} }
if (filters.notEqual) { if (filters.notEqual) {
iterate(filters.notEqual, (q, key, value) => { iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => {
const fnc = allOr ? "orWhereRaw" : "whereRaw" const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (this.client === SqlClient.MS_SQL) { if (this.client === SqlClient.MS_SQL) {
return q[fnc]( return q[fnc](
@ -822,13 +883,13 @@ class InternalBuilder {
}) })
} }
if (filters.empty) { if (filters.empty) {
iterate(filters.empty, (q, key) => { iterate(filters.empty, BasicOperator.EMPTY, (q, key) => {
const fnc = allOr ? "orWhereNull" : "whereNull" const fnc = allOr ? "orWhereNull" : "whereNull"
return q[fnc](key) return q[fnc](key)
}) })
} }
if (filters.notEmpty) { if (filters.notEmpty) {
iterate(filters.notEmpty, (q, key) => { iterate(filters.notEmpty, BasicOperator.NOT_EMPTY, (q, key) => {
const fnc = allOr ? "orWhereNotNull" : "whereNotNull" const fnc = allOr ? "orWhereNotNull" : "whereNotNull"
return q[fnc](key) return q[fnc](key)
}) })
@ -1224,12 +1285,10 @@ class InternalBuilder {
}) })
: undefined : undefined
if (!throughTable) { if (!throughTable) {
// @ts-ignore
query = query.leftJoin(toTableWithSchema, function () { query = query.leftJoin(toTableWithSchema, function () {
for (let relationship of columns) { for (let relationship of columns) {
const from = relationship.from, const from = relationship.from,
to = relationship.to to = relationship.to
// @ts-ignore
this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`) this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
} }
}) })
@ -1240,7 +1299,6 @@ class InternalBuilder {
for (let relationship of columns) { for (let relationship of columns) {
const fromPrimary = relationship.fromPrimary const fromPrimary = relationship.fromPrimary
const from = relationship.from const from = relationship.from
// @ts-ignore
this.orOn( this.orOn(
`${fromAlias}.${fromPrimary}`, `${fromAlias}.${fromPrimary}`,
"=", "=",
@ -1252,7 +1310,6 @@ class InternalBuilder {
for (let relationship of columns) { for (let relationship of columns) {
const toPrimary = relationship.toPrimary const toPrimary = relationship.toPrimary
const to = relationship.to const to = relationship.to
// @ts-ignore
this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`) this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
} }
}) })

View File

@ -19,6 +19,7 @@
AutomationEventType.ROW_DELETE, AutomationEventType.ROW_DELETE,
AutomationEventType.ROW_UPDATE, AutomationEventType.ROW_UPDATE,
AutomationEventType.ROW_SAVE, AutomationEventType.ROW_SAVE,
AutomationEventType.ROW_ACTION,
] ]
/** /**

View File

@ -74,6 +74,7 @@
TriggerStepID.ROW_UPDATED, TriggerStepID.ROW_UPDATED,
TriggerStepID.ROW_SAVED, TriggerStepID.ROW_SAVED,
TriggerStepID.ROW_DELETED, TriggerStepID.ROW_DELETED,
TriggerStepID.ROW_ACTION,
] ]
const rowEvents = [ const rowEvents = [

View File

@ -2,6 +2,7 @@ export const TriggerStepID = {
ROW_SAVED: "ROW_SAVED", ROW_SAVED: "ROW_SAVED",
ROW_UPDATED: "ROW_UPDATED", ROW_UPDATED: "ROW_UPDATED",
ROW_DELETED: "ROW_DELETED", ROW_DELETED: "ROW_DELETED",
ROW_ACTION: "ROW_ACTION",
WEBHOOK: "WEBHOOK", WEBHOOK: "WEBHOOK",
APP: "APP", APP: "APP",
CRON: "CRON", CRON: "CRON",

View File

@ -76,9 +76,7 @@
const params = new URLSearchParams({ const params = new URLSearchParams({
open: "error", open: "error",
}) })
$goto( $goto(`/builder/app/${appId}/settings/automations?${params.toString()}`)
`/builder/app/${appId}/settings/automation-history?${params.toString()}`
)
} }
const errorCount = errors => { const errorCount = errors => {

View File

@ -126,6 +126,9 @@
} }
const extendQuery = (defaultQuery, extensions) => { const extendQuery = (defaultQuery, extensions) => {
if (!Object.keys(extensions).length) {
return defaultQuery
}
const extended = { const extended = {
[LogicalOperator.AND]: { [LogicalOperator.AND]: {
conditions: [ conditions: [

View File

@ -1,3 +1,7 @@
<script context="module">
const NumberFormatter = Intl.NumberFormat()
</script>
<script> <script>
import TextCell from "./TextCell.svelte" import TextCell from "./TextCell.svelte"
@ -9,6 +13,24 @@
const newValue = isNaN(float) ? null : float const newValue = isNaN(float) ? null : float
onChange(newValue) onChange(newValue)
} }
const formatNumber = value => {
const type = typeof value
if (type !== "string" && type !== "number") {
return ""
}
if (type === "string" && !value.trim().length) {
return ""
}
const res = NumberFormatter.format(value)
return res === "NaN" ? value : res
}
</script> </script>
<TextCell {...$$props} onChange={numberOnChange} bind:api type="number" /> <TextCell
{...$$props}
onChange={numberOnChange}
bind:api
type="number"
format={formatNumber}
/>

View File

@ -7,11 +7,13 @@
export let type = "text" export let type = "text"
export let readonly = false export let readonly = false
export let api export let api
export let format = null
let input let input
let active = false let active = false
$: editable = focused && !readonly $: editable = focused && !readonly
$: displayValue = format?.(value) ?? value ?? ""
const handleChange = e => { const handleChange = e => {
onChange(e.target.value) onChange(e.target.value)
@ -52,7 +54,7 @@
{:else} {:else}
<div class="text-cell" class:number={type === "number"}> <div class="text-cell" class:number={type === "number"}>
<div class="value"> <div class="value">
{value ?? ""} {displayValue}
</div> </div>
</div> </div>
{/if} {/if}

View File

@ -208,9 +208,8 @@ export async function fetchAppDefinition(
export async function fetchAppPackage( export async function fetchAppPackage(
ctx: UserCtx<void, FetchAppPackageResponse> ctx: UserCtx<void, FetchAppPackageResponse>
) { ) {
const db = context.getAppDB()
const appId = context.getAppId() const appId = context.getAppId()
let application = await db.get<App>(DocumentType.APP_METADATA) const application = await sdk.applications.metadata.get()
const layouts = await getLayouts() const layouts = await getLayouts()
let screens = await getScreens() let screens = await getScreens()
const license = await licensing.cache.getCachedLicense() const license = await licensing.cache.getCachedLicense()
@ -272,6 +271,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
path: ctx.request.body.file?.path, path: ctx.request.body.file?.path,
} }
} }
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
const appId = generateDevAppID(generateAppID(tenantId)) const appId = generateDevAppID(generateAppID(tenantId))
@ -279,7 +279,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
const instance = await createInstance(appId, instanceConfig) const instance = await createInstance(appId, instanceConfig)
const db = context.getAppDB() const db = context.getAppDB()
let newApplication: App = { const newApplication: App = {
_id: DocumentType.APP_METADATA, _id: DocumentType.APP_METADATA,
_rev: undefined, _rev: undefined,
appId, appId,
@ -310,12 +310,18 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
disableUserMetadata: true, disableUserMetadata: true,
skeletonLoader: true, skeletonLoader: true,
}, },
creationVersion: undefined,
} }
const isImport = !!instanceConfig.file
if (!isImport) {
newApplication.creationVersion = envCore.VERSION
}
const existing = await sdk.applications.metadata.tryGet()
// If we used a template or imported an app there will be an existing doc. // If we used a template or imported an app there will be an existing doc.
// Fetch and migrate some metadata from the existing app. // Fetch and migrate some metadata from the existing app.
try { if (existing) {
const existing: App = await db.get(DocumentType.APP_METADATA)
const keys: (keyof App)[] = [ const keys: (keyof App)[] = [
"_rev", "_rev",
"navigation", "navigation",
@ -323,6 +329,7 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
"customTheme", "customTheme",
"icon", "icon",
"snippets", "snippets",
"creationVersion",
] ]
keys.forEach(key => { keys.forEach(key => {
if (existing[key]) { if (existing[key]) {
@ -340,14 +347,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
} }
// Migrate navigation settings and screens if required // Migrate navigation settings and screens if required
if (existing) { const navigation = await migrateAppNavigation()
const navigation = await migrateAppNavigation() if (navigation) {
if (navigation) { newApplication.navigation = navigation
newApplication.navigation = navigation
}
} }
} catch (err) {
// Nothing to do
} }
const response = await db.put(newApplication, { force: true }) const response = await db.put(newApplication, { force: true })
@ -489,8 +492,7 @@ export async function update(
export async function updateClient(ctx: UserCtx) { export async function updateClient(ctx: UserCtx) {
// Get current app version // Get current app version
const db = context.getAppDB() const application = await sdk.applications.metadata.get()
const application = await db.get<App>(DocumentType.APP_METADATA)
const currentVersion = application.version const currentVersion = application.version
let manifest let manifest
@ -518,8 +520,7 @@ export async function updateClient(ctx: UserCtx) {
export async function revertClient(ctx: UserCtx) { export async function revertClient(ctx: UserCtx) {
// Check app can be reverted // Check app can be reverted
const db = context.getAppDB() const application = await sdk.applications.metadata.get()
const application = await db.get<App>(DocumentType.APP_METADATA)
if (!application.revertableVersion) { if (!application.revertableVersion) {
ctx.throw(400, "There is no version to revert to") ctx.throw(400, "There is no version to revert to")
} }
@ -577,7 +578,7 @@ async function destroyApp(ctx: UserCtx) {
const db = dbCore.getDB(devAppId) const db = dbCore.getDB(devAppId)
// standard app deletion flow // standard app deletion flow
const app = await db.get<App>(DocumentType.APP_METADATA) const app = await sdk.applications.metadata.get()
const result = await db.destroy() const result = await db.destroy()
await quotas.removeApp() await quotas.removeApp()
await events.app.deleted(app) await events.app.deleted(app)
@ -728,7 +729,7 @@ export async function updateAppPackage(
) { ) {
return context.doInAppContext(appId, async () => { return context.doInAppContext(appId, async () => {
const db = context.getAppDB() const db = context.getAppDB()
const application = await db.get<App>(DocumentType.APP_METADATA) const application = await sdk.applications.metadata.get()
const newAppPackage: App = { ...application, ...appPackage } const newAppPackage: App = { ...application, ...appPackage }
if (appPackage._rev !== application._rev) { if (appPackage._rev !== application._rev) {
@ -754,7 +755,7 @@ export async function setRevertableVersion(
return return
} }
const db = context.getAppDB() const db = context.getAppDB()
const app = await db.get<App>(DocumentType.APP_METADATA) const app = await sdk.applications.metadata.get()
app.revertableVersion = ctx.request.body.revertableVersion app.revertableVersion = ctx.request.body.revertableVersion
await db.put(app) await db.put(app)
@ -763,7 +764,7 @@ export async function setRevertableVersion(
async function migrateAppNavigation() { async function migrateAppNavigation() {
const db = context.getAppDB() const db = context.getAppDB()
const existing: App = await db.get(DocumentType.APP_METADATA) const existing = await sdk.applications.metadata.get()
const layouts: Layout[] = await getLayouts() const layouts: Layout[] = await getLayouts()
const screens: Screen[] = await getScreens() const screens: Screen[] = await getScreens()

View File

@ -19,7 +19,7 @@ import {
UserMetadata, UserMetadata,
DocumentType, DocumentType,
} from "@budibase/types" } from "@budibase/types"
import { RoleColor, sdk as sharedSdk } from "@budibase/shared-core" import { RoleColor, sdk as sharedSdk, helpers } from "@budibase/shared-core"
import sdk from "../../sdk" import sdk from "../../sdk"
const UpdateRolesOptions = { const UpdateRolesOptions = {
@ -27,6 +27,30 @@ const UpdateRolesOptions = {
REMOVED: "removed", REMOVED: "removed",
} }
async function removeRoleFromOthers(roleId: string) {
const allOtherRoles = await roles.getAllRoles()
const updated: Role[] = []
for (let role of allOtherRoles) {
let changed = false
if (Array.isArray(role.inherits)) {
const newInherits = role.inherits.filter(
id => !roles.compareRoleIds(id, roleId)
)
changed = role.inherits.length !== newInherits.length
role.inherits = newInherits
} else if (role.inherits && roles.compareRoleIds(role.inherits, roleId)) {
role.inherits = roles.BUILTIN_ROLE_IDS.PUBLIC
changed = true
}
if (changed) {
updated.push(role)
}
}
if (updated.length) {
await roles.saveRoles(updated)
}
}
async function updateRolesOnUserTable( async function updateRolesOnUserTable(
db: Database, db: Database,
roleId: string, roleId: string,
@ -53,18 +77,25 @@ async function updateRolesOnUserTable(
} }
export async function fetch(ctx: UserCtx<void, FetchRolesResponse>) { export async function fetch(ctx: UserCtx<void, FetchRolesResponse>) {
ctx.body = await roles.getAllRoles() ctx.body = (await roles.getAllRoles()).map(role => roles.externalRole(role))
} }
export async function find(ctx: UserCtx<void, FindRoleResponse>) { export async function find(ctx: UserCtx<void, FindRoleResponse>) {
ctx.body = await roles.getRole(ctx.params.roleId) const role = await roles.getRole(ctx.params.roleId)
if (!role) {
ctx.throw(404, { message: "Role not found" })
}
ctx.body = roles.externalRole(role)
} }
export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) { export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
const db = context.getAppDB() const db = context.getAppDB()
let { _id, name, inherits, permissionId, version, uiMetadata } = let { _id, _rev, name, inherits, permissionId, version, uiMetadata } =
ctx.request.body ctx.request.body
let isCreate = false let isCreate = false
if (!_rev && !version) {
version = roles.RoleIDVersion.NAME
}
const isNewVersion = version === roles.RoleIDVersion.NAME const isNewVersion = version === roles.RoleIDVersion.NAME
if (_id && roles.isBuiltin(_id)) { if (_id && roles.isBuiltin(_id)) {
@ -81,9 +112,13 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
_id = dbCore.prefixRoleID(_id) _id = dbCore.prefixRoleID(_id)
} }
const allRoles = (await roles.getAllRoles()).map(role => ({
...role,
_id: dbCore.prefixRoleID(role._id!),
}))
let dbRole: Role | undefined let dbRole: Role | undefined
if (!isCreate && _id?.startsWith(DocumentType.ROLE)) { if (!isCreate && _id?.startsWith(DocumentType.ROLE)) {
dbRole = await db.get<Role>(_id) dbRole = allRoles.find(role => role._id === _id)
} }
if (dbRole && dbRole.name !== name && isNewVersion) { if (dbRole && dbRole.name !== name && isNewVersion) {
ctx.throw(400, "Cannot change custom role name") ctx.throw(400, "Cannot change custom role name")
@ -97,7 +132,19 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
if (dbRole?.permissions && !role.permissions) { if (dbRole?.permissions && !role.permissions) {
role.permissions = dbRole.permissions role.permissions = dbRole.permissions
} }
const foundRev = ctx.request.body._rev || dbRole?._rev
// add the new role to the list and check for loops
const index = allRoles.findIndex(r => r._id === role._id)
if (index === -1) {
allRoles.push(role)
} else {
allRoles[index] = role
}
if (helpers.roles.checkForRoleInheritanceLoops(allRoles)) {
ctx.throw(400, "Role inheritance contains a loop, this is not supported")
}
const foundRev = _rev || dbRole?._rev
if (foundRev) { if (foundRev) {
role._rev = foundRev role._rev = foundRev
} }
@ -114,7 +161,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
role.version role.version
) )
role._rev = result.rev role._rev = result.rev
ctx.body = role ctx.body = roles.externalRole(role)
const devDb = context.getDevAppDB() const devDb = context.getDevAppDB()
const prodDb = context.getProdAppDB() const prodDb = context.getProdAppDB()
@ -163,6 +210,10 @@ export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
UpdateRolesOptions.REMOVED, UpdateRolesOptions.REMOVED,
role.version role.version
) )
// clean up inherits
await removeRoleFromOthers(roleId)
ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.message = `Role ${ctx.params.roleId} deleted successfully`
ctx.status = 200 ctx.status = 200
} }
@ -172,30 +223,35 @@ export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
if (!roleId) { if (!roleId) {
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
} }
let roleIds: string[] = []
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) { if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
const appId = context.getAppId() const appId = context.getAppId()
if (!appId) { if (appId) {
ctx.body = [] roleIds = await roles.getAllRoleIds(appId)
} else {
ctx.body = await roles.getAllRoleIds(appId)
} }
} else { } else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!) roleIds = await roles.getUserRoleIdHierarchy(roleId!)
} }
// If a custom role is provided in the header, filter out higher level roles // If a custom role is provided in the header, filter out higher level roles
const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) { if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
const inherits = (await roles.getRole(roleHeader))?.inherits const role = await roles.getRole(roleHeader)
const orderedRoles = ctx.body.reverse() const inherits = role?.inherits
const orderedRoles = roleIds.reverse()
let filteredRoles = [roleHeader] let filteredRoles = [roleHeader]
for (let role of orderedRoles) { for (let role of orderedRoles) {
filteredRoles = [role, ...filteredRoles] filteredRoles = [role, ...filteredRoles]
if (role === inherits) { if (
(Array.isArray(inherits) && inherits.includes(role)) ||
role === inherits
) {
break break
} }
} }
filteredRoles.pop() filteredRoles.pop()
ctx.body = [roleHeader, ...filteredRoles] roleIds = [roleHeader, ...filteredRoles]
} }
ctx.body = roleIds.map(roleId => roles.getExternalRoleID(roleId))
} }

View File

@ -205,18 +205,6 @@ export class ExternalRequest<T extends Operation> {
filters: SearchFilters, filters: SearchFilters,
table: Table table: Table
): SearchFilters { ): SearchFilters {
// replace any relationship columns initially, table names and relationship column names are acceptable
const relationshipColumns = sdk.rows.filters.getRelationshipColumns(table)
filters = sdk.rows.filters.updateFilterKeys(
filters,
relationshipColumns.map(({ name, definition }) => {
const { tableName } = breakExternalTableId(definition.tableId)
return {
original: name,
updated: tableName,
}
})
)
const primary = table.primary const primary = table.primary
// if passed in array need to copy for shifting etc // if passed in array need to copy for shifting etc
let idCopy: undefined | string | any[] = cloneDeep(id) let idCopy: undefined | string | any[] = cloneDeep(id)

View File

@ -15,13 +15,16 @@ import {
ExportRowsResponse, ExportRowsResponse,
FieldType, FieldType,
GetRowResponse, GetRowResponse,
isRelationshipField,
PatchRowRequest, PatchRowRequest,
PatchRowResponse, PatchRowResponse,
Row, Row,
RowAttachment, RowAttachment,
RowSearchParams, RowSearchParams,
SearchFilters,
SearchRowRequest, SearchRowRequest,
SearchRowResponse, SearchRowResponse,
Table,
UserCtx, UserCtx,
ValidateResponse, ValidateResponse,
} from "@budibase/types" } from "@budibase/types"
@ -33,6 +36,7 @@ import sdk from "../../../sdk"
import * as exporters from "../view/exporters" import * as exporters from "../view/exporters"
import { Format } from "../view/exporters" import { Format } from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem" import { apiFileReturn } from "../../../utilities/fileSystem"
import { dataFilters } from "@budibase/shared-core"
export * as views from "./views" export * as views from "./views"
@ -211,12 +215,15 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
await context.ensureSnippetContext(true) await context.ensureSnippetContext(true)
const enrichedQuery = await utils.enrichSearchContext( let { query } = ctx.request.body
{ ...ctx.request.body.query }, if (query) {
{ const allTables = await sdk.tables.getAllTables()
user: sdk.users.getUserContextBindings(ctx.user), query = replaceTableNamesInFilters(tableId, query, allTables)
} }
)
let enrichedQuery: SearchFilters = await utils.enrichSearchContext(query, {
user: sdk.users.getUserContextBindings(ctx.user),
})
const searchParams: RowSearchParams = { const searchParams: RowSearchParams = {
...ctx.request.body, ...ctx.request.body,
@ -229,6 +236,47 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
ctx.body = await sdk.rows.search(searchParams) ctx.body = await sdk.rows.search(searchParams)
} }
function replaceTableNamesInFilters(
tableId: string,
filters: SearchFilters,
allTables: Table[]
): SearchFilters {
for (const filter of Object.values(filters)) {
for (const key of Object.keys(filter)) {
const matches = key.match(`^(?<relation>.+)\\.(?<field>.+)`)
const relation = matches?.groups?.["relation"]
const field = matches?.groups?.["field"]
if (!relation || !field) {
continue
}
const table = allTables.find(r => r._id === tableId)!
if (Object.values(table.schema).some(f => f.name === relation)) {
continue
}
const matchedTable = allTables.find(t => t.name === relation)
const relationship = Object.values(table.schema).find(
f => isRelationshipField(f) && f.tableId === matchedTable?._id
)
if (!relationship) {
continue
}
const updatedField = `${relationship.name}.${field}`
if (updatedField && updatedField !== key) {
filter[updatedField] = filter[key]
delete filter[key]
}
}
}
return dataFilters.recurseLogicalOperators(filters, (f: SearchFilters) => {
return replaceTableNamesInFilters(tableId, f, allTables)
})
}
export async function validate(ctx: Ctx<Row, ValidateResponse>) { export async function validate(ctx: Ctx<Row, ValidateResponse>) {
const source = await utils.getSource(ctx) const source = await utils.getSource(ctx)
const table = await utils.getTableFromSource(source) const table = await utils.getTableFromSource(source)

View File

@ -1,5 +1,5 @@
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { Document, PermissionLevel, Row } from "@budibase/types" import { Document, PermissionLevel, Role, Row, Table } 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"
@ -288,6 +288,88 @@ describe("/permission", () => {
}) })
}) })
describe("multi-inheritance permissions", () => {
let table1: Table, table2: Table, role1: Role, role2: Role
beforeEach(async () => {
// create new app
await config.init()
table1 = await config.createTable()
table2 = await config.createTable()
await config.api.row.save(table1._id!, {
name: "a",
})
await config.api.row.save(table2._id!, {
name: "b",
})
role1 = await config.api.roles.save(
{
name: "test_1",
permissionId: PermissionLevel.WRITE,
inherits: BUILTIN_ROLE_IDS.BASIC,
},
{ status: 200 }
)
role2 = await config.api.roles.save(
{
name: "test_2",
permissionId: PermissionLevel.WRITE,
inherits: BUILTIN_ROLE_IDS.BASIC,
},
{ status: 200 }
)
await config.api.permission.add({
roleId: role1._id!,
level: PermissionLevel.READ,
resourceId: table1._id!,
})
await config.api.permission.add({
roleId: role2._id!,
level: PermissionLevel.READ,
resourceId: table2._id!,
})
})
it("should be unable to search for table 2 using role 1", async () => {
await config.loginAsRole(role1._id!, async () => {
const response2 = await config.api.row.search(
table2._id!,
{
query: {},
},
{ status: 403 }
)
expect(response2.rows).toBeUndefined()
})
})
it("should be able to fetch two tables, with different roles, using multi-inheritance", async () => {
const role3 = await config.api.roles.save({
name: "role3",
permissionId: PermissionLevel.WRITE,
inherits: [role1._id!, role2._id!],
})
await config.loginAsRole(role3._id!, async () => {
const response1 = await config.api.row.search(
table1._id!,
{
query: {},
},
{ status: 200 }
)
const response2 = await config.api.row.search(
table2._id!,
{
query: {},
},
{ status: 200 }
)
expect(response1.rows[0].name).toEqual("a")
expect(response2.rows[0].name).toEqual("b")
})
})
})
describe("fetch builtins", () => { describe("fetch builtins", () => {
it("should be able to fetch builtin definitions", async () => { it("should be able to fetch builtin definitions", async () => {
const res = await request const res = await request

View File

@ -1,182 +0,0 @@
const { roles, events, permissions } = require("@budibase/backend-core")
const setup = require("./utilities")
const { PermissionLevel } = require("@budibase/types")
const { basicRole } = setup.structures
const { BUILTIN_ROLE_IDS } = roles
const { BuiltinPermissionID } = permissions
describe("/roles", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
})
const createRole = async role => {
if (!role) {
role = basicRole()
}
return request
.post(`/api/roles`)
.send(role)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
}
describe("create", () => {
it("returns a success message when role is successfully created", async () => {
const role = basicRole()
const res = await createRole(role)
expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined()
expect(events.role.updated).not.toBeCalled()
expect(events.role.created).toBeCalledTimes(1)
expect(events.role.created).toBeCalledWith(res.body)
})
})
describe("update", () => {
it("updates a role", async () => {
const role = basicRole()
let res = await createRole(role)
jest.clearAllMocks()
res = await createRole(res.body)
expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined()
expect(events.role.created).not.toBeCalled()
expect(events.role.updated).toBeCalledTimes(1)
expect(events.role.updated).toBeCalledWith(res.body)
})
})
describe("fetch", () => {
beforeAll(async () => {
// Recreate the app
await config.init()
})
it("should list custom roles, plus 2 default roles", async () => {
const customRole = await config.createRole()
const res = await request
.get(`/api/roles`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBe(5)
const adminRole = res.body.find(r => r._id === BUILTIN_ROLE_IDS.ADMIN)
expect(adminRole).toBeDefined()
expect(adminRole.inherits).toEqual(BUILTIN_ROLE_IDS.POWER)
expect(adminRole.permissionId).toEqual(BuiltinPermissionID.ADMIN)
const powerUserRole = res.body.find(r => r._id === BUILTIN_ROLE_IDS.POWER)
expect(powerUserRole).toBeDefined()
expect(powerUserRole.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(powerUserRole.permissionId).toEqual(BuiltinPermissionID.POWER)
const customRoleFetched = res.body.find(r => r._id === customRole.name)
expect(customRoleFetched).toBeDefined()
expect(customRoleFetched.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(customRoleFetched.permissionId).toEqual(
BuiltinPermissionID.READ_ONLY
)
})
it("should be able to get the role with a permission added", async () => {
const table = await config.createTable()
await config.api.permission.add({
roleId: BUILTIN_ROLE_IDS.POWER,
resourceId: table._id,
level: PermissionLevel.READ,
})
const res = await request
.get(`/api/roles`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBeGreaterThan(0)
const power = res.body.find(role => role._id === BUILTIN_ROLE_IDS.POWER)
expect(power.permissions[table._id]).toEqual(["read"])
})
})
describe("destroy", () => {
it("should delete custom roles", async () => {
const customRole = await config.createRole({
name: "user",
permissionId: BuiltinPermissionID.READ_ONLY,
inherits: BUILTIN_ROLE_IDS.BASIC,
})
delete customRole._rev_tree
await request
.delete(`/api/roles/${customRole._id}/${customRole._rev}`)
.set(config.defaultHeaders())
.expect(200)
await request
.get(`/api/roles/${customRole._id}`)
.set(config.defaultHeaders())
.expect(404)
expect(events.role.deleted).toBeCalledTimes(1)
expect(events.role.deleted).toBeCalledWith(customRole)
})
})
describe("accessible", () => {
it("should be able to fetch accessible roles (with builder)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(config.defaultHeaders())
.expect(200)
expect(res.body.length).toBe(5)
expect(typeof res.body[0]).toBe("string")
})
it("should be able to fetch accessible roles (basic user)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(await config.basicRoleHeaders())
.expect(200)
expect(res.body.length).toBe(2)
expect(res.body[0]).toBe("BASIC")
expect(res.body[1]).toBe("PUBLIC")
})
it("should be able to fetch accessible roles (no user)", async () => {
const res = await request
.get("/api/roles/accessible")
.set(config.publicHeaders())
.expect(200)
expect(res.body.length).toBe(1)
expect(res.body[0]).toBe("PUBLIC")
})
it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
await createRole({
name: `custom_role_1`,
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name",
})
const res = await request
.get("/api/roles/accessible")
.set({
...config.defaultHeaders(),
"x-budibase-role": "custom_role_1",
})
.expect(200)
expect(res.body.length).toBe(3)
expect(res.body[0]).toBe("custom_role_1")
expect(res.body[1]).toBe("BASIC")
expect(res.body[2]).toBe("PUBLIC")
})
})
})

View File

@ -0,0 +1,337 @@
import {
roles,
events,
permissions,
db as dbCore,
} from "@budibase/backend-core"
import * as setup from "./utilities"
import { PermissionLevel } from "@budibase/types"
const { basicRole } = setup.structures
const { BUILTIN_ROLE_IDS } = roles
const { BuiltinPermissionID } = permissions
const LOOP_ERROR = "Role inheritance contains a loop, this is not supported"
describe("/roles", () => {
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
})
describe("create", () => {
it("returns a success message when role is successfully created", async () => {
const role = basicRole()
const res = await config.api.roles.save(role, {
status: 200,
})
expect(res._id).toBeDefined()
expect(res._rev).toBeDefined()
expect(events.role.updated).not.toHaveBeenCalled()
expect(events.role.created).toHaveBeenCalledTimes(1)
expect(events.role.created).toHaveBeenCalledWith({
...res,
_id: dbCore.prefixRoleID(res._id!),
})
})
})
describe("update", () => {
beforeEach(async () => {
// Recreate the app
await config.init()
})
it("updates a role", async () => {
const role = basicRole()
let res = await config.api.roles.save(role, {
status: 200,
})
jest.clearAllMocks()
res = await config.api.roles.save(res, {
status: 200,
})
expect(res._id).toBeDefined()
expect(res._rev).toBeDefined()
expect(events.role.created).not.toHaveBeenCalled()
expect(events.role.updated).toHaveBeenCalledTimes(1)
expect(events.role.updated).toHaveBeenCalledWith({
...res,
_id: dbCore.prefixRoleID(res._id!),
})
})
it("disallow loops", async () => {
const role1 = await config.api.roles.save(basicRole(), {
status: 200,
})
const role2 = await config.api.roles.save(
{
...basicRole(),
inherits: [role1._id!],
},
{
status: 200,
}
)
await config.api.roles.save(
{
...role1,
inherits: [role2._id!],
},
{ status: 400, body: { message: LOOP_ERROR } }
)
})
it("disallow more complex loops", async () => {
let role1 = await config.api.roles.save({
...basicRole(),
name: "role1",
inherits: [BUILTIN_ROLE_IDS.POWER],
})
let role2 = await config.api.roles.save({
...basicRole(),
name: "role2",
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!],
})
let role3 = await config.api.roles.save({
...basicRole(),
name: "role3",
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!, role2._id!],
})
// go back to role1
await config.api.roles.save(
{
...role1,
inherits: [BUILTIN_ROLE_IDS.POWER, role2._id!, role3._id!],
},
{ status: 400, body: { message: LOOP_ERROR } }
)
// go back to role2
await config.api.roles.save(
{
...role2,
inherits: [BUILTIN_ROLE_IDS.POWER, role1._id!, role3._id!],
},
{ status: 400, body: { message: LOOP_ERROR } }
)
})
it("frontend example - should deny", async () => {
const id1 = "cb27c4ec9415042f4800411adb346fb7c",
id2 = "cbc72a9d61ab64d49b31d90d1df4c1fdb"
const role1 = await config.api.roles.save({
_id: id1,
name: id1,
permissions: {},
permissionId: "write",
version: "name",
inherits: ["POWER"],
})
await config.api.roles.save({
_id: id2,
permissions: {},
name: id2,
permissionId: "write",
version: "name",
inherits: [id1],
})
await config.api.roles.save(
{
...role1,
inherits: [BUILTIN_ROLE_IDS.POWER, id2],
},
{ status: 400, body: { message: LOOP_ERROR } }
)
})
})
describe("fetch", () => {
beforeAll(async () => {
// Recreate the app
await config.init()
})
it("should list custom roles, plus 2 default roles", async () => {
const customRole = await config.createRole()
const res = await config.api.roles.fetch({
status: 200,
})
expect(res.length).toBe(5)
const adminRole = res.find(r => r._id === BUILTIN_ROLE_IDS.ADMIN)
expect(adminRole).toBeDefined()
expect(adminRole!.inherits).toEqual(BUILTIN_ROLE_IDS.POWER)
expect(adminRole!.permissionId).toEqual(BuiltinPermissionID.ADMIN)
const powerUserRole = res.find(r => r._id === BUILTIN_ROLE_IDS.POWER)
expect(powerUserRole).toBeDefined()
expect(powerUserRole!.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(powerUserRole!.permissionId).toEqual(BuiltinPermissionID.POWER)
const customRoleFetched = res.find(r => r._id === customRole.name)
expect(customRoleFetched).toBeDefined()
expect(customRoleFetched!.inherits).toEqual(BUILTIN_ROLE_IDS.BASIC)
expect(customRoleFetched!.permissionId).toEqual(
BuiltinPermissionID.READ_ONLY
)
})
it("should be able to get the role with a permission added", async () => {
const table = await config.createTable()
await config.api.permission.add({
roleId: BUILTIN_ROLE_IDS.POWER,
resourceId: table._id!,
level: PermissionLevel.READ,
})
const res = await config.api.roles.fetch()
expect(res.length).toBeGreaterThan(0)
const power = res.find(role => role._id === BUILTIN_ROLE_IDS.POWER)
expect(power?.permissions[table._id!]).toEqual(["read"])
})
})
describe("destroy", () => {
it("should delete custom roles", async () => {
const customRole = await config.createRole({
name: "user",
permissionId: BuiltinPermissionID.READ_ONLY,
inherits: BUILTIN_ROLE_IDS.BASIC,
})
await config.api.roles.destroy(customRole, {
status: 200,
})
await config.api.roles.find(customRole._id!, {
status: 404,
})
expect(events.role.deleted).toHaveBeenCalledTimes(1)
expect(events.role.deleted).toHaveBeenCalledWith({
...customRole,
_id: dbCore.prefixRoleID(customRole._id!),
})
})
it("should disconnection roles when deleted", async () => {
const role1 = await config.api.roles.save({
name: "role1",
permissionId: BuiltinPermissionID.WRITE,
inherits: [BUILTIN_ROLE_IDS.BASIC],
})
const role2 = await config.api.roles.save({
name: "role2",
permissionId: BuiltinPermissionID.WRITE,
inherits: [BUILTIN_ROLE_IDS.BASIC, role1._id!],
})
const role3 = await config.api.roles.save({
name: "role3",
permissionId: BuiltinPermissionID.WRITE,
inherits: [BUILTIN_ROLE_IDS.BASIC, role2._id!],
})
await config.api.roles.destroy(role2, { status: 200 })
const found = await config.api.roles.find(role3._id!, { status: 200 })
expect(found.inherits).toEqual([BUILTIN_ROLE_IDS.BASIC])
})
})
describe("accessible", () => {
beforeAll(async () => {
// new app, reset roles
await config.init()
// create one custom role
await config.createRole()
})
it("should be able to fetch accessible roles (with builder)", async () => {
await config.withHeaders(config.defaultHeaders(), async () => {
const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(5)
expect(typeof res[0]).toBe("string")
})
})
it("should be able to fetch accessible roles (basic user)", async () => {
const headers = await config.basicRoleHeaders()
await config.withHeaders(headers, async () => {
const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(2)
expect(res[0]).toBe("BASIC")
expect(res[1]).toBe("PUBLIC")
})
})
it("should be able to fetch accessible roles (no user)", async () => {
await config.withHeaders(config.publicHeaders(), async () => {
const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(1)
expect(res[0]).toBe("PUBLIC")
})
})
it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
const customRoleName = "custom_role_1"
await config.api.roles.save({
name: customRoleName,
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name",
})
await config.withHeaders(
{ "x-budibase-role": customRoleName },
async () => {
const res = await config.api.roles.accessible({
status: 200,
})
expect(res).toEqual([customRoleName, "BASIC", "PUBLIC"])
}
)
})
})
describe("accessible - multi-inheritance", () => {
it("should list access correctly for multi-inheritance role", async () => {
const role1 = "multi_role_1",
role2 = "multi_role_2",
role3 = "multi_role_3"
const { _id: roleId1 } = await config.api.roles.save({
name: role1,
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.WRITE,
version: "name",
})
const { _id: roleId2 } = await config.api.roles.save({
name: role2,
inherits: roles.BUILTIN_ROLE_IDS.POWER,
permissionId: permissions.BuiltinPermissionID.POWER,
version: "name",
})
await config.api.roles.save({
name: role3,
inherits: [roleId1!, roleId2!],
permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name",
})
const headers = await config.roleHeaders({
roleId: role3,
})
await config.withHeaders(headers, async () => {
const res = await config.api.roles.accessible({
status: 200,
})
expect(res).toEqual([role3, role1, "BASIC", "PUBLIC", role2, "POWER"])
})
})
})
})

View File

@ -1,103 +0,0 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { basicScreen } = setup.structures
const { events } = require("@budibase/backend-core")
describe("/screens", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let screen
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
screen = await config.createScreen()
})
describe("fetch", () => {
it("should be able to create a layout", async () => {
const res = await request
.get(`/api/screens`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body.some(s => s._id === screen._id)).toEqual(true)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/screens`,
})
})
})
describe("save", () => {
const saveScreen = async screen => {
const res = await request
.post(`/api/screens`)
.send(screen)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
return res
}
it("should be able to create a screen", async () => {
jest.clearAllMocks()
const screen = basicScreen()
const res = await saveScreen(screen)
expect(res.body._rev).toBeDefined()
expect(res.body.name).toEqual(screen.name)
expect(events.screen.created).toBeCalledTimes(1)
})
it("should be able to update a screen", async () => {
const screen = basicScreen()
let res = await saveScreen(screen)
screen._id = res.body._id
screen._rev = res.body._rev
screen.name = "edit"
jest.clearAllMocks()
res = await saveScreen(screen)
expect(res.body._rev).toBeDefined()
expect(res.body.name).toEqual(screen.name)
expect(events.screen.created).not.toBeCalled()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/screens`,
})
})
})
describe("destroy", () => {
it("should be able to delete the screen", async () => {
const res = await request
.delete(`/api/screens/${screen._id}/${screen._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
expect(events.screen.deleted).toBeCalledTimes(1)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/screens/${screen._id}/${screen._rev}`,
})
})
})
})

View File

@ -0,0 +1,171 @@
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities"
import { events, roles } from "@budibase/backend-core"
import { Screen, PermissionLevel, Role } from "@budibase/types"
const { basicScreen } = setup.structures
describe("/screens", () => {
let config = setup.getConfig()
let screen: Screen
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
screen = await config.createScreen()
})
describe("fetch", () => {
it("should be able to create a layout", async () => {
const screens = await config.api.screen.list({ status: 200 })
expect(screens.length).toEqual(1)
expect(screens.some(s => s._id === screen._id)).toEqual(true)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/screens`,
})
})
})
describe("permissions", () => {
let screen1: Screen, screen2: Screen
let role1: Role, role2: Role, multiRole: Role
beforeAll(async () => {
role1 = await config.api.roles.save({
name: "role1",
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: PermissionLevel.WRITE,
})
role2 = await config.api.roles.save({
name: "role2",
inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: PermissionLevel.WRITE,
})
multiRole = await config.api.roles.save({
name: "multiRole",
inherits: [role1._id!, role2._id!],
permissionId: PermissionLevel.WRITE,
})
screen1 = await config.api.screen.save(
{
...basicScreen(),
routing: {
roleId: role1._id!,
route: "/foo",
homeScreen: false,
},
},
{ status: 200 }
)
screen2 = await config.api.screen.save(
{
...basicScreen(),
routing: {
roleId: role2._id!,
route: "/bar",
homeScreen: false,
},
},
{ status: 200 }
)
// get into prod app
await config.publish()
})
async function checkScreens(roleId: string, screenIds: string[]) {
await config.loginAsRole(roleId, async () => {
const res = await config.api.application.getDefinition(
config.prodAppId!,
{
status: 200,
}
)
// basic and role1 screen
expect(res.screens.length).toEqual(screenIds.length)
expect(res.screens.map(s => s._id).sort()).toEqual(screenIds.sort())
})
}
it("should be able to fetch basic and screen1 with role1", async () => {
await checkScreens(role1._id!, [screen._id!, screen1._id!])
})
it("should be able to fetch basic and screen2 with role2", async () => {
await checkScreens(role2._id!, [screen._id!, screen2._id!])
})
it("should be able to fetch basic, screen1 and screen2 with multi-inheritance role", async () => {
await checkScreens(multiRole._id!, [
screen._id!,
screen1._id!,
screen2._id!,
])
})
})
describe("save", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("should be able to create a screen", async () => {
const screen = basicScreen()
const responseScreen = await config.api.screen.save(screen, {
status: 200,
})
expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name)
expect(events.screen.created).toHaveBeenCalledTimes(1)
})
it("should be able to update a screen", async () => {
const screen = basicScreen()
let responseScreen = await config.api.screen.save(screen, { status: 200 })
screen._id = responseScreen._id
screen._rev = responseScreen._rev
screen.name = "edit"
jest.clearAllMocks()
responseScreen = await config.api.screen.save(screen, { status: 200 })
expect(responseScreen._rev).toBeDefined()
expect(responseScreen.name).toEqual(screen.name)
expect(events.screen.created).not.toHaveBeenCalled()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/screens`,
})
})
})
describe("destroy", () => {
it("should be able to delete the screen", async () => {
const response = await config.api.screen.destroy(
screen._id!,
screen._rev!,
{ status: 200 }
)
expect(response.message).toBeDefined()
expect(events.screen.deleted).toHaveBeenCalledTimes(1)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/screens/${screen._id}/${screen._rev}`,
})
})
})
})

View File

@ -2364,12 +2364,16 @@ describe.each([
// It also can't work for in-memory searching because the related table name // It also can't work for in-memory searching because the related table name
// isn't available. // isn't available.
!isInMemory && !isInMemory &&
describe("relations", () => { describe.each([
RelationshipType.ONE_TO_MANY,
RelationshipType.MANY_TO_ONE,
RelationshipType.MANY_TO_MANY,
])("relations (%s)", relationshipType => {
let productCategoryTable: Table, productCatRows: Row[] let productCategoryTable: Table, productCatRows: Row[]
beforeAll(async () => { beforeAll(async () => {
const { relatedTable, tableId } = await basicRelationshipTables( const { relatedTable, tableId } = await basicRelationshipTables(
RelationshipType.ONE_TO_MANY relationshipType
) )
tableOrViewId = tableId tableOrViewId = tableId
productCategoryTable = relatedTable productCategoryTable = relatedTable
@ -2466,7 +2470,10 @@ describe.each([
], ],
}, },
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, {
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
]) ])
} }
) )
@ -2544,7 +2551,7 @@ describe.each([
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, { name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] }, { name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
// { name: "baz", productCat: undefined }, // TODO { name: "baz", productCat: undefined },
]) ])
}) })
@ -2566,7 +2573,10 @@ describe.each([
], ],
}, },
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, {
name: "foo",
productCat: [{ _id: productCatRows[0]._id }],
},
]) ])
} }
) )
@ -2590,9 +2600,15 @@ describe.each([
], ],
}, },
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, {
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] }, name: "foo",
// { name: "baz", productCat: undefined }, // TODO productCat: [{ _id: productCatRows[0]._id }],
},
{
name: "bar",
productCat: [{ _id: productCatRows[1]._id }],
},
{ name: "baz", productCat: undefined },
]) ])
} }
) )
@ -2616,7 +2632,7 @@ describe.each([
}).toContainExactly([ }).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] }, { name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] }, { name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
// { name: "baz", productCat: undefined }, // TODO { name: "baz", productCat: undefined },
]) ])
}) })
}) })
@ -2624,10 +2640,13 @@ describe.each([
}) })
isSql && isSql &&
describe("big relations", () => { describe.each([
RelationshipType.MANY_TO_ONE,
RelationshipType.MANY_TO_MANY,
])("big relations (%s)", relationshipType => {
beforeAll(async () => { beforeAll(async () => {
const { relatedTable, tableId } = await basicRelationshipTables( const { relatedTable, tableId } = await basicRelationshipTables(
RelationshipType.MANY_TO_ONE relationshipType
) )
tableOrViewId = tableId tableOrViewId = tableId
const mainRow = await config.api.row.save(tableOrViewId, { const mainRow = await config.api.row.save(tableOrViewId, {
@ -2653,7 +2672,8 @@ describe.each([
expect(response.rows[0].productCat).toBeArrayOfSize(11) expect(response.rows[0].productCat).toBeArrayOfSize(11)
}) })
}) })
;(isSqs || isLucene) &&
isSql &&
describe("relations to same table", () => { describe("relations to same table", () => {
let relatedTable: string, relatedRows: Row[] let relatedTable: string, relatedRows: Row[]
@ -2695,6 +2715,11 @@ describe.each([
related1: [relatedRows[2]._id!], related1: [relatedRows[2]._id!],
related2: [relatedRows[3]._id!], related2: [relatedRows[3]._id!],
}), }),
config.api.row.save(tableOrViewId, {
name: "test3",
related1: [relatedRows[1]._id],
related2: [relatedRows[2]._id!],
}),
]) ])
}) })
@ -2712,42 +2737,59 @@ describe.each([
related1: [{ _id: relatedRows[2]._id }], related1: [{ _id: relatedRows[2]._id }],
related2: [{ _id: relatedRows[3]._id }], related2: [{ _id: relatedRows[3]._id }],
}, },
{
name: "test3",
related1: [{ _id: relatedRows[1]._id }],
related2: [{ _id: relatedRows[2]._id }],
},
]) ])
}) })
isSqs && it("should be able to filter via the first relation field with equal", async () => {
it("should be able to filter down to second row with equal", async () => { await expectSearch({
await expectSearch({ query: {
query: { equal: {
equal: { ["related1.name"]: "baz",
["related1.name"]: "baz",
},
}, },
}).toContainExactly([ },
{ }).toContainExactly([
name: "test2", {
related1: [{ _id: relatedRows[2]._id }], name: "test2",
}, related1: [{ _id: relatedRows[2]._id }],
]) },
}) ])
})
isSqs && it("should be able to filter via the second relation field with not equal", async () => {
it("should be able to filter down to first row with not equal", async () => { await expectSearch({
await expectSearch({ query: {
query: { notEqual: {
notEqual: { ["1:related2.name"]: "foo",
["1:related2.name"]: "bar", ["2:related2.name"]: "baz",
["2:related2.name"]: "baz", ["3:related2.name"]: "boo",
["3:related2.name"]: "boo",
},
}, },
}).toContainExactly([ },
{ }).toContainExactly([
name: "test", {
related1: [{ _id: relatedRows[0]._id }], name: "test",
},
])
})
it("should be able to filter on both fields", async () => {
await expectSearch({
query: {
notEqual: {
["related1.name"]: "foo",
["related2.name"]: "baz",
}, },
]) },
}) }).toContainExactly([
{
name: "test2",
},
])
})
}) })
isInternal && isInternal &&

View File

@ -225,7 +225,10 @@ export function roleValidator() {
) )
) )
.optional(), .optional(),
inherits: OPTIONAL_STRING, inherits: Joi.alternatives().try(
OPTIONAL_STRING,
Joi.array().items(OPTIONAL_STRING)
),
}).unknown(true) }).unknown(true)
) )
} }

View File

@ -78,8 +78,7 @@ describe("Captures of real examples", () => {
bindings: ["assembling", primaryLimit, relationshipLimit], bindings: ["assembling", primaryLimit, relationshipLimit],
sql: expect.stringContaining( sql: expect.stringContaining(
multiline( multiline(
`where exists (select 1 from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid" `where (exists (select 1 from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid" and (COALESCE("b"."taskname" = $1, FALSE)))`
and (COALESCE("b"."taskname" = $1, FALSE))`
) )
), ),
}) })
@ -133,6 +132,8 @@ describe("Captures of real examples", () => {
expect(query).toEqual({ expect(query).toEqual({
bindings: [ bindings: [
rangeValue.low,
rangeValue.high,
rangeValue.low, rangeValue.low,
rangeValue.high, rangeValue.high,
equalValue, equalValue,
@ -144,7 +145,7 @@ describe("Captures of real examples", () => {
], ],
sql: expect.stringContaining( sql: expect.stringContaining(
multiline( multiline(
`where exists (select 1 from "persons" as "c" where "c"."personid" = "a"."executorid" and ("c"."year" between $1 and $2))` `where (exists (select 1 from "persons" as "c" where "c"."personid" = "a"."executorid" and ("c"."year" between $1 and $2))) and (exists (select 1 from "persons" as "c" where "c"."personid" = "a"."qaid" and ("c"."year" between $3 and $4))) and (exists (select 1 from "products" as "b" inner join "products_tasks" as "d" on "b"."productid" = "d"."productid" where "d"."taskid" = "a"."taskid" and (COALESCE("b"."productname" = $5, FALSE))))`
) )
), ),
}) })

View File

@ -59,11 +59,15 @@ export default async (ctx: UserCtx, next: any) => {
// Ensure the role is valid by ensuring a definition exists // Ensure the role is valid by ensuring a definition exists
try { try {
if (roleHeader) { if (roleHeader) {
await roles.getRole(roleHeader) const role = await roles.getRole(roleHeader)
roleId = roleHeader if (role) {
roleId = roleHeader
// Delete admin and builder flags so that the specified role is honoured // Delete admin and builder flags so that the specified role is honoured
ctx.user = users.removePortalUserPermissions(ctx.user) as ContextUser ctx.user = users.removePortalUserPermissions(
ctx.user
) as ContextUser
}
} }
} catch (error) { } catch (error) {
// Swallow error and do nothing // Swallow error and do nothing

View File

@ -2,10 +2,12 @@ import * as sync from "./sync"
import * as utils from "./utils" import * as utils from "./utils"
import * as applications from "./applications" import * as applications from "./applications"
import * as imports from "./import" import * as imports from "./import"
import * as metadata from "./metadata"
export default { export default {
...sync, ...sync,
...utils, ...utils,
...applications, ...applications,
...imports, ...imports,
metadata,
} }

View File

@ -0,0 +1,18 @@
import { context, DocumentType } from "@budibase/backend-core"
import { App } from "@budibase/types"
/**
* @deprecated the plan is to get everything using `tryGet` instead, then rename
* `tryGet` to `get`.
*/
export async function get() {
const db = context.getAppDB()
const application = await db.get<App>(DocumentType.APP_METADATA)
return application
}
export async function tryGet() {
const db = context.getAppDB()
const application = await db.tryGet<App>(DocumentType.APP_METADATA)
return application
}

View File

@ -3,14 +3,12 @@ import * as rows from "./rows"
import * as search from "./search" import * as search from "./search"
import * as utils from "./utils" import * as utils from "./utils"
import * as external from "./external" import * as external from "./external"
import * as filters from "./search/filters"
import AliasTables from "./sqlAlias" import AliasTables from "./sqlAlias"
export default { export default {
...attachments, ...attachments,
...rows, ...rows,
...search, ...search,
filters,
utils, utils,
external, external,
AliasTables, AliasTables,

View File

@ -1,65 +0,0 @@
import {
FieldType,
RelationshipFieldMetadata,
SearchFilters,
Table,
} from "@budibase/types"
import { isPlainObject } from "lodash"
import { dataFilters } from "@budibase/shared-core"
export function getRelationshipColumns(table: Table): {
name: string
definition: RelationshipFieldMetadata
}[] {
// performing this with a for loop rather than an array filter improves
// type guarding, as no casts are required
const linkEntries: [string, RelationshipFieldMetadata][] = []
for (let entry of Object.entries(table.schema)) {
if (entry[1].type === FieldType.LINK) {
const linkColumn: RelationshipFieldMetadata = entry[1]
linkEntries.push([entry[0], linkColumn])
}
}
return linkEntries.map(entry => ({
name: entry[0],
definition: entry[1],
}))
}
export function getTableIDList(
tables: Table[]
): { name: string; id: string }[] {
return tables
.filter(table => table.originalName && table._id)
.map(table => ({ id: table._id!, name: table.originalName! }))
}
export function updateFilterKeys(
filters: SearchFilters,
updates: { original: string; updated: string }[]
): SearchFilters {
const makeFilterKeyRegex = (str: string) =>
new RegExp(`^${str}\\.|:${str}\\.`)
for (let filter of Object.values(filters)) {
if (!isPlainObject(filter)) {
continue
}
for (let [key, keyFilter] of Object.entries(filter)) {
if (keyFilter === "") {
delete filter[key]
}
const possibleKey = updates.find(({ original }) =>
key.match(makeFilterKeyRegex(original))
)
if (possibleKey && possibleKey.original !== possibleKey.updated) {
// only replace the first, not replaceAll
filter[key.replace(possibleKey.original, possibleKey.updated)] =
filter[key]
delete filter[key]
}
}
}
return dataFilters.recurseLogicalOperators(filters, (f: SearchFilters) => {
return updateFilterKeys(f, updates)
})
}

View File

@ -39,11 +39,6 @@ import AliasTables from "../../sqlAlias"
import { outputProcessing } from "../../../../../utilities/rowProcessor" import { outputProcessing } from "../../../../../utilities/rowProcessor"
import pick from "lodash/pick" import pick from "lodash/pick"
import { processRowCountResponse } from "../../utils" import { processRowCountResponse } from "../../utils"
import {
getRelationshipColumns,
getTableIDList,
updateFilterKeys,
} from "../filters"
import { import {
dataFilters, dataFilters,
helpers, helpers,
@ -133,31 +128,7 @@ async function buildInternalFieldList(
return [...new Set(fieldList)] return [...new Set(fieldList)]
} }
function cleanupFilters( function cleanupFilters(filters: SearchFilters, allTables: Table[]) {
filters: SearchFilters,
table: Table,
allTables: Table[]
) {
// get a list of all relationship columns in the table for updating
const relationshipColumns = getRelationshipColumns(table)
// get table names to ID map for relationships
const tableNameToID = getTableIDList(allTables)
// all should be applied at once
filters = updateFilterKeys(
filters,
relationshipColumns
.map(({ name, definition }) => ({
original: name,
updated: definition.tableId,
}))
.concat(
tableNameToID.map(({ name, id }) => ({
original: name,
updated: id,
}))
)
)
// generate a map of all possible column names (these can be duplicated across tables // generate a map of all possible column names (these can be duplicated across tables
// the map of them will always be the same // the map of them will always be the same
const userColumnMap: Record<string, string> = {} const userColumnMap: Record<string, string> = {}
@ -356,7 +327,7 @@ export async function search(
const relationships = buildInternalRelationships(table, allTables) const relationships = buildInternalRelationships(table, allTables)
const searchFilters: SearchFilters = { const searchFilters: SearchFilters = {
...cleanupFilters(query, table, allTables), ...cleanupFilters(query, allTables),
documentType: DocumentType.ROW, documentType: DocumentType.ROW,
} }

View File

@ -110,6 +110,7 @@ export default class TestConfiguration {
tenantId?: string tenantId?: string
api: API api: API
csrfToken?: string csrfToken?: string
temporaryHeaders?: Record<string, string | string[]>
constructor(openServer = true) { constructor(openServer = true) {
if (openServer) { if (openServer) {
@ -428,6 +429,38 @@ export default class TestConfiguration {
// HEADERS // HEADERS
// sets the role for the headers, for the period of a callback
async loginAsRole(roleId: string, cb: () => Promise<unknown>) {
const roleUser = await this.createUser({
roles: {
[this.getProdAppId()]: roleId,
},
builder: { global: false },
admin: { global: false },
})
await this.login({
roleId,
userId: roleUser._id!,
builder: false,
prodApp: true,
})
await this.withUser(roleUser, async () => {
await cb()
})
}
async withHeaders(
headers: Record<string, string | string[]>,
cb: () => Promise<unknown>
) {
this.temporaryHeaders = headers
try {
await cb()
} finally {
this.temporaryHeaders = undefined
}
}
defaultHeaders(extras = {}, prodApp = false) { defaultHeaders(extras = {}, prodApp = false) {
const tenantId = this.getTenantId() const tenantId = this.getTenantId()
const user = this.getUser() const user = this.getUser()
@ -451,7 +484,10 @@ export default class TestConfiguration {
} else if (this.appId) { } else if (this.appId) {
headers[constants.Header.APP_ID] = this.appId headers[constants.Header.APP_ID] = this.appId
} }
return headers return {
...headers,
...this.temporaryHeaders,
}
} }
publicHeaders({ prodApp = true } = {}) { publicHeaders({ prodApp = true } = {}) {
@ -459,6 +495,7 @@ export default class TestConfiguration {
const headers: any = { const headers: any = {
Accept: "application/json", Accept: "application/json",
Cookie: "",
} }
if (appId) { if (appId) {
headers[constants.Header.APP_ID] = appId headers[constants.Header.APP_ID] = appId
@ -466,7 +503,10 @@ export default class TestConfiguration {
headers[constants.Header.TENANT_ID] = this.getTenantId() headers[constants.Header.TENANT_ID] = this.getTenantId()
return headers return {
...headers,
...this.temporaryHeaders,
}
} }
async basicRoleHeaders() { async basicRoleHeaders() {

View File

@ -4,6 +4,7 @@ import {
FindRoleResponse, FindRoleResponse,
SaveRoleRequest, SaveRoleRequest,
SaveRoleResponse, SaveRoleResponse,
Role,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -27,13 +28,13 @@ export class RoleAPI extends TestAPI {
}) })
} }
destroy = async (roleId: string, expectations?: Expectations) => { destroy = async (role: Role, expectations?: Expectations) => {
return await this._delete(`/api/roles/${roleId}`, { return await this._delete(`/api/roles/${role._id}/${role._rev}`, {
expectations, expectations,
}) })
} }
accesssible = async (expectations?: Expectations) => { accessible = async (expectations?: Expectations) => {
return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, { return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, {
expectations, expectations,
}) })

View File

@ -5,4 +5,27 @@ export class ScreenAPI extends TestAPI {
list = async (expectations?: Expectations): Promise<Screen[]> => { list = async (expectations?: Expectations): Promise<Screen[]> => {
return await this._get<Screen[]>(`/api/screens`, { expectations }) return await this._get<Screen[]>(`/api/screens`, { expectations })
} }
save = async (
screen: Screen,
expectations?: Expectations
): Promise<Screen> => {
return await this._post<Screen>(`/api/screens`, {
expectations,
body: screen,
})
}
destroy = async (
screenId: string,
screenRev: string,
expectations?: Expectations
): Promise<{ message: string }> => {
return this._delete<{ message: string }>(
`/api/screens/${screenId}/${screenRev}`,
{
expectations,
}
)
}
} }

View File

@ -31,6 +31,7 @@ import {
BBReferenceFieldSubType, BBReferenceFieldSubType,
JsonFieldSubType, JsonFieldSubType,
AutoFieldSubType, AutoFieldSubType,
Role,
CreateViewRequest, CreateViewRequest,
} from "@budibase/types" } from "@budibase/types"
import { LoopInput } from "../../definitions/automations" import { LoopInput } from "../../definitions/automations"
@ -511,11 +512,12 @@ export function basicLinkedRow(
} }
} }
export function basicRole() { export function basicRole(): Role {
return { return {
name: `NewRole_${utils.newid()}`, name: `NewRole_${utils.newid()}`,
inherits: roles.BUILTIN_ROLE_IDS.BASIC, inherits: roles.BUILTIN_ROLE_IDS.BASIC,
permissionId: permissions.BuiltinPermissionID.READ_ONLY, permissionId: permissions.BuiltinPermissionID.READ_ONLY,
permissions: {},
version: "name", version: "name",
} }
} }

View File

@ -3,3 +3,4 @@ export * from "./integrations"
export * as cron from "./cron" export * as cron from "./cron"
export * as schema from "./schema" export * as schema from "./schema"
export * as views from "./views" export * as views from "./views"
export * as roles from "./roles"

View File

@ -0,0 +1,55 @@
import { Role, DocumentType, SEPARATOR } from "@budibase/types"
// need to have a way to prefix, so we can check if the ID has its prefix or not
// all new IDs should be the same in the future, but old roles they are never prefixed
// while the role IDs always are - best to check both, also we can't access backend-core here
function prefixForCheck(id: string) {
return `${DocumentType.ROLE}${SEPARATOR}${id}`
}
// Function to detect loops in roles
export function checkForRoleInheritanceLoops(roles: Role[]): boolean {
const roleMap = new Map<string, Role>()
roles.forEach(role => {
roleMap.set(role._id!, role)
})
const checked = new Set<string>()
const checking = new Set<string>()
function hasLoop(roleId: string): boolean {
const prefixed = prefixForCheck(roleId)
if (checking.has(roleId) || checking.has(prefixed)) {
return true
}
if (checked.has(roleId) || checked.has(prefixed)) {
return false
}
checking.add(roleId)
const role = roleMap.get(prefixed) || roleMap.get(roleId)
if (!role) {
// role not found - ignore
checking.delete(roleId)
return false
}
const inherits = Array.isArray(role.inherits)
? role.inherits
: [role.inherits]
for (const inheritedId of inherits) {
if (inheritedId && hasLoop(inheritedId)) {
return true
}
}
// mark this role has been fully checked
checking.delete(roleId)
checked.add(roleId)
return false
}
return !!roles.find(role => hasLoop(role._id!))
}

View File

@ -0,0 +1,73 @@
import { checkForRoleInheritanceLoops } from "../roles"
import { Role } from "@budibase/types"
/**
* This unit test exists as this utility will be used in the frontend and backend, confirmation
* of its API and expected results is useful since the backend tests won't confirm it works
* exactly as the frontend needs it to - easy to add specific test cases here that the frontend
* might need to check/cover.
*/
interface TestRole extends Omit<Role, "_id"> {
_id: string
}
let allRoles: TestRole[] = []
function role(id: string, inherits: string | string[]): TestRole {
const role = {
_id: id,
inherits: inherits,
name: "ROLE",
permissionId: "PERMISSION",
permissions: {}, // not needed for this test
}
allRoles.push(role)
return role
}
describe("role utilities", () => {
let role1: TestRole, role2: TestRole
beforeEach(() => {
role1 = role("role_1", [])
role2 = role("role_2", [role1._id])
})
afterEach(() => {
allRoles = []
})
function check(hasLoop: boolean) {
const result = checkForRoleInheritanceLoops(allRoles)
expect(result).toBe(hasLoop)
}
describe("checkForRoleInheritanceLoops", () => {
it("should confirm no loops", () => {
check(false)
})
it("should confirm there is a loop", () => {
const role3 = role("role_3", [role2._id])
const role4 = role("role_4", [role3._id, role2._id, role1._id])
role3.inherits = [
...(Array.isArray(role3.inherits) ? role3.inherits : []),
role4._id,
]
check(true)
})
it("should handle new and old inherits structure", () => {
const role1 = role("role_role_1", "role_1")
role("role_role_2", ["role_1"])
role1.inherits = "role_2"
check(true)
})
it("self reference contains loop", () => {
role("role1", "role1")
check(true)
})
})
})

View File

@ -1,12 +1,14 @@
import { Role, RoleUIMetadata } from "../../documents" import { Role, RoleUIMetadata } from "../../documents"
import { PermissionLevel } from "../../sdk"
export interface SaveRoleRequest { export interface SaveRoleRequest {
_id?: string _id?: string
_rev?: string _rev?: string
name: string name: string
inherits: string inherits?: string | string[]
permissionId: string permissionId: string
version: string permissions?: Record<string, PermissionLevel[]>
version?: string
uiMetadata?: RoleUIMetadata uiMetadata?: RoleUIMetadata
} }

View File

@ -27,6 +27,7 @@ export interface App extends Document {
usedPlugins?: Plugin[] usedPlugins?: Plugin[]
upgradableVersion?: string upgradableVersion?: string
snippets?: Snippet[] snippets?: Snippet[]
creationVersion?: string
} }
export interface AppInstance { export interface AppInstance {

View File

@ -9,7 +9,7 @@ export interface RoleUIMetadata {
export interface Role extends Document { export interface Role extends Document {
permissionId: string permissionId: string
inherits?: string inherits?: string | string[]
permissions: Record<string, PermissionLevel[]> permissions: Record<string, PermissionLevel[]>
version?: string version?: string
name: string name: string

View File

@ -3,19 +3,19 @@ import { BaseEvent } from "./event"
export interface RoleCreatedEvent extends BaseEvent { export interface RoleCreatedEvent extends BaseEvent {
roleId: string roleId: string
permissionId: string permissionId: string
inherits?: string inherits?: string | string[]
} }
export interface RoleUpdatedEvent extends BaseEvent { export interface RoleUpdatedEvent extends BaseEvent {
roleId: string roleId: string
permissionId: string permissionId: string
inherits?: string inherits?: string | string[]
} }
export interface RoleDeletedEvent extends BaseEvent { export interface RoleDeletedEvent extends BaseEvent {
roleId: string roleId: string
permissionId: string permissionId: string
inherits?: string inherits?: string | string[]
} }
export interface RoleAssignedEvent extends BaseEvent { export interface RoleAssignedEvent extends BaseEvent {

View File

@ -1,6 +1,6 @@
import { structures, TestConfiguration } from "../../../../tests" import { structures, TestConfiguration } from "../../../../tests"
import { context, db, permissions, roles } from "@budibase/backend-core" import { context, db, permissions, roles } from "@budibase/backend-core"
import { Database } from "@budibase/types" import { App, Database } from "@budibase/types"
jest.mock("@budibase/backend-core", () => { jest.mock("@budibase/backend-core", () => {
const core = jest.requireActual("@budibase/backend-core") const core = jest.requireActual("@budibase/backend-core")
@ -30,6 +30,14 @@ async function addAppMetadata() {
}) })
} }
async function updateAppMetadata(update: Partial<Omit<App, "_id" | "_rev">>) {
const app = await appDb.get("app_metadata")
await appDb.put({
...app,
...update,
})
}
describe("/api/global/roles", () => { describe("/api/global/roles", () => {
const config = new TestConfiguration() const config = new TestConfiguration()
@ -69,6 +77,53 @@ describe("/api/global/roles", () => {
expect(res.body[appId].roles.length).toEqual(5) expect(res.body[appId].roles.length).toEqual(5)
expect(res.body[appId].roles.map((r: any) => r._id)).toContain(ROLE_NAME) expect(res.body[appId].roles.map((r: any) => r._id)).toContain(ROLE_NAME)
}) })
it.each(["3.0.0", "3.0.1", "3.1.0", "3.0.0+2146.b125a7c"])(
"exclude POWER roles after v3 (%s)",
async creationVersion => {
await updateAppMetadata({ creationVersion })
const res = await config.api.roles.get()
expect(res.body).toBeDefined()
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
ROLE_NAME,
roles.BUILTIN_ROLE_IDS.ADMIN,
roles.BUILTIN_ROLE_IDS.BASIC,
roles.BUILTIN_ROLE_IDS.PUBLIC,
])
}
)
it.each(["2.9.0", "1.0.0", "0.0.0", "2.32.17+2146.b125a7c"])(
"include POWER roles before v3 (%s)",
async creationVersion => {
await updateAppMetadata({ creationVersion })
const res = await config.api.roles.get()
expect(res.body).toBeDefined()
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
ROLE_NAME,
roles.BUILTIN_ROLE_IDS.ADMIN,
roles.BUILTIN_ROLE_IDS.POWER,
roles.BUILTIN_ROLE_IDS.BASIC,
roles.BUILTIN_ROLE_IDS.PUBLIC,
])
}
)
it.each(["invalid", ""])(
"include POWER roles when the version is corrupted (%s)",
async creationVersion => {
await updateAppMetadata({ creationVersion })
const res = await config.api.roles.get()
expect(res.body[appId].roles.map((r: any) => r._id)).toEqual([
ROLE_NAME,
roles.BUILTIN_ROLE_IDS.ADMIN,
roles.BUILTIN_ROLE_IDS.POWER,
roles.BUILTIN_ROLE_IDS.BASIC,
roles.BUILTIN_ROLE_IDS.PUBLIC,
])
}
)
}) })
describe("GET api/global/roles/:appId", () => { describe("GET api/global/roles/:appId", () => {