Merge branch 'feature/role-multi-inheritance' of github.com:Budibase/budibase into new-rbac-ui

This commit is contained in:
Andrew Kingston 2024-10-15 13:43:56 +01:00
commit f7f300b251
No known key found for this signature in database
23 changed files with 557 additions and 307 deletions

View File

@ -2,13 +2,11 @@ name: deploy-featurebranch
on: on:
pull_request: pull_request:
types: [ types:
labeled, - labeled
# default types below (https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request) - opened
opened, - synchronize
synchronize, - reopened
reopened,
]
jobs: jobs:
release: release:
@ -22,31 +20,21 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise') contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise')
) )
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set PAYLOAD_LICENSE_TYPE
id: set_license_type
run: |
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch') }}" == "true" ]]; then
echo "PAYLOAD_LICENSE_TYPE=free" >> $GITHUB_ENV
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') }}" == "true" ]]; then
echo "PAYLOAD_LICENSE_TYPE=pro" >> $GITHUB_ENV
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-team') }}" == "true" ]]; then
echo "PAYLOAD_LICENSE_TYPE=team" >> $GITHUB_ENV
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-business') }}" == "true" ]]; then
echo "PAYLOAD_LICENSE_TYPE=business" >> $GITHUB_ENV
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise') }}" == "true" ]]; then
echo "PAYLOAD_LICENSE_TYPE=enterprise" >> $GITHUB_ENV
else
echo "PAYLOAD_LICENSE_TYPE=free" >> $GITHUB_ENV
fi
- uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }} PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
PAYLOAD_LICENSE_TYPE: ${{ env.PAYLOAD_LICENSE_TYPE }} PAYLOAD_LICENSE_TYPE: |
${{
contains(github.event.pull_request.labels.*.name, 'feature-branch') && 'free' ||
contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') && 'pro' ||
contains(github.event.pull_request.labels.*.name, 'feature-branch-team') && 'team' ||
contains(github.event.pull_request.labels.*.name, 'feature-branch-business') && 'business' ||
contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise') && 'enterprise' || 'free'
}}
steps:
- uses: actions/checkout@v4
- uses: passeidireto/trigger-external-workflow-action@main
with: with:
repository: budibase/budibase-deploys repository: budibase/budibase-deploys
event: featurebranch-qa-deploy event: featurebranch-qa-deploy

View File

@ -42,14 +42,12 @@ spec:
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }} {{ end }}
{{ if .Values.globals.sqs.enabled }}
- name: COUCH_DB_SQL_URL - name: COUCH_DB_SQL_URL
{{ if .Values.globals.sqs.url }} {{ if .Values.globals.sqs.url }}
value: {{ .Values.globals.sqs.url }} value: {{ .Values.globals.sqs.url | quote }}
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
{{ end }} {{ end }}
{{ end }}
{{ if .Values.services.couchdb.enabled }} {{ if .Values.services.couchdb.enabled }}
- name: COUCH_DB_USER - name: COUCH_DB_USER
valueFrom: valueFrom:

View File

@ -43,6 +43,12 @@ spec:
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }} {{ end }}
- name: COUCH_DB_SQL_URL
{{ if .Values.globals.sqs.url }}
value: {{ .Values.globals.sqs.url | quote }}
{{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
{{ end }}
{{ if .Values.services.couchdb.enabled }} {{ if .Values.services.couchdb.enabled }}
- name: COUCH_DB_USER - name: COUCH_DB_USER
valueFrom: valueFrom:

View File

@ -56,14 +56,12 @@ spec:
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
{{ end }} {{ end }}
{{ if .Values.globals.sqs.enabled }}
- name: COUCH_DB_SQL_URL - name: COUCH_DB_SQL_URL
{{ if .Values.globals.sqs.url }} {{ if .Values.globals.sqs.url }}
value: {{ .Values.globals.sqs.url }} value: {{ .Values.globals.sqs.url | quote }}
{{ else }} {{ else }}
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }} value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
{{ end }} {{ end }}
{{ end }}
- name: API_ENCRYPTION_KEY - name: API_ENCRYPTION_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@ -139,9 +139,6 @@ globals:
password: "" password: ""
sqs: sqs:
# -- Whether to use the CouchDB "structured query service" or not. This is disabled by
# default for now, but will become the default in a future release.
enabled: false
# @ignore # @ignore
url: "" url: ""
# @ignore # @ignore

View File

@ -5,7 +5,7 @@ version: "3"
services: services:
app-service: app-service:
restart: unless-stopped restart: unless-stopped
image: budibase.docker.scarf.sh/budibase/apps image: budibase/apps
container_name: bbapps container_name: bbapps
environment: environment:
SELF_HOSTED: 1 SELF_HOSTED: 1
@ -35,7 +35,7 @@ services:
worker-service: worker-service:
restart: unless-stopped restart: unless-stopped
image: budibase.docker.scarf.sh/budibase/worker image: budibase/worker
container_name: bbworker container_name: bbworker
environment: environment:
SELF_HOSTED: 1 SELF_HOSTED: 1
@ -97,7 +97,7 @@ services:
couchdb-service: couchdb-service:
restart: unless-stopped restart: unless-stopped
image: budibase/couchdb image: budibase/couchdb:v3.3.3-sqs-v2.1.1
environment: environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
- COUCHDB_USER=${COUCH_DB_USER} - COUCHDB_USER=${COUCH_DB_USER}

View File

@ -69,6 +69,9 @@ WORKDIR /minio
COPY scripts/install-minio.sh ./install.sh COPY scripts/install-minio.sh ./install.sh
RUN chmod +x install.sh && ./install.sh RUN chmod +x install.sh && ./install.sh
# setup redis
COPY hosting/single/redis.conf /etc/redis/redis.conf
# setup runner file # setup runner file
WORKDIR / WORKDIR /
COPY hosting/single/runner.sh . COPY hosting/single/runner.sh .

View File

@ -0,0 +1,7 @@
dir "DATA_DIR/redis"
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

View File

@ -75,13 +75,17 @@ fi
for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done
ln -s ${DATA_DIR}/.env /app/.env ln -s ${DATA_DIR}/.env /app/.env
ln -s ${DATA_DIR}/.env /worker/.env ln -s ${DATA_DIR}/.env /worker/.env
# make these directories in runner, incase of mount # make these directories in runner, incase of mount
mkdir -p ${DATA_DIR}/minio mkdir -p ${DATA_DIR}/minio
mkdir -p ${DATA_DIR}/redis
chown -R couchdb:couchdb ${DATA_DIR}/couch chown -R couchdb:couchdb ${DATA_DIR}/couch
sed -i "s#DATA_DIR#${DATA_DIR}#g" /etc/redis/redis.conf
if [[ -n "${REDIS_PASSWORD}" ]]; then if [[ -n "${REDIS_PASSWORD}" ]]; then
redis-server --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 & redis-server /etc/redis/redis.conf --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 &
else else
redis-server > /dev/stdout 2>&1 & redis-server /etc/redis/redis.conf > /dev/stdout 2>&1 &
fi fi
/bbcouch-runner.sh & /bbcouch-runner.sh &

View File

@ -269,7 +269,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
export const flags = new FlagSet({ export const flags = new FlagSet({
DEFAULT_VALUES: Flag.boolean(env.isDev()), DEFAULT_VALUES: Flag.boolean(env.isDev()),
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()), AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
SQS: Flag.boolean(env.isDev()), SQS: Flag.boolean(true),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()), [FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()), [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
[FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()), [FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()),

View File

@ -10,6 +10,7 @@ const schema = {
TEST_BOOLEAN: Flag.boolean(false), TEST_BOOLEAN: Flag.boolean(false),
TEST_STRING: Flag.string("default value"), TEST_STRING: Flag.string("default value"),
TEST_NUMBER: Flag.number(0), TEST_NUMBER: Flag.number(0),
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
} }
const flags = new FlagSet(schema) const flags = new FlagSet(schema)
@ -123,6 +124,11 @@ describe("feature flags", () => {
}, },
expected: flags.defaults(), expected: flags.defaults(),
}, },
{
it: "should be possible to override a default true flag to false",
environmentFlags: "default:!TEST_BOOLEAN_DEFAULT_TRUE",
expected: { TEST_BOOLEAN_DEFAULT_TRUE: false },
},
])( ])(
"$it", "$it",
async ({ async ({

View File

@ -9,7 +9,7 @@ import {
import { getAppDB } from "../context" import { getAppDB } from "../context"
import { Screen, Role as RoleDoc, RoleUIMetadata } from "@budibase/types" import { Screen, Role as RoleDoc, RoleUIMetadata } 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"
export const BUILTIN_ROLE_IDS = { export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN", ADMIN: "ADMIN",
@ -157,7 +157,7 @@ export function builtinRoleToNumber(id: string) {
break break
} }
if (Array.isArray(role.inherits)) { if (Array.isArray(role.inherits)) {
// TODO: role inheritance throw new Error("Built-in roles don't support multi-inheritance")
} else { } else {
role = builtins[role.inherits!] role = builtins[role.inherits!]
} }
@ -176,17 +176,36 @@ 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) { if (!role.inherits) {
continue return 0
} }
if (Array.isArray(role.inherits)) { if (Array.isArray(role.inherits)) {
// TODO: role inheritance // 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)) { } else if (isBuiltin(role.inherits)) {
return builtinRoleToNumber(role.inherits) + 1 return builtinRoleToNumber(role.inherits) + 1
} }
}
return 0 return 0
}
let highest = 0
for (let role of hierarchy) {
const roleNumber = findNumber(role)
highest = Math.max(roleNumber, highest)
}
return highest
} }
/** /**
@ -204,6 +223,36 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
: roleId1 : roleId1
} }
/**
* 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 {
// 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 && role._id === getExternalRoleID(roleId, role.version)
)
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
}
if (!dbRole && (!role || Object.keys(role).length === 0)) {
throw new Error("Role could not be found")
}
role = Object.assign(role || {}, dbRole)
// finalise the ID
role._id = getExternalRoleID(role._id!, role.version)
return 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.
@ -215,29 +264,15 @@ export async function getRole(
roleId: string, roleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc> { ): Promise<RoleDoc> {
// 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)
}
try {
const db = getAppDB() const db = getAppDB()
const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId)) const roleList = []
role = Object.assign(role || {}, dbRole) if (!isBuiltin(roleId)) {
// finalise the ID const role = await db.tryGet<RoleDoc>(getDBRoleID(roleId))
role._id = getExternalRoleID(role._id!, role.version) if (role) {
} catch (err) { roleList.push(role)
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)
} }
/** /**
@ -247,13 +282,14 @@ async function getAllUserRoles(
userRoleId: string, userRoleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc[]> { ): Promise<RoleDoc[]> {
const allRoles = await getAllRoles()
if (helpers.roles.checkForRoleInheritanceLoops(allRoles)) {
throw new Error("Loop detected in roles - cannot list roles")
}
// 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]
const rolesFound = (ids: string | string[]) => { const rolesFound = (ids: string | string[]) => {
if (Array.isArray(ids)) { if (Array.isArray(ids)) {
return ids.filter(id => roleIds.includes(id)).length === ids.length return ids.filter(id => roleIds.includes(id)).length === ids.length
@ -261,23 +297,49 @@ async function getAllUserRoles(
return roleIds.includes(ids) return roleIds.includes(ids)
} }
} }
// get all the inherited roles
while ( const roleIds = [userRoleId]
currentRole && const roles: RoleDoc[] = []
currentRole.inherits && const iterateInherited = (role: RoleDoc) => {
!rolesFound(currentRole.inherits) if (!role || !role._id) {
) { return
if (Array.isArray(currentRole.inherits)) { }
// TODO: role inheritance roleIds.push(role._id)
roles.push(role)
if (Array.isArray(role.inherits)) {
role.inherits.forEach(roleId => {
const foundRole = findRole(roleId, allRoles, opts)
if (foundRole) {
iterateInherited(foundRole)
}
})
} else { } else {
roleIds.push(currentRole.inherits) while (role && role.inherits && !rolesFound(role.inherits)) {
currentRole = await getRole(currentRole.inherits) if (Array.isArray(role.inherits)) {
if (currentRole) { iterateInherited(role)
roles.push(currentRole) break
} else {
roleIds.push(role.inherits)
role = findRole(role.inherits, allRoles, opts)
if (role) {
roles.push(role)
} }
} }
} }
return roles }
}
// get all the inherited roles
iterateInherited(findRole(userRoleId, allRoles, opts))
const foundRoleIds: string[] = []
return roles.filter(role => {
if (role._id && !foundRoleIds.includes(role._id)) {
foundRoleIds.push(role._id)
return true
} else {
return false
}
})
} }
export async function getUserRoleIdHierarchy( export async function getUserRoleIdHierarchy(
@ -454,7 +516,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)

View File

@ -521,8 +521,11 @@ class InternalBuilder {
const [filterTableName, ...otherProperties] = key.split(".") const [filterTableName, ...otherProperties] = key.split(".")
const property = otherProperties.join(".") const property = otherProperties.join(".")
const alias = getTableAlias(filterTableName) const alias = getTableAlias(filterTableName)
return fn(q, alias ? `${alias}.${property}` : property, value) return q.andWhere(subquery =>
fn(subquery, alias ? `${alias}.${property}` : property, value)
)
} }
for (const key in structure) { for (const key in structure) {
const value = structure[key] const value = structure[key]
const updatedKey = dbCore.removeKeyNumbering(key) const updatedKey = dbCore.removeKeyNumbering(key)
@ -552,6 +555,9 @@ class InternalBuilder {
value value
) )
} else if (shouldProcessRelationship) { } else if (shouldProcessRelationship) {
if (allOr) {
query = query.or
}
query = builder.addRelationshipForFilter(query, updatedKey, q => { query = builder.addRelationshipForFilter(query, updatedKey, q => {
return handleRelationship(q, updatedKey, value) return handleRelationship(q, updatedKey, value)
}) })

View File

@ -2,36 +2,31 @@
export let isMigrationDone export let isMigrationDone
export let onMigrationDone export let onMigrationDone
export let timeoutSeconds = 60 // 1 minute export let timeoutSeconds = 60 // 1 minute
export let minTimeSeconds = 3
const loadTime = Date.now()
const intervalMs = 1000
let timedOut = false let timedOut = false
let secondsWaited = 0
async function checkMigrationsFinished() { async function checkMigrationsFinished() {
setTimeout(async () => { let totalWaitMs = 0
// eslint-disable-next-line no-constant-condition
while (true) {
const waitForMs = 5000 + Math.random() * 5000
await new Promise(resolve => setTimeout(resolve, waitForMs))
totalWaitMs += waitForMs
const isMigrated = await isMigrationDone() const isMigrated = await isMigrationDone()
if (isMigrated) {
const timeoutMs = timeoutSeconds * 1000
if (!isMigrated || secondsWaited <= minTimeSeconds) {
if (loadTime + timeoutMs > Date.now()) {
secondsWaited += 1
return checkMigrationsFinished()
}
return migrationTimeout()
}
onMigrationDone() onMigrationDone()
}, intervalMs) return
}
if (totalWaitMs > timeoutSeconds * 1000) {
timedOut = true
return
}
}
} }
checkMigrationsFinished() checkMigrationsFinished()
function migrationTimeout() {
timedOut = true
}
</script> </script>
<div class="loading" class:timeout={timedOut}> <div class="loading" class:timeout={timedOut}>

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"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
@ -82,9 +82,10 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
_id = dbCore.prefixRoleID(_id) _id = dbCore.prefixRoleID(_id)
} }
const allRoles = await roles.getAllRoles()
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")
@ -98,6 +99,18 @@ 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
} }
// 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 = ctx.request.body._rev || dbRole?._rev const foundRev = ctx.request.body._rev || dbRole?._rev
if (foundRev) { if (foundRev) {
role._rev = foundRev role._rev = foundRev

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,86 @@ describe("/permission", () => {
}) })
}) })
describe("multi-inheritance permissions", () => {
let table1: Table, table2: Table, role1: Role, role2: Role
beforeEach(async () => {
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: "role1",
permissionId: PermissionLevel.WRITE,
inherits: BUILTIN_ROLE_IDS.BASIC,
},
{ status: 200 }
)
role2 = await config.api.roles.save(
{
name: "role2",
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.setRole(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.setRole(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

@ -142,7 +142,7 @@ describe("/roles", () => {
}) })
it("should not fetch higher level accessible roles when a custom role header is provided", async () => { it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
const customRoleName = "CUSTOM_ROLE" const customRoleName = "custom_role_1"
await config.api.roles.save({ await config.api.roles.save({
name: customRoleName, name: customRoleName,
inherits: roles.BUILTIN_ROLE_IDS.BASIC, inherits: roles.BUILTIN_ROLE_IDS.BASIC,
@ -155,10 +155,40 @@ describe("/roles", () => {
status: 200, status: 200,
} }
) )
expect(res.length).toBe(3) expect(res).toEqual([customRoleName, "BASIC", "PUBLIC"])
expect(res[0]).toBe(customRoleName) })
expect(res[1]).toBe("BASIC") })
expect(res[2]).toBe("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,
})
const res = await config.api.roles.accessible(headers, {
status: 200,
})
expect(res).toEqual([role3, role1, "BASIC", "PUBLIC", role2, "POWER"])
}) })
}) })
}) })

View File

@ -24,6 +24,7 @@ import {
EmptyFilterOption, EmptyFilterOption,
FieldType, FieldType,
JsonFieldSubType, JsonFieldSubType,
LogicalOperator,
RelationshipType, RelationshipType,
Row, Row,
RowSearchParams, RowSearchParams,
@ -2415,6 +2416,211 @@ describe.each([
equal: { ["name"]: "baz" }, equal: { ["name"]: "baz" },
}).toContainExactly([{ name: "baz", productCat: undefined }]) }).toContainExactly([{ name: "baz", productCat: undefined }])
}) })
describe("logical filters", () => {
const logicalOperators = [LogicalOperator.AND, LogicalOperator.OR]
describe("$and", () => {
it("should allow single conditions", async () => {
await expectQuery({
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
})
it("should allow exclusive conditions", async () => {
await expectQuery({
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([])
})
it.each([logicalOperators])(
"should allow nested ands with single conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
}
)
it.each([logicalOperators])(
"should allow nested ands with exclusive conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([])
}
)
it.each([logicalOperators])(
"should allow nested ands with multiple conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$and: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([])
}
)
})
describe("$ors", () => {
it("should allow single conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
})
it("should allow exclusive conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
// { name: "baz", productCat: undefined }, // TODO
])
})
it.each([logicalOperators])(
"should allow nested ors with single conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
])
}
)
it.each([logicalOperators])(
"should allow nested ors with exclusive conditions (with %s as root)",
async rootOperator => {
await expectQuery({
[rootOperator]: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
notEqual: { ["productCat.name"]: "foo" },
},
],
},
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
// { name: "baz", productCat: undefined }, // TODO
])
}
)
it("should allow nested ors with multiple conditions", async () => {
await expectQuery({
$or: {
conditions: [
{
$or: {
conditions: [
{
equal: { ["productCat.name"]: "foo" },
},
],
},
notEqual: { ["productCat.name"]: "foo" },
},
],
},
}).toContainExactly([
{ name: "foo", productCat: [{ _id: productCatRows[0]._id }] },
{ name: "bar", productCat: [{ _id: productCatRows[1]._id }] },
// { name: "baz", productCat: undefined }, // TODO
])
})
})
})
}) })
isSql && isSql &&

View File

@ -79,7 +79,7 @@ describe("Captures of real examples", () => {
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))`
) )
), ),
}) })
@ -144,7 +144,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))`
) )
), ),
}) })

View File

@ -428,6 +428,34 @@ export default class TestConfiguration {
// HEADERS // HEADERS
// sets the role for the headers, for the period of a callback
async setRole(roleId: string, cb: () => Promise<unknown>) {
const roleUser = await this.createUser({
roles: {
[this.prodAppId!]: roleId,
},
builder: { global: false },
admin: { global: false },
})
await this.login({
roleId,
userId: roleUser._id!,
builder: false,
prodApp: true,
})
const temp = this.user
this.user = roleUser
await cb()
if (temp) {
this.user = temp
await this.login({
userId: temp._id!,
builder: true,
prodApp: false,
})
}
}
defaultHeaders(extras = {}, prodApp = false) { defaultHeaders(extras = {}, prodApp = false) {
const tenantId = this.getTenantId() const tenantId = this.getTenantId()
const user = this.getUser() const user = this.getUser()

View File

@ -22,6 +22,10 @@ export class RoleAPI extends TestAPI {
} }
save = async (body: SaveRoleRequest, expectations?: Expectations) => { save = async (body: SaveRoleRequest, expectations?: Expectations) => {
// the tests should always be creating the "new" version of roles
if (body.version === undefined) {
body.version = "name"
}
return await this._post<SaveRoleResponse>(`/api/roles`, { return await this._post<SaveRoleResponse>(`/api/roles`, {
body, body,
expectations, expectations,

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"