Merge pull request #1112 from Budibase/rbac-ui

Rbac UI
This commit is contained in:
Martin McKeaveney 2021-02-12 14:14:38 +00:00 committed by GitHub
commit 80ebb9a740
14 changed files with 377 additions and 47 deletions

View File

@ -30,6 +30,7 @@ export const getBackendUiStore = () => {
const queries = await queriesResponse.json() const queries = await queriesResponse.json()
const integrationsResponse = await api.get("/api/integrations") const integrationsResponse = await api.get("/api/integrations")
const integrations = await integrationsResponse.json() const integrations = await integrationsResponse.json()
const permissionLevels = await store.actions.permissions.fetchLevels()
store.update(state => { store.update(state => {
state.selectedDatabase = db state.selectedDatabase = db
@ -37,6 +38,7 @@ export const getBackendUiStore = () => {
state.datasources = datasources state.datasources = datasources
state.queries = queries state.queries = queries
state.integrations = integrations state.integrations = integrations
state.permissionLevels = permissionLevels
return state return state
}) })
}, },
@ -328,6 +330,25 @@ export const getBackendUiStore = () => {
return response return response
}, },
}, },
permissions: {
fetchLevels: async () => {
const response = await api.get("/api/permission/levels")
const json = await response.json()
return json
},
forResource: async resourceId => {
const response = await api.get(`/api/permission/${resourceId}`)
const json = await response.json()
return json
},
save: async ({ role, resource, level }) => {
const response = await api.post(
`/api/permission/${role}/${resource}/${level}`
)
const json = await response.json()
return json
},
},
} }
return store return store

View File

@ -5,6 +5,7 @@
import CreateViewButton from "./buttons/CreateViewButton.svelte" import CreateViewButton from "./buttons/CreateViewButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import * as api from "./api" import * as api from "./api"
import Table from "./Table.svelte" import Table from "./Table.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
@ -47,6 +48,7 @@
title={isUsersTable ? 'Create New User' : 'Create New Row'} title={isUsersTable ? 'Create New User' : 'Create New Row'}
modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} /> modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} />
<CreateViewButton /> <CreateViewButton />
<ManageAccessButton resourceId={$backendUiStore.selectedTable?._id} />
<ExportButton view={tableView} /> <ExportButton view={tableView} />
{/if} {/if}
{#if isUsersTable} {#if isUsersTable}

View File

@ -6,6 +6,7 @@
import GroupByButton from "./buttons/GroupByButton.svelte" import GroupByButton from "./buttons/GroupByButton.svelte"
import FilterButton from "./buttons/FilterButton.svelte" import FilterButton from "./buttons/FilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
export let view = {} export let view = {}
@ -53,5 +54,6 @@
{#if view.calculation} {#if view.calculation}
<GroupByButton {view} /> <GroupByButton {view} />
{/if} {/if}
<ManageAccessButton resourceId={decodeURI(name)} />
<ExportButton {view} /> <ExportButton {view} />
</Table> </Table>

View File

@ -0,0 +1,43 @@
<script>
import { TextButton, Icon, Popover } from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import { Roles } from "constants/backend"
import api from "builderStore/api"
import ManageAccessPopover from "../popovers/ManageAccessPopover.svelte"
export let resourceId
let anchor
let dropdown
let levels
let permissions
async function openDropdown() {
permissions = await backendUiStore.actions.permissions.forResource(
resourceId
)
levels = await backendUiStore.actions.permissions.fetchLevels()
dropdown.show()
}
</script>
<div bind:this={anchor}>
<TextButton text small on:click={openDropdown}>
<i class="ri-lock-line" />
Manage Access
</TextButton>
</div>
<Popover bind:this={dropdown} {anchor} align="left">
<ManageAccessPopover
{resourceId}
{levels}
{permissions}
onClosed={dropdown.hide} />
</Popover>
<style>
i {
margin-right: var(--spacing-xs);
font-size: var(--font-size-s);
}
</style>

View File

@ -142,7 +142,7 @@
thin thin
text="Use as table display column" /> text="Use as table display column" />
<Label gray small>Search Indexes</Label> <Label grey small>Search Indexes</Label>
<Toggle <Toggle
checked={indexes[0] === field.name} checked={indexes[0] === field.name}
disabled={indexes[1] === field.name} disabled={indexes[1] === field.name}

View File

@ -0,0 +1,92 @@
<script>
import { onMount } from "svelte"
import { backendUiStore } from "builderStore"
import { Roles } from "constants/backend"
import api from "builderStore/api"
import { notifier } from "builderStore/store/notifications"
import { Button, Label, Input, Select, Spacer } from "@budibase/bbui"
export let resourceId
export let permissions
export let onClosed
async function changePermission(level, role) {
await backendUiStore.actions.permissions.save({
level,
role,
resource: resourceId,
})
// Show updated permissions in UI: REMOVE
permissions = await backendUiStore.actions.permissions.forResource(
resourceId
)
notifier.success("Updated permissions.")
// TODO: update permissions
// permissions[]
}
</script>
<div class="popover">
<h5>Who Can Access This Data?</h5>
<div class="note">
<Label extraSmall grey>Specify the minimum access level role for this data.</Label>
</div>
<Spacer large />
<div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level}
<Input secondary thin value={level} disabled={true} />
<Select
secondary
thin
value={permissions[level]}
on:change={e => changePermission(level, e.target.value)}>
{#each $backendUiStore.roles as role}
<option value={role._id}>{role.name}</option>
{/each}
</Select>
{/each}
</div>
<Spacer large />
<div class="footer">
<Button secondary on:click={onClosed}>Cancel</Button>
</div>
</div>
<style>
.popover {
display: grid;
width: 400px;
}
h5 {
margin: 0;
font-weight: 500;
}
hr {
margin: var(--spacing-s) 0 var(--spacing-m) 0;
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-m);
margin-top: var(--spacing-l);
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-m);
}
.note {
margin-top: 10px;
margin-bottom: 0;
}
</style>

View File

@ -92,3 +92,11 @@ export const HostingTypes = {
CLOUD: "cloud", CLOUD: "cloud",
SELF: "self", SELF: "self",
} }
export const Roles = {
ADMIN: "ADMIN",
POWER: "POWER",
BASIC: "BASIC",
PUBLIC: "PUBLIC",
BUILDER: "BUILDER",
}

View File

@ -1,6 +1,7 @@
const { const {
BUILTIN_PERMISSIONS, BUILTIN_PERMISSIONS,
PermissionLevels, PermissionLevels,
isPermissionLevelHigherThanRead,
higherPermission, higherPermission,
} = require("../../utilities/security/permissions") } = require("../../utilities/security/permissions")
const { const {
@ -12,12 +13,34 @@ const {
const { getRoleParams } = require("../../db/utils") const { getRoleParams } = require("../../db/utils")
const CouchDB = require("../../db") const CouchDB = require("../../db")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const {
CURRENTLY_SUPPORTED_LEVELS,
getBasePermissions,
} = require("../../utilities/security/utilities")
const PermissionUpdateType = { const PermissionUpdateType = {
REMOVE: "remove", REMOVE: "remove",
ADD: "add", ADD: "add",
} }
const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS
// quick function to perform a bit of weird logic, make sure fetch calls
// always say a write role also has read permission
function fetchLevelPerms(permissions, level, roleId) {
if (!permissions) {
permissions = {}
}
permissions[level] = roleId
if (
isPermissionLevelHigherThanRead(level) &&
!permissions[PermissionLevels.READ]
) {
permissions[PermissionLevels.READ] = roleId
}
return permissions
}
// utility function to stop this repetition - permissions always stored under roles // utility function to stop this repetition - permissions always stored under roles
async function getAllDBRoles(db) { async function getAllDBRoles(db) {
const body = await db.allDocs( const body = await db.allDocs(
@ -65,7 +88,10 @@ async function updatePermissionOnRole(
} }
// handle the adding, we're on the correct role, at it to this // handle the adding, we're on the correct role, at it to this
if (!remove && role._id === dbRoleId) { if (!remove && role._id === dbRoleId) {
rolePermissions[resourceId] = level rolePermissions[resourceId] = higherPermission(
rolePermissions[resourceId],
level
)
updated = true updated = true
} }
// handle the update, add it to bulk docs to perform at end // handle the update, add it to bulk docs to perform at end
@ -89,7 +115,7 @@ exports.fetchBuiltin = function(ctx) {
exports.fetchLevels = function(ctx) { exports.fetchLevels = function(ctx) {
// for now only provide the read/write perms externally // for now only provide the read/write perms externally
ctx.body = [PermissionLevels.WRITE, PermissionLevels.READ] ctx.body = SUPPORTED_LEVELS
} }
exports.fetch = async function(ctx) { exports.fetch = async function(ctx) {
@ -98,20 +124,25 @@ exports.fetch = async function(ctx) {
let permissions = {} let permissions = {}
// create an object with structure role ID -> resource ID -> level // create an object with structure role ID -> resource ID -> level
for (let role of roles) { for (let role of roles) {
if (role.permissions) { if (!role.permissions) {
const roleId = getExternalRoleID(role._id) continue
if (permissions[roleId] == null) { }
permissions[roleId] = {} const roleId = getExternalRoleID(role._id)
} for (let [resource, level] of Object.entries(role.permissions)) {
for (let [resource, level] of Object.entries(role.permissions)) { permissions[resource] = fetchLevelPerms(
permissions[roleId][resource] = higherPermission( permissions[resource],
permissions[roleId][resource], level,
level roleId
) )
}
} }
} }
ctx.body = permissions // apply the base permissions
const finalPermissions = {}
for (let [resource, permission] of Object.entries(permissions)) {
const basePerms = getBasePermissions(resource)
finalPermissions[resource] = Object.assign(basePerms, permission)
}
ctx.body = finalPermissions
} }
exports.getResourcePerms = async function(ctx) { exports.getResourcePerms = async function(ctx) {
@ -123,18 +154,20 @@ exports.getResourcePerms = async function(ctx) {
}) })
) )
const roles = body.rows.map(row => row.doc) const roles = body.rows.map(row => row.doc)
const resourcePerms = {} let permissions = {}
for (let role of roles) { for (let level of SUPPORTED_LEVELS) {
// update the various roleIds in the resource permissions // update the various roleIds in the resource permissions
if (role.permissions && role.permissions[resourceId]) { for (let role of roles) {
const roleId = getExternalRoleID(role._id) if (role.permissions && role.permissions[resourceId] === level) {
resourcePerms[roleId] = higherPermission( permissions = fetchLevelPerms(
resourcePerms[roleId], permissions,
role.permissions[resourceId] level,
) getExternalRoleID(role._id)
)
}
} }
} }
ctx.body = resourcePerms ctx.body = Object.assign(getBasePermissions(resourceId), permissions)
} }
exports.addPermission = async function(ctx) { exports.addPermission = async function(ctx) {

View File

@ -57,7 +57,7 @@ exports.fetch = async function(ctx) {
include_docs: true, include_docs: true,
}) })
) )
const roles = body.rows.map(row => row.doc) let roles = body.rows.map(row => row.doc)
// need to combine builtin with any DB record of them (for sake of permissions) // need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
@ -68,6 +68,8 @@ exports.fetch = async function(ctx) {
if (dbBuiltin == null) { if (dbBuiltin == null) {
roles.push(builtinRole) roles.push(builtinRole)
} else { } else {
// remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id) dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin)) roles.push(Object.assign(builtinRole, dbBuiltin))
} }

View File

@ -71,21 +71,22 @@ describe("/permission", () => {
.set(defaultHeaders(appId)) .set(defaultHeaders(appId))
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body[STD_ROLE_ID]).toEqual("read") expect(res.body["read"]).toEqual(STD_ROLE_ID)
expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
}) })
it("should get resource permissions with multiple roles", async () => { it("should get resource permissions with multiple roles", async () => {
perms = await addPermission(request, appId, HIGHER_ROLE_ID, table._id, "write") perms = await addPermission(request, appId, HIGHER_ROLE_ID, table._id, "write")
const res = await getTablePermissions() const res = await getTablePermissions()
expect(res.body[HIGHER_ROLE_ID]).toEqual("write") expect(res.body["read"]).toEqual(STD_ROLE_ID)
expect(res.body[STD_ROLE_ID]).toEqual("read") expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
const allRes = await request const allRes = await request
.get(`/api/permission`) .get(`/api/permission`)
.set(defaultHeaders(appId)) .set(defaultHeaders(appId))
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(allRes.body[HIGHER_ROLE_ID][table._id]).toEqual("write") expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID)
expect(allRes.body[STD_ROLE_ID][table._id]).toEqual("read") expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID)
}) })
}) })

View File

@ -2,6 +2,7 @@ const Router = require("@koa/router")
const viewController = require("../controllers/view") const viewController = require("../controllers/view")
const rowController = require("../controllers/row") const rowController = require("../controllers/row")
const authorized = require("../../middleware/authorized") const authorized = require("../../middleware/authorized")
const { paramResource } = require("../../middleware/resourceId")
const { const {
BUILDER, BUILDER,
PermissionTypes, PermissionTypes,
@ -15,12 +16,14 @@ router
.get("/api/views/export", authorized(BUILDER), viewController.exportView) .get("/api/views/export", authorized(BUILDER), viewController.exportView)
.get( .get(
"/api/views/:viewName", "/api/views/:viewName",
paramResource("viewName"),
authorized(PermissionTypes.VIEW, PermissionLevels.READ), authorized(PermissionTypes.VIEW, PermissionLevels.READ),
rowController.fetchView rowController.fetchView
) )
.get("/api/views", authorized(BUILDER), viewController.fetch) .get("/api/views", authorized(BUILDER), viewController.fetch)
.delete( .delete(
"/api/views/:viewName", "/api/views/:viewName",
paramResource("viewName"),
authorized(BUILDER), authorized(BUILDER),
usage, usage,
viewController.destroy viewController.destroy

View File

@ -23,6 +23,22 @@ function Permission(type, level) {
this.type = type this.type = type
} }
function levelToNumber(perm) {
switch (perm) {
// not everything has execute privileges
case PermissionLevels.EXECUTE:
return 0
case PermissionLevels.READ:
return 1
case PermissionLevels.WRITE:
return 2
case PermissionLevels.ADMIN:
return 3
default:
return -1
}
}
/** /**
* Given the specified permission level for the user return the levels they are allowed to carry out. * Given the specified permission level for the user return the levels they are allowed to carry out.
* @param {string} userPermLevel The permission level of the user. * @param {string} userPermLevel The permission level of the user.
@ -47,6 +63,7 @@ function getAllowedLevels(userPermLevel) {
} }
exports.BUILTIN_PERMISSION_IDS = { exports.BUILTIN_PERMISSION_IDS = {
PUBLIC: "public",
READ_ONLY: "read_only", READ_ONLY: "read_only",
WRITE: "write", WRITE: "write",
ADMIN: "admin", ADMIN: "admin",
@ -54,6 +71,13 @@ exports.BUILTIN_PERMISSION_IDS = {
} }
exports.BUILTIN_PERMISSIONS = { exports.BUILTIN_PERMISSIONS = {
PUBLIC: {
_id: exports.BUILTIN_PERMISSION_IDS.PUBLIC,
name: "Public",
permissions: [
new Permission(PermissionTypes.WEBHOOK, PermissionLevels.EXECUTE),
],
},
READ_ONLY: { READ_ONLY: {
_id: exports.BUILTIN_PERMISSION_IDS.READ_ONLY, _id: exports.BUILTIN_PERMISSION_IDS.READ_ONLY,
name: "Read only", name: "Read only",
@ -97,6 +121,11 @@ exports.BUILTIN_PERMISSIONS = {
}, },
} }
exports.getBuiltinPermissionByID = id => {
const perms = Object.values(exports.BUILTIN_PERMISSIONS)
return perms.find(perm => perm._id === id)
}
exports.doesHaveResourcePermission = ( exports.doesHaveResourcePermission = (
permissions, permissions,
permLevel, permLevel,
@ -144,22 +173,11 @@ exports.doesHaveBasePermission = (permType, permLevel, permissionIds) => {
} }
exports.higherPermission = (perm1, perm2) => { exports.higherPermission = (perm1, perm2) => {
function toNum(perm) { return levelToNumber(perm1) > levelToNumber(perm2) ? perm1 : perm2
switch (perm) { }
// not everything has execute privileges
case PermissionLevels.EXECUTE: exports.isPermissionLevelHigherThanRead = level => {
return 0 return levelToNumber(level) > 1
case PermissionLevels.READ:
return 1
case PermissionLevels.WRITE:
return 2
case PermissionLevels.ADMIN:
return 3
default:
return -1
}
}
return toNum(perm1) > toNum(perm2) ? perm1 : perm2
} }
// utility as a lot of things need simply the builder permission // utility as a lot of things need simply the builder permission

View File

@ -37,7 +37,7 @@ exports.BUILTIN_ROLES = {
.addPermission(BUILTIN_PERMISSION_IDS.WRITE) .addPermission(BUILTIN_PERMISSION_IDS.WRITE)
.addInheritance(BUILTIN_IDS.PUBLIC), .addInheritance(BUILTIN_IDS.PUBLIC),
PUBLIC: new Role(BUILTIN_IDS.PUBLIC, "Public").addPermission( PUBLIC: new Role(BUILTIN_IDS.PUBLIC, "Public").addPermission(
BUILTIN_PERMISSION_IDS.READ_ONLY BUILTIN_PERMISSION_IDS.PUBLIC
), ),
BUILDER: new Role(BUILTIN_IDS.BUILDER, "Builder").addPermission( BUILDER: new Role(BUILTIN_IDS.BUILDER, "Builder").addPermission(
BUILTIN_PERMISSION_IDS.ADMIN BUILTIN_PERMISSION_IDS.ADMIN
@ -56,6 +56,41 @@ function isBuiltin(role) {
return exports.BUILTIN_ROLE_ID_ARRAY.some(builtin => role.includes(builtin)) return exports.BUILTIN_ROLE_ID_ARRAY.some(builtin => role.includes(builtin))
} }
/**
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/
function builtinRoleToNumber(id) {
const MAX = Object.values(BUILTIN_IDS).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
return MAX
}
let role = exports.BUILTIN_ROLES[id],
count = 0
do {
if (!role) {
break
}
role = exports.BUILTIN_ROLES[role.inherits]
count++
} while (role !== null)
return count
}
/**
* Returns whichever builtin roleID is lower.
*/
exports.lowerBuiltinRoleID = (roleId1, roleId2) => {
if (!roleId1) {
return roleId2
}
if (!roleId2) {
return roleId1
}
return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2)
? roleId2
: roleId1
}
/** /**
* 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.

View File

@ -0,0 +1,70 @@
const {
PermissionLevels,
PermissionTypes,
getBuiltinPermissionByID,
isPermissionLevelHigherThanRead,
} = require("../../utilities/security/permissions")
const {
lowerBuiltinRoleID,
BUILTIN_ROLES,
} = require("../../utilities/security/roles")
const { DocumentTypes } = require("../../db/utils")
const CURRENTLY_SUPPORTED_LEVELS = [
PermissionLevels.WRITE,
PermissionLevels.READ,
]
exports.getPermissionType = resourceId => {
const docType = Object.values(DocumentTypes).filter(docType =>
resourceId.startsWith(docType)
)[0]
switch (docType) {
case DocumentTypes.TABLE:
case DocumentTypes.ROW:
return PermissionTypes.TABLE
case DocumentTypes.AUTOMATION:
return PermissionTypes.AUTOMATION
case DocumentTypes.WEBHOOK:
return PermissionTypes.WEBHOOK
case DocumentTypes.QUERY:
case DocumentTypes.DATASOURCE:
return PermissionTypes.QUERY
default:
// views don't have an ID, will end up here
return PermissionTypes.VIEW
}
}
/**
* works out the basic permissions based on builtin roles for a resource, using its ID
* @param resourceId
* @returns {{}}
*/
exports.getBasePermissions = resourceId => {
const type = exports.getPermissionType(resourceId)
const permissions = {}
for (let [roleId, role] of Object.entries(BUILTIN_ROLES)) {
if (!role.permissionId) {
continue
}
const perms = getBuiltinPermissionByID(role.permissionId)
const typedPermission = perms.permissions.find(perm => perm.type === type)
if (
typedPermission &&
CURRENTLY_SUPPORTED_LEVELS.indexOf(typedPermission.level) !== -1
) {
const level = typedPermission.level
permissions[level] = lowerBuiltinRoleID(permissions[level], roleId)
if (isPermissionLevelHigherThanRead(level)) {
permissions[PermissionLevels.READ] = lowerBuiltinRoleID(
permissions[PermissionLevels.READ],
roleId
)
}
}
}
return permissions
}
exports.CURRENTLY_SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS