Merge pull request #11911 from Budibase/fix/BUDI-7236
Fix navigation bar custom role restrictions
This commit is contained in:
commit
11dd586b62
|
@ -1,8 +1,9 @@
|
|||
import { PermissionType, PermissionLevel } from "@budibase/types"
|
||||
export { PermissionType, PermissionLevel } from "@budibase/types"
|
||||
import { PermissionLevel, PermissionType } from "@budibase/types"
|
||||
import flatten from "lodash/flatten"
|
||||
import cloneDeep from "lodash/fp/cloneDeep"
|
||||
|
||||
export { PermissionType, PermissionLevel } from "@budibase/types"
|
||||
|
||||
export type RoleHierarchy = {
|
||||
permissionId: string
|
||||
}[]
|
||||
|
@ -78,6 +79,7 @@ export const BUILTIN_PERMISSIONS = {
|
|||
permissions: [
|
||||
new Permission(PermissionType.QUERY, PermissionLevel.READ),
|
||||
new Permission(PermissionType.TABLE, PermissionLevel.READ),
|
||||
new Permission(PermissionType.APP, PermissionLevel.READ),
|
||||
],
|
||||
},
|
||||
WRITE: {
|
||||
|
@ -88,6 +90,7 @@ export const BUILTIN_PERMISSIONS = {
|
|||
new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
|
||||
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
|
||||
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
|
||||
new Permission(PermissionType.APP, PermissionLevel.READ),
|
||||
],
|
||||
},
|
||||
POWER: {
|
||||
|
@ -99,6 +102,7 @@ export const BUILTIN_PERMISSIONS = {
|
|||
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
|
||||
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
|
||||
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
|
||||
new Permission(PermissionType.APP, PermissionLevel.READ),
|
||||
],
|
||||
},
|
||||
ADMIN: {
|
||||
|
@ -111,6 +115,7 @@ export const BUILTIN_PERMISSIONS = {
|
|||
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
|
||||
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
|
||||
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
|
||||
new Permission(PermissionType.APP, PermissionLevel.READ),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
|
|
@ -215,21 +215,23 @@ async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
|
|||
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
|
||||
* 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 {object} opts Various options, such as whether to only retrieve the IDs (default true).
|
||||
* @returns {Promise<string[]|object[]>} returns an ordered array of the roles, with the first being their
|
||||
* @returns {Promise<object[]>} returns an ordered array of the roles, with the first being their
|
||||
* highest level of access and the last being the lowest level.
|
||||
*/
|
||||
export async function getUserRoleHierarchy(
|
||||
userRoleId?: string,
|
||||
opts = { idOnly: true }
|
||||
) {
|
||||
export async function getUserRoleHierarchy(userRoleId?: string) {
|
||||
// special case, if they don't have a role then they are a public user
|
||||
const roles = await getAllUserRoles(userRoleId)
|
||||
return opts.idOnly ? roles.map(role => role._id) : roles
|
||||
return getAllUserRoles(userRoleId)
|
||||
}
|
||||
|
||||
// this function checks that the provided permissions are in an array format
|
||||
|
@ -249,6 +251,11 @@ export function checkForRoleResourceArray(
|
|||
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.
|
||||
* @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
|
||||
if (!roleIds && userRoleId) {
|
||||
roleIds = (await getUserRoleHierarchy(userRoleId, {
|
||||
idOnly: true,
|
||||
})) as string[]
|
||||
roleIds = await getUserRoleIdHierarchy(userRoleId)
|
||||
this.userHierarchies[userRoleId] = roleIds
|
||||
}
|
||||
|
||||
|
|
|
@ -4,15 +4,14 @@
|
|||
import { Heading, Icon, clickOutside } from "@budibase/bbui"
|
||||
import { FieldTypes } from "constants"
|
||||
import active from "svelte-spa-router/active"
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
|
||||
const sdk = getContext("sdk")
|
||||
const {
|
||||
routeStore,
|
||||
roleStore,
|
||||
styleable,
|
||||
linkable,
|
||||
builderStore,
|
||||
currentRole,
|
||||
sidePanelStore,
|
||||
} = sdk
|
||||
const component = getContext("component")
|
||||
|
@ -61,7 +60,7 @@
|
|||
})
|
||||
setContext("layout", store)
|
||||
|
||||
$: validLinks = getValidLinks(links, $currentRole)
|
||||
$: validLinks = getValidLinks(links, $roleStore)
|
||||
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
|
||||
$: navWidthClass = WidthClasses[navWidth || 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
|
||||
let validLinks = (allLinks || []).filter(link => link.text && link.url)
|
||||
|
||||
// Filter to only links allowed by the current role
|
||||
const priority = RoleUtils.getRolePriority(role)
|
||||
return validLinks.filter(link => {
|
||||
return !link.roleId || RoleUtils.getRolePriority(link.roleId) <= priority
|
||||
return userRoleHierarchy?.find(roleId => roleId === link.roleId)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,39 @@
|
|||
<script>
|
||||
import { Heading, Select, ActionButton } from "@budibase/bbui"
|
||||
import { devToolsStore, appStore } from "../../stores"
|
||||
import { getContext } from "svelte"
|
||||
import { devToolsStore, appStore, roleStore } from "../../stores"
|
||||
import { getContext, onMount } from "svelte"
|
||||
|
||||
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",
|
||||
value: "self",
|
||||
},
|
||||
{
|
||||
label: "View as public user",
|
||||
value: "PUBLIC",
|
||||
},
|
||||
{
|
||||
label: "View as basic user",
|
||||
value: "BASIC",
|
||||
},
|
||||
{
|
||||
label: "View as power user",
|
||||
value: "POWER",
|
||||
},
|
||||
{
|
||||
label: "View as admin user",
|
||||
value: "ADMIN",
|
||||
},
|
||||
]
|
||||
value: SELF_ROLE,
|
||||
})
|
||||
if (!roleIds) {
|
||||
return list
|
||||
}
|
||||
for (let roleId of roleIds) {
|
||||
list.push({
|
||||
label: `View as ${roleId.toLowerCase()} user`,
|
||||
value: roleId,
|
||||
})
|
||||
}
|
||||
devToolsStore.actions.changeRole(SELF_ROLE)
|
||||
return list
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// make sure correct before starting
|
||||
await devToolsStore.actions.changeRole(SELF_ROLE)
|
||||
staticRoleList = await roleStore.actions.fetchAccessibleRoles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="dev-preview-header" class:mobile={$context.device.mobile}>
|
||||
|
@ -34,7 +41,7 @@
|
|||
<Select
|
||||
quiet
|
||||
options={previewOptions}
|
||||
value={$devToolsStore.role || "self"}
|
||||
value={$devToolsStore.role || SELF_ROLE}
|
||||
placeholder={null}
|
||||
autoWidth
|
||||
on:change={e => devToolsStore.actions.changeRole(e.detail)}
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
sidePanelStore,
|
||||
dndIsDragging,
|
||||
confirmationStore,
|
||||
roleStore,
|
||||
} from "stores"
|
||||
import { styleable } from "utils/styleable"
|
||||
import { linkable } from "utils/linkable"
|
||||
|
@ -39,6 +40,7 @@ export default {
|
|||
dndIsDragging,
|
||||
currentRole,
|
||||
confirmationStore,
|
||||
roleStore,
|
||||
styleable,
|
||||
linkable,
|
||||
getAction,
|
||||
|
|
|
@ -11,12 +11,13 @@ export { stateStore } from "./state"
|
|||
export { themeStore } from "./theme"
|
||||
export { devToolsStore } from "./devTools"
|
||||
export { componentStore } from "./components"
|
||||
export { uploadStore } from "./uploads.js"
|
||||
export { rowSelectionStore } from "./rowSelection.js"
|
||||
export { blockStore } from "./blocks.js"
|
||||
export { uploadStore } from "./uploads"
|
||||
export { rowSelectionStore } from "./rowSelection"
|
||||
export { blockStore } from "./blocks"
|
||||
export { environmentStore } from "./environment"
|
||||
export { eventStore } from "./events.js"
|
||||
export { orgStore } from "./org.js"
|
||||
export { eventStore } from "./events"
|
||||
export { orgStore } from "./org"
|
||||
export { roleStore } from "./roles"
|
||||
export {
|
||||
dndStore,
|
||||
dndIndex,
|
||||
|
@ -25,7 +26,7 @@ export {
|
|||
dndIsNewComponent,
|
||||
dndIsDragging,
|
||||
} from "./dnd"
|
||||
export { sidePanelStore } from "./sidePanel.js"
|
||||
export { sidePanelStore } from "./sidePanel"
|
||||
|
||||
// Context stores are layered and duplicated, so it is not a singleton
|
||||
export { createContextStore } from "./context"
|
||||
|
|
|
@ -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)
|
|
@ -38,4 +38,13 @@ export const buildRoleEndpoints = API => ({
|
|||
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`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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 { 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"
|
||||
|
||||
const UpdateRolesOptions = {
|
||||
|
@ -94,7 +95,6 @@ export async function save(ctx: UserCtx) {
|
|||
)
|
||||
role._rev = result.rev
|
||||
ctx.body = role
|
||||
ctx.message = `Role '${role.name}' created successfully.`
|
||||
}
|
||||
|
||||
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.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!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,9 +63,7 @@ export async function fetch(ctx: UserCtx) {
|
|||
export async function clientFetch(ctx: UserCtx) {
|
||||
const routing = await getRoutingStructure()
|
||||
let roleId = ctx.user?.role?._id
|
||||
const roleIds = (await roles.getUserRoleHierarchy(roleId, {
|
||||
idOnly: true,
|
||||
})) as string[]
|
||||
const roleIds = await roles.getUserRoleIdHierarchy(roleId)
|
||||
for (let topLevel of Object.values(routing.routes) as any) {
|
||||
for (let subpathKey of Object.keys(topLevel.subpaths)) {
|
||||
let found = false
|
||||
|
|
|
@ -107,6 +107,11 @@ export const serveApp = async function (ctx: any) {
|
|||
//Public Settings
|
||||
const { config } = await configs.getSettingsConfigDoc()
|
||||
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
|
||||
try {
|
||||
|
@ -138,7 +143,7 @@ export const serveApp = async function (ctx: any) {
|
|||
? objectStore.getGlobalFileUrl("settings", "logoUrl")
|
||||
: "",
|
||||
})
|
||||
const appHbs = loadHandlebarsFile(`${__dirname}/app.hbs`)
|
||||
const appHbs = loadHandlebarsFile(appHbsPath)
|
||||
ctx.body = await processString(appHbs, {
|
||||
head,
|
||||
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, {
|
||||
head,
|
||||
body: html,
|
||||
|
@ -193,8 +198,13 @@ export const serveBuilderPreview = 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", {
|
||||
root: join(NODE_MODULES_PATH, "@budibase", "client", "dist"),
|
||||
root: rootPath,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ import { roleValidator } from "./utils/validators"
|
|||
const router: Router = new 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(
|
||||
"/api/roles",
|
||||
authorized(permissions.BUILDER),
|
||||
|
|
|
@ -15,7 +15,7 @@ describe("/roles", () => {
|
|||
await config.init()
|
||||
})
|
||||
|
||||
const createRole = async (role) => {
|
||||
const createRole = async role => {
|
||||
if (!role) {
|
||||
role = basicRole()
|
||||
}
|
||||
|
@ -33,9 +33,6 @@ describe("/roles", () => {
|
|||
const role = basicRole()
|
||||
const res = await createRole(role)
|
||||
|
||||
expect(res.res.statusMessage).toEqual(
|
||||
`Role '${role.name}' created successfully.`
|
||||
)
|
||||
expect(res.body._id).toBeDefined()
|
||||
expect(res.body._rev).toBeDefined()
|
||||
expect(events.role.updated).not.toBeCalled()
|
||||
|
@ -51,9 +48,6 @@ describe("/roles", () => {
|
|||
jest.clearAllMocks()
|
||||
res = await createRole(res.body)
|
||||
|
||||
expect(res.res.statusMessage).toEqual(
|
||||
`Role '${role.name}' created successfully.`
|
||||
)
|
||||
expect(res.body._id).toBeDefined()
|
||||
expect(res.body._rev).toBeDefined()
|
||||
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 () => {
|
||||
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
|
||||
.get(`/api/roles`)
|
||||
.set(config.defaultHeaders())
|
||||
|
@ -131,4 +129,34 @@ describe("/roles", () => {
|
|||
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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -55,9 +55,7 @@ const checkAuthorizedResource = async (
|
|||
) => {
|
||||
// get the user's roles
|
||||
const roleId = ctx.roleId || roles.BUILTIN_ROLE_IDS.PUBLIC
|
||||
const userRoles = (await roles.getUserRoleHierarchy(roleId, {
|
||||
idOnly: false,
|
||||
})) as Role[]
|
||||
const userRoles = await roles.getUserRoleHierarchy(roleId)
|
||||
const permError = "User does not have permission"
|
||||
// check if the user has the required role
|
||||
if (resourceRoles.length > 0) {
|
||||
|
|
|
@ -425,6 +425,15 @@ class TestConfiguration {
|
|||
return headers
|
||||
}
|
||||
|
||||
async basicRoleHeaders() {
|
||||
return await this.roleHeaders({
|
||||
email: this.defaultUserValues.email,
|
||||
builder: false,
|
||||
prodApp: true,
|
||||
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
|
||||
})
|
||||
}
|
||||
|
||||
async roleHeaders({
|
||||
email = this.defaultUserValues.email,
|
||||
roleId = roles.BUILTIN_ROLE_IDS.ADMIN,
|
||||
|
|
|
@ -8,7 +8,7 @@ import path from "path"
|
|||
* @param args Any number of string arguments to add to a path
|
||||
* @returns {string} The final path ready to use
|
||||
*/
|
||||
export function join(...args: any) {
|
||||
export function join(...args: string[]) {
|
||||
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
|
||||
* @returns {string} The final path ready to use
|
||||
*/
|
||||
export function resolve(...args: any) {
|
||||
export function resolve(...args: string[]) {
|
||||
return path.resolve(...args)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue