Merge branch 'v3-ui' of github.com:Budibase/budibase into new-rbac-ui

This commit is contained in:
Andrew Kingston 2024-10-18 11:46:18 +01:00
commit 761c9d3c18
No known key found for this signature in database
13 changed files with 296 additions and 134 deletions

View File

@ -17,6 +17,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
import { RoleColor, helpers } 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",
@ -37,6 +38,14 @@ 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
@ -66,15 +75,65 @@ export class Role implements RoleDoc {
if (inherits && typeof inherits === "string") { if (inherits && typeof inherits === "string") {
inherits = prefixRoleIDNoBuiltin(inherits) inherits = prefixRoleIDNoBuiltin(inherits)
} else if (inherits && Array.isArray(inherits)) { } else if (inherits && Array.isArray(inherits)) {
inherits = inherits.map(inherit => prefixRoleIDNoBuiltin(inherit)) inherits = inherits.map(prefixRoleIDNoBuiltin)
}
if (inherits) {
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,
@ -202,7 +261,7 @@ export async function roleToNumber(id: string) {
return findNumber(foundRole) + 1 return findNumber(foundRole) + 1
} }
}) })
.filter(number => !!number) .filter(number => number)
.sort() .sort()
.pop() .pop()
if (highestBuiltin != undefined) { if (highestBuiltin != undefined) {
@ -213,12 +272,7 @@ export async function roleToNumber(id: string) {
} }
return 0 return 0
} }
let highest = 0 return Math.max(...hierarchy.map(findNumber))
for (let role of hierarchy) {
const roleNumber = findNumber(role)
highest = Math.max(roleNumber, highest)
}
return highest
} }
/** /**
@ -236,11 +290,23 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
: roleId1 : roleId1
} }
function compareRoleIds(roleId1: string, roleId2: string) { export function compareRoleIds(roleId1: string, roleId2: string) {
// make sure both role IDs are prefixed correctly // make sure both role IDs are prefixed correctly
return prefixRoleID(roleId1) === prefixRoleID(roleId2) 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. * Given a list of roles, this will pick the role out, accounting for built ins.
*/ */
@ -293,6 +359,18 @@ export async function getRole(
return findRole(roleId, roleList, opts) 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!),
}))
)
}
/** /**
* Simple function to get all the roles based on the top level user role ID. * Simple function to get all the roles based on the top level user role ID.
*/ */
@ -301,66 +379,19 @@ async function getAllUserRoles(
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc[]> { ): Promise<RoleDoc[]> {
const allRoles = await getAllRoles() 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 allRoles return allRoles
} }
const rolesFound = (ids: string | string[]) => {
if (Array.isArray(ids)) {
return ids.filter(id => roleIds.includes(id)).length === ids.length
} else {
return roleIds.includes(ids)
}
}
const roleIds = [userRoleId]
const roles: RoleDoc[] = []
const iterateInherited = (role: RoleDoc | undefined) => {
if (!role || !role._id) {
return
}
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 {
while (role && role.inherits && !rolesFound(role.inherits)) {
if (Array.isArray(role.inherits)) {
iterateInherited(role)
break
} else {
roleIds.push(role.inherits)
role = findRole(role.inherits, allRoles, opts)
if (role) {
roles.push(role)
}
}
}
}
}
// get all the inherited roles // get all the inherited roles
const foundRole = findRole(userRoleId, allRoles, opts) const foundRole = findRole(userRoleId, allRoles, opts)
let roles: RoleDoc[] = []
if (foundRole) { if (foundRole) {
iterateInherited(foundRole) const traversal = new RoleHierarchyTraversal(allRoles, opts)
roles = traversal.walk(foundRole)
} }
const foundRoleIds: string[] = [] return roles
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(

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

@ -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

@ -28,15 +28,27 @@ const UpdateRolesOptions = {
REMOVED: "removed", REMOVED: "removed",
} }
function externalRole(role: Role): Role { async function removeRoleFromOthers(roleId: string) {
let _id: string | undefined const allOtherRoles = await roles.getAllRoles()
if (role._id) { const updated: Role[] = []
_id = roles.getExternalRoleID(role._id) 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)
}
} }
return { if (updated.length) {
...role, await roles.saveRoles(updated)
_id,
inherits: roles.getExternalRoleIDs(role.inherits, role.version),
} }
} }
@ -66,23 +78,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()).map(role => externalRole(role)) 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>) {
const role = await roles.getRole(ctx.params.roleId) const role = await roles.getRole(ctx.params.roleId)
if (!role) { if (!role) {
ctx.throw(404, { message: "Role not found" }) ctx.throw(404, { message: "Role not found" })
} else {
ctx.body = externalRole(role)
} }
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)) {
@ -131,7 +145,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
ctx.throw(400, "Role inheritance contains a loop, this is not supported") ctx.throw(400, "Role inheritance contains a loop, this is not supported")
} }
const foundRev = ctx.request.body._rev || dbRole?._rev const foundRev = _rev || dbRole?._rev
if (foundRev) { if (foundRev) {
role._rev = foundRev role._rev = foundRev
} }
@ -148,7 +162,7 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
role.version role.version
) )
role._rev = result.rev role._rev = result.rev
ctx.body = externalRole(role) ctx.body = roles.externalRole(role)
const devDb = context.getDevAppDB() const devDb = context.getDevAppDB()
const prodDb = context.getProdAppDB() const prodDb = context.getProdAppDB()
@ -198,6 +212,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
builderSocket?.emitRoleDeletion(ctx, role) builderSocket?.emitRoleDeletion(ctx, role)

View File

@ -291,6 +291,8 @@ describe("/permission", () => {
describe("multi-inheritance permissions", () => { describe("multi-inheritance permissions", () => {
let table1: Table, table2: Table, role1: Role, role2: Role let table1: Table, table2: Table, role1: Role, role2: Role
beforeEach(async () => { beforeEach(async () => {
// create new app
await config.init()
table1 = await config.createTable() table1 = await config.createTable()
table2 = await config.createTable() table2 = await config.createTable()
await config.api.row.save(table1._id!, { await config.api.row.save(table1._id!, {
@ -301,7 +303,7 @@ describe("/permission", () => {
}) })
role1 = await config.api.roles.save( role1 = await config.api.roles.save(
{ {
name: "role1", name: "test_1",
permissionId: PermissionLevel.WRITE, permissionId: PermissionLevel.WRITE,
inherits: BUILTIN_ROLE_IDS.BASIC, inherits: BUILTIN_ROLE_IDS.BASIC,
}, },
@ -309,7 +311,7 @@ describe("/permission", () => {
) )
role2 = await config.api.roles.save( role2 = await config.api.roles.save(
{ {
name: "role2", name: "test_2",
permissionId: PermissionLevel.WRITE, permissionId: PermissionLevel.WRITE,
inherits: BUILTIN_ROLE_IDS.BASIC, inherits: BUILTIN_ROLE_IDS.BASIC,
}, },
@ -328,7 +330,7 @@ describe("/permission", () => {
}) })
it("should be unable to search for table 2 using role 1", async () => { it("should be unable to search for table 2 using role 1", async () => {
await config.setRole(role1._id!, async () => { await config.loginAsRole(role1._id!, async () => {
const response2 = await config.api.row.search( const response2 = await config.api.row.search(
table2._id!, table2._id!,
{ {
@ -347,7 +349,7 @@ describe("/permission", () => {
inherits: [role1._id!, role2._id!], inherits: [role1._id!, role2._id!],
}) })
await config.setRole(role3._id!, async () => { await config.loginAsRole(role3._id!, async () => {
const response1 = await config.api.row.search( const response1 = await config.api.row.search(
table1._id!, table1._id!,
{ {

View File

@ -121,6 +121,34 @@ describe("/roles", () => {
{ status: 400, body: { message: LOOP_ERROR } } { 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", () => { describe("fetch", () => {
@ -189,6 +217,27 @@ describe("/roles", () => {
_id: dbCore.prefixRoleID(customRole._id!), _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", () => { describe("accessible", () => {
@ -200,29 +249,35 @@ describe("/roles", () => {
}) })
it("should be able to fetch accessible roles (with builder)", async () => { it("should be able to fetch accessible roles (with builder)", async () => {
const res = await config.api.roles.accessible(config.defaultHeaders(), { await config.withHeaders(config.defaultHeaders(), async () => {
status: 200, const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(5)
expect(typeof res[0]).toBe("string")
}) })
expect(res.length).toBe(5)
expect(typeof res[0]).toBe("string")
}) })
it("should be able to fetch accessible roles (basic user)", async () => { it("should be able to fetch accessible roles (basic user)", async () => {
const headers = await config.basicRoleHeaders() const headers = await config.basicRoleHeaders()
const res = await config.api.roles.accessible(headers, { await config.withHeaders(headers, async () => {
status: 200, const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(2)
expect(res[0]).toBe("BASIC")
expect(res[1]).toBe("PUBLIC")
}) })
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 () => { it("should be able to fetch accessible roles (no user)", async () => {
const res = await config.api.roles.accessible(config.publicHeaders(), { await config.withHeaders(config.publicHeaders(), async () => {
status: 200, const res = await config.api.roles.accessible({
status: 200,
})
expect(res.length).toBe(1)
expect(res[0]).toBe("PUBLIC")
}) })
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 () => { it("should not fetch higher level accessible roles when a custom role header is provided", async () => {
@ -233,13 +288,15 @@ describe("/roles", () => {
permissionId: permissions.BuiltinPermissionID.READ_ONLY, permissionId: permissions.BuiltinPermissionID.READ_ONLY,
version: "name", version: "name",
}) })
const res = await config.api.roles.accessible( await config.withHeaders(
{ "x-budibase-role": customRoleName }, { "x-budibase-role": customRoleName },
{ async () => {
status: 200, const res = await config.api.roles.accessible({
status: 200,
})
expect(res).toEqual([customRoleName, "BASIC", "PUBLIC"])
} }
) )
expect(res).toEqual([customRoleName, "BASIC", "PUBLIC"])
}) })
}) })
@ -269,10 +326,12 @@ describe("/roles", () => {
const headers = await config.roleHeaders({ const headers = await config.roleHeaders({
roleId: role3, roleId: role3,
}) })
const res = await config.api.roles.accessible(headers, { await config.withHeaders(headers, async () => {
status: 200, const res = await config.api.roles.accessible({
status: 200,
})
expect(res).toEqual([role3, role1, "BASIC", "PUBLIC", role2, "POWER"])
}) })
expect(res).toEqual([role3, role1, "BASIC", "PUBLIC", role2, "POWER"])
}) })
}) })
}) })

View File

@ -79,7 +79,7 @@ describe("/screens", () => {
}) })
async function checkScreens(roleId: string, screenIds: string[]) { async function checkScreens(roleId: string, screenIds: string[]) {
await config.setRole(roleId, async () => { await config.loginAsRole(roleId, async () => {
const res = await config.api.application.getDefinition( const res = await config.api.application.getDefinition(
config.prodAppId!, config.prodAppId!,
{ {
@ -110,6 +110,10 @@ describe("/screens", () => {
}) })
describe("save", () => { describe("save", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("should be able to create a screen", async () => { it("should be able to create a screen", async () => {
const screen = basicScreen() const screen = basicScreen()
const responseScreen = await config.api.screen.save(screen, { const responseScreen = await config.api.screen.save(screen, {
@ -127,6 +131,7 @@ describe("/screens", () => {
screen._id = responseScreen._id screen._id = responseScreen._id
screen._rev = responseScreen._rev screen._rev = responseScreen._rev
screen.name = "edit" screen.name = "edit"
jest.clearAllMocks()
responseScreen = await config.api.screen.save(screen, { status: 200 }) responseScreen = await config.api.screen.save(screen, { status: 200 })

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) {
@ -429,10 +430,10 @@ export default class TestConfiguration {
// HEADERS // HEADERS
// sets the role for the headers, for the period of a callback // sets the role for the headers, for the period of a callback
async setRole(roleId: string, cb: () => Promise<unknown>) { async loginAsRole(roleId: string, cb: () => Promise<unknown>) {
const roleUser = await this.createUser({ const roleUser = await this.createUser({
roles: { roles: {
[this.prodAppId!]: roleId, [this.getProdAppId()]: roleId,
}, },
builder: { global: false }, builder: { global: false },
admin: { global: false }, admin: { global: false },
@ -443,16 +444,20 @@ export default class TestConfiguration {
builder: false, builder: false,
prodApp: true, prodApp: true,
}) })
const temp = this.user await this.withUser(roleUser, async () => {
this.user = roleUser await cb()
await cb() })
if (temp) { }
this.user = temp
await this.login({ async withHeaders(
userId: temp._id!, headers: Record<string, string | string[]>,
builder: true, cb: () => Promise<unknown>
prodApp: false, ) {
}) this.temporaryHeaders = headers
try {
await cb()
} finally {
this.temporaryHeaders = undefined
} }
} }
@ -479,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 } = {}) {
@ -495,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

@ -22,10 +22,6 @@ 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,
@ -38,12 +34,8 @@ export class RoleAPI extends TestAPI {
}) })
} }
accessible = async ( accessible = async (expectations?: Expectations) => {
headers: Record<string, string | string[]>,
expectations?: Expectations
) => {
return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, { return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, {
headers,
expectations, expectations,
}) })
} }

View File

@ -1,4 +1,11 @@
import { Role } from "@budibase/types" 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 // Function to detect loops in roles
export function checkForRoleInheritanceLoops(roles: Role[]): boolean { export function checkForRoleInheritanceLoops(roles: Role[]): boolean {
@ -11,16 +18,17 @@ export function checkForRoleInheritanceLoops(roles: Role[]): boolean {
const checking = new Set<string>() const checking = new Set<string>()
function hasLoop(roleId: string): boolean { function hasLoop(roleId: string): boolean {
if (checking.has(roleId)) { const prefixed = prefixForCheck(roleId)
if (checking.has(roleId) || checking.has(prefixed)) {
return true return true
} }
if (checked.has(roleId)) { if (checked.has(roleId) || checked.has(prefixed)) {
return false return false
} }
checking.add(roleId) checking.add(roleId)
const role = roleMap.get(roleId) const role = roleMap.get(prefixed) || roleMap.get(roleId)
if (!role) { if (!role) {
// role not found - ignore // role not found - ignore
checking.delete(roleId) checking.delete(roleId)

View File

@ -57,5 +57,17 @@ describe("role utilities", () => {
] ]
check(true) 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,4 +1,5 @@
import { Role, RoleUIMetadata } from "../../documents" import { Role, RoleUIMetadata } from "../../documents"
import { PermissionLevel } from "../../sdk"
export interface SaveRoleRequest { export interface SaveRoleRequest {
_id?: string _id?: string
@ -6,6 +7,7 @@ export interface SaveRoleRequest {
name: string name: string
inherits?: string | string[] inherits?: string | string[]
permissionId: string permissionId: string
permissions?: Record<string, PermissionLevel[]>
version?: string version?: string
uiMetadata?: RoleUIMetadata uiMetadata?: RoleUIMetadata
} }