Merge pull request #11644 from Budibase/BUDI-7393/display_inheritance_permission

Handle inheritance permissions
This commit is contained in:
Adria Navarro 2023-09-04 12:22:03 +02:00 committed by GitHub
commit 4610a202bd
15 changed files with 362 additions and 89 deletions

View File

@ -73,7 +73,7 @@
if (!perms["execute"]) {
role = "BASIC"
} else {
role = perms["execute"]
role = perms["execute"].role
}
}

View File

@ -5,25 +5,19 @@
export let resourceId
export let disabled = false
export let requiresLicence
let modal
let resourcePermissions
async function openDropdown() {
resourcePermissions = await permissions.forResource(resourceId)
async function openModal() {
resourcePermissions = await permissions.forResourceDetailed(resourceId)
modal.show()
}
</script>
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}>
<ActionButton icon="LockClosed" quiet on:click={openModal} {disabled}>
Access
</ActionButton>
<Modal bind:this={modal}>
<ManageAccessModal
{resourceId}
{requiresLicence}
levels={$permissions}
permissions={resourcePermissions}
/>
<ManageAccessModal {resourceId} permissions={resourcePermissions} />
</Modal>

View File

@ -1,5 +1,4 @@
<script>
import { licensing, admin } from "stores/portal"
import ManageAccessButton from "../ManageAccessButton.svelte"
import { getContext } from "svelte"
@ -13,17 +12,6 @@
}
return datasource.type === "table" ? datasource.tableId : datasource.id
}
var requiresLicence
$: {
if ($datasource.type === "viewV2" && !$licensing.isViewPermissionsEnabled) {
const requiredLicense = $admin?.cloud ? "Premium" : "Business"
requiresLicence = {
tier: requiredLicense,
message: `A ${requiredLicense} subscription is required to specify access level roles for this view.`,
}
}
}
</script>
<ManageAccessButton {resourceId} {requiresLicence} />
<ManageAccessButton {resourceId} />

View File

@ -1,4 +1,5 @@
<script>
import { PermissionSource } from "@budibase/types"
import { roles, permissions as permissionsStore } from "stores/backend"
import {
Label,
@ -9,59 +10,127 @@
ModalContent,
Tags,
Tag,
Icon,
} from "@budibase/bbui"
import { capitalise } from "helpers"
import { get } from "svelte/store"
export let resourceId
export let permissions
export let requiresLicence
const inheritedRoleId = "inherited"
async function changePermission(level, role) {
try {
if (role === inheritedRoleId) {
await permissionsStore.remove({
level,
role,
resource: resourceId,
})
} else {
await permissionsStore.save({
level,
role,
resource: resourceId,
})
}
// Show updated permissions in UI: REMOVE
permissions = await permissionsStore.forResource(resourceId)
permissions = await permissionsStore.forResourceDetailed(resourceId)
notifications.success("Updated permissions")
} catch (error) {
notifications.error("Error updating permissions")
}
}
$: computedPermissions = Object.entries(permissions.permissions).reduce(
(p, [level, roleInfo]) => {
p[level] = {
selectedValue:
roleInfo.permissionType === PermissionSource.INHERITED
? inheritedRoleId
: roleInfo.role,
options: [...get(roles)],
}
if (roleInfo.inheritablePermission) {
p[level].inheritOption = roleInfo.inheritablePermission
p[level].options.unshift({
_id: inheritedRoleId,
name: `Inherit (${
get(roles).find(x => x._id === roleInfo.inheritablePermission).name
})`,
})
}
return p
},
{}
)
$: requiresPlanToModify = permissions.requiresPlanToModify
let dependantsInfoMessage
async function loadDependantInfo() {
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
const resourceByType = dependantsInfo?.resourceByType
if (resourceByType) {
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
let resourceDisplay =
Object.keys(resourceByType).length === 1 && resourceByType.view
? "view"
: "resource"
if (total === 1) {
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.`
} else if (total > 1) {
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.`
}
}
}
loadDependantInfo()
</script>
<ModalContent showCancelButton={false} confirmText="Done">
<span slot="header">
Manage Access
{#if requiresLicence}
{#if requiresPlanToModify}
<span class="lock-tag">
<Tags>
<Tag icon="LockClosed">{requiresLicence.tier}</Tag>
<Tag icon="LockClosed">{capitalise(requiresPlanToModify)}</Tag>
</Tags>
</span>
{/if}
</span>
{#if requiresLicence}
<Body size="S">{requiresLicence.message}</Body>
{:else}
<Body size="S">Specify the minimum access level role for this data.</Body>
<div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level}
{#each Object.keys(computedPermissions) as level}
<Input value={capitalise(level)} disabled />
<Select
value={permissions[level]}
disabled={requiresPlanToModify}
placeholder={false}
value={computedPermissions[level].selectedValue}
on:change={e => changePermission(level, e.detail)}
options={$roles}
options={computedPermissions[level].options}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
{/each}
</div>
{#if dependantsInfoMessage}
<div class="inheriting-resources">
<Icon name="Alert" />
<Body size="S">
<i>
{dependantsInfoMessage}
</i>
</Body>
</div>
{/if}
</ModalContent>
@ -75,4 +144,9 @@
.lock-tag {
padding-left: var(--spacing-s);
}
.inheriting-resources {
display: flex;
gap: var(--spacing-s);
}
</style>

View File

@ -40,7 +40,7 @@
return
}
try {
roleId = (await permissions.forResource(queryToFetch._id))["read"]
roleId = (await permissions.forResource(queryToFetch._id))["read"].role
} catch (err) {
roleId = Constants.Roles.BASIC
}

View File

@ -13,9 +13,22 @@ export function createPermissionStore() {
level,
})
},
remove: async ({ level, role, resource }) => {
return await API.removePermissionFromResource({
resourceId: resource,
roleId: role,
level,
})
},
forResource: async resourceId => {
return (await API.getPermissionForResource(resourceId)).permissions
},
forResourceDetailed: async resourceId => {
return await API.getPermissionForResource(resourceId)
},
getDependantsInfo: async resourceId => {
return await API.getDependants(resourceId)
},
}
}

View File

@ -21,4 +21,27 @@ export const buildPermissionsEndpoints = API => ({
url: `/api/permission/${roleId}/${resourceId}/${level}`,
})
},
/**
* Remove the the permissions for a certain resource
* @param resourceId the ID of the resource to update
* @param roleId the ID of the role to update the permissions of
* @param level the level to remove the role for this resource
* @return {Promise<*>}
*/
removePermissionFromResource: async ({ resourceId, roleId, level }) => {
return await API.delete({
url: `/api/permission/${roleId}/${resourceId}/${level}`,
})
},
/**
* Gets info about the resources that depend on this resource permissions
* @param resourceId the resource ID to check
*/
getDependants: async resourceId => {
return await API.get({
url: `/api/permission/${resourceId}/dependants`,
})
},
})

View File

@ -1,5 +1,13 @@
import { permissions, roles, context, HTTPError } from "@budibase/backend-core"
import { UserCtx, Database, Role, PermissionLevel } from "@budibase/types"
import {
UserCtx,
Database,
Role,
PermissionLevel,
GetResourcePermsResponse,
ResourcePermissionInfo,
GetDependantResourcesResponse,
} from "@budibase/types"
import { getRoleParams } from "../../db/utils"
import {
CURRENTLY_SUPPORTED_LEVELS,
@ -145,33 +153,40 @@ export async function fetch(ctx: UserCtx) {
ctx.body = finalPermissions
}
export async function getResourcePerms(ctx: UserCtx) {
const resourceId = ctx.params.resourceId
const db = context.getAppDB()
const body = await db.allDocs(
getRoleParams(null, {
include_docs: true,
})
)
const rolesList = body.rows.map(row => row.doc)
let permissions: Record<string, string> = {}
for (let level of SUPPORTED_LEVELS) {
// update the various roleIds in the resource permissions
for (let role of rolesList) {
const rolePerms = roles.checkForRoleResourceArray(
role.permissions,
resourceId
)
if (
rolePerms &&
rolePerms[resourceId] &&
rolePerms[resourceId].indexOf(level) !== -1
export async function getResourcePerms(
ctx: UserCtx<void, GetResourcePermsResponse>
) {
permissions[level] = roles.getExternalRoleID(role._id, role.version)!
const resourceId = ctx.params.resourceId
const resourcePermissions = await sdk.permissions.getResourcePerms(resourceId)
const inheritablePermissions =
await sdk.permissions.getInheritablePermissions(resourceId)
ctx.body = {
permissions: Object.entries(resourcePermissions).reduce(
(p, [level, role]) => {
p[level] = {
role: role.role,
permissionType: role.type,
inheritablePermission:
inheritablePermissions && inheritablePermissions[level].role,
}
return p
},
{} as Record<string, ResourcePermissionInfo>
),
requiresPlanToModify: (
await sdk.permissions.allowsExplicitPermissions(resourceId)
).minPlan,
}
}
export async function getDependantResources(
ctx: UserCtx<void, GetDependantResourcesResponse>
) {
const resourceId = ctx.params.resourceId
ctx.body = {
resourceByType: await sdk.permissions.getDependantResources(resourceId),
}
ctx.body = Object.assign(getBasePermissions(resourceId), permissions)
}
export async function addPermission(ctx: UserCtx) {

View File

@ -23,6 +23,11 @@ router
authorized(permissions.BUILDER),
controller.getResourcePerms
)
.get(
"/api/permission/:resourceId/dependants",
authorized(permissions.BUILDER),
controller.getDependantResources
)
// adding a specific role/level for the resource overrides the underlying access control
.post(
"/api/permission/:roleId/:resourceId/:level",

View File

@ -1,5 +1,6 @@
const mockedSdk = sdk.permissions as jest.Mocked<typeof sdk.permissions>
jest.mock("../../../sdk/app/permissions", () => ({
...jest.requireActual("../../../sdk/app/permissions"),
resourceActionAllowed: jest.fn(),
}))
@ -78,8 +79,12 @@ describe("/permission", () => {
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body["read"]).toEqual(STD_ROLE_ID)
expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
expect(res.body).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "BASE", role: HIGHER_ROLE_ID },
},
})
})
it("should get resource permissions with multiple roles", async () => {
@ -89,15 +94,20 @@ describe("/permission", () => {
level: PermissionLevel.WRITE,
})
const res = await config.api.permission.get(table._id)
expect(res.body["read"]).toEqual(STD_ROLE_ID)
expect(res.body["write"]).toEqual(HIGHER_ROLE_ID)
expect(res.body).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "EXPLICIT", role: HIGHER_ROLE_ID },
},
})
const allRes = await request
.get(`/api/permission`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID)
expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID)
expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID)
})
it("throw forbidden if the action is not allowed for the resource", async () => {

View File

@ -1,7 +1,7 @@
import Router from "@koa/router"
import * as viewController from "../controllers/view"
import * as rowController from "../controllers/row"
import authorized from "../../middleware/authorized"
import authorized, { authorizedResource } from "../../middleware/authorized"
import { paramResource } from "../../middleware/resourceId"
import { permissions } from "@budibase/backend-core"
@ -10,10 +10,10 @@ const router: Router = new Router()
router
.get(
"/api/v2/views/:viewId",
paramResource("viewId"),
authorized(
permissions.PermissionType.TABLE,
permissions.PermissionLevel.READ
authorizedResource(
permissions.PermissionType.VIEW,
permissions.PermissionLevel.READ,
"viewId"
),
viewController.v2.get
)

View File

@ -1,10 +1,24 @@
import { context, db, env, roles } from "@budibase/backend-core"
import { features } from "@budibase/pro"
import {
DocumentType,
PermissionLevel,
PermissionSource,
PlanType,
Role,
VirtualDocumentType,
} from "@budibase/types"
import { isViewID } from "../../../db/utils"
import { features } from "@budibase/pro"
import {
extractViewInfoFromID,
getRoleParams,
isViewID,
} from "../../../db/utils"
import {
CURRENTLY_SUPPORTED_LEVELS,
getBasePermissions,
} from "../../../utilities/security"
import sdk from "../../../sdk"
import { isV2 } from "../views"
type ResourceActionAllowedResult =
| { allowed: true }
@ -35,3 +49,117 @@ export async function resourceActionAllowed({
resourceType: VirtualDocumentType.VIEW,
}
}
type ResourcePermissions = Record<
string,
{ role: string; type: PermissionSource }
>
export async function getInheritablePermissions(
resourceId: string
): Promise<ResourcePermissions | undefined> {
if (isViewID(resourceId)) {
return await getResourcePerms(extractViewInfoFromID(resourceId).tableId)
}
}
export async function allowsExplicitPermissions(resourceId: string) {
if (isViewID(resourceId)) {
const allowed = await features.isViewPermissionEnabled()
const minPlan = !allowed
? env.SELF_HOSTED
? PlanType.BUSINESS
: PlanType.PREMIUM
: undefined
return {
allowed,
minPlan,
}
}
return { allowed: true }
}
export async function getResourcePerms(
resourceId: string
): Promise<ResourcePermissions> {
const db = context.getAppDB()
const body = await db.allDocs(
getRoleParams(null, {
include_docs: true,
})
)
const rolesList = body.rows.map<Role>(row => row.doc)
let permissions: ResourcePermissions = {}
const permsToInherit = await getInheritablePermissions(resourceId)
const allowsExplicitPerm = (await allowsExplicitPermissions(resourceId))
.allowed
for (let level of CURRENTLY_SUPPORTED_LEVELS) {
// update the various roleIds in the resource permissions
for (let role of rolesList) {
const rolePerms = allowsExplicitPerm
? roles.checkForRoleResourceArray(role.permissions, resourceId)
: {}
if (rolePerms[resourceId]?.indexOf(level) > -1) {
permissions[level] = {
role: roles.getExternalRoleID(role._id!, role.version),
type: PermissionSource.EXPLICIT,
}
} else if (
!permissions[level] &&
permsToInherit &&
permsToInherit[level]
) {
permissions[level] = {
role: permsToInherit[level].role,
type: PermissionSource.INHERITED,
}
}
}
}
const basePermissions = Object.entries(
getBasePermissions(resourceId)
).reduce<ResourcePermissions>((p, [level, role]) => {
p[level] = { role, type: PermissionSource.BASE }
return p
}, {})
const result = Object.assign(basePermissions, permissions)
return result
}
export async function getDependantResources(
resourceId: string
): Promise<Record<string, number> | undefined> {
if (db.isTableId(resourceId)) {
const dependants: Record<string, Set<string>> = {}
const table = await sdk.tables.getTable(resourceId)
const views = Object.values(table.views || {})
for (const view of views) {
if (!isV2(view)) {
continue
}
const permissions = await getResourcePerms(view.id)
for (const [level, roleInfo] of Object.entries(permissions)) {
if (roleInfo.type === PermissionSource.INHERITED) {
dependants[VirtualDocumentType.VIEW] ??= new Set()
dependants[VirtualDocumentType.VIEW].add(view.id)
}
}
}
return Object.entries(dependants).reduce((p, [type, resources]) => {
p[type] = resources.size
return p
}, {} as Record<string, number>)
}
return
}

View File

@ -4,3 +4,4 @@ export * from "./row"
export * from "./view"
export * from "./rows"
export * from "./table"
export * from "./permission"

View File

@ -0,0 +1,16 @@
import { PlanType } from "../../../sdk"
export interface ResourcePermissionInfo {
role: string
permissionType: string
inheritablePermission?: string
}
export interface GetResourcePermsResponse {
permissions: Record<string, ResourcePermissionInfo>
requiresPlanToModify?: PlanType
}
export interface GetDependantResourcesResponse {
resourceByType?: Record<string, number>
}

View File

@ -17,3 +17,9 @@ export enum PermissionType {
QUERY = "query",
VIEW = "view",
}
export enum PermissionSource {
EXPLICIT = "EXPLICIT",
INHERITED = "INHERITED",
BASE = "BASE",
}