budibase/packages/auth/src/security/roles.js

324 lines
9.4 KiB
JavaScript
Raw Normal View History

const { getDB } = require("../db")
const { cloneDeep } = require("lodash/fp")
const { BUILTIN_PERMISSION_IDS } = require("./permissions")
2021-05-14 17:32:51 +02:00
const {
generateRoleID,
getRoleParams,
DocumentTypes,
SEPARATOR,
} = require("../db/utils")
const BUILTIN_IDS = {
ADMIN: "ADMIN",
POWER: "POWER",
BASIC: "BASIC",
PUBLIC: "PUBLIC",
}
2021-05-14 17:31:07 +02:00
// exclude internal roles like builder
const EXTERNAL_BUILTIN_ROLE_IDS = [
BUILTIN_IDS.ADMIN,
BUILTIN_IDS.POWER,
BUILTIN_IDS.BASIC,
BUILTIN_IDS.PUBLIC,
]
function Role(id, name) {
this._id = id
this.name = name
}
2021-05-03 09:31:09 +02:00
Role.prototype.addPermission = function (permissionId) {
this.permissionId = permissionId
return this
}
2021-05-03 09:31:09 +02:00
Role.prototype.addInheritance = function (inherits) {
this.inherits = inherits
return this
}
const BUILTIN_ROLES = {
ADMIN: new Role(BUILTIN_IDS.ADMIN, "Admin")
.addPermission(BUILTIN_PERMISSION_IDS.ADMIN)
.addInheritance(BUILTIN_IDS.POWER),
POWER: new Role(BUILTIN_IDS.POWER, "Power")
.addPermission(BUILTIN_PERMISSION_IDS.POWER)
.addInheritance(BUILTIN_IDS.BASIC),
BASIC: new Role(BUILTIN_IDS.BASIC, "Basic")
.addPermission(BUILTIN_PERMISSION_IDS.WRITE)
.addInheritance(BUILTIN_IDS.PUBLIC),
PUBLIC: new Role(BUILTIN_IDS.PUBLIC, "Public").addPermission(
BUILTIN_PERMISSION_IDS.PUBLIC
),
BUILDER: new Role(BUILTIN_IDS.BUILDER, "Builder").addPermission(
BUILTIN_PERMISSION_IDS.ADMIN
),
}
exports.getBuiltinRoles = () => {
return cloneDeep(BUILTIN_ROLES)
}
exports.BUILTIN_ROLE_ID_ARRAY = Object.values(BUILTIN_ROLES).map(
2021-05-04 12:32:22 +02:00
role => role._id
)
exports.BUILTIN_ROLE_NAME_ARRAY = Object.values(BUILTIN_ROLES).map(
2021-05-04 12:32:22 +02:00
role => role.name
)
function isBuiltin(role) {
2021-05-04 12:32:22 +02:00
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 builtins = exports.getBuiltinRoles()
const MAX = Object.values(BUILTIN_IDS).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
return MAX
}
2021-02-12 21:41:30 +01:00
let role = builtins[id],
count = 0
do {
if (!role) {
break
}
role = builtins[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
}
2021-02-12 10:55:37 +01:00
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
* to check if the role inherits any others.
* @param {string} appId The app in which to look for the role.
* @param {string|null} roleId The level ID to lookup.
* @returns {Promise<Role|object|null>} The role object, which may contain an "inherits" property.
*/
exports.getRole = async (appId, roleId) => {
if (!roleId) {
return null
}
2021-02-05 16:58:25 +01:00
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)) {
role = cloneDeep(
2021-05-04 12:32:22 +02:00
Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
)
2021-02-05 16:58:25 +01:00
}
try {
const db = getDB(appId)
const dbRole = await db.get(exports.getDBRoleID(roleId))
2021-02-05 16:58:25 +01:00
role = Object.assign(role, dbRole)
// finalise the ID
role._id = exports.getExternalRoleID(role._id)
2021-02-05 16:58:25 +01:00
} catch (err) {
// only throw an error if there is no role at all
if (Object.keys(role).length === 0) {
throw err
}
}
return role
}
/**
* Simple function to get all the roles based on the top level user role ID.
*/
async function getAllUserRoles(appId, userRoleId) {
if (!userRoleId) {
return [BUILTIN_IDS.BASIC]
}
let currentRole = await exports.getRole(appId, userRoleId)
let roles = currentRole ? [currentRole] : []
let roleIds = [userRoleId]
// get all the inherited roles
while (
currentRole &&
currentRole.inherits &&
roleIds.indexOf(currentRole.inherits) === -1
2021-05-14 17:32:51 +02:00
) {
roleIds.push(currentRole.inherits)
currentRole = await exports.getRole(appId, currentRole.inherits)
roles.push(currentRole)
}
return roles
}
/**
* Returns an ordered array of the user's inherited role IDs, this can be used
* to determine if a user can access something that requires a specific role.
* @param {string} appId The ID of the application from which roles should be obtained.
* @param {string} userRoleId The user's role ID, this can be found in their access token.
* @returns {Promise<string[]>} returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level.
*/
exports.getUserRoleHierarchy = async (appId, userRoleId) => {
// special case, if they don't have a role then they are a public user
2021-05-04 12:32:22 +02:00
return (await getAllUserRoles(appId, userRoleId)).map(role => role._id)
}
/**
* 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 userRoleId The user's role ID, this can be found in their access token.
* @returns {Promise<{basePermissions: string[], permissions: Object}>} the base
* permission IDs as well as any custom resource permissions.
*/
exports.getUserPermissions = async (appId, userRoleId) => {
const rolesHierarchy = await getAllUserRoles(appId, userRoleId)
const basePermissions = [
2021-05-04 12:32:22 +02:00
...new Set(rolesHierarchy.map(role => role.permissionId)),
]
const permissions = {}
for (let role of rolesHierarchy) {
if (role.permissions) {
for (let [resource, levels] of Object.entries(role.permissions)) {
if (!permissions[resource]) {
permissions[resource] = []
}
const permsSet = new Set(permissions[resource])
if (Array.isArray(levels)) {
levels.forEach(level => permsSet.add(level))
} else {
permsSet.add(levels)
}
permissions[resource] = [...permsSet]
}
}
}
return {
basePermissions,
permissions,
}
}
2021-05-14 17:31:07 +02:00
/**
* Given an app ID this will retrieve all of the roles that are currently within that app.
* @param {string} appId The ID of the app to retrieve the roles from.
* @return {Promise<object[]>} An array of the role objects that were found.
*/
exports.getAllRoles = async appId => {
const db = getDB(appId)
const body = await db.allDocs(
getRoleParams(null, {
include_docs: true,
})
)
let roles = body.rows.map(row => row.doc)
const builtinRoles = exports.getBuiltinRoles()
// need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter(
dbRole => exports.getExternalRoleID(dbRole._id) === builtinRoleId
)[0]
if (dbBuiltin == null) {
roles.push(builtinRole || builtinRoles.BASIC)
2021-05-14 17:31:07 +02:00
} else {
// remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = exports.getExternalRoleID(dbBuiltin._id)
roles.push(Object.assign(builtinRole, dbBuiltin))
}
}
return roles
}
class AccessController {
constructor(appId) {
this.appId = appId
this.userHierarchies = {}
}
async hasAccess(tryingRoleId, userRoleId) {
// special cases, the screen has no role, the roles are the same or the user
// is currently in the builder
if (
tryingRoleId == null ||
tryingRoleId === "" ||
tryingRoleId === userRoleId ||
tryingRoleId === BUILTIN_IDS.BUILDER ||
userRoleId === BUILTIN_IDS.BUILDER
) {
return true
}
let roleIds = this.userHierarchies[userRoleId]
if (!roleIds) {
roleIds = await exports.getUserRoleHierarchy(this.appId, userRoleId)
2020-12-04 15:01:02 +01:00
this.userHierarchies[userRoleId] = roleIds
}
return roleIds.indexOf(tryingRoleId) !== -1
}
async checkScreensAccess(screens, userRoleId) {
let accessibleScreens = []
// don't want to handle this with Promise.all as this would mean all custom roles would be
// retrieved at same time, it is likely a custom role will be re-used and therefore want
// to work in sync for performance save
for (let screen of screens) {
const accessible = await this.checkScreenAccess(screen, userRoleId)
if (accessible) {
accessibleScreens.push(accessible)
}
}
return accessibleScreens
}
async checkScreenAccess(screen, userRoleId) {
const roleId = screen && screen.routing ? screen.routing.roleId : null
if (await this.hasAccess(roleId, userRoleId)) {
return screen
}
return null
}
}
/**
* Adds the "role_" for builtin role IDs which are to be written to the DB (for permissions).
*/
2021-05-04 12:32:22 +02:00
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).
*/
2021-05-04 12:32:22 +02:00
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.BUILTIN_ROLE_IDS = BUILTIN_IDS
exports.isBuiltin = isBuiltin
exports.Role = Role