forked from tsai/budibase
67 changed files with 1322 additions and 8261 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) { |
const PermissionUpdateType = { |
||||
// TODO: need to build out custom permissions
|
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) |
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 Router = require("@koa/router") |
||||
const controller = require("../controllers/permission") |
const controller = require("../controllers/permission") |
||||
const authorized = require("../../middleware/authorized") |
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() |
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 |
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,182 @@ |
|||||
|
<script> |
||||
|
import { getContext } from "svelte" |
||||
|
import { |
||||
|
Button, |
||||
|
DatePicker, |
||||
|
Label, |
||||
|
Select, |
||||
|
Toggle, |
||||
|
Input, |
||||
|
} from "@budibase/bbui" |
||||
|
|
||||
|
const { API, styleable, Provider, builderStore, ActionTypes } = 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] }, |
||||
|
{} |
||||
|
) |
||||
|
$: actions = [ |
||||
|
{ |
||||
|
type: ActionTypes.RefreshDatasource, |
||||
|
callback: () => fetchData(table, page), |
||||
|
metadata: { datasource: { type: "table", tableId: table } }, |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
async function fetchData(table, page) { |
||||
|
if (table) { |
||||
|
const tableDef = await API.fetchTableDefinition(table) |
||||
|
schema = tableDef.schema |
||||
|
rows = await API.searchTableData({ |
||||
|
tableId: table, |
||||
|
search: parsedSearch, |
||||
|
pagination: { |
||||
|
pageSize, |
||||
|
page, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
loaded = true |
||||
|
} |
||||
|
|
||||
|
function nextPage() { |
||||
|
page += 1 |
||||
|
} |
||||
|
|
||||
|
function previousPage() { |
||||
|
page -= 1 |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Provider {actions}> |
||||
|
<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><i class="ri-image-line" />Add some components to display.</p> |
||||
|
{:else} |
||||
|
{#each rows as row} |
||||
|
<Provider data={row}> |
||||
|
<slot /> |
||||
|
</Provider> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
{:else if noRowsMessage} |
||||
|
<p><i class="ri-search-2-line" />{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> |
||||
|
</Provider> |
||||
|
|
||||
|
<style> |
||||
|
p { |
||||
|
margin: 0 var(--spacing-m); |
||||
|
background-color: var(--grey-2); |
||||
|
color: var(--grey-6); |
||||
|
font-size: var(--font-size-s); |
||||
|
padding: var(--spacing-l); |
||||
|
border-radius: var(--border-radius-s); |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
p i { |
||||
|
margin-bottom: var(--spacing-m); |
||||
|
font-size: 1.5rem; |
||||
|
color: var(--grey-5); |
||||
|
} |
||||
|
|
||||
|
.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