mirror of https://github.com/Budibase/budibase.git
46 changed files with 1185 additions and 8175 deletions
File diff suppressed because it is too large
@ -1,6 +1,154 @@ |
|||
const { BUILTIN_PERMISSIONS } = require("../../utilities/security/permissions") |
|||
const { |
|||
BUILTIN_PERMISSIONS, |
|||
PermissionLevels, |
|||
higherPermission, |
|||
} = require("../../utilities/security/permissions") |
|||
const { |
|||
isBuiltin, |
|||
getDBRoleID, |
|||
getExternalRoleID, |
|||
BUILTIN_ROLES, |
|||
} = require("../../utilities/security/roles") |
|||
const { getRoleParams } = require("../../db/utils") |
|||
const CouchDB = require("../../db") |
|||
const { cloneDeep } = require("lodash/fp") |
|||
|
|||
exports.fetch = async function(ctx) { |
|||
// TODO: need to build out custom permissions
|
|||
const PermissionUpdateType = { |
|||
REMOVE: "remove", |
|||
ADD: "add", |
|||
} |
|||
|
|||
// utility function to stop this repetition - permissions always stored under roles
|
|||
async function getAllDBRoles(db) { |
|||
const body = await db.allDocs( |
|||
getRoleParams(null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
return body.rows.map(row => row.doc) |
|||
} |
|||
|
|||
async function updatePermissionOnRole( |
|||
appId, |
|||
{ roleId, resourceId, level }, |
|||
updateType |
|||
) { |
|||
const db = new CouchDB(appId) |
|||
const remove = updateType === PermissionUpdateType.REMOVE |
|||
const isABuiltin = isBuiltin(roleId) |
|||
const dbRoleId = getDBRoleID(roleId) |
|||
const dbRoles = await getAllDBRoles(db) |
|||
const docUpdates = [] |
|||
|
|||
// the permission is for a built in, make sure it exists
|
|||
if (isABuiltin && !dbRoles.some(role => role._id === dbRoleId)) { |
|||
const builtin = cloneDeep(BUILTIN_ROLES[roleId]) |
|||
builtin._id = getDBRoleID(builtin._id) |
|||
dbRoles.push(builtin) |
|||
} |
|||
|
|||
// now try to find any roles which need updated, e.g. removing the
|
|||
// resource from another role and then adding to the new role
|
|||
for (let role of dbRoles) { |
|||
let updated = false |
|||
const rolePermissions = role.permissions ? role.permissions : {} |
|||
// handle the removal/updating the role which has this permission first
|
|||
// the updating (role._id !== dbRoleId) is required because a resource/level can
|
|||
// only be permitted in a single role (this reduces hierarchy confusion and simplifies
|
|||
// the general UI for this, rather than needing to show everywhere it is used)
|
|||
if ( |
|||
(role._id !== dbRoleId || remove) && |
|||
rolePermissions[resourceId] === level |
|||
) { |
|||
delete rolePermissions[resourceId] |
|||
updated = true |
|||
} |
|||
// handle the adding, we're on the correct role, at it to this
|
|||
if (!remove && role._id === dbRoleId) { |
|||
rolePermissions[resourceId] = level |
|||
updated = true |
|||
} |
|||
// handle the update, add it to bulk docs to perform at end
|
|||
if (updated) { |
|||
role.permissions = rolePermissions |
|||
docUpdates.push(role) |
|||
} |
|||
} |
|||
|
|||
const response = await db.bulkDocs(docUpdates) |
|||
return response.map(resp => { |
|||
resp._id = getExternalRoleID(resp.id) |
|||
delete resp.id |
|||
return resp |
|||
}) |
|||
} |
|||
|
|||
exports.fetchBuiltin = function(ctx) { |
|||
ctx.body = Object.values(BUILTIN_PERMISSIONS) |
|||
} |
|||
|
|||
exports.fetchLevels = function(ctx) { |
|||
// for now only provide the read/write perms externally
|
|||
ctx.body = [PermissionLevels.WRITE, PermissionLevels.READ] |
|||
} |
|||
|
|||
exports.fetch = async function(ctx) { |
|||
const db = new CouchDB(ctx.appId) |
|||
const roles = await getAllDBRoles(db) |
|||
let permissions = {} |
|||
// create an object with structure role ID -> resource ID -> level
|
|||
for (let role of roles) { |
|||
if (role.permissions) { |
|||
const roleId = getExternalRoleID(role._id) |
|||
if (permissions[roleId] == null) { |
|||
permissions[roleId] = {} |
|||
} |
|||
for (let [resource, level] of Object.entries(role.permissions)) { |
|||
permissions[roleId][resource] = higherPermission( |
|||
permissions[roleId][resource], |
|||
level |
|||
) |
|||
} |
|||
} |
|||
} |
|||
ctx.body = permissions |
|||
} |
|||
|
|||
exports.getResourcePerms = async function(ctx) { |
|||
const resourceId = ctx.params.resourceId |
|||
const db = new CouchDB(ctx.appId) |
|||
const body = await db.allDocs( |
|||
getRoleParams(null, { |
|||
include_docs: true, |
|||
}) |
|||
) |
|||
const roles = body.rows.map(row => row.doc) |
|||
const resourcePerms = {} |
|||
for (let role of roles) { |
|||
// update the various roleIds in the resource permissions
|
|||
if (role.permissions && role.permissions[resourceId]) { |
|||
const roleId = getExternalRoleID(role._id) |
|||
resourcePerms[roleId] = higherPermission( |
|||
resourcePerms[roleId], |
|||
role.permissions[resourceId] |
|||
) |
|||
} |
|||
} |
|||
ctx.body = resourcePerms |
|||
} |
|||
|
|||
exports.addPermission = async function(ctx) { |
|||
ctx.body = await updatePermissionOnRole( |
|||
ctx.appId, |
|||
ctx.params, |
|||
PermissionUpdateType.ADD |
|||
) |
|||
} |
|||
|
|||
exports.removePermission = async function(ctx) { |
|||
ctx.body = await updatePermissionOnRole( |
|||
ctx.appId, |
|||
ctx.params, |
|||
PermissionUpdateType.REMOVE |
|||
) |
|||
} |
|||
|
|||
@ -1,10 +1,47 @@ |
|||
const Router = require("@koa/router") |
|||
const controller = require("../controllers/permission") |
|||
const authorized = require("../../middleware/authorized") |
|||
const { BUILDER } = require("../../utilities/security/permissions") |
|||
const { |
|||
BUILDER, |
|||
PermissionLevels, |
|||
} = require("../../utilities/security/permissions") |
|||
const Joi = require("joi") |
|||
const joiValidator = require("../../middleware/joi-validator") |
|||
|
|||
const router = Router() |
|||
|
|||
router.get("/api/permissions", authorized(BUILDER), controller.fetch) |
|||
function generateValidator() { |
|||
const permLevelArray = Object.values(PermissionLevels) |
|||
// prettier-ignore
|
|||
return joiValidator.params(Joi.object({ |
|||
level: Joi.string().valid(...permLevelArray).required(), |
|||
resourceId: Joi.string(), |
|||
roleId: Joi.string(), |
|||
}).unknown(true)) |
|||
} |
|||
|
|||
router |
|||
.get("/api/permission/builtin", authorized(BUILDER), controller.fetchBuiltin) |
|||
.get("/api/permission/levels", authorized(BUILDER), controller.fetchLevels) |
|||
.get("/api/permission", authorized(BUILDER), controller.fetch) |
|||
.get( |
|||
"/api/permission/:resourceId", |
|||
authorized(BUILDER), |
|||
controller.getResourcePerms |
|||
) |
|||
// adding a specific role/level for the resource overrides the underlying access control
|
|||
.post( |
|||
"/api/permission/:roleId/:resourceId/:level", |
|||
authorized(BUILDER), |
|||
generateValidator(), |
|||
controller.addPermission |
|||
) |
|||
// deleting the level defaults it back the underlying access control for the resource
|
|||
.delete( |
|||
"/api/permission/:roleId/:resourceId/:level", |
|||
authorized(BUILDER), |
|||
generateValidator(), |
|||
controller.removePermission |
|||
) |
|||
|
|||
module.exports = router |
|||
|
|||
@ -0,0 +1,125 @@ |
|||
const { |
|||
createApplication, |
|||
createTable, |
|||
createRow, |
|||
supertest, |
|||
defaultHeaders, |
|||
addPermission, |
|||
publicHeaders, |
|||
makeBasicRow, |
|||
} = require("./couchTestUtils") |
|||
const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles") |
|||
|
|||
const HIGHER_ROLE_ID = BUILTIN_ROLE_IDS.BASIC |
|||
const STD_ROLE_ID = BUILTIN_ROLE_IDS.PUBLIC |
|||
|
|||
describe("/permission", () => { |
|||
let server |
|||
let request |
|||
let appId |
|||
let table |
|||
let perms |
|||
let row |
|||
|
|||
beforeAll(async () => { |
|||
;({ request, server } = await supertest()) |
|||
}) |
|||
|
|||
afterAll(() => { |
|||
server.close() |
|||
}) |
|||
|
|||
beforeEach(async () => { |
|||
let app = await createApplication(request) |
|||
appId = app.instance._id |
|||
table = await createTable(request, appId) |
|||
perms = await addPermission(request, appId, STD_ROLE_ID, table._id) |
|||
row = await createRow(request, appId, table._id) |
|||
}) |
|||
|
|||
async function getTablePermissions() { |
|||
return request |
|||
.get(`/api/permission/${table._id}`) |
|||
.set(defaultHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
} |
|||
|
|||
describe("levels", () => { |
|||
it("should be able to get levels", async () => { |
|||
const res = await request |
|||
.get(`/api/permission/levels`) |
|||
.set(defaultHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body).toBeDefined() |
|||
expect(res.body.length).toEqual(2) |
|||
expect(res.body).toContain("read") |
|||
expect(res.body).toContain("write") |
|||
}) |
|||
}) |
|||
|
|||
describe("add", () => { |
|||
it("should be able to add permission to a role for the table", async () => { |
|||
expect(perms.length).toEqual(1) |
|||
expect(perms[0]._id).toEqual(`${STD_ROLE_ID}`) |
|||
}) |
|||
|
|||
it("should get the resource permissions", async () => { |
|||
const res = await request |
|||
.get(`/api/permission/${table._id}`) |
|||
.set(defaultHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body[STD_ROLE_ID]).toEqual("read") |
|||
}) |
|||
|
|||
it("should get resource permissions with multiple roles", async () => { |
|||
perms = await addPermission(request, appId, HIGHER_ROLE_ID, table._id, "write") |
|||
const res = await getTablePermissions() |
|||
expect(res.body[HIGHER_ROLE_ID]).toEqual("write") |
|||
expect(res.body[STD_ROLE_ID]).toEqual("read") |
|||
const allRes = await request |
|||
.get(`/api/permission`) |
|||
.set(defaultHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(allRes.body[HIGHER_ROLE_ID][table._id]).toEqual("write") |
|||
expect(allRes.body[STD_ROLE_ID][table._id]).toEqual("read") |
|||
}) |
|||
}) |
|||
|
|||
describe("remove", () => { |
|||
it("should be able to remove the permission", async () => { |
|||
const res = await request |
|||
.delete(`/api/permission/${STD_ROLE_ID}/${table._id}/read`) |
|||
.set(defaultHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body[0]._id).toEqual(STD_ROLE_ID) |
|||
const permsRes = await getTablePermissions() |
|||
expect(permsRes.body[STD_ROLE_ID]).toBeUndefined() |
|||
}) |
|||
}) |
|||
|
|||
describe("check public user allowed", () => { |
|||
it("should be able to read the row", async () => { |
|||
const res = await request |
|||
.get(`/api/${table._id}/rows`) |
|||
.set(publicHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body[0]._id).toEqual(row._id) |
|||
}) |
|||
|
|||
it("shouldn't allow writing from a public user", async () => { |
|||
const res = await request |
|||
.post(`/api/${table._id}/rows`) |
|||
.send(makeBasicRow(table._id)) |
|||
.set(publicHeaders(appId)) |
|||
.expect("Content-Type", /json/) |
|||
.expect(403) |
|||
expect(res.status).toEqual(403) |
|||
}) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,59 @@ |
|||
class ResourceIdGetter { |
|||
constructor(ctxProperty) { |
|||
this.parameter = ctxProperty |
|||
this.main = null |
|||
this.sub = null |
|||
return this |
|||
} |
|||
|
|||
mainResource(field) { |
|||
this.main = field |
|||
return this |
|||
} |
|||
|
|||
subResource(field) { |
|||
this.sub = field |
|||
return this |
|||
} |
|||
|
|||
build() { |
|||
const parameter = this.parameter, |
|||
main = this.main, |
|||
sub = this.sub |
|||
return (ctx, next) => { |
|||
const request = ctx.request[parameter] || ctx[parameter] |
|||
if (request == null) { |
|||
return next() |
|||
} |
|||
if (main != null && request[main]) { |
|||
ctx.resourceId = request[main] |
|||
} |
|||
if (sub != null && request[sub]) { |
|||
ctx.subResourceId = request[sub] |
|||
} |
|||
return next() |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports.paramResource = main => { |
|||
return new ResourceIdGetter("params").mainResource(main).build() |
|||
} |
|||
|
|||
module.exports.paramSubResource = (main, sub) => { |
|||
return new ResourceIdGetter("params") |
|||
.mainResource(main) |
|||
.subResource(sub) |
|||
.build() |
|||
} |
|||
|
|||
module.exports.bodyResource = main => { |
|||
return new ResourceIdGetter("body").mainResource(main).build() |
|||
} |
|||
|
|||
module.exports.bodySubResource = (main, sub) => { |
|||
return new ResourceIdGetter("body") |
|||
.mainResource(main) |
|||
.subResource(sub) |
|||
.build() |
|||
} |
|||
@ -0,0 +1,166 @@ |
|||
<script> |
|||
import { getContext } from "svelte" |
|||
import { isEmpty } from "lodash/fp" |
|||
import { |
|||
Button, |
|||
DatePicker, |
|||
Label, |
|||
Select, |
|||
Toggle, |
|||
Input, |
|||
} from "@budibase/bbui" |
|||
|
|||
const { API, styleable, DataProvider, builderStore } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
export let table = [] |
|||
export let columns = [] |
|||
export let pageSize |
|||
export let noRowsMessage |
|||
|
|||
let rows = [] |
|||
let loaded = false |
|||
let search = {} |
|||
let tableDefinition |
|||
let schema |
|||
|
|||
// pagination |
|||
let page = 0 |
|||
|
|||
$: fetchData(table, page) |
|||
// omit empty strings |
|||
$: parsedSearch = Object.keys(search).reduce( |
|||
(acc, next) => |
|||
search[next] === "" ? acc : { ...acc, [next]: search[next] }, |
|||
{} |
|||
) |
|||
|
|||
async function fetchData(table, page) { |
|||
if (!isEmpty(table)) { |
|||
const tableDef = await API.fetchTableDefinition(table) |
|||
schema = tableDef.schema |
|||
rows = await API.searchTable({ |
|||
tableId: table, |
|||
search: parsedSearch, |
|||
pagination: { |
|||
pageSize, |
|||
page, |
|||
}, |
|||
}) |
|||
} |
|||
loaded = true |
|||
} |
|||
|
|||
function nextPage() { |
|||
page += 1 |
|||
} |
|||
|
|||
function previousPage() { |
|||
page -= 1 |
|||
} |
|||
</script> |
|||
|
|||
<div use:styleable={$component.styles}> |
|||
<div class="query-builder"> |
|||
{#if schema} |
|||
{#each columns as field} |
|||
<div class="form-field"> |
|||
<Label extraSmall grey>{schema[field].name}</Label> |
|||
{#if schema[field].type === 'options'} |
|||
<Select secondary bind:value={search[field]}> |
|||
<option value="">Choose an option</option> |
|||
{#each schema[field].constraints.inclusion as opt} |
|||
<option>{opt}</option> |
|||
{/each} |
|||
</Select> |
|||
{:else if schema[field].type === 'datetime'} |
|||
<DatePicker bind:value={search[field]} /> |
|||
{:else if schema[field].type === 'boolean'} |
|||
<Toggle text={schema[field].name} bind:checked={search[field]} /> |
|||
{:else if schema[field].type === 'number'} |
|||
<Input type="number" bind:value={search[field]} /> |
|||
{:else if schema[field].type === 'string'} |
|||
<Input bind:value={search[field]} /> |
|||
{/if} |
|||
</div> |
|||
{/each} |
|||
{/if} |
|||
<div class="actions"> |
|||
<Button |
|||
secondary |
|||
on:click={() => { |
|||
search = {} |
|||
page = 0 |
|||
}}> |
|||
Reset |
|||
</Button> |
|||
<Button |
|||
primary |
|||
on:click={() => { |
|||
page = 0 |
|||
fetchData(table, page) |
|||
}}> |
|||
Search |
|||
</Button> |
|||
</div> |
|||
</div> |
|||
{#if loaded} |
|||
{#if rows.length > 0} |
|||
{#if $component.children === 0 && $builderStore.inBuilder} |
|||
<p>Add some components too</p> |
|||
{:else} |
|||
{#each rows as row} |
|||
<DataProvider {row}> |
|||
<slot /> |
|||
</DataProvider> |
|||
{/each} |
|||
{/if} |
|||
{:else if $builderStore.inBuilder} |
|||
<p>Feed me some data</p> |
|||
{:else} |
|||
<p>{noRowsMessage}</p> |
|||
{/if} |
|||
{/if} |
|||
<div class="pagination"> |
|||
{#if page > 0} |
|||
<Button primary on:click={previousPage}>Back</Button> |
|||
{/if} |
|||
{#if rows.length === pageSize} |
|||
<Button primary on:click={nextPage}>Next</Button> |
|||
{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
p { |
|||
display: grid; |
|||
place-items: center; |
|||
background: #f5f5f5; |
|||
border: #ccc 1px solid; |
|||
padding: var(--spacing-m); |
|||
} |
|||
|
|||
.query-builder { |
|||
padding: var(--spacing-m); |
|||
border-radius: var(--border-radius-s); |
|||
} |
|||
|
|||
.actions { |
|||
display: grid; |
|||
grid-gap: var(--spacing-s); |
|||
justify-content: flex-end; |
|||
grid-auto-flow: column; |
|||
} |
|||
|
|||
.form-field { |
|||
margin-bottom: var(--spacing-m); |
|||
} |
|||
|
|||
.pagination { |
|||
display: grid; |
|||
grid-gap: var(--spacing-s); |
|||
justify-content: flex-end; |
|||
margin-top: var(--spacing-m); |
|||
grid-auto-flow: column; |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue