diff --git a/lerna.json b/lerna.json index c9c790e6d7..46999f07d8 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.9.30-alpha.3", + "version": "2.9.30-alpha.4", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index 55cc97bb1c..14140cba81 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -1,30 +1,30 @@ import env from "../environment" import * as eventHelpers from "./events" import * as accounts from "../accounts" +import * as accountSdk from "../accounts" import * as cache from "../cache" -import { getIdentity, getTenantId, getGlobalDB } from "../context" +import { getGlobalDB, getIdentity, getTenantId } from "../context" import * as dbUtils from "../db" import { EmailUnavailableError, HTTPError } from "../errors" import * as platform from "../platform" import * as sessions from "../security/sessions" import * as usersCore from "./users" import { + Account, AllDocsResponse, BulkUserCreated, BulkUserDeleted, + isSSOAccount, + isSSOUser, RowResponse, SaveUserOpts, User, - Account, - isSSOUser, - isSSOAccount, UserStatus, } from "@budibase/types" -import * as accountSdk from "../accounts" import { - validateUniqueUser, getAccountHolderFromUserIds, isAdmin, + validateUniqueUser, } from "./utils" import { searchExistingEmails } from "./lookup" import { hash } from "../utils" @@ -179,6 +179,14 @@ export class UserDB { return user } + static async bulkGet(userIds: string[]) { + return await usersCore.bulkGetGlobalUsersById(userIds) + } + + static async bulkUpdate(users: User[]) { + return await usersCore.bulkUpdateGlobalUsers(users) + } + static async save(user: User, opts: SaveUserOpts = {}): Promise { // default booleans to true if (opts.hashPassword == null) { diff --git a/packages/backend-core/tests/core/utilities/mocks/licenses.ts b/packages/backend-core/tests/core/utilities/mocks/licenses.ts index 6747282040..14a1f1f4d3 100644 --- a/packages/backend-core/tests/core/utilities/mocks/licenses.ts +++ b/packages/backend-core/tests/core/utilities/mocks/licenses.ts @@ -86,6 +86,10 @@ export const useAuditLogs = () => { return useFeature(Feature.AUDIT_LOGS) } +export const usePublicApiUserRoles = () => { + return useFeature(Feature.USER_ROLE_PUBLIC_API) +} + export const useScimIntegration = () => { return useFeature(Feature.SCIM) } diff --git a/packages/server/specs/openapi.json b/packages/server/specs/openapi.json index d97b09568c..1e5718c5b5 100644 --- a/packages/server/specs/openapi.json +++ b/packages/server/specs/openapi.json @@ -1521,7 +1521,7 @@ "type": "boolean" }, "builder": { - "description": "Describes if the user is a builder user or not.", + "description": "Describes if the user is a builder user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1531,7 +1531,7 @@ } }, "admin": { - "description": "Describes if the user is an admin user or not.", + "description": "Describes if the user is an admin user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1541,7 +1541,7 @@ } }, "roles": { - "description": "Contains the roles of the user per app (assuming they are not a builder user).", + "description": "Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license.", "type": "object", "additionalProperties": { "type": "string", @@ -1588,7 +1588,7 @@ "type": "boolean" }, "builder": { - "description": "Describes if the user is a builder user or not.", + "description": "Describes if the user is a builder user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1598,7 +1598,7 @@ } }, "admin": { - "description": "Describes if the user is an admin user or not.", + "description": "Describes if the user is an admin user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1608,7 +1608,7 @@ } }, "roles": { - "description": "Contains the roles of the user per app (assuming they are not a builder user).", + "description": "Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license.", "type": "object", "additionalProperties": { "type": "string", @@ -1667,7 +1667,7 @@ "type": "boolean" }, "builder": { - "description": "Describes if the user is a builder user or not.", + "description": "Describes if the user is a builder user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1677,7 +1677,7 @@ } }, "admin": { - "description": "Describes if the user is an admin user or not.", + "description": "Describes if the user is an admin user or not. This field can only be set on a business or enterprise license.", "type": "object", "properties": { "global": { @@ -1687,7 +1687,7 @@ } }, "roles": { - "description": "Contains the roles of the user per app (assuming they are not a builder user).", + "description": "Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license.", "type": "object", "additionalProperties": { "type": "string", @@ -1833,6 +1833,137 @@ "required": [ "name" ] + }, + "rolesAssign": { + "type": "object", + "properties": { + "appBuilder": { + "type": "object", + "properties": { + "appId": { + "description": "The app that the users should have app builder privileges granted for.", + "type": "string" + } + }, + "description": "Allow setting users to builders per app.", + "required": [ + "appId" + ] + }, + "builder": { + "type": "boolean", + "description": "Add/remove global builder permissions from the list of users." + }, + "admin": { + "type": "boolean", + "description": "Add/remove global admin permissions from the list of users." + }, + "role": { + "type": "object", + "properties": { + "roleId": { + "description": "The role ID, such as BASIC, ADMIN or a custom role ID.", + "type": "string" + }, + "appId": { + "description": "The app that the role relates to.", + "type": "string" + } + }, + "description": "Add/remove a per-app role, such as BASIC, ADMIN etc.", + "required": [ + "roleId", + "appId" + ] + }, + "userIds": { + "description": "The user IDs to be updated to add/remove the specified roles.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "userIds" + ] + }, + "rolesUnAssign": { + "type": "object", + "properties": { + "appBuilder": { + "type": "object", + "properties": { + "appId": { + "description": "The app that the users should have app builder privileges granted for.", + "type": "string" + } + }, + "description": "Allow setting users to builders per app.", + "required": [ + "appId" + ] + }, + "builder": { + "type": "boolean", + "description": "Add/remove global builder permissions from the list of users." + }, + "admin": { + "type": "boolean", + "description": "Add/remove global admin permissions from the list of users." + }, + "role": { + "type": "object", + "properties": { + "roleId": { + "description": "The role ID, such as BASIC, ADMIN or a custom role ID.", + "type": "string" + }, + "appId": { + "description": "The app that the role relates to.", + "type": "string" + } + }, + "description": "Add/remove a per-app role, such as BASIC, ADMIN etc.", + "required": [ + "roleId", + "appId" + ] + }, + "userIds": { + "description": "The user IDs to be updated to add/remove the specified roles.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "userIds" + ] + }, + "rolesOutput": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "userIds": { + "description": "The updated users' IDs", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "userIds" + ] + } + }, + "required": [ + "data" + ] } } }, @@ -2186,6 +2317,70 @@ } } }, + "/roles/assign": { + "post": { + "operationId": "roleAssign", + "summary": "Assign a role to a list of users", + "description": "This is a business/enterprise only endpoint", + "tags": [ + "roles" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/rolesAssign" + } + } + } + }, + "responses": { + "200": { + "description": "Returns a list of updated user IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/rolesOutput" + } + } + } + } + } + } + }, + "/roles/unassign": { + "post": { + "operationId": "roleUnAssign", + "summary": "Un-assign a role from a list of users", + "description": "This is a business/enterprise only endpoint", + "tags": [ + "roles" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/rolesUnAssign" + } + } + } + }, + "responses": { + "200": { + "description": "Returns a list of updated user IDs", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/rolesOutput" + } + } + } + } + } + } + }, "/tables/{tableId}/rows": { "post": { "operationId": "rowCreate", diff --git a/packages/server/specs/openapi.yaml b/packages/server/specs/openapi.yaml index 86807c9981..07320917b8 100644 --- a/packages/server/specs/openapi.yaml +++ b/packages/server/specs/openapi.yaml @@ -1297,7 +1297,8 @@ components: login. type: boolean builder: - description: Describes if the user is a builder user or not. + description: Describes if the user is a builder user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1305,7 +1306,8 @@ components: system. type: boolean admin: - description: Describes if the user is an admin user or not. + description: Describes if the user is an admin user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1313,7 +1315,8 @@ components: type: boolean roles: description: Contains the roles of the user per app (assuming they are not a - builder user). + builder user). This field can only be set on a business or + enterprise license. type: object additionalProperties: type: string @@ -1352,7 +1355,8 @@ components: login. type: boolean builder: - description: Describes if the user is a builder user or not. + description: Describes if the user is a builder user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1360,7 +1364,8 @@ components: system. type: boolean admin: - description: Describes if the user is an admin user or not. + description: Describes if the user is an admin user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1368,7 +1373,8 @@ components: type: boolean roles: description: Contains the roles of the user per app (assuming they are not a - builder user). + builder user). This field can only be set on a business or + enterprise license. type: object additionalProperties: type: string @@ -1415,7 +1421,8 @@ components: login. type: boolean builder: - description: Describes if the user is a builder user or not. + description: Describes if the user is a builder user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1423,7 +1430,8 @@ components: system. type: boolean admin: - description: Describes if the user is an admin user or not. + description: Describes if the user is an admin user or not. This field can only + be set on a business or enterprise license. type: object properties: global: @@ -1431,7 +1439,8 @@ components: type: boolean roles: description: Contains the roles of the user per app (assuming they are not a - builder user). + builder user). This field can only be set on a business or + enterprise license. type: object additionalProperties: type: string @@ -1547,6 +1556,99 @@ components: insensitive starts with match. required: - name + rolesAssign: + type: object + properties: + appBuilder: + type: object + properties: + appId: + description: The app that the users should have app builder privileges granted + for. + type: string + description: Allow setting users to builders per app. + required: + - appId + builder: + type: boolean + description: Add/remove global builder permissions from the list of users. + admin: + type: boolean + description: Add/remove global admin permissions from the list of users. + role: + type: object + properties: + roleId: + description: The role ID, such as BASIC, ADMIN or a custom role ID. + type: string + appId: + description: The app that the role relates to. + type: string + description: Add/remove a per-app role, such as BASIC, ADMIN etc. + required: + - roleId + - appId + userIds: + description: The user IDs to be updated to add/remove the specified roles. + type: array + items: + type: string + required: + - userIds + rolesUnAssign: + type: object + properties: + appBuilder: + type: object + properties: + appId: + description: The app that the users should have app builder privileges granted + for. + type: string + description: Allow setting users to builders per app. + required: + - appId + builder: + type: boolean + description: Add/remove global builder permissions from the list of users. + admin: + type: boolean + description: Add/remove global admin permissions from the list of users. + role: + type: object + properties: + roleId: + description: The role ID, such as BASIC, ADMIN or a custom role ID. + type: string + appId: + description: The app that the role relates to. + type: string + description: Add/remove a per-app role, such as BASIC, ADMIN etc. + required: + - roleId + - appId + userIds: + description: The user IDs to be updated to add/remove the specified roles. + type: array + items: + type: string + required: + - userIds + rolesOutput: + type: object + properties: + data: + type: object + properties: + userIds: + description: The updated users' IDs + type: array + items: + type: string + required: + - userIds + required: + - data security: - ApiKeyAuth: [] paths: @@ -1757,6 +1859,46 @@ paths: examples: queries: $ref: "#/components/examples/queries" + /roles/assign: + post: + operationId: roleAssign + summary: Assign a role to a list of users + description: This is a business/enterprise only endpoint + tags: + - roles + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/rolesAssign" + responses: + "200": + description: Returns a list of updated user IDs + content: + application/json: + schema: + $ref: "#/components/schemas/rolesOutput" + /roles/unassign: + post: + operationId: roleUnAssign + summary: Un-assign a role from a list of users + description: This is a business/enterprise only endpoint + tags: + - roles + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/rolesUnAssign" + responses: + "200": + description: Returns a list of updated user IDs + content: + application/json: + schema: + $ref: "#/components/schemas/rolesOutput" "/tables/{tableId}/rows": post: operationId: rowCreate diff --git a/packages/server/specs/resources/index.ts b/packages/server/specs/resources/index.ts index 6b8a1aa437..c06148b7de 100644 --- a/packages/server/specs/resources/index.ts +++ b/packages/server/specs/resources/index.ts @@ -5,6 +5,7 @@ import query from "./query" import user from "./user" import metrics from "./metrics" import misc from "./misc" +import roles from "./roles" export const examples = { ...application.getExamples(), @@ -23,4 +24,5 @@ export const schemas = { ...query.getSchemas(), ...user.getSchemas(), ...misc.getSchemas(), + ...roles.getSchemas(), } diff --git a/packages/server/specs/resources/roles.ts b/packages/server/specs/resources/roles.ts new file mode 100644 index 0000000000..1033d640ce --- /dev/null +++ b/packages/server/specs/resources/roles.ts @@ -0,0 +1,65 @@ +import { object } from "./utils" +import Resource from "./utils/Resource" + +const roleSchema = object( + { + appBuilder: object( + { + appId: { + description: + "The app that the users should have app builder privileges granted for.", + type: "string", + }, + }, + { description: "Allow setting users to builders per app." } + ), + builder: { + type: "boolean", + description: + "Add/remove global builder permissions from the list of users.", + }, + admin: { + type: "boolean", + description: + "Add/remove global admin permissions from the list of users.", + }, + role: object( + { + roleId: { + description: "The role ID, such as BASIC, ADMIN or a custom role ID.", + type: "string", + }, + appId: { + description: "The app that the role relates to.", + type: "string", + }, + }, + { description: "Add/remove a per-app role, such as BASIC, ADMIN etc." } + ), + userIds: { + description: + "The user IDs to be updated to add/remove the specified roles.", + type: "array", + items: { + type: "string", + }, + }, + }, + { required: ["userIds"] } +) + +export default new Resource().setSchemas({ + rolesAssign: roleSchema, + rolesUnAssign: roleSchema, + rolesOutput: object({ + data: object({ + userIds: { + description: "The updated users' IDs", + type: "array", + items: { + type: "string", + }, + }, + }), + }), +}) diff --git a/packages/server/specs/resources/user.ts b/packages/server/specs/resources/user.ts index a7b9f1ddb9..d00ed02f81 100644 --- a/packages/server/specs/resources/user.ts +++ b/packages/server/specs/resources/user.ts @@ -58,7 +58,8 @@ const userSchema = object( type: "boolean", }, builder: { - description: "Describes if the user is a builder user or not.", + description: + "Describes if the user is a builder user or not. This field can only be set on a business or enterprise license.", type: "object", properties: { global: { @@ -69,7 +70,8 @@ const userSchema = object( }, }, admin: { - description: "Describes if the user is an admin user or not.", + description: + "Describes if the user is an admin user or not. This field can only be set on a business or enterprise license.", type: "object", properties: { global: { @@ -81,7 +83,7 @@ const userSchema = object( }, roles: { description: - "Contains the roles of the user per app (assuming they are not a builder user).", + "Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license.", type: "object", additionalProperties: { type: "string", diff --git a/packages/server/src/api/controllers/public/applications.ts b/packages/server/src/api/controllers/public/applications.ts index c4041bedcf..fd72db95d3 100644 --- a/packages/server/src/api/controllers/public/applications.ts +++ b/packages/server/src/api/controllers/public/applications.ts @@ -3,6 +3,8 @@ import { search as stringSearch, addRev } from "./utils" import * as controller from "../application" import * as deployController from "../deploy" import { Application } from "../../../definitions/common" +import { UserCtx } from "@budibase/types" +import { Next } from "koa" function fixAppID(app: Application, params: any) { if (!params) { @@ -14,7 +16,7 @@ function fixAppID(app: Application, params: any) { return app } -async function setResponseApp(ctx: any) { +async function setResponseApp(ctx: UserCtx) { const appId = ctx.body?.appId if (appId && (!ctx.params || !ctx.params.appId)) { ctx.params = { appId } @@ -28,14 +30,14 @@ async function setResponseApp(ctx: any) { } } -export async function search(ctx: any, next: any) { +export async function search(ctx: UserCtx, next: Next) { const { name } = ctx.request.body const apps = await dbCore.getAllApps({ all: true }) ctx.body = stringSearch(apps, name) await next() } -export async function create(ctx: any, next: any) { +export async function create(ctx: UserCtx, next: Next) { if (!ctx.request.body || !ctx.request.body.useTemplate) { ctx.request.body = { useTemplate: false, @@ -47,14 +49,14 @@ export async function create(ctx: any, next: any) { await next() } -export async function read(ctx: any, next: any) { +export async function read(ctx: UserCtx, next: Next) { await context.doInAppContext(ctx.params.appId, async () => { await setResponseApp(ctx) await next() }) } -export async function update(ctx: any, next: any) { +export async function update(ctx: UserCtx, next: Next) { ctx.request.body = await addRev(fixAppID(ctx.request.body, ctx.params)) await context.doInAppContext(ctx.params.appId, async () => { await controller.update(ctx) @@ -63,7 +65,7 @@ export async function update(ctx: any, next: any) { }) } -export async function destroy(ctx: any, next: any) { +export async function destroy(ctx: UserCtx, next: Next) { await context.doInAppContext(ctx.params.appId, async () => { // get the app before deleting it await setResponseApp(ctx) @@ -75,14 +77,14 @@ export async function destroy(ctx: any, next: any) { }) } -export async function unpublish(ctx: any, next: any) { +export async function unpublish(ctx: UserCtx, next: Next) { await context.doInAppContext(ctx.params.appId, async () => { await controller.unpublish(ctx) await next() }) } -export async function publish(ctx: any, next: any) { +export async function publish(ctx: UserCtx, next: Next) { await context.doInAppContext(ctx.params.appId, async () => { await deployController.publishApp(ctx) await next() diff --git a/packages/server/src/api/controllers/public/mapping/types.ts b/packages/server/src/api/controllers/public/mapping/types.ts index e3c8719d87..9fea9b7213 100644 --- a/packages/server/src/api/controllers/public/mapping/types.ts +++ b/packages/server/src/api/controllers/public/mapping/types.ts @@ -16,6 +16,10 @@ export type CreateRowParams = components["schemas"]["row"] export type User = components["schemas"]["userOutput"]["data"] export type CreateUserParams = components["schemas"]["user"] +export type RoleAssignRequest = components["schemas"]["rolesAssign"] +export type RoleUnAssignRequest = components["schemas"]["rolesUnAssign"] +export type RoleAssignmentResponse = components["schemas"]["rolesOutput"] + export type SearchInputParams = | components["schemas"]["nameSearch"] | components["schemas"]["rowSearch"] diff --git a/packages/server/src/api/controllers/public/queries.ts b/packages/server/src/api/controllers/public/queries.ts index 57ec608379..3cb1ab3812 100644 --- a/packages/server/src/api/controllers/public/queries.ts +++ b/packages/server/src/api/controllers/public/queries.ts @@ -1,14 +1,16 @@ import { search as stringSearch } from "./utils" import * as queryController from "../query" +import { UserCtx } from "@budibase/types" +import { Next } from "koa" -export async function search(ctx: any, next: any) { +export async function search(ctx: UserCtx, next: Next) { await queryController.fetch(ctx) const { name } = ctx.request.body ctx.body = stringSearch(ctx.body, name) await next() } -export async function execute(ctx: any, next: any) { +export async function execute(ctx: UserCtx, next: Next) { // don't wrap this, already returns "data" await queryController.executeV2(ctx) await next() diff --git a/packages/server/src/api/controllers/public/roles.ts b/packages/server/src/api/controllers/public/roles.ts new file mode 100644 index 0000000000..362f25da58 --- /dev/null +++ b/packages/server/src/api/controllers/public/roles.ts @@ -0,0 +1,33 @@ +import { UserCtx } from "@budibase/types" +import { Next } from "koa" +import { sdk } from "@budibase/pro" +import { + RoleAssignmentResponse, + RoleUnAssignRequest, + RoleAssignRequest, +} from "./mapping/types" + +async function assign( + ctx: UserCtx, + next: Next +) { + const { userIds, ...assignmentProps } = ctx.request.body + await sdk.publicApi.roles.assign(userIds, assignmentProps) + ctx.body = { data: { userIds } } + await next() +} + +async function unAssign( + ctx: UserCtx, + next: Next +) { + const { userIds, ...unAssignmentProps } = ctx.request.body + await sdk.publicApi.roles.unAssign(userIds, unAssignmentProps) + ctx.body = { data: { userIds } } + await next() +} + +export default { + assign, + unAssign, +} diff --git a/packages/server/src/api/controllers/public/rows.ts b/packages/server/src/api/controllers/public/rows.ts index 39cf85a2a3..16403b06c9 100644 --- a/packages/server/src/api/controllers/public/rows.ts +++ b/packages/server/src/api/controllers/public/rows.ts @@ -1,7 +1,8 @@ import * as rowController from "../row" import { addRev } from "./utils" -import { Row } from "@budibase/types" +import { Row, UserCtx } from "@budibase/types" import { convertBookmark } from "../../../utilities" +import { Next } from "koa" // makes sure that the user doesn't need to pass in the type, tableId or _id params for // the call to be correct @@ -21,7 +22,7 @@ export function fixRow(row: Row, params: any) { return row } -export async function search(ctx: any, next: any) { +export async function search(ctx: UserCtx, next: Next) { let { sort, paginate, bookmark, limit, query } = ctx.request.body // update the body to the correct format of the internal search if (!sort) { @@ -40,25 +41,25 @@ export async function search(ctx: any, next: any) { await next() } -export async function create(ctx: any, next: any) { +export async function create(ctx: UserCtx, next: Next) { ctx.request.body = fixRow(ctx.request.body, ctx.params) await rowController.save(ctx) await next() } -export async function read(ctx: any, next: any) { +export async function read(ctx: UserCtx, next: Next) { await rowController.fetchEnrichedRow(ctx) await next() } -export async function update(ctx: any, next: any) { +export async function update(ctx: UserCtx, next: Next) { const { tableId } = ctx.params ctx.request.body = await addRev(fixRow(ctx.request.body, ctx.params), tableId) await rowController.save(ctx) await next() } -export async function destroy(ctx: any, next: any) { +export async function destroy(ctx: UserCtx, next: Next) { const { tableId } = ctx.params // set the body as expected, with the _id and _rev fields ctx.request.body = await addRev( diff --git a/packages/server/src/api/controllers/public/tables.ts b/packages/server/src/api/controllers/public/tables.ts index a346a750da..7486172fa3 100644 --- a/packages/server/src/api/controllers/public/tables.ts +++ b/packages/server/src/api/controllers/public/tables.ts @@ -1,6 +1,7 @@ import { search as stringSearch, addRev } from "./utils" import * as controller from "../table" -import { Table } from "@budibase/types" +import { Table, UserCtx } from "@budibase/types" +import { Next } from "koa" function fixTable(table: Table, params: any) { if (!params || !table) { @@ -15,24 +16,24 @@ function fixTable(table: Table, params: any) { return table } -export async function search(ctx: any, next: any) { +export async function search(ctx: UserCtx, next: Next) { const { name } = ctx.request.body await controller.fetch(ctx) ctx.body = stringSearch(ctx.body, name) await next() } -export async function create(ctx: any, next: any) { +export async function create(ctx: UserCtx, next: Next) { await controller.save(ctx) await next() } -export async function read(ctx: any, next: any) { +export async function read(ctx: UserCtx, next: Next) { await controller.find(ctx) await next() } -export async function update(ctx: any, next: any) { +export async function update(ctx: UserCtx, next: Next) { ctx.request.body = await addRev( fixTable(ctx.request.body, ctx.params), ctx.params.tableId @@ -41,7 +42,7 @@ export async function update(ctx: any, next: any) { await next() } -export async function destroy(ctx: any, next: any) { +export async function destroy(ctx: UserCtx, next: Next) { await controller.destroy(ctx) ctx.body = ctx.table await next() diff --git a/packages/server/src/api/controllers/public/users.ts b/packages/server/src/api/controllers/public/users.ts index 7192077d04..bb6fc3a6e7 100644 --- a/packages/server/src/api/controllers/public/users.ts +++ b/packages/server/src/api/controllers/public/users.ts @@ -7,16 +7,18 @@ import { import { publicApiUserFix } from "../../../utilities/users" import { db as dbCore } from "@budibase/backend-core" import { search as stringSearch } from "./utils" -import { BBContext, User } from "@budibase/types" +import { UserCtx, User } from "@budibase/types" +import { Next } from "koa" +import { sdk } from "@budibase/pro" -function isLoggedInUser(ctx: BBContext, user: User) { +function isLoggedInUser(ctx: UserCtx, user: User) { const loggedInId = ctx.user?._id const globalUserId = dbCore.getGlobalIDFromUserMetadataID(loggedInId!) // check both just incase return globalUserId === user._id || loggedInId === user._id } -function getUser(ctx: BBContext, userId?: string) { +function getUser(ctx: UserCtx, userId?: string) { if (userId) { ctx.params = { userId } } else if (!ctx.params?.userId) { @@ -25,42 +27,38 @@ function getUser(ctx: BBContext, userId?: string) { return readGlobalUser(ctx) } -export async function search(ctx: BBContext, next: any) { +export async function search(ctx: UserCtx, next: Next) { const { name } = ctx.request.body const users = await allGlobalUsers(ctx) ctx.body = stringSearch(users, name, "email") await next() } -export async function create(ctx: BBContext, next: any) { - const response = await saveGlobalUser(publicApiUserFix(ctx)) +export async function create(ctx: UserCtx, next: Next) { + ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx)) + const response = await saveGlobalUser(ctx) ctx.body = await getUser(ctx, response._id) await next() } -export async function read(ctx: BBContext, next: any) { +export async function read(ctx: UserCtx, next: Next) { ctx.body = await readGlobalUser(ctx) await next() } -export async function update(ctx: BBContext, next: any) { +export async function update(ctx: UserCtx, next: Next) { const user = await readGlobalUser(ctx) ctx.request.body = { ...ctx.request.body, _rev: user._rev, } - // disallow updating your own role - always overwrite with DB roles - if (isLoggedInUser(ctx, user)) { - ctx.request.body.builder = user.builder - ctx.request.body.admin = user.admin - ctx.request.body.roles = user.roles - } - const response = await saveGlobalUser(publicApiUserFix(ctx)) + ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx, user)) + const response = await saveGlobalUser(ctx) ctx.body = await getUser(ctx, response._id) await next() } -export async function destroy(ctx: BBContext, next: any) { +export async function destroy(ctx: UserCtx, next: Next) { const user = await getUser(ctx) // disallow deleting yourself if (isLoggedInUser(ctx, user)) { diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index cec7d5e1f8..491cbfa8f9 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -16,6 +16,7 @@ import { EmptyFilterOption, } from "@budibase/types" import sdk from "../../../sdk" +import * as utils from "./utils" import { dataFilters } from "@budibase/shared-core" export async function handleRequest( @@ -52,7 +53,7 @@ export async function handleRequest( } export async function patch(ctx: UserCtx) { - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const { _id, ...rowData } = ctx.request.body const validateResult = await sdk.rows.utils.validate({ @@ -79,7 +80,7 @@ export async function patch(ctx: UserCtx) { export async function save(ctx: UserCtx) { const inputs = ctx.request.body - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const validateResult = await sdk.rows.utils.validate({ row: inputs, tableId, @@ -107,12 +108,12 @@ export async function save(ctx: UserCtx) { export async function find(ctx: UserCtx) { const id = ctx.params.rowId - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) return sdk.rows.external.getRow(tableId, id) } export async function destroy(ctx: UserCtx) { - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const _id = ctx.request.body._id const { row } = (await handleRequest(Operation.DELETE, tableId, { id: breakRowIdField(_id), @@ -123,7 +124,7 @@ export async function destroy(ctx: UserCtx) { export async function bulkDestroy(ctx: UserCtx) { const { rows } = ctx.request.body - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) let promises: Promise[] = [] for (let row of rows) { promises.push( @@ -139,7 +140,7 @@ export async function bulkDestroy(ctx: UserCtx) { export async function fetchEnrichedRow(ctx: UserCtx) { const id = ctx.params.rowId - const tableId = ctx.params.tableId + const tableId = utils.getTableId(ctx) const { datasourceId, tableName } = breakExternalTableId(tableId) const datasource: Datasource = await sdk.datasources.get(datasourceId!) if (!tableName) { diff --git a/packages/server/src/api/routes/public/roles.ts b/packages/server/src/api/routes/public/roles.ts new file mode 100644 index 0000000000..905f364cbe --- /dev/null +++ b/packages/server/src/api/routes/public/roles.ts @@ -0,0 +1,56 @@ +import controller from "../../controllers/public/roles" +import Endpoint from "./utils/Endpoint" + +const write = [] + +/** + * @openapi + * /roles/assign: + * post: + * operationId: roleAssign + * summary: Assign a role to a list of users + * description: This is a business/enterprise only endpoint + * tags: + * - roles + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/rolesAssign' + * responses: + * 200: + * description: Returns a list of updated user IDs + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/rolesOutput' + */ +write.push(new Endpoint("post", "/roles/assign", controller.assign)) + +/** + * @openapi + * /roles/unassign: + * post: + * operationId: roleUnAssign + * summary: Un-assign a role from a list of users + * description: This is a business/enterprise only endpoint + * tags: + * - roles + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/rolesUnAssign' + * responses: + * 200: + * description: Returns a list of updated user IDs + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/rolesOutput' + */ +write.push(new Endpoint("post", "/roles/unassign", controller.unAssign)) + +export default { write, read: [] } diff --git a/packages/server/src/api/routes/public/tests/users.spec.js b/packages/server/src/api/routes/public/tests/users.spec.js deleted file mode 100644 index 1daa611df8..0000000000 --- a/packages/server/src/api/routes/public/tests/users.spec.js +++ /dev/null @@ -1,38 +0,0 @@ -const setup = require("../../tests/utilities") -const { generateMakeRequest } = require("./utils") - -const workerRequests = require("../../../../utilities/workerRequests") - -let config = setup.getConfig() -let apiKey, globalUser, makeRequest - -beforeAll(async () => { - await config.init() - globalUser = await config.globalUser() - apiKey = await config.generateApiKey(globalUser._id) - makeRequest = generateMakeRequest(apiKey) - workerRequests.readGlobalUser.mockReturnValue(globalUser) -}) - -afterAll(setup.afterAll) - -describe("check user endpoints", () => { - it("should not allow a user to update their own roles", async () => { - const res = await makeRequest("put", `/users/${globalUser._id}`, { - ...globalUser, - roles: { - "app_1": "ADMIN", - } - }) - expect(workerRequests.saveGlobalUser.mock.lastCall[0].body.data.roles["app_1"]).toBeUndefined() - expect(res.status).toBe(200) - expect(res.body.data.roles["app_1"]).toBeUndefined() - }) - - it("should not allow a user to delete themselves", async () => { - const res = await makeRequest("delete", `/users/${globalUser._id}`) - expect(res.status).toBe(405) - expect(workerRequests.deleteGlobalUser.mock.lastCall).toBeUndefined() - }) -}) - diff --git a/packages/server/src/api/routes/public/tests/users.spec.ts b/packages/server/src/api/routes/public/tests/users.spec.ts new file mode 100644 index 0000000000..c81acca1df --- /dev/null +++ b/packages/server/src/api/routes/public/tests/users.spec.ts @@ -0,0 +1,126 @@ +import * as setup from "../../tests/utilities" +import { generateMakeRequest, MakeRequestResponse } from "./utils" +import { User } from "@budibase/types" +import { mocks } from "@budibase/backend-core/tests" + +import * as workerRequests from "../../../../utilities/workerRequests" + +const mockedWorkerReq = jest.mocked(workerRequests) + +let config = setup.getConfig() +let apiKey: string, globalUser: User, makeRequest: MakeRequestResponse + +beforeAll(async () => { + await config.init() + globalUser = await config.globalUser() + apiKey = await config.generateApiKey(globalUser._id) + makeRequest = generateMakeRequest(apiKey) + mockedWorkerReq.readGlobalUser.mockImplementation(() => + Promise.resolve(globalUser) + ) +}) + +afterAll(setup.afterAll) + +function base() { + return { + tenantId: config.getTenantId(), + firstName: "Test", + lastName: "Test", + } +} + +function updateMock() { + mockedWorkerReq.readGlobalUser.mockImplementation(ctx => ctx.request.body) +} + +describe("check user endpoints", () => { + it("should not allow a user to update their own roles", async () => { + const res = await makeRequest("put", `/users/${globalUser._id}`, { + ...globalUser, + roles: { + app_1: "ADMIN", + }, + }) + expect( + mockedWorkerReq.saveGlobalUser.mock.lastCall?.[0].body.data.roles["app_1"] + ).toBeUndefined() + expect(res.status).toBe(200) + expect(res.body.data.roles["app_1"]).toBeUndefined() + }) + + it("should not allow a user to delete themselves", async () => { + const res = await makeRequest("delete", `/users/${globalUser._id}`) + expect(res.status).toBe(405) + expect(mockedWorkerReq.deleteGlobalUser.mock.lastCall).toBeUndefined() + }) +}) + +describe("no user role update in free", () => { + beforeAll(() => { + updateMock() + }) + + it("should not allow 'roles' to be updated", async () => { + const res = await makeRequest("post", "/users", { + ...base(), + roles: { app_a: "BASIC" }, + }) + expect(res.status).toBe(200) + expect(res.body.data.roles["app_a"]).toBeUndefined() + }) + + it("should not allow 'admin' to be updated", async () => { + const res = await makeRequest("post", "/users", { + ...base(), + admin: { global: true }, + }) + expect(res.status).toBe(200) + expect(res.body.data.admin).toBeUndefined() + }) + + it("should not allow 'builder' to be updated", async () => { + const res = await makeRequest("post", "/users", { + ...base(), + builder: { global: true }, + }) + expect(res.status).toBe(200) + expect(res.body.data.builder).toBeUndefined() + }) +}) + +describe("no user role update in business", () => { + beforeAll(() => { + updateMock() + mocks.licenses.usePublicApiUserRoles() + }) + + it("should allow 'roles' to be updated", async () => { + const res = await makeRequest("post", "/users", { + ...base(), + roles: { app_a: "BASIC" }, + }) + expect(res.status).toBe(200) + expect(res.body.data.roles["app_a"]).toBe("BASIC") + }) + + it("should allow 'admin' to be updated", async () => { + mocks.licenses.usePublicApiUserRoles() + const res = await makeRequest("post", "/users", { + ...base(), + admin: { global: true }, + }) + expect(res.status).toBe(200) + expect(res.body.data.admin.global).toBe(true) + }) + + it("should allow 'builder' to be updated", async () => { + mocks.licenses.usePublicApiUserRoles() + const res = await makeRequest("post", "/users", { + ...base(), + builder: { global: true }, + }) + expect(res.status).toBe(200) + expect(res.body.data.builder.global).toBe(true) + }) +}) diff --git a/packages/server/src/definitions/openapi.ts b/packages/server/src/definitions/openapi.ts index 5ca4990647..fe5c17b218 100644 --- a/packages/server/src/definitions/openapi.ts +++ b/packages/server/src/definitions/openapi.ts @@ -34,6 +34,14 @@ export interface paths { /** Based on query properties (currently only name) search for queries. */ post: operations["querySearch"]; }; + "/roles/assign": { + /** This is a business/enterprise only endpoint */ + post: operations["roleAssign"]; + }; + "/roles/unassign": { + /** This is a business/enterprise only endpoint */ + post: operations["roleUnAssign"]; + }; "/tables/{tableId}/rows": { /** Creates a row within the specified table. */ post: operations["rowCreate"]; @@ -256,7 +264,8 @@ export interface components { | "auto" | "json" | "internal" - | "barcodeqr"; + | "barcodeqr" + | "bigint"; /** @description A constraint can be applied to the column which will be validated against when a row is saved. */ constraints?: { /** @enum {string} */ @@ -362,7 +371,8 @@ export interface components { | "auto" | "json" | "internal" - | "barcodeqr"; + | "barcodeqr" + | "bigint"; /** @description A constraint can be applied to the column which will be validated against when a row is saved. */ constraints?: { /** @enum {string} */ @@ -470,7 +480,8 @@ export interface components { | "auto" | "json" | "internal" - | "barcodeqr"; + | "barcodeqr" + | "bigint"; /** @description A constraint can be applied to the column which will be validated against when a row is saved. */ constraints?: { /** @enum {string} */ @@ -577,17 +588,17 @@ export interface components { lastName?: string; /** @description If set to true forces the user to reset their password on first login. */ forceResetPassword?: boolean; - /** @description Describes if the user is a builder user or not. */ + /** @description Describes if the user is a builder user or not. This field can only be set on a business or enterprise license. */ builder?: { /** @description If set to true the user will be able to build any app in the system. */ global?: boolean; }; - /** @description Describes if the user is an admin user or not. */ + /** @description Describes if the user is an admin user or not. This field can only be set on a business or enterprise license. */ admin?: { /** @description If set to true the user will be able to administrate the system. */ global?: boolean; }; - /** @description Contains the roles of the user per app (assuming they are not a builder user). */ + /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ roles: { [key: string]: string }; }; userOutput: { @@ -607,17 +618,17 @@ export interface components { lastName?: string; /** @description If set to true forces the user to reset their password on first login. */ forceResetPassword?: boolean; - /** @description Describes if the user is a builder user or not. */ + /** @description Describes if the user is a builder user or not. This field can only be set on a business or enterprise license. */ builder?: { /** @description If set to true the user will be able to build any app in the system. */ global?: boolean; }; - /** @description Describes if the user is an admin user or not. */ + /** @description Describes if the user is an admin user or not. This field can only be set on a business or enterprise license. */ admin?: { /** @description If set to true the user will be able to administrate the system. */ global?: boolean; }; - /** @description Contains the roles of the user per app (assuming they are not a builder user). */ + /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ roles: { [key: string]: string }; /** @description The ID of the user. */ _id: string; @@ -640,17 +651,17 @@ export interface components { lastName?: string; /** @description If set to true forces the user to reset their password on first login. */ forceResetPassword?: boolean; - /** @description Describes if the user is a builder user or not. */ + /** @description Describes if the user is a builder user or not. This field can only be set on a business or enterprise license. */ builder?: { /** @description If set to true the user will be able to build any app in the system. */ global?: boolean; }; - /** @description Describes if the user is an admin user or not. */ + /** @description Describes if the user is an admin user or not. This field can only be set on a business or enterprise license. */ admin?: { /** @description If set to true the user will be able to administrate the system. */ global?: boolean; }; - /** @description Contains the roles of the user per app (assuming they are not a builder user). */ + /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ roles: { [key: string]: string }; /** @description The ID of the user. */ _id: string; @@ -712,6 +723,52 @@ export interface components { /** @description The name to be used when searching - this will be used in a case insensitive starts with match. */ name: string; }; + rolesAssign: { + /** @description Allow setting users to builders per app. */ + appBuilder?: { + /** @description The app that the users should have app builder privileges granted for. */ + appId: string; + }; + /** @description Add/remove global builder permissions from the list of users. */ + builder?: boolean; + /** @description Add/remove global admin permissions from the list of users. */ + admin?: boolean; + /** @description Add/remove a per-app role, such as BASIC, ADMIN etc. */ + role?: { + /** @description The role ID, such as BASIC, ADMIN or a custom role ID. */ + roleId: string; + /** @description The app that the role relates to. */ + appId: string; + }; + /** @description The user IDs to be updated to add/remove the specified roles. */ + userIds: string[]; + }; + rolesUnAssign: { + /** @description Allow setting users to builders per app. */ + appBuilder?: { + /** @description The app that the users should have app builder privileges granted for. */ + appId: string; + }; + /** @description Add/remove global builder permissions from the list of users. */ + builder?: boolean; + /** @description Add/remove global admin permissions from the list of users. */ + admin?: boolean; + /** @description Add/remove a per-app role, such as BASIC, ADMIN etc. */ + role?: { + /** @description The role ID, such as BASIC, ADMIN or a custom role ID. */ + roleId: string; + /** @description The app that the role relates to. */ + appId: string; + }; + /** @description The user IDs to be updated to add/remove the specified roles. */ + userIds: string[]; + }; + rolesOutput: { + data: { + /** @description The updated users' IDs */ + userIds: string[]; + }; + }; }; parameters: { /** @description The ID of the table which this request is targeting. */ @@ -907,6 +964,38 @@ export interface operations { }; }; }; + /** This is a business/enterprise only endpoint */ + roleAssign: { + responses: { + /** Returns a list of updated user IDs */ + 200: { + content: { + "application/json": components["schemas"]["rolesOutput"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["rolesAssign"]; + }; + }; + }; + /** This is a business/enterprise only endpoint */ + roleUnAssign: { + responses: { + /** Returns a list of updated user IDs */ + 200: { + content: { + "application/json": components["schemas"]["rolesOutput"]; + }; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["rolesUnAssign"]; + }; + }; + }; /** Creates a row within the specified table. */ rowCreate: { parameters: { diff --git a/packages/server/src/utilities/users.ts b/packages/server/src/utilities/users.ts index 1498a79719..f841ec3646 100644 --- a/packages/server/src/utilities/users.ts +++ b/packages/server/src/utilities/users.ts @@ -1,9 +1,9 @@ import { InternalTables } from "../db/utils" import { getGlobalUser } from "./global" -import { context, db as dbCore, roles } from "@budibase/backend-core" -import { BBContext } from "@budibase/types" +import { context, roles } from "@budibase/backend-core" +import { UserCtx } from "@budibase/types" -export async function getFullUser(ctx: BBContext, userId: string) { +export async function getFullUser(ctx: UserCtx, userId: string) { const global = await getGlobalUser(userId) let metadata: any = {} @@ -29,21 +29,12 @@ export async function getFullUser(ctx: BBContext, userId: string) { } } -export function publicApiUserFix(ctx: BBContext) { +export function publicApiUserFix(ctx: UserCtx) { if (!ctx.request.body) { return ctx } if (!ctx.request.body._id && ctx.params.userId) { ctx.request.body._id = ctx.params.userId } - if (!ctx.request.body.roles) { - ctx.request.body.roles = {} - } else { - const newRoles: { [key: string]: any } = {} - for (let [appId, role] of Object.entries(ctx.request.body.roles)) { - newRoles[dbCore.getProdAppID(appId)] = role - } - ctx.request.body.roles = newRoles - } return ctx } diff --git a/packages/types/src/sdk/licensing/feature.ts b/packages/types/src/sdk/licensing/feature.ts index 20f6813409..a1ace01e48 100644 --- a/packages/types/src/sdk/licensing/feature.ts +++ b/packages/types/src/sdk/licensing/feature.ts @@ -11,6 +11,7 @@ export enum Feature { SYNC_AUTOMATIONS = "syncAutomations", APP_BUILDERS = "appBuilders", OFFLINE = "offline", + USER_ROLE_PUBLIC_API = "userRolePublicApi", } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }