Merge pull request #11911 from Budibase/fix/BUDI-7236

Fix navigation bar custom role restrictions
This commit is contained in:
Michael Drury 2023-09-27 17:22:01 +01:00 committed by GitHub
commit 11dd586b62
16 changed files with 181 additions and 72 deletions

View File

@ -1,8 +1,9 @@
import { PermissionType, PermissionLevel } from "@budibase/types" import { PermissionLevel, PermissionType } from "@budibase/types"
export { PermissionType, PermissionLevel } from "@budibase/types"
import flatten from "lodash/flatten" import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep" import cloneDeep from "lodash/fp/cloneDeep"
export { PermissionType, PermissionLevel } from "@budibase/types"
export type RoleHierarchy = { export type RoleHierarchy = {
permissionId: string permissionId: string
}[] }[]
@ -78,6 +79,7 @@ export const BUILTIN_PERMISSIONS = {
permissions: [ permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ), new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ), new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
WRITE: { WRITE: {
@ -88,6 +90,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
POWER: { POWER: {
@ -99,6 +102,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
ADMIN: { ADMIN: {
@ -111,6 +115,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ), new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
], ],
}, },
} }

View File

@ -215,21 +215,23 @@ async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
return roles return roles
} }
export async function getUserRoleIdHierarchy(
userRoleId?: string
): Promise<string[]> {
const roles = await getUserRoleHierarchy(userRoleId)
return roles.map(role => role._id!)
}
/** /**
* Returns an ordered array of the user's inherited role IDs, this can be used * Returns an ordered array of the user's inherited role IDs, this can be used
* to determine if a user can access something that requires a specific role. * to determine if a user can access something that requires a specific role.
* @param {string} userRoleId The user's role ID, this can be found in their access token. * @param {string} userRoleId The user's role ID, this can be found in their access token.
* @param {object} opts Various options, such as whether to only retrieve the IDs (default true). * @returns {Promise<object[]>} returns an ordered array of the roles, with the first being their
* @returns {Promise<string[]|object[]>} returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level. * highest level of access and the last being the lowest level.
*/ */
export async function getUserRoleHierarchy( export async function getUserRoleHierarchy(userRoleId?: string) {
userRoleId?: string,
opts = { idOnly: true }
) {
// special case, if they don't have a role then they are a public user // special case, if they don't have a role then they are a public user
const roles = await getAllUserRoles(userRoleId) return getAllUserRoles(userRoleId)
return opts.idOnly ? roles.map(role => role._id) : roles
} }
// this function checks that the provided permissions are in an array format // this function checks that the provided permissions are in an array format
@ -249,6 +251,11 @@ export function checkForRoleResourceArray(
return rolePerms return rolePerms
} }
export async function getAllRoleIds(appId?: string) {
const roles = await getAllRoles(appId)
return roles.map(role => role._id)
}
/** /**
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
@ -332,9 +339,7 @@ export class AccessController {
} }
let roleIds = userRoleId ? this.userHierarchies[userRoleId] : null let roleIds = userRoleId ? this.userHierarchies[userRoleId] : null
if (!roleIds && userRoleId) { if (!roleIds && userRoleId) {
roleIds = (await getUserRoleHierarchy(userRoleId, { roleIds = await getUserRoleIdHierarchy(userRoleId)
idOnly: true,
})) as string[]
this.userHierarchies[userRoleId] = roleIds this.userHierarchies[userRoleId] = roleIds
} }

View File

@ -4,15 +4,14 @@
import { Heading, Icon, clickOutside } from "@budibase/bbui" import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants" import { FieldTypes } from "constants"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core"
const sdk = getContext("sdk") const sdk = getContext("sdk")
const { const {
routeStore, routeStore,
roleStore,
styleable, styleable,
linkable, linkable,
builderStore, builderStore,
currentRole,
sidePanelStore, sidePanelStore,
} = sdk } = sdk
const component = getContext("component") const component = getContext("component")
@ -61,7 +60,7 @@
}) })
setContext("layout", store) setContext("layout", store)
$: validLinks = getValidLinks(links, $currentRole) $: validLinks = getValidLinks(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None $: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large $: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large $: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
@ -99,14 +98,12 @@
} }
} }
const getValidLinks = (allLinks, role) => { const getValidLinks = (allLinks, userRoleHierarchy) => {
// Strip links missing required info // Strip links missing required info
let validLinks = (allLinks || []).filter(link => link.text && link.url) let validLinks = (allLinks || []).filter(link => link.text && link.url)
// Filter to only links allowed by the current role // Filter to only links allowed by the current role
const priority = RoleUtils.getRolePriority(role)
return validLinks.filter(link => { return validLinks.filter(link => {
return !link.roleId || RoleUtils.getRolePriority(link.roleId) <= priority return userRoleHierarchy?.find(roleId => roleId === link.roleId)
}) })
} }

View File

@ -1,32 +1,39 @@
<script> <script>
import { Heading, Select, ActionButton } from "@budibase/bbui" import { Heading, Select, ActionButton } from "@budibase/bbui"
import { devToolsStore, appStore } from "../../stores" import { devToolsStore, appStore, roleStore } from "../../stores"
import { getContext } from "svelte" import { getContext, onMount } from "svelte"
const context = getContext("context") const context = getContext("context")
const SELF_ROLE = "self"
$: previewOptions = [ let staticRoleList
{
$: previewOptions = buildRoleList(staticRoleList)
function buildRoleList(roleIds) {
const list = []
list.push({
label: "View as yourself", label: "View as yourself",
value: "self", value: SELF_ROLE,
}, })
{ if (!roleIds) {
label: "View as public user", return list
value: "PUBLIC", }
}, for (let roleId of roleIds) {
{ list.push({
label: "View as basic user", label: `View as ${roleId.toLowerCase()} user`,
value: "BASIC", value: roleId,
}, })
{ }
label: "View as power user", devToolsStore.actions.changeRole(SELF_ROLE)
value: "POWER", return list
}, }
{
label: "View as admin user", onMount(async () => {
value: "ADMIN", // make sure correct before starting
}, await devToolsStore.actions.changeRole(SELF_ROLE)
] staticRoleList = await roleStore.actions.fetchAccessibleRoles()
})
</script> </script>
<div class="dev-preview-header" class:mobile={$context.device.mobile}> <div class="dev-preview-header" class:mobile={$context.device.mobile}>
@ -34,7 +41,7 @@
<Select <Select
quiet quiet
options={previewOptions} options={previewOptions}
value={$devToolsStore.role || "self"} value={$devToolsStore.role || SELF_ROLE}
placeholder={null} placeholder={null}
autoWidth autoWidth
on:change={e => devToolsStore.actions.changeRole(e.detail)} on:change={e => devToolsStore.actions.changeRole(e.detail)}

View File

@ -13,6 +13,7 @@ import {
sidePanelStore, sidePanelStore,
dndIsDragging, dndIsDragging,
confirmationStore, confirmationStore,
roleStore,
} from "stores" } from "stores"
import { styleable } from "utils/styleable" import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable" import { linkable } from "utils/linkable"
@ -39,6 +40,7 @@ export default {
dndIsDragging, dndIsDragging,
currentRole, currentRole,
confirmationStore, confirmationStore,
roleStore,
styleable, styleable,
linkable, linkable,
getAction, getAction,

View File

@ -11,12 +11,13 @@ export { stateStore } from "./state"
export { themeStore } from "./theme" export { themeStore } from "./theme"
export { devToolsStore } from "./devTools" export { devToolsStore } from "./devTools"
export { componentStore } from "./components" export { componentStore } from "./components"
export { uploadStore } from "./uploads.js" export { uploadStore } from "./uploads"
export { rowSelectionStore } from "./rowSelection.js" export { rowSelectionStore } from "./rowSelection"
export { blockStore } from "./blocks.js" export { blockStore } from "./blocks"
export { environmentStore } from "./environment" export { environmentStore } from "./environment"
export { eventStore } from "./events.js" export { eventStore } from "./events"
export { orgStore } from "./org.js" export { orgStore } from "./org"
export { roleStore } from "./roles"
export { export {
dndStore, dndStore,
dndIndex, dndIndex,
@ -25,7 +26,7 @@ export {
dndIsNewComponent, dndIsNewComponent,
dndIsDragging, dndIsDragging,
} from "./dnd" } from "./dnd"
export { sidePanelStore } from "./sidePanel.js" export { sidePanelStore } from "./sidePanel"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context" export { createContextStore } from "./context"

View File

@ -0,0 +1,24 @@
import { API } from "api"
import { writable } from "svelte/store"
import { currentRole } from "./derived"
const createRoleStore = () => {
const store = writable([])
// Fetches the user object if someone is logged in and has reloaded the page
const fetchAccessibleRoles = async () => {
const accessible = await API.getAccessibleRoles()
// Use the app self if present, otherwise fallback to the global self
store.set(accessible || [])
return accessible
}
return {
subscribe: store.subscribe,
actions: { fetchAccessibleRoles },
}
}
export const roleStore = createRoleStore()
currentRole.subscribe(roleStore.actions.fetchAccessibleRoles)

View File

@ -38,4 +38,13 @@ export const buildRoleEndpoints = API => ({
url: `/api/global/roles/${appId}`, url: `/api/global/roles/${appId}`,
}) })
}, },
/**
* For the logged in user and current app - retrieves accessible roles.
*/
getAccessibleRoles: async () => {
return await API.get({
url: `/api/roles/accessible`,
})
},
}) })

View File

@ -1,6 +1,7 @@
import { roles, context, events, db as dbCore } from "@budibase/backend-core" import { context, db as dbCore, events, roles } from "@budibase/backend-core"
import { getUserMetadataParams, InternalTables } from "../../db/utils" import { getUserMetadataParams, InternalTables } from "../../db/utils"
import { UserCtx, Database, UserRoles, Role } from "@budibase/types" import { Database, Role, UserCtx, UserRoles } from "@budibase/types"
import { sdk as sharedSdk } from "@budibase/shared-core"
import sdk from "../../sdk" import sdk from "../../sdk"
const UpdateRolesOptions = { const UpdateRolesOptions = {
@ -94,7 +95,6 @@ export async function save(ctx: UserCtx) {
) )
role._rev = result.rev role._rev = result.rev
ctx.body = role ctx.body = role
ctx.message = `Role '${role.name}' created successfully.`
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
@ -131,3 +131,16 @@ export async function destroy(ctx: UserCtx) {
ctx.message = `Role ${ctx.params.roleId} deleted successfully` ctx.message = `Role ${ctx.params.roleId} deleted successfully`
ctx.status = 200 ctx.status = 200
} }
export async function accessible(ctx: UserCtx) {
let roleId = ctx.user?.roleId
if (!roleId) {
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
}
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
const appId = context.getAppId()
ctx.body = await roles.getAllRoleIds(appId)
} else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
}
}

View File

@ -63,9 +63,7 @@ export async function fetch(ctx: UserCtx) {
export async function clientFetch(ctx: UserCtx) { export async function clientFetch(ctx: UserCtx) {
const routing = await getRoutingStructure() const routing = await getRoutingStructure()
let roleId = ctx.user?.role?._id let roleId = ctx.user?.role?._id
const roleIds = (await roles.getUserRoleHierarchy(roleId, { const roleIds = await roles.getUserRoleIdHierarchy(roleId)
idOnly: true,
})) as string[]
for (let topLevel of Object.values(routing.routes) as any) { for (let topLevel of Object.values(routing.routes) as any) {
for (let subpathKey of Object.keys(topLevel.subpaths)) { for (let subpathKey of Object.keys(topLevel.subpaths)) {
let found = false let found = false

View File

@ -107,6 +107,11 @@ export const serveApp = async function (ctx: any) {
//Public Settings //Public Settings
const { config } = await configs.getSettingsConfigDoc() const { config } = await configs.getSettingsConfigDoc()
const branding = await pro.branding.getBrandingConfig(config) const branding = await pro.branding.getBrandingConfig(config)
// incase running direct from TS
let appHbsPath = join(__dirname, "app.hbs")
if (!fs.existsSync(appHbsPath)) {
appHbsPath = join(__dirname, "templates", "app.hbs")
}
let db let db
try { try {
@ -138,7 +143,7 @@ export const serveApp = async function (ctx: any) {
? objectStore.getGlobalFileUrl("settings", "logoUrl") ? objectStore.getGlobalFileUrl("settings", "logoUrl")
: "", : "",
}) })
const appHbs = loadHandlebarsFile(`${__dirname}/app.hbs`) const appHbs = loadHandlebarsFile(appHbsPath)
ctx.body = await processString(appHbs, { ctx.body = await processString(appHbs, {
head, head,
body: html, body: html,
@ -166,7 +171,7 @@ export const serveApp = async function (ctx: any) {
: "", : "",
}) })
const appHbs = loadHandlebarsFile(`${__dirname}/app.hbs`) const appHbs = loadHandlebarsFile(appHbsPath)
ctx.body = await processString(appHbs, { ctx.body = await processString(appHbs, {
head, head,
body: html, body: html,
@ -193,8 +198,13 @@ export const serveBuilderPreview = async function (ctx: any) {
} }
export const serveClientLibrary = async function (ctx: any) { export const serveClientLibrary = async function (ctx: any) {
let rootPath = join(NODE_MODULES_PATH, "@budibase", "client", "dist")
// incase running from TS directly
if (env.isDev() && !fs.existsSync(rootPath)) {
rootPath = join(require.resolve("@budibase/client"), "..")
}
return send(ctx, "budibase-client.js", { return send(ctx, "budibase-client.js", {
root: join(NODE_MODULES_PATH, "@budibase", "client", "dist"), root: rootPath,
}) })
} }

View File

@ -7,6 +7,9 @@ import { roleValidator } from "./utils/validators"
const router: Router = new Router() const router: Router = new Router()
router router
// retrieve a list of the roles a user can access
// needs to be public for public screens
.get("/api/roles/accessible", controller.accessible)
.post( .post(
"/api/roles", "/api/roles",
authorized(permissions.BUILDER), authorized(permissions.BUILDER),

View File

@ -15,7 +15,7 @@ describe("/roles", () => {
await config.init() await config.init()
}) })
const createRole = async (role) => { const createRole = async role => {
if (!role) { if (!role) {
role = basicRole() role = basicRole()
} }
@ -33,9 +33,6 @@ describe("/roles", () => {
const role = basicRole() const role = basicRole()
const res = await createRole(role) const res = await createRole(role)
expect(res.res.statusMessage).toEqual(
`Role '${role.name}' created successfully.`
)
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
expect(events.role.updated).not.toBeCalled() expect(events.role.updated).not.toBeCalled()
@ -51,9 +48,6 @@ describe("/roles", () => {
jest.clearAllMocks() jest.clearAllMocks()
res = await createRole(res.body) res = await createRole(res.body)
expect(res.res.statusMessage).toEqual(
`Role '${role.name}' created successfully.`
)
expect(res.body._id).toBeDefined() expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined() expect(res.body._rev).toBeDefined()
expect(events.role.created).not.toBeCalled() expect(events.role.created).not.toBeCalled()
@ -99,7 +93,11 @@ describe("/roles", () => {
it("should be able to get the role with a permission added", async () => { it("should be able to get the role with a permission added", async () => {
const table = await config.createTable() const table = await config.createTable()
await config.api.permission.set({ roleId: BUILTIN_ROLE_IDS.POWER, resourceId: table._id, level: PermissionLevel.READ }) await config.api.permission.set({
roleId: BUILTIN_ROLE_IDS.POWER,
resourceId: table._id,
level: PermissionLevel.READ,
})
const res = await request const res = await request
.get(`/api/roles`) .get(`/api/roles`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
@ -131,4 +129,34 @@ describe("/roles", () => {
expect(events.role.deleted).toBeCalledWith(customRole) 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")
})
})
}) })

View File

@ -55,9 +55,7 @@ const checkAuthorizedResource = async (
) => { ) => {
// get the user's roles // get the user's roles
const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC
const userRoles = (await roles.getUserRoleHierarchy(roleId, { const userRoles = await roles.getUserRoleHierarchy(roleId)
idOnly: false,
})) as Role[]
const permError = "User does not have permission" const permError = "User does not have permission"
// check if the user has the required role // check if the user has the required role
if (resourceRoles.length > 0) { if (resourceRoles.length > 0) {

View File

@ -425,6 +425,15 @@ class TestConfiguration {
return headers return headers
} }
async basicRoleHeaders() {
return await this.roleHeaders({
email: this.defaultUserValues.email,
builder: false,
prodApp: true,
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
})
}
async roleHeaders({ async roleHeaders({
email = this.defaultUserValues.email, email = this.defaultUserValues.email,
roleId = roles.BUILTIN_ROLE_IDS.ADMIN, roleId = roles.BUILTIN_ROLE_IDS.ADMIN,

View File

@ -8,7 +8,7 @@ import path from "path"
* @param args Any number of string arguments to add to a path * @param args Any number of string arguments to add to a path
* @returns {string} The final path ready to use * @returns {string} The final path ready to use
*/ */
export function join(...args: any) { export function join(...args: string[]) {
return path.join(...args) return path.join(...args)
} }
@ -17,6 +17,6 @@ export function join(...args: any) {
* @param args Any number of string arguments to add to a path * @param args Any number of string arguments to add to a path
* @returns {string} The final path ready to use * @returns {string} The final path ready to use
*/ */
export function resolve(...args: any) { export function resolve(...args: string[]) {
return path.resolve(...args) return path.resolve(...args)
} }