diff --git a/lerna.json b/lerna.json index e1e95f2ab7..5107e7ca64 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.9.30-alpha.11", + "version": "2.9.30-alpha.12", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/tests/core/utilities/mocks/licenses.ts b/packages/backend-core/tests/core/utilities/mocks/licenses.ts index 14a1f1f4d3..309f0fd159 100644 --- a/packages/backend-core/tests/core/utilities/mocks/licenses.ts +++ b/packages/backend-core/tests/core/utilities/mocks/licenses.ts @@ -102,6 +102,10 @@ export const useAppBuilders = () => { return useFeature(Feature.APP_BUILDERS) } +export const useViewPermissions = () => { + return useFeature(Feature.VIEW_PERMISSIONS) +} + // QUOTAS export const setAutomationLogsQuota = (value: number) => { diff --git a/packages/pro b/packages/pro index af8a400898..b7815e099b 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit af8a40089809485712c7ef626d9e04090ef09975 +Subproject commit b7815e099bbd5e1410185c464dbd54f7287e732f diff --git a/packages/server/src/api/controllers/permission.ts b/packages/server/src/api/controllers/permission.ts index 20e64d2cfb..8314f29398 100644 --- a/packages/server/src/api/controllers/permission.ts +++ b/packages/server/src/api/controllers/permission.ts @@ -1,11 +1,12 @@ -import { permissions, roles, context } from "@budibase/backend-core" +import { permissions, roles, context, HTTPError } from "@budibase/backend-core" +import { UserCtx, Database, Role, PermissionLevel } from "@budibase/types" import { getRoleParams } from "../../db/utils" import { CURRENTLY_SUPPORTED_LEVELS, getBasePermissions, } from "../../utilities/security" import { removeFromArray } from "../../utilities" -import { UserCtx, Database, Role } from "@budibase/types" +import sdk from "../../sdk" const PermissionUpdateType = { REMOVE: "remove", @@ -25,14 +26,25 @@ async function getAllDBRoles(db: Database) { } async function updatePermissionOnRole( - appId: string, { roleId, resourceId, level, - }: { roleId: string; resourceId: string; level: string }, + }: { roleId: string; resourceId: string; level: PermissionLevel }, updateType: string ) { + const allowedAction = await sdk.permissions.resourceActionAllowed({ + resourceId, + level, + }) + + if (!allowedAction.allowed) { + throw new HTTPError( + `You are not allowed to '${allowedAction.level}' the resource type '${allowedAction.resourceType}'`, + 403 + ) + } + const db = context.getAppDB() const remove = updateType === PermissionUpdateType.REMOVE const isABuiltin = roles.isBuiltin(roleId) @@ -163,16 +175,11 @@ export async function getResourcePerms(ctx: UserCtx) { } export async function addPermission(ctx: UserCtx) { - ctx.body = await updatePermissionOnRole( - ctx.appId, - ctx.params, - PermissionUpdateType.ADD - ) + ctx.body = await updatePermissionOnRole(ctx.params, PermissionUpdateType.ADD) } export async function removePermission(ctx: UserCtx) { ctx.body = await updatePermissionOnRole( - ctx.appId, ctx.params, PermissionUpdateType.REMOVE ) diff --git a/packages/server/src/api/routes/tests/permissions.spec.js b/packages/server/src/api/routes/tests/permissions.spec.ts similarity index 57% rename from packages/server/src/api/routes/tests/permissions.spec.js rename to packages/server/src/api/routes/tests/permissions.spec.ts index ed131aed80..118d35f8fd 100644 --- a/packages/server/src/api/routes/tests/permissions.spec.js +++ b/packages/server/src/api/routes/tests/permissions.spec.ts @@ -1,5 +1,20 @@ -const { roles } = require("@budibase/backend-core") -const setup = require("./utilities") +const mockedSdk = sdk.permissions as jest.Mocked +jest.mock("../../../sdk/app/permissions", () => ({ + resourceActionAllowed: jest.fn(), +})) + +import sdk from "../../../sdk" + +import { roles } from "@budibase/backend-core" +import { + Document, + DocumentType, + PermissionLevel, + Row, + Table, +} from "@budibase/types" +import * as setup from "./utilities" + const { basicRow } = setup.structures const { BUILTIN_ROLE_IDS } = roles @@ -9,29 +24,27 @@ const STD_ROLE_ID = BUILTIN_ROLE_IDS.PUBLIC describe("/permission", () => { let request = setup.getRequest() let config = setup.getConfig() - let table - let perms - let row + let table: Table & { _id: string } + let perms: Document[] + let row: Row afterAll(setup.afterAll) beforeAll(async () => { await config.init() }) - - beforeEach(async () => { - table = await config.createTable() - row = await config.createRow() - perms = await config.addPermission(STD_ROLE_ID, table._id) - }) - async function getTablePermissions() { - return request - .get(`/api/permission/${table._id}`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - } + beforeEach(async () => { + mockedSdk.resourceActionAllowed.mockResolvedValue({ allowed: true }) + + table = (await config.createTable()) as typeof table + row = await config.createRow() + perms = await config.api.permission.set({ + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.READ, + }) + }) describe("levels", () => { it("should be able to get levels", async () => { @@ -65,8 +78,12 @@ describe("/permission", () => { }) it("should get resource permissions with multiple roles", async () => { - perms = await config.addPermission(HIGHER_ROLE_ID, table._id, "write") - const res = await getTablePermissions() + perms = await config.api.permission.set({ + roleId: HIGHER_ROLE_ID, + resourceId: table._id, + 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) const allRes = await request @@ -77,19 +94,59 @@ describe("/permission", () => { expect(allRes.body[table._id]["write"]).toEqual(HIGHER_ROLE_ID) expect(allRes.body[table._id]["read"]).toEqual(STD_ROLE_ID) }) + + it("throw forbidden if the action is not allowed for the resource", async () => { + mockedSdk.resourceActionAllowed.mockResolvedValue({ + allowed: false, + resourceType: DocumentType.DATASOURCE, + level: PermissionLevel.READ, + }) + + const response = await config.api.permission.set( + { + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.EXECUTE, + }, + { expectStatus: 403 } + ) + expect(response.message).toEqual( + "You are not allowed to 'read' the resource type 'datasource'" + ) + }) }) 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(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const res = await config.api.permission.revoke({ + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.READ, + }) expect(res.body[0]._id).toEqual(STD_ROLE_ID) - const permsRes = await getTablePermissions() + const permsRes = await config.api.permission.get(table._id) expect(permsRes.body[STD_ROLE_ID]).toBeUndefined() }) + + it("throw forbidden if the action is not allowed for the resource", async () => { + mockedSdk.resourceActionAllowed.mockResolvedValue({ + allowed: false, + resourceType: DocumentType.DATASOURCE, + level: PermissionLevel.READ, + }) + + const response = await config.api.permission.revoke( + { + roleId: STD_ROLE_ID, + resourceId: table._id, + level: PermissionLevel.EXECUTE, + }, + { expectStatus: 403 } + ) + expect(response.body.message).toEqual( + "You are not allowed to 'read' the resource type 'datasource'" + ) + }) }) describe("check public user allowed", () => { @@ -124,7 +181,9 @@ describe("/permission", () => { .expect("Content-Type", /json/) .expect(200) expect(Array.isArray(res.body)).toEqual(true) - const publicPerm = res.body.find(perm => perm._id === "public") + const publicPerm = res.body.find( + (perm: Document) => perm._id === "public" + ) expect(publicPerm).toBeDefined() expect(publicPerm.permissions).toBeDefined() expect(publicPerm.name).toBeDefined() diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js index 5282597897..a6418c2277 100644 --- a/packages/server/src/api/routes/tests/role.spec.js +++ b/packages/server/src/api/routes/tests/role.spec.js @@ -1,5 +1,6 @@ const { roles, events, permissions } = require("@budibase/backend-core") const setup = require("./utilities") +const { PermissionLevel } = require("@budibase/types") const { basicRole } = setup.structures const { BUILTIN_ROLE_IDS } = roles const { BuiltinPermissionID } = permissions @@ -16,7 +17,7 @@ describe("/roles", () => { const createRole = async (role) => { if (!role) { - role = basicRole() + role = basicRole() } return request @@ -98,7 +99,7 @@ describe("/roles", () => { it("should be able to get the role with a permission added", async () => { const table = await config.createTable() - await config.addPermission(BUILTIN_ROLE_IDS.POWER, table._id) + await config.api.permission.set({ roleId: BUILTIN_ROLE_IDS.POWER, resourceId: table._id, level: PermissionLevel.READ }) const res = await request .get(`/api/roles`) .set(config.defaultHeaders()) diff --git a/packages/server/src/sdk/app/permissions/index.ts b/packages/server/src/sdk/app/permissions/index.ts new file mode 100644 index 0000000000..2219120db6 --- /dev/null +++ b/packages/server/src/sdk/app/permissions/index.ts @@ -0,0 +1,37 @@ +import { + DocumentType, + PermissionLevel, + VirtualDocumentType, +} from "@budibase/types" +import { isViewID } from "../../../db/utils" +import { features } from "@budibase/pro" + +type ResourceActionAllowedResult = + | { allowed: true } + | { + allowed: false + level: PermissionLevel + resourceType: DocumentType | VirtualDocumentType + } + +export async function resourceActionAllowed({ + resourceId, + level, +}: { + resourceId: string + level: PermissionLevel +}): Promise { + if (!isViewID(resourceId)) { + return { allowed: true } + } + + if (await features.isViewPermissionEnabled()) { + return { allowed: true } + } + + return { + allowed: false, + level, + resourceType: VirtualDocumentType.VIEW, + } +} diff --git a/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts b/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts new file mode 100644 index 0000000000..4c2768dde4 --- /dev/null +++ b/packages/server/src/sdk/app/permissions/tests/permissions.spec.ts @@ -0,0 +1,52 @@ +import TestConfiguration from "../../../../tests/utilities/TestConfiguration" +import { PermissionLevel } from "@budibase/types" +import { mocks, structures } from "@budibase/backend-core/tests" +import { resourceActionAllowed } from ".." +import { generateViewID } from "../../../../db/utils" + +describe("permissions sdk", () => { + beforeEach(() => { + new TestConfiguration() + mocks.licenses.useCloudFree() + }) + + describe("resourceActionAllowed", () => { + it("non view resources actions are always allowed", async () => { + const resourceId = structures.users.user()._id! + + const result = await resourceActionAllowed({ + resourceId, + level: PermissionLevel.READ, + }) + + expect(result).toEqual({ allowed: true }) + }) + + it("view resources actions allowed if the feature flag is enabled", async () => { + mocks.licenses.useViewPermissions() + const resourceId = generateViewID(structures.generator.guid()) + + const result = await resourceActionAllowed({ + resourceId, + level: PermissionLevel.READ, + }) + + expect(result).toEqual({ allowed: true }) + }) + + it("view resources actions allowed if the feature flag is disabled", async () => { + const resourceId = generateViewID(structures.generator.guid()) + + const result = await resourceActionAllowed({ + resourceId, + level: PermissionLevel.READ, + }) + + expect(result).toEqual({ + allowed: false, + level: "read", + resourceType: "view", + }) + }) + }) +}) diff --git a/packages/server/src/sdk/index.ts b/packages/server/src/sdk/index.ts index 85ac483c05..24eb1ebf3c 100644 --- a/packages/server/src/sdk/index.ts +++ b/packages/server/src/sdk/index.ts @@ -8,6 +8,7 @@ import { default as rows } from "./app/rows" import { default as users } from "./users" import { default as plugins } from "./plugins" import * as views from "./app/views" +import * as permissions from "./app/permissions" const sdk = { backups, @@ -20,6 +21,7 @@ const sdk = { queries, plugins, views, + permissions, } // default export for TS diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index c8b917f626..c1db54fe60 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -620,18 +620,6 @@ class TestConfiguration { return this._req(config, null, controllers.role.save) } - async addPermission(roleId: string, resourceId: string, level = "read") { - return this._req( - null, - { - roleId, - resourceId, - level, - }, - controllers.perms.addPermission - ) - } - // VIEW async createView(config?: any) { diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index a6002a72d8..40995b62f2 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -1,4 +1,5 @@ import TestConfiguration from "../TestConfiguration" +import { PermissionAPI } from "./permission" import { RowAPI } from "./row" import { TableAPI } from "./table" import { ViewV2API } from "./viewV2" @@ -7,10 +8,12 @@ export default class API { table: TableAPI viewV2: ViewV2API row: RowAPI + permission: PermissionAPI constructor(config: TestConfiguration) { this.table = new TableAPI(config) this.viewV2 = new ViewV2API(config) this.row = new RowAPI(config) + this.permission = new PermissionAPI(config) } } diff --git a/packages/server/src/tests/utilities/api/permission.ts b/packages/server/src/tests/utilities/api/permission.ts new file mode 100644 index 0000000000..ffa89e88f9 --- /dev/null +++ b/packages/server/src/tests/utilities/api/permission.ts @@ -0,0 +1,52 @@ +import { AnyDocument, PermissionLevel } from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" + +export class PermissionAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + get = async ( + resourceId: string, + { expectStatus } = { expectStatus: 200 } + ) => { + return this.request + .get(`/api/permission/${resourceId}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + } + + set = async ( + { + roleId, + resourceId, + level, + }: { roleId: string; resourceId: string; level: PermissionLevel }, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .post(`/api/permission/${roleId}/${resourceId}/${level}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return res.body + } + + revoke = async ( + { + roleId, + resourceId, + level, + }: { roleId: string; resourceId: string; level: PermissionLevel }, + { expectStatus } = { expectStatus: 200 } + ) => { + const res = await this.request + .delete(`/api/permission/${roleId}/${resourceId}/${level}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(expectStatus) + return res + } +} diff --git a/packages/types/src/sdk/licensing/feature.ts b/packages/types/src/sdk/licensing/feature.ts index a1ace01e48..218c2c5429 100644 --- a/packages/types/src/sdk/licensing/feature.ts +++ b/packages/types/src/sdk/licensing/feature.ts @@ -12,6 +12,7 @@ export enum Feature { APP_BUILDERS = "appBuilders", OFFLINE = "offline", USER_ROLE_PUBLIC_API = "userRolePublicApi", + VIEW_PERMISSIONS = "viewPermission", } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }