Merge pull request #11644 from Budibase/BUDI-7393/display_inheritance_permission
Handle inheritance permissions
This commit is contained in:
commit
4610a202bd
|
@ -73,7 +73,7 @@
|
|||
if (!perms["execute"]) {
|
||||
role = "BASIC"
|
||||
} else {
|
||||
role = perms["execute"]
|
||||
role = perms["execute"].role
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -4,3 +4,4 @@ export * from "./row"
|
|||
export * from "./view"
|
||||
export * from "./rows"
|
||||
export * from "./table"
|
||||
export * from "./permission"
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -17,3 +17,9 @@ export enum PermissionType {
|
|||
QUERY = "query",
|
||||
VIEW = "view",
|
||||
}
|
||||
|
||||
export enum PermissionSource {
|
||||
EXPLICIT = "EXPLICIT",
|
||||
INHERITED = "INHERITED",
|
||||
BASE = "BASE",
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue