@budibase/worker 3.28.3 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.28.3",
4
+ "version": "3.30.0",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -66,6 +66,8 @@
66
66
  "passport-local": "1.0.0",
67
67
  "pouchdb": "9.0.0",
68
68
  "pouchdb-all-dbs": "1.1.1",
69
+ "scim-patch": "^0.8.1",
70
+ "scim2-parse-filter": "^0.2.8",
69
71
  "server-destroy": "1.0.1",
70
72
  "undici": "^7.16.0",
71
73
  "uuid": "^8.3.2",
@@ -107,5 +109,5 @@
107
109
  }
108
110
  }
109
111
  },
110
- "gitHead": "40bc76dc62aa2c1876e442d464c17aa388630975"
112
+ "gitHead": "58456cc695d2c71a354bcf5c064943b247ad4969"
111
113
  }
@@ -0,0 +1,38 @@
1
+ import { Readable } from "stream"
2
+ import { events } from "@budibase/backend-core"
3
+ import { auditLogs } from "@budibase/pro"
4
+ import {
5
+ SearchAuditLogsRequest,
6
+ SearchAuditLogsResponse,
7
+ DownloadAuditLogsRequest,
8
+ DefinitionsAuditLogsResponse,
9
+ AuditLogSearchParams,
10
+ UserCtx,
11
+ } from "@budibase/types"
12
+
13
+ export async function search(
14
+ ctx: UserCtx<SearchAuditLogsRequest, SearchAuditLogsResponse>
15
+ ) {
16
+ const search: AuditLogSearchParams = ctx.request.body
17
+ const fetched = await auditLogs.fetch(search)
18
+ await events.auditLog.filtered(search)
19
+ ctx.body = fetched
20
+ }
21
+
22
+ export async function download(
23
+ ctx: UserCtx<DownloadAuditLogsRequest, Readable>
24
+ ) {
25
+ const search: AuditLogSearchParams = ctx.request.body
26
+ const { stream } = auditLogs.download(search)
27
+ await events.auditLog.downloaded(search)
28
+ ctx.attachment(`audit-logs-${Date.now()}.log`)
29
+ ctx.body = stream
30
+ }
31
+
32
+ export async function definitions(
33
+ ctx: UserCtx<void, DefinitionsAuditLogsResponse>
34
+ ) {
35
+ ctx.body = {
36
+ events: auditLogs.definitions(),
37
+ }
38
+ }
@@ -0,0 +1,212 @@
1
+ import { csv } from "@budibase/backend-core"
2
+ import {
3
+ BulkAddUsersToGroupRequest,
4
+ BulkAddUsersToGroupResponse,
5
+ Ctx,
6
+ DatabaseQueryOpts,
7
+ SearchGroupRequest,
8
+ SearchGroupResponse,
9
+ SearchUserGroupResponse,
10
+ UpdateGroupAppRequest,
11
+ UpdateGroupAppResponse,
12
+ UserCtx,
13
+ UserGroup,
14
+ } from "@budibase/types"
15
+ import { db, groups, users as usersSdk } from "@budibase/pro"
16
+
17
+ export async function save(ctx: UserCtx) {
18
+ const group: UserGroup = ctx.request.body
19
+ group.name = group.name.trim()
20
+
21
+ // don't allow updating the roles through this endpoint
22
+ delete group.roles
23
+ if (group._id) {
24
+ const oldGroup = await groups.get(group._id)
25
+ group.roles = oldGroup.roles
26
+ group.scimInfo = oldGroup.scimInfo
27
+ }
28
+ const response = await groups.save(group)
29
+ ctx.body = {
30
+ _id: response.id,
31
+ _rev: response.rev,
32
+ }
33
+ }
34
+
35
+ export async function updateGroupUsers(ctx: UserCtx) {
36
+ const groupId = ctx.params.groupId
37
+ const toAdd = ctx.request.body.add,
38
+ toRemove = ctx.request.body.remove
39
+ if (
40
+ (toAdd && !Array.isArray(toAdd)) ||
41
+ (toRemove && !Array.isArray(toRemove))
42
+ ) {
43
+ ctx.throw(400, "Must supply a list of users to add or to remove")
44
+ }
45
+ let added, removed
46
+ if (toAdd) {
47
+ added = await groups.addUsers(groupId, toAdd)
48
+ }
49
+ if (toRemove) {
50
+ removed = await groups.removeUsers(groupId, toRemove)
51
+ }
52
+ ctx.body = { added, removed }
53
+ }
54
+
55
+ export async function updateGroupApps(
56
+ ctx: UserCtx<UpdateGroupAppRequest, UpdateGroupAppResponse>
57
+ ) {
58
+ const groupId = ctx.params.groupId
59
+ const toAdd = ctx.request.body.add,
60
+ toRemove = ctx.request.body.remove
61
+ if (
62
+ (toAdd && !Array.isArray(toAdd)) ||
63
+ (toRemove && !Array.isArray(toRemove))
64
+ ) {
65
+ ctx.throw(
66
+ 400,
67
+ "Must supply a list of objects, with appId and roleId to add or remove"
68
+ )
69
+ }
70
+ ctx.body = await groups.updateGroupApps(groupId, {
71
+ appsToAdd: toAdd,
72
+ appsToRemove: toRemove,
73
+ })
74
+ }
75
+
76
+ export async function fetch(ctx: Ctx<SearchGroupRequest, SearchGroupResponse>) {
77
+ ctx.body = { data: await groups.fetch() }
78
+ }
79
+
80
+ export async function destroy(ctx: UserCtx) {
81
+ const { groupId, rev } = ctx.params
82
+ try {
83
+ await groups.remove(groupId, rev)
84
+ ctx.body = { message: "Group deleted successfully" }
85
+ } catch (err: any) {
86
+ ctx.throw(err.status, err)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Gets a group by ID from the global database.
92
+ */
93
+ export async function find(ctx: UserCtx) {
94
+ try {
95
+ ctx.body = await groups.get(ctx.params.groupId)
96
+ } catch (err: any) {
97
+ ctx.throw(err.status, err)
98
+ }
99
+ }
100
+
101
+ export async function searchUsers(ctx: Ctx<{}, SearchUserGroupResponse>) {
102
+ const { pageSize = 10, bookmark, emailSearch } = ctx.request.query as any
103
+ const groupId = ctx.params.groupId
104
+
105
+ const params: DatabaseQueryOpts = { limit: pageSize + 1 }
106
+
107
+ const users = await db.groups.getGroupUsers(groupId, {
108
+ ...params,
109
+ emailSearch,
110
+ bookmark,
111
+ })
112
+
113
+ const nextBookmark = emailSearch
114
+ ? users[pageSize]?.email
115
+ : users[pageSize]?._id
116
+ const hasNextPage = !!nextBookmark
117
+
118
+ ctx.body = {
119
+ users: users.slice(0, pageSize),
120
+ bookmark: nextBookmark,
121
+ hasNextPage,
122
+ }
123
+ }
124
+
125
+ export async function bulkAddUsersFromCsv(
126
+ ctx: UserCtx<BulkAddUsersToGroupRequest, BulkAddUsersToGroupResponse>
127
+ ) {
128
+ const { groupId } = ctx.params
129
+ const { csvContent } = ctx.request.body
130
+
131
+ if (csvContent === undefined || csvContent.trim().length === 0) {
132
+ ctx.throw(400, "CSV is empty")
133
+ }
134
+
135
+ const csvData = await csv.jsonFromCsvString(csvContent, {
136
+ allowSingleColumn: true,
137
+ })
138
+
139
+ if (!csvData || csvData.length === 0) {
140
+ ctx.throw(400, "CSV file is invalid")
141
+ }
142
+
143
+ // Find email column
144
+ const headers = Object.keys(csvData[0])
145
+ const emailColumn = headers.find(header =>
146
+ /^(email|e-mail|email address|mail|e_mail)$/i.test(header.trim())
147
+ )
148
+ if (!emailColumn) {
149
+ ctx.throw(400, "CSV file must contain an email column")
150
+ }
151
+
152
+ // Extract emails from CSV
153
+ const emails = Array.from(
154
+ new Set(
155
+ csvData
156
+ .map(row => row[emailColumn])
157
+ .filter(
158
+ email => email && typeof email === "string" && email.trim().length > 0
159
+ )
160
+ .map(email => email.trim())
161
+ )
162
+ )
163
+ if (emails.length === 0) {
164
+ ctx.throw(400, "No valid email addresses found in CSV")
165
+ }
166
+
167
+ // Check if group exists
168
+ try {
169
+ await groups.get(groupId)
170
+ } catch (err: any) {
171
+ if (err.status === 404) {
172
+ ctx.throw(404, "Group not found")
173
+ } else {
174
+ throw err
175
+ }
176
+ }
177
+
178
+ // Find existing users by email
179
+ const added: { _id: string; email: string }[] = []
180
+ const skipped: { email: string; reason: string }[] = []
181
+ const userIds: string[] = []
182
+
183
+ const users = await Promise.all(
184
+ emails.map(email => usersSdk.db.getUserByEmail(email))
185
+ )
186
+
187
+ for (const email of emails) {
188
+ const user = users.find(u => u?.email === email)
189
+ if (user) {
190
+ added.push({
191
+ _id: user._id!,
192
+ email: user.email,
193
+ })
194
+ userIds.push(user._id!)
195
+ } else {
196
+ skipped.push({
197
+ email,
198
+ reason: "User not found",
199
+ })
200
+ }
201
+ }
202
+
203
+ // Add users to group if any were found
204
+ if (userIds.length > 0) {
205
+ await groups.addUsers(groupId, userIds)
206
+ }
207
+
208
+ ctx.body = {
209
+ added,
210
+ skipped,
211
+ }
212
+ }
@@ -0,0 +1,157 @@
1
+ import groupBy from "lodash/groupBy"
2
+ import { patchBodyValidation, scimPatch } from "scim-patch"
3
+ import { filter, parse } from "scim2-parse-filter"
4
+ import {
5
+ Ctx,
6
+ ScimCreateGroupRequest,
7
+ ScimGroupListResponse,
8
+ ScimGroupResponse,
9
+ ScimUpdateRequest,
10
+ UserGroup,
11
+ } from "@budibase/types"
12
+ import { utils } from "@budibase/shared-core"
13
+ import { groups, mappers, scimGroups, scimUsers } from "@budibase/pro"
14
+
15
+ function cleanResponse(group: ScimGroupResponse, excludedAttributes: string) {
16
+ for (const attr of (excludedAttributes as string).split(",")) {
17
+ delete (group as any)[attr]
18
+ }
19
+ }
20
+
21
+ export const get = async (ctx: Ctx<void, ScimGroupListResponse>) => {
22
+ const fetchedGroups = await groups.fetch()
23
+ let result = fetchedGroups
24
+ .filter(g => g.scimInfo?.isSync)
25
+ .map(mappers.group.toScimGroupResponse)
26
+
27
+ const { filter: reqFilter, excludedAttributes } = ctx.request.query
28
+
29
+ if (reqFilter) {
30
+ const filterFunc = filter(parse(reqFilter as string))
31
+ result = result.filter(filterFunc)
32
+ }
33
+
34
+ if (excludedAttributes) {
35
+ result.forEach((g: any) => {
36
+ cleanResponse(g, excludedAttributes as string)
37
+ })
38
+ }
39
+
40
+ ctx.body = {
41
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
42
+ totalResults: result.length,
43
+ Resources: result,
44
+ startIndex: 1,
45
+ itemsPerPage: result.length,
46
+ }
47
+ }
48
+
49
+ export const create = async (
50
+ ctx: Ctx<ScimCreateGroupRequest, ScimGroupResponse>
51
+ ) => {
52
+ const groupToCreate = mappers.group.fromScimGroup(ctx.request.body)
53
+ const group = await scimGroups.create(groupToCreate)
54
+ ctx.body = mappers.group.toScimGroupResponse(group)
55
+ }
56
+
57
+ export const find = async (ctx: Ctx<void, ScimGroupResponse>) => {
58
+ const { id } = ctx.params
59
+ const group = await groups.get(id)
60
+ const response = mappers.group.toScimGroupResponse(group)
61
+
62
+ const { excludedAttributes } = ctx.request.query
63
+ if (excludedAttributes) {
64
+ cleanResponse(response, excludedAttributes as string)
65
+ }
66
+
67
+ ctx.body = response
68
+ }
69
+
70
+ export const remove = async (ctx: Ctx) => {
71
+ const { id } = ctx.params
72
+ const existingGroup = await groups.get(id)
73
+ await groups.remove(id, existingGroup._rev!)
74
+ ctx.status = 204
75
+ }
76
+
77
+ export const update = async (
78
+ ctx: Ctx<ScimUpdateRequest, ScimGroupResponse>
79
+ ) => {
80
+ const { id } = ctx.params
81
+ const group = await groups.get(id)
82
+
83
+ const scimGroup = mappers.group.toScimGroupResponse(group)
84
+
85
+ const patchs = ctx.request.body
86
+ try {
87
+ // Validate request
88
+ patchBodyValidation(patchs)
89
+ } catch (error) {
90
+ ctx.throw(400)
91
+ }
92
+
93
+ const { true: memberOps, false: fieldOps } = groupBy(
94
+ patchs.Operations,
95
+ p => p.path === "members"
96
+ )
97
+
98
+ if (fieldOps?.length) {
99
+ const patchedScimGroup = scimPatch(scimGroup, fieldOps)
100
+ if (!patchedScimGroup) {
101
+ ctx.throw(500)
102
+ }
103
+
104
+ const groupToUpdate: UserGroup = {
105
+ ...mappers.group.fromScimGroup(patchedScimGroup),
106
+ _rev: group._rev,
107
+ }
108
+ await groups.save(groupToUpdate)
109
+ }
110
+
111
+ if (memberOps?.length) {
112
+ const usersToAdd = []
113
+ const usersToRemove = []
114
+ for (const { op, value } of memberOps) {
115
+ switch (op) {
116
+ case "add":
117
+ case "Add":
118
+ for (const u of value) {
119
+ usersToAdd.push(await scimUsers.find(u.value))
120
+ }
121
+ break
122
+ case "remove":
123
+ case "Remove":
124
+ for (const u of value) {
125
+ try {
126
+ usersToRemove.push(await scimUsers.find(u.value))
127
+ } catch (e: any) {
128
+ if (e.status !== 404) {
129
+ throw e
130
+ }
131
+ }
132
+ }
133
+ break
134
+ case "replace":
135
+ case "Replace":
136
+ throw new Error("Replacing members is not allowed")
137
+ default:
138
+ utils.unreachable(op)
139
+ }
140
+ }
141
+
142
+ if (usersToAdd.length) {
143
+ await groups.addUsers(
144
+ id,
145
+ usersToAdd.map(u => u._id!)
146
+ )
147
+ }
148
+ if (usersToRemove.length) {
149
+ await groups.removeUsers(
150
+ id,
151
+ usersToRemove.map(u => u._id!)
152
+ )
153
+ }
154
+ }
155
+
156
+ ctx.body = mappers.group.toScimGroupResponse(await groups.get(id))
157
+ }
@@ -0,0 +1,124 @@
1
+ import { patchBodyValidation, scimPatch } from "scim-patch"
2
+ import { EmailUnavailableError } from "@budibase/backend-core"
3
+ import { mappers, scimUsers } from "@budibase/pro"
4
+ import {
5
+ Ctx,
6
+ ScimUserListResponse,
7
+ ScimCreateUserRequest,
8
+ ScimUserResponse,
9
+ ScimUpdateRequest,
10
+ } from "@budibase/types"
11
+
12
+ function tryGetQueryAsNumber(ctx: Ctx, name: string) {
13
+ const value = ctx.request.query[name]
14
+ if (value === undefined) {
15
+ return undefined
16
+ }
17
+
18
+ return +value
19
+ }
20
+
21
+ export const get = async (ctx: Ctx<void, ScimUserListResponse>) => {
22
+ const pageSize = tryGetQueryAsNumber(ctx, "pageSize") ?? 20
23
+ const skip = tryGetQueryAsNumber(ctx, "startIndex")
24
+
25
+ let filters
26
+ if (ctx.request.query.filter) {
27
+ filters = mappers.user.userFilters(ctx.request.query.filter as string)
28
+ }
29
+
30
+ const getResponse = await scimUsers.get({ pageSize, skip, filters })
31
+ ctx.body = {
32
+ schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
33
+ totalResults: getResponse.total,
34
+ Resources: getResponse.users.map(mappers.user.toScimUserResponse),
35
+ startIndex: (skip || 0) + 1,
36
+ itemsPerPage: pageSize,
37
+ }
38
+ }
39
+
40
+ export const find = async (ctx: Ctx<void, ScimUserResponse>) => {
41
+ const { id } = ctx.params
42
+ if (typeof id !== "string") {
43
+ ctx.throw(404)
44
+ }
45
+
46
+ const user = await scimUsers.find(id)
47
+ ctx.body = mappers.user.toScimUserResponse(user)
48
+ }
49
+
50
+ export const create = async (
51
+ ctx: Ctx<ScimCreateUserRequest, ScimUserResponse>
52
+ ) => {
53
+ const userToCreate = mappers.user.fromScimUser(ctx.request.body)
54
+ try {
55
+ const user = await scimUsers.create(userToCreate)
56
+ ctx.body = mappers.user.toScimUserResponse(user)
57
+ } catch (e) {
58
+ if (e instanceof EmailUnavailableError) {
59
+ ctx.throw(409, "Email already in use")
60
+ }
61
+
62
+ throw e
63
+ }
64
+ }
65
+
66
+ function isDeactivation(request: ScimUpdateRequest) {
67
+ const activeFieldChange = request.Operations.find(
68
+ o => (o.op === "Replace" || o.op === "replace") && o.path === "active"
69
+ )
70
+ if (!activeFieldChange) {
71
+ return false
72
+ }
73
+
74
+ return (
75
+ activeFieldChange.value === false ||
76
+ activeFieldChange.value?.toLowerCase?.() === "false"
77
+ )
78
+ }
79
+
80
+ export const update = async (ctx: Ctx<ScimUpdateRequest, ScimUserResponse>) => {
81
+ const user = await scimUsers.find(ctx.params.id)
82
+ if (!user) {
83
+ ctx.throw(404)
84
+ }
85
+
86
+ const scimUser = mappers.user.toScimUserResponse(user)
87
+
88
+ const patchs = ctx.request.body
89
+ try {
90
+ patchBodyValidation(patchs)
91
+ } catch (error) {
92
+ // Here if there are an error in you SCIM request.
93
+ }
94
+
95
+ if (isDeactivation(patchs)) {
96
+ return remove(ctx)
97
+ }
98
+
99
+ let patchedScimUser
100
+ try {
101
+ patchedScimUser = scimPatch(scimUser, patchs.Operations)
102
+ } catch (error) {
103
+ // Here if there is an error during the patch.
104
+ }
105
+
106
+ if (!patchedScimUser) {
107
+ ctx.throw(500)
108
+ }
109
+
110
+ const userToUpdate = mappers.user.fromScimUser(patchedScimUser)
111
+ await scimUsers.update(userToUpdate, { allowChangingEmail: true })
112
+
113
+ ctx.body = mappers.user.toScimUserResponse(userToUpdate)
114
+ }
115
+
116
+ export const remove = async (ctx: Ctx) => {
117
+ const { id } = ctx.params
118
+ if (typeof id !== "string") {
119
+ ctx.throw(404)
120
+ }
121
+
122
+ await scimUsers.remove(id)
123
+ ctx.status = 204
124
+ }
@@ -0,0 +1,46 @@
1
+ import Router from "@koa/router"
2
+ import Joi from "joi"
3
+ import { auth, middleware } from "@budibase/backend-core"
4
+ import { Event } from "@budibase/types"
5
+ import * as controllers from "../../controllers/global/auditLogs"
6
+
7
+ function buildAuditLogSearchValidator() {
8
+ return auth.joiValidator.body(
9
+ Joi.object({
10
+ userIds: Joi.array().items(Joi.string()).optional(),
11
+ appIds: Joi.array().items(Joi.string()).optional(),
12
+ events: Joi.array()
13
+ .items(Joi.string().valid(...Object.values(Event)))
14
+ .optional(),
15
+ startDate: Joi.string().optional().allow(""),
16
+ endDate: Joi.string().optional().allow(""),
17
+ fullSearch: Joi.string().optional().allow(""),
18
+ bookmark: Joi.number(),
19
+ })
20
+ )
21
+ }
22
+
23
+ const router: Router = new Router()
24
+
25
+ router
26
+ .post(
27
+ "/api/global/auditlogs/search",
28
+ auth.adminOnly,
29
+ buildAuditLogSearchValidator(),
30
+ controllers.search
31
+ )
32
+ .get(
33
+ "/api/global/auditlogs/download",
34
+ auth.adminOnly,
35
+ // convert query string param to body
36
+ middleware.querystringToBody,
37
+ buildAuditLogSearchValidator(),
38
+ controllers.download
39
+ )
40
+ .get(
41
+ "/api/global/auditlogs/definitions",
42
+ auth.adminOnly,
43
+ controllers.definitions
44
+ )
45
+
46
+ export default router
@@ -0,0 +1,88 @@
1
+ import Router from "@koa/router"
2
+ import Joi from "joi"
3
+ import { auth } from "@budibase/backend-core"
4
+ import { middleware as proMiddleware } from "@budibase/pro"
5
+ import { Feature } from "@budibase/types"
6
+ import * as controller from "../../controllers/global/groups"
7
+
8
+ const router: Router = new Router()
9
+
10
+ function buildGroupSaveValidation() {
11
+ return auth.joiValidator.body(
12
+ Joi.object({
13
+ _id: Joi.string().optional(),
14
+ _rev: Joi.string().optional(),
15
+ color: Joi.string().required(),
16
+ icon: Joi.string().required(),
17
+ name: Joi.string().trim().required().max(50),
18
+ role: Joi.string().optional(),
19
+ users: Joi.array().optional(),
20
+ apps: Joi.array().optional(),
21
+ roles: Joi.object().optional(),
22
+ createdAt: Joi.string().optional(),
23
+ updatedAt: Joi.string().optional(),
24
+ }).required()
25
+ )
26
+ }
27
+
28
+ router
29
+ .post(
30
+ "/api/global/groups",
31
+ auth.adminOnly,
32
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
33
+ buildGroupSaveValidation(),
34
+ controller.save
35
+ )
36
+ .get(
37
+ "/api/global/groups",
38
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
39
+ controller.fetch
40
+ )
41
+
42
+ .delete(
43
+ "/api/global/groups/:groupId/:rev",
44
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
45
+ auth.adminOnly,
46
+ proMiddleware.internalGroupOnly("groupId"),
47
+ controller.destroy
48
+ )
49
+ .get(
50
+ "/api/global/groups/:groupId",
51
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
52
+ auth.builderOrAdmin,
53
+ controller.find
54
+ )
55
+ .get(
56
+ "/api/global/groups/:groupId/users",
57
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
58
+ auth.builderOrAdmin,
59
+ controller.searchUsers
60
+ )
61
+ // these endpoints adjust existing groups
62
+ .post(
63
+ "/api/global/groups/:groupId/users",
64
+ auth.adminOnly,
65
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
66
+ proMiddleware.internalGroupOnly("groupId"),
67
+ controller.updateGroupUsers
68
+ )
69
+ .post(
70
+ "/api/global/groups/:groupId/users/bulk",
71
+ auth.adminOnly,
72
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
73
+ proMiddleware.internalGroupOnly("groupId"),
74
+ auth.joiValidator.body(
75
+ Joi.object({
76
+ csvContent: Joi.string().required(),
77
+ }).required()
78
+ ),
79
+ controller.bulkAddUsersFromCsv
80
+ )
81
+ .post(
82
+ "/api/global/groups/:groupId/apps",
83
+ auth.builderOrAdmin,
84
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
85
+ controller.updateGroupApps
86
+ )
87
+
88
+ export default router
@@ -0,0 +1,53 @@
1
+ import Router from "@koa/router"
2
+ import { middleware as proMiddleware } from "@budibase/pro"
3
+ import { Feature } from "@budibase/types"
4
+ import * as userController from "../../controllers/global/scim/users"
5
+ import * as groupController from "../../controllers/global/scim/groups"
6
+
7
+ const router: Router = new Router({
8
+ prefix: "/api/global/scim/v2",
9
+ })
10
+
11
+ router.use(proMiddleware.requireSCIM)
12
+ router.use(proMiddleware.doInScimContext)
13
+
14
+ router.get("/users", userController.get)
15
+ router.get("/users/:id", proMiddleware.scimUserOnly("id"), userController.find)
16
+ router.post("/users", userController.create)
17
+ router.patch(
18
+ "/users/:id",
19
+ proMiddleware.scimUserOnly("id"),
20
+ userController.update
21
+ )
22
+ router.delete(
23
+ "/users/:id",
24
+ proMiddleware.scimUserOnly("id"),
25
+ userController.remove
26
+ )
27
+
28
+ router.get("/groups", groupController.get)
29
+ router.post(
30
+ "/groups",
31
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
32
+ groupController.create
33
+ )
34
+ router.get(
35
+ "/groups/:id",
36
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
37
+ proMiddleware.scimGroupOnly("id"),
38
+ groupController.find
39
+ )
40
+ router.delete(
41
+ "/groups/:id",
42
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
43
+ proMiddleware.scimGroupOnly("id"),
44
+ groupController.remove
45
+ )
46
+ router.patch(
47
+ "/groups/:id",
48
+ proMiddleware.feature.requireFeature(Feature.USER_GROUPS),
49
+ proMiddleware.scimGroupOnly("id"),
50
+ groupController.update
51
+ )
52
+
53
+ export default router
@@ -87,6 +87,24 @@ describe("/api/global/users", () => {
87
87
  expect(events.user.invited).toHaveBeenCalledTimes(0)
88
88
  })
89
89
 
90
+ it("should not invite the same user twice when email casing differs", async () => {
91
+ const email = structures.users.newEmail().toLowerCase()
92
+ await config.api.users.sendUserInvite(sendMailMock, email)
93
+
94
+ jest.clearAllMocks()
95
+
96
+ const { code, res } = await config.api.users.sendUserInvite(
97
+ sendMailMock,
98
+ email.toUpperCase(),
99
+ 400
100
+ )
101
+
102
+ expect(res.body.message).toBe(`Unavailable`)
103
+ expect(sendMailMock).toHaveBeenCalledTimes(0)
104
+ expect(code).toBeUndefined()
105
+ expect(events.user.invited).toHaveBeenCalledTimes(0)
106
+ })
107
+
90
108
  it("should not allow creator users to access single invite endpoint", async () => {
91
109
  const user = await createBuilderUser()
92
110
 
@@ -1,4 +1,3 @@
1
- import { api as pro } from "@budibase/pro"
2
1
  import Router from "@koa/router"
3
2
  import { endpointGroupList } from "./endpointGroups"
4
3
 
@@ -19,6 +18,9 @@ import "./system/logs"
19
18
  import "./system/restore"
20
19
  import "./system/status"
21
20
  import "./system/tenants"
21
+ import auditLogsRoutes from "./global/auditLogs"
22
+ import groupRoutes from "./global/groups"
23
+ import scimRoutes from "./global/scim"
22
24
 
23
25
  const endpointGroupsRouter = new Router()
24
26
  for (let endpoint of endpointGroupList.listAllEndpoints()) {
@@ -27,7 +29,7 @@ for (let endpoint of endpointGroupList.listAllEndpoints()) {
27
29
 
28
30
  export const routes: Router[] = [
29
31
  endpointGroupsRouter,
30
- pro.groups,
31
- pro.auditLogs,
32
- pro.scim,
32
+ auditLogsRoutes,
33
+ groupRoutes,
34
+ scimRoutes,
33
35
  ]
@@ -22,7 +22,7 @@ export async function invite(
22
22
 
23
23
  // separate duplicates from new users
24
24
  for (let user of users) {
25
- if (matchedEmails.includes(user.email)) {
25
+ if (matchedEmails.includes(user.email.toLowerCase())) {
26
26
  // This "Unavailable" is load bearing. The tests and frontend both check for it
27
27
  // specifically
28
28
  response.unsuccessful.push({ email: user.email, reason: "Unavailable" })