Merge pull request #1106 from Budibase/feature/rbac

RBAC - resource level access control (backend)
This commit is contained in:
Michael Drury 2021-02-10 11:35:25 +00:00 committed by GitHub
commit 352b3d43d9
23 changed files with 703 additions and 86 deletions

View File

@ -6,7 +6,7 @@
import ErrorsBox from "components/common/ErrorsBox.svelte" import ErrorsBox from "components/common/ErrorsBox.svelte"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
let permissions = [] let basePermissions = []
let selectedRole = {} let selectedRole = {}
let errors = [] let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"] let builtInRoles = ["Admin", "Power", "Basic", "Public"]
@ -16,9 +16,9 @@
) )
$: isCreating = selectedRoleId == null || selectedRoleId === "" $: isCreating = selectedRoleId == null || selectedRoleId === ""
const fetchPermissions = async () => { const fetchBasePermissions = async () => {
const permissionsResponse = await api.get("/api/permissions") const permissionsResponse = await api.get("/api/permission/builtin")
permissions = await permissionsResponse.json() basePermissions = await permissionsResponse.json()
} }
// Changes the selected role // Changes the selected role
@ -81,7 +81,7 @@
} }
} }
onMount(fetchPermissions) onMount(fetchBasePermissions)
</script> </script>
<ModalContent <ModalContent
@ -121,11 +121,11 @@
<Select <Select
thin thin
secondary secondary
label="Permissions" label="Base Permissions"
bind:value={selectedRole.permissionId}> bind:value={selectedRole.permissionId}>
<option value="">Choose permissions</option> <option value="">Choose permissions</option>
{#each permissions as permission} {#each basePermissions as basePerm}
<option value={permission._id}>{permission.name}</option> <option value={basePerm._id}>{basePerm.name}</option>
{/each} {/each}
</Select> </Select>
{/if} {/if}

View File

@ -14,6 +14,7 @@ exports.fetchInfo = async ctx => {
} }
exports.save = async ctx => { exports.save = async ctx => {
console.trace("DID A SAVE!")
const db = new CouchDB(BUILDER_CONFIG_DB) const db = new CouchDB(BUILDER_CONFIG_DB)
const { type } = ctx.request.body const { type } = ctx.request.body
if (type === HostingTypes.CLOUD && ctx.request.body._rev) { if (type === HostingTypes.CLOUD && ctx.request.body._rev) {

View File

@ -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
)
}

View File

@ -1,8 +1,11 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const { const {
BUILTIN_ROLES, BUILTIN_ROLES,
BUILTIN_ROLE_IDS,
Role, Role,
getRole, getRole,
isBuiltin,
getExternalRoleID,
} = require("../../utilities/security/roles") } = require("../../utilities/security/roles")
const { const {
generateRoleID, generateRoleID,
@ -16,6 +19,14 @@ const UpdateRolesOptions = {
REMOVED: "removed", REMOVED: "removed",
} }
// exclude internal roles like builder
const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_ROLE_IDS.ADMIN,
BUILTIN_ROLE_IDS.POWER,
BUILTIN_ROLE_IDS.BASIC,
BUILTIN_ROLE_IDS.PUBLIC,
]
async function updateRolesOnUserTable(db, roleId, updateOption) { async function updateRolesOnUserTable(db, roleId, updateOption) {
const table = await db.get(ViewNames.USERS) const table = await db.get(ViewNames.USERS)
const schema = table.schema const schema = table.schema
@ -46,16 +57,22 @@ exports.fetch = async function(ctx) {
include_docs: true, include_docs: true,
}) })
) )
const customRoles = body.rows.map(row => row.doc) const roles = body.rows.map(row => row.doc)
// exclude internal roles like builder // need to combine builtin with any DB record of them (for sake of permissions)
const staticRoles = [ for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
BUILTIN_ROLES.ADMIN, const builtinRole = BUILTIN_ROLES[builtinRoleId]
BUILTIN_ROLES.POWER, const dbBuiltin = roles.filter(
BUILTIN_ROLES.BASIC, dbRole => getExternalRoleID(dbRole._id) === builtinRoleId
BUILTIN_ROLES.PUBLIC, )[0]
] if (dbBuiltin == null) {
ctx.body = [...staticRoles, ...customRoles] roles.push(builtinRole)
} else {
dbBuiltin._id = getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
ctx.body = roles
} }
exports.find = async function(ctx) { exports.find = async function(ctx) {
@ -67,6 +84,8 @@ exports.save = async function(ctx) {
let { _id, name, inherits, permissionId } = ctx.request.body let { _id, name, inherits, permissionId } = ctx.request.body
if (!_id) { if (!_id) {
_id = generateRoleID() _id = generateRoleID()
} else if (isBuiltin(_id)) {
ctx.throw(400, "Cannot update builtin roles.")
} }
const role = new Role(_id, name) const role = new Role(_id, name)
.addPermission(permissionId) .addPermission(permissionId)
@ -84,6 +103,9 @@ exports.save = async function(ctx) {
exports.destroy = async function(ctx) { exports.destroy = async function(ctx) {
const db = new CouchDB(ctx.user.appId) const db = new CouchDB(ctx.user.appId)
const roleId = ctx.params.roleId const roleId = ctx.params.roleId
if (isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.")
}
// first check no users actively attached to role // first check no users actively attached to role
const users = ( const users = (
await db.allDocs( await db.allDocs(
@ -94,7 +116,7 @@ exports.destroy = async function(ctx) {
).rows.map(row => row.doc) ).rows.map(row => row.doc)
const usersWithRole = users.filter(user => user.roleId === roleId) const usersWithRole = users.filter(user => user.roleId === roleId)
if (usersWithRole.length !== 0) { if (usersWithRole.length !== 0) {
ctx.throw("Cannot delete role when it is in use.") ctx.throw(400, "Cannot delete role when it is in use.")
} }
await db.remove(roleId, ctx.params.rev) await db.remove(roleId, ctx.params.rev)

View File

@ -54,7 +54,7 @@ async function findRow(db, appId, tableId, rowId) {
exports.patch = async function(ctx) { exports.patch = async function(ctx) {
const appId = ctx.user.appId const appId = ctx.user.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
let row = await db.get(ctx.params.id) let row = await db.get(ctx.params.rowId)
const table = await db.get(row.tableId) const table = await db.get(row.tableId)
const patchfields = ctx.request.body const patchfields = ctx.request.body
@ -123,7 +123,7 @@ exports.save = async function(ctx) {
// if the row obj had an _id then it will have been retrieved // if the row obj had an _id then it will have been retrieved
const existingRow = ctx.preExisting const existingRow = ctx.preExisting
if (existingRow) { if (existingRow) {
ctx.params.id = row._id ctx.params.rowId = row._id
await exports.patch(ctx) await exports.patch(ctx)
return return
} }

View File

@ -47,6 +47,7 @@ router.use(async (ctx, next) => {
message: err.message, message: err.message,
status: ctx.status, status: ctx.status,
} }
console.trace(err)
} }
}) })

View File

@ -8,6 +8,7 @@ const {
PermissionTypes, PermissionTypes,
} = require("../../utilities/security/permissions") } = require("../../utilities/security/permissions")
const Joi = require("joi") const Joi = require("joi")
const { bodyResource, paramResource } = require("../../middleware/resourceId")
const router = Router() const router = Router()
@ -64,9 +65,15 @@ router
controller.getDefinitionList controller.getDefinitionList
) )
.get("/api/automations", authorized(BUILDER), controller.fetch) .get("/api/automations", authorized(BUILDER), controller.fetch)
.get("/api/automations/:id", authorized(BUILDER), controller.find) .get(
"/api/automations/:id",
paramResource("id"),
authorized(BUILDER),
controller.find
)
.put( .put(
"/api/automations", "/api/automations",
bodyResource("_id"),
authorized(BUILDER), authorized(BUILDER),
generateValidator(true), generateValidator(true),
controller.update controller.update
@ -79,9 +86,15 @@ router
) )
.post( .post(
"/api/automations/:id/trigger", "/api/automations/:id/trigger",
paramResource("id"),
authorized(PermissionTypes.AUTOMATION, PermissionLevels.EXECUTE), authorized(PermissionTypes.AUTOMATION, PermissionLevels.EXECUTE),
controller.trigger controller.trigger
) )
.delete("/api/automations/:id/:rev", authorized(BUILDER), controller.destroy) .delete(
"/api/automations/:id/:rev",
paramResource("id"),
authorized(BUILDER),
controller.destroy
)
module.exports = router module.exports = router

View File

@ -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

View File

@ -8,6 +8,11 @@ const {
PermissionTypes, PermissionTypes,
} = require("../../utilities/security/permissions") } = require("../../utilities/security/permissions")
const joiValidator = require("../../middleware/joi-validator") const joiValidator = require("../../middleware/joi-validator")
const {
bodyResource,
bodySubResource,
paramResource,
} = require("../../middleware/resourceId")
const router = Router() const router = Router()
@ -50,23 +55,27 @@ router
.get("/api/queries", authorized(BUILDER), queryController.fetch) .get("/api/queries", authorized(BUILDER), queryController.fetch)
.post( .post(
"/api/queries", "/api/queries",
bodySubResource("datasourceId", "_id"),
authorized(BUILDER), authorized(BUILDER),
generateQueryValidation(), generateQueryValidation(),
queryController.save queryController.save
) )
.post( .post(
"/api/queries/preview", "/api/queries/preview",
bodyResource("datasourceId"),
authorized(BUILDER), authorized(BUILDER),
generateQueryPreviewValidation(), generateQueryPreviewValidation(),
queryController.preview queryController.preview
) )
.post( .post(
"/api/queries/:queryId", "/api/queries/:queryId",
paramResource("queryId"),
authorized(PermissionTypes.QUERY, PermissionLevels.WRITE), authorized(PermissionTypes.QUERY, PermissionLevels.WRITE),
queryController.execute queryController.execute
) )
.delete( .delete(
"/api/queries/:queryId/:revId", "/api/queries/:queryId/:revId",
paramResource("queryId"),
authorized(BUILDER), authorized(BUILDER),
queryController.destroy queryController.destroy
) )

View File

@ -1,7 +1,10 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../controllers/role") const controller = require("../controllers/role")
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 Joi = require("joi")
const joiValidator = require("../../middleware/joi-validator") const joiValidator = require("../../middleware/joi-validator")
const { const {
@ -11,12 +14,17 @@ const {
const router = Router() const router = Router()
function generateValidator() { function generateValidator() {
const permLevelArray = Object.values(PermissionLevels)
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
_id: Joi.string().optional(), _id: Joi.string().optional(),
_rev: Joi.string().optional(), _rev: Joi.string().optional(),
name: Joi.string().required(), name: Joi.string().required(),
// this is the base permission ID (for now a built in)
permissionId: Joi.string().valid(...Object.values(BUILTIN_PERMISSION_IDS)).required(), permissionId: Joi.string().valid(...Object.values(BUILTIN_PERMISSION_IDS)).required(),
permissions: Joi.object()
.pattern(/.*/, [Joi.string().valid(...permLevelArray)])
.optional(),
inherits: Joi.string().optional(), inherits: Joi.string().optional(),
}).unknown(true)) }).unknown(true))
} }

View File

@ -2,6 +2,10 @@ const Router = require("@koa/router")
const rowController = require("../controllers/row") const rowController = require("../controllers/row")
const authorized = require("../../middleware/authorized") const authorized = require("../../middleware/authorized")
const usage = require("../../middleware/usageQuota") const usage = require("../../middleware/usageQuota")
const {
paramResource,
paramSubResource,
} = require("../../middleware/resourceId")
const { const {
PermissionLevels, PermissionLevels,
PermissionTypes, PermissionTypes,
@ -12,37 +16,44 @@ const router = Router()
router router
.get( .get(
"/api/:tableId/:rowId/enrich", "/api/:tableId/:rowId/enrich",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ), authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.fetchEnrichedRow rowController.fetchEnrichedRow
) )
.get( .get(
"/api/:tableId/rows", "/api/:tableId/rows",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ), authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.fetchTableRows rowController.fetchTableRows
) )
.get( .get(
"/api/:tableId/rows/:rowId", "/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ), authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.find rowController.find
) )
.post( .post(
"/api/:tableId/rows", "/api/:tableId/rows",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE), authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
usage, usage,
rowController.save rowController.save
) )
.patch( .patch(
"/api/:tableId/rows/:id", "/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE), authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
rowController.patch rowController.patch
) )
.post( .post(
"/api/:tableId/rows/validate", "/api/:tableId/rows/validate",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE), authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
rowController.validate rowController.validate
) )
.delete( .delete(
"/api/:tableId/rows/:rowId/:revId", "/api/:tableId/rows/:rowId/:revId",
paramSubResource("tableId", "rowId"),
authorized(PermissionTypes.TABLE, PermissionLevels.WRITE), authorized(PermissionTypes.TABLE, PermissionLevels.WRITE),
usage, usage,
rowController.destroy rowController.destroy

View File

@ -1,6 +1,7 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const tableController = require("../controllers/table") const tableController = require("../controllers/table")
const authorized = require("../../middleware/authorized") const authorized = require("../../middleware/authorized")
const { paramResource, bodyResource } = require("../../middleware/resourceId")
const { const {
BUILDER, BUILDER,
PermissionLevels, PermissionLevels,
@ -13,10 +14,17 @@ router
.get("/api/tables", authorized(BUILDER), tableController.fetch) .get("/api/tables", authorized(BUILDER), tableController.fetch)
.get( .get(
"/api/tables/:id", "/api/tables/:id",
paramResource("id"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ), authorized(PermissionTypes.TABLE, PermissionLevels.READ),
tableController.find tableController.find
) )
.post("/api/tables", authorized(BUILDER), tableController.save) .post(
"/api/tables",
// allows control over updating a table
bodyResource("_id"),
authorized(BUILDER),
tableController.save
)
.post( .post(
"/api/tables/csv/validate", "/api/tables/csv/validate",
authorized(BUILDER), authorized(BUILDER),
@ -24,6 +32,7 @@ router
) )
.delete( .delete(
"/api/tables/:tableId/:revId", "/api/tables/:tableId/:revId",
paramResource("tableId"),
authorized(BUILDER), authorized(BUILDER),
tableController.destroy tableController.destroy
) )

View File

@ -4,6 +4,9 @@ const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles")
const packageJson = require("../../../../package") const packageJson = require("../../../../package")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const env = require("../../../environment") const env = require("../../../environment")
const {
BUILTIN_PERMISSION_IDS,
} = require("../../../utilities/security/permissions")
const TEST_CLIENT_ID = "test-client-id" const TEST_CLIENT_ID = "test-client-id"
@ -37,6 +40,17 @@ exports.defaultHeaders = appId => {
return headers return headers
} }
exports.publicHeaders = appId => {
const headers = {
Accept: "application/json",
}
if (appId) {
headers["x-budibase-app-id"] = appId
}
return headers
}
exports.BASE_TABLE = { exports.BASE_TABLE = {
name: "TestTable", name: "TestTable",
type: "table", type: "table",
@ -70,6 +84,56 @@ exports.createTable = async (request, appId, table, removeId = true) => {
return res.body return res.body
} }
exports.makeBasicRow = tableId => {
return {
name: "Test Contact",
description: "original description",
status: "new",
tableId: tableId,
}
}
exports.createRow = async (request, appId, tableId, row = null) => {
row = row || exports.makeBasicRow(tableId)
const res = await request
.post(`/api/${tableId}/rows`)
.send(row)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.createRole = async (request, appId) => {
const roleBody = {
name: "NewRole",
inherits: BUILTIN_ROLE_IDS.BASIC,
permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY,
}
const res = await request
.post(`/api/roles`)
.send(roleBody)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.addPermission = async (
request,
appId,
role,
resource,
level = "read"
) => {
const res = await request
.post(`/api/permission/${role}/${resource}/${level}`)
.set(exports.defaultHeaders(appId))
.expect("Content-Type", /json/)
.expect(200)
return res.body
}
exports.createLinkedTable = async (request, appId) => { exports.createLinkedTable = async (request, appId) => {
// get the ID to link to // get the ID to link to
const table = await exports.createTable(request, appId) const table = await exports.createTable(request, appId)

View File

@ -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)
})
})
})

View File

@ -1,7 +1,5 @@
const { const {
createApplication, createApplication,
createTable,
createView,
supertest, supertest,
defaultHeaders, defaultHeaders,
} = require("./couchTestUtils") } = require("./couchTestUtils")
@ -20,8 +18,6 @@ describe("/roles", () => {
let server let server
let request let request
let appId let appId
let table
let view
beforeAll(async () => { beforeAll(async () => {
;({ request, server } = await supertest()) ;({ request, server } = await supertest())
@ -34,8 +30,6 @@ describe("/roles", () => {
beforeEach(async () => { beforeEach(async () => {
let app = await createApplication(request) let app = await createApplication(request)
appId = app.instance._id appId = app.instance._id
table = await createTable(request, appId)
view = await createView(request, appId, table._id)
}) })
describe("create", () => { describe("create", () => {

View File

@ -5,6 +5,7 @@ const {
defaultHeaders, defaultHeaders,
createLinkedTable, createLinkedTable,
createAttachmentTable, createAttachmentTable,
makeBasicRow,
} = require("./couchTestUtils"); } = require("./couchTestUtils");
const { enrichRows } = require("../../../utilities") const { enrichRows } = require("../../../utilities")
const env = require("../../../environment") const env = require("../../../environment")
@ -30,12 +31,7 @@ describe("/rows", () => {
app = await createApplication(request) app = await createApplication(request)
appId = app.instance._id appId = app.instance._id
table = await createTable(request, appId) table = await createTable(request, appId)
row = { row = makeBasicRow(table._id)
name: "Test Contact",
description: "original description",
status: "new",
tableId: table._id
}
}) })
const createRow = async r => const createRow = async r =>

View File

@ -170,8 +170,8 @@ exports.getAppParams = (appId = null, otherProps = {}) => {
* Generates a new role ID. * Generates a new role ID.
* @returns {string} The new role ID which the role doc can be stored under. * @returns {string} The new role ID which the role doc can be stored under.
*/ */
exports.generateRoleID = () => { exports.generateRoleID = id => {
return `${DocumentTypes.ROLE}${SEPARATOR}${newid()}` return `${DocumentTypes.ROLE}${SEPARATOR}${id || newid()}`
} }
/** /**

View File

@ -1,10 +1,11 @@
const { const {
BUILTIN_ROLE_IDS, BUILTIN_ROLE_IDS,
getUserPermissionIds, getUserPermissions,
} = require("../utilities/security/roles") } = require("../utilities/security/roles")
const { const {
PermissionTypes, PermissionTypes,
doesHavePermission, doesHaveResourcePermission,
doesHaveBasePermission,
} = require("../utilities/security/permissions") } = require("../utilities/security/permissions")
const env = require("../environment") const env = require("../environment")
const { isAPIKeyValid } = require("../utilities/security/apikey") const { isAPIKeyValid } = require("../utilities/security/apikey")
@ -14,6 +15,10 @@ const ADMIN_ROLES = [BUILTIN_ROLE_IDS.ADMIN, BUILTIN_ROLE_IDS.BUILDER]
const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|")) const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|"))
function hasResource(ctx) {
return ctx.resourceId != null
}
module.exports = (permType, permLevel = null) => async (ctx, next) => { module.exports = (permType, permLevel = null) => async (ctx, next) => {
// webhooks can pass locally // webhooks can pass locally
if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) { if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) {
@ -38,25 +43,39 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
// don't expose builder endpoints in the cloud // don't expose builder endpoints in the cloud
if (env.CLOUD && permType === PermissionTypes.BUILDER) return if (env.CLOUD && permType === PermissionTypes.BUILDER) return
if (!ctx.auth.authenticated) {
ctx.throw(403, "Session not authenticated")
}
if (!ctx.user) { if (!ctx.user) {
ctx.throw(403, "User not found") ctx.throw(403, "No user info found")
} }
const role = ctx.user.role const role = ctx.user.role
const permissions = await getUserPermissionIds(ctx.appId, role._id) const { basePermissions, permissions } = await getUserPermissions(
if (ADMIN_ROLES.indexOf(role._id) !== -1) { ctx.appId,
return next() role._id
} )
const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1
const isAuthed = ctx.auth.authenticated
if (permType === PermissionTypes.BUILDER) { // this may need to change in the future, right now only admins
// can have access to builder features, this is hard coded into
// our rules
if (isAdmin && isAuthed) {
return next()
} else if (permType === PermissionTypes.BUILDER) {
ctx.throw(403, "Not Authorized") ctx.throw(403, "Not Authorized")
} }
if (!doesHavePermission(permType, permLevel, permissions)) { if (
hasResource(ctx) &&
doesHaveResourcePermission(permissions, permLevel, ctx)
) {
return next()
}
if (!isAuthed) {
ctx.throw(403, "Session not authenticated")
}
if (!doesHaveBasePermission(permType, permLevel, basePermissions)) {
ctx.throw(403, "User does not have permission") ctx.throw(403, "User does not have permission")
} }

View File

@ -22,3 +22,7 @@ function validate(schema, property) {
module.exports.body = schema => { module.exports.body = schema => {
return validate(schema, "body") return validate(schema, "body")
} }
module.exports.params = schema => {
return validate(schema, "params")
}

View File

@ -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()
}

View File

@ -94,6 +94,7 @@ exports.getDeployedApps = async () => {
} }
const workerUrl = !env.CLOUD ? await exports.getWorkerUrl() : env.WORKER_URL const workerUrl = !env.CLOUD ? await exports.getWorkerUrl() : env.WORKER_URL
const hostingKey = !env.CLOUD ? hostingInfo.selfHostKey : env.HOSTING_KEY const hostingKey = !env.CLOUD ? hostingInfo.selfHostKey : env.HOSTING_KEY
try {
const response = await fetch(`${workerUrl}/api/apps`, { const response = await fetch(`${workerUrl}/api/apps`, {
method: "GET", method: "GET",
headers: { headers: {
@ -107,4 +108,8 @@ exports.getDeployedApps = async () => {
} }
} }
return json return json
} catch (err) {
// error, cannot determine deployed apps, don't stop app creation - sort this later
return {}
}
} }

View File

@ -7,6 +7,7 @@ const PermissionLevels = {
ADMIN: "admin", ADMIN: "admin",
} }
// these are the global types, that govern the underlying default behaviour
const PermissionTypes = { const PermissionTypes = {
TABLE: "table", TABLE: "table",
USER: "user", USER: "user",
@ -29,12 +30,11 @@ function Permission(type, level) {
*/ */
function getAllowedLevels(userPermLevel) { function getAllowedLevels(userPermLevel) {
switch (userPermLevel) { switch (userPermLevel) {
case PermissionLevels.READ:
return [PermissionLevels.READ]
case PermissionLevels.WRITE:
return [PermissionLevels.READ, PermissionLevels.WRITE]
case PermissionLevels.EXECUTE: case PermissionLevels.EXECUTE:
return [PermissionLevels.EXECUTE] return [PermissionLevels.EXECUTE]
case PermissionLevels.READ:
return [PermissionLevels.EXECUTE, PermissionLevels.READ]
case PermissionLevels.WRITE:
case PermissionLevels.ADMIN: case PermissionLevels.ADMIN:
return [ return [
PermissionLevels.READ, PermissionLevels.READ,
@ -97,7 +97,35 @@ exports.BUILTIN_PERMISSIONS = {
}, },
} }
exports.doesHavePermission = (permType, permLevel, permissionIds) => { exports.doesHaveResourcePermission = (
permissions,
permLevel,
{ resourceId, subResourceId }
) => {
// set foundSub to not subResourceId, incase there is no subResource
let foundMain = false,
foundSub = !subResourceId
for (let [resource, level] of Object.entries(permissions)) {
const levels = getAllowedLevels(level)
if (resource === resourceId && levels.indexOf(permLevel) !== -1) {
foundMain = true
}
if (
subResourceId &&
resource === subResourceId &&
levels.indexOf(permLevel) !== -1
) {
foundSub = true
}
// this will escape if foundMain only when no sub resource
if (foundMain && foundSub) {
break
}
}
return foundMain && foundSub
}
exports.doesHaveBasePermission = (permType, permLevel, permissionIds) => {
const builtins = Object.values(exports.BUILTIN_PERMISSIONS) const builtins = Object.values(exports.BUILTIN_PERMISSIONS)
let permissions = flatten( let permissions = flatten(
builtins builtins
@ -115,6 +143,25 @@ exports.doesHavePermission = (permType, permLevel, permissionIds) => {
return false return false
} }
exports.higherPermission = (perm1, perm2) => {
function toNum(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
}
}
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
exports.BUILDER = PermissionTypes.BUILDER exports.BUILDER = PermissionTypes.BUILDER
exports.PermissionTypes = PermissionTypes exports.PermissionTypes = PermissionTypes

View File

@ -1,6 +1,7 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { BUILTIN_PERMISSION_IDS } = require("./permissions") const { BUILTIN_PERMISSION_IDS, higherPermission } = require("./permissions")
const { generateRoleID, DocumentTypes, SEPARATOR } = require("../../db/utils")
const BUILTIN_IDS = { const BUILTIN_IDS = {
ADMIN: "ADMIN", ADMIN: "ADMIN",
@ -44,15 +45,15 @@ exports.BUILTIN_ROLES = {
} }
exports.BUILTIN_ROLE_ID_ARRAY = Object.values(exports.BUILTIN_ROLES).map( exports.BUILTIN_ROLE_ID_ARRAY = Object.values(exports.BUILTIN_ROLES).map(
level => level._id role => role._id
) )
exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(exports.BUILTIN_ROLES).map( exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(exports.BUILTIN_ROLES).map(
level => level.name role => role.name
) )
function isBuiltin(role) { function isBuiltin(role) {
return exports.BUILTIN_ROLE_ID_ARRAY.indexOf(role) !== -1 return exports.BUILTIN_ROLE_ID_ARRAY.some(builtin => role.includes(builtin))
} }
/** /**
@ -66,14 +67,25 @@ exports.getRole = async (appId, roleId) => {
if (!roleId) { if (!roleId) {
return null return null
} }
let role let role = {}
// built in roles mostly come from the in-code implementation,
// but can be extended by a doc stored about them (e.g. permissions)
if (isBuiltin(roleId)) { if (isBuiltin(roleId)) {
role = cloneDeep( role = cloneDeep(
Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId) Object.values(exports.BUILTIN_ROLES).find(role => role._id === roleId)
) )
} else { }
try {
const db = new CouchDB(appId) const db = new CouchDB(appId)
role = await db.get(roleId) const dbRole = await db.get(exports.getDBRoleID(roleId))
role = Object.assign(role, dbRole)
// finalise the ID
role._id = exports.getExternalRoleID(role._id)
} catch (err) {
// only throw an error if there is no role at all
if (Object.keys(role).length === 0) {
throw err
}
} }
return role return role
} }
@ -118,14 +130,26 @@ exports.getUserRoleHierarchy = async (appId, userRoleId) => {
* Get all of the user permissions which could be found across the role hierarchy * Get all of the user permissions which could be found across the role hierarchy
* @param appId The ID of the application from which roles should be obtained. * @param appId The ID of the application from which roles should be obtained.
* @param userRoleId The user's role ID, this can be found in their access token. * @param userRoleId The user's role ID, this can be found in their access token.
* @returns {Promise<string[]>} A list of permission IDs these should all be unique. * @returns {Promise<{basePermissions: string[], permissions: Object}>} the base
* permission IDs as well as any custom resource permissions.
*/ */
exports.getUserPermissionIds = async (appId, userRoleId) => { exports.getUserPermissions = async (appId, userRoleId) => {
return [ const rolesHierarchy = await getAllUserRoles(appId, userRoleId)
...new Set( const basePermissions = [
(await getAllUserRoles(appId, userRoleId)).map(role => role.permissionId) ...new Set(rolesHierarchy.map(role => role.permissionId)),
),
] ]
const permissions = {}
for (let role of rolesHierarchy) {
if (role.permissions) {
for (let [resource, level] of Object.entries(role.permissions)) {
permissions[resource] = higherPermission(permissions[resource], level)
}
}
}
return {
basePermissions,
permissions,
}
} }
class AccessController { class AccessController {
@ -177,6 +201,27 @@ class AccessController {
} }
} }
/**
* Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions).
*/
exports.getDBRoleID = roleId => {
if (roleId.startsWith(DocumentTypes.ROLE)) {
return roleId
}
return generateRoleID(roleId)
}
/**
* Remove the "role_" from builtin role IDs that have been written to the DB (for permissions).
*/
exports.getExternalRoleID = roleId => {
// for built in roles we want to remove the DB role ID element (role_)
if (roleId.startsWith(DocumentTypes.ROLE) && isBuiltin(roleId)) {
return roleId.split(`${DocumentTypes.ROLE}${SEPARATOR}`)[1]
}
return roleId
}
exports.AccessController = AccessController exports.AccessController = AccessController
exports.BUILTIN_ROLE_IDS = BUILTIN_IDS exports.BUILTIN_ROLE_IDS = BUILTIN_IDS
exports.isBuiltin = isBuiltin exports.isBuiltin = isBuiltin