@budibase/worker 3.18.15 → 3.19.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.18.15",
4
+ "version": "3.19.0",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -109,5 +109,5 @@
109
109
  }
110
110
  }
111
111
  },
112
- "gitHead": "95620e87e1a4f31518d8577e659c8fc4420b60d8"
112
+ "gitHead": "72863c719173848d3128cbef9556a0af09708c20"
113
113
  }
@@ -1,5 +1,17 @@
1
- import * as userSdk from "../../../sdk/users"
2
- import env from "../../../environment"
1
+ import {
2
+ utils as backendCoreUtils,
3
+ cache,
4
+ context,
5
+ db,
6
+ events,
7
+ HTTPError,
8
+ locks,
9
+ platform,
10
+ tenancy,
11
+ users,
12
+ } from "@budibase/backend-core"
13
+ import { features } from "@budibase/pro"
14
+ import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core"
3
15
  import {
4
16
  AcceptUserInviteRequest,
5
17
  AcceptUserInviteResponse,
@@ -17,6 +29,7 @@ import {
17
29
  DeleteInviteUsersRequest,
18
30
  DeleteInviteUsersResponse,
19
31
  DeleteUserResponse,
32
+ EditUserPermissionsResponse,
20
33
  ErrorCode,
21
34
  FetchUsersResponse,
22
35
  FindUserResponse,
@@ -36,27 +49,17 @@ import {
36
49
  SearchUsersResponse,
37
50
  StrippedUser,
38
51
  UnsavedUser,
39
- UpdateInviteRequest,
40
52
  UpdateInviteResponse,
41
53
  User,
42
54
  UserCtx,
43
55
  UserIdentifier,
44
56
  } from "@budibase/types"
45
- import {
46
- users,
47
- cache,
48
- events,
49
- platform,
50
- tenancy,
51
- db,
52
- locks,
53
- context,
54
- } from "@budibase/backend-core"
55
- import { checkAnyUserExists } from "../../../utilities/users"
56
- import { isEmailConfigured } from "../../../utilities/email"
57
- import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core"
58
- import emailValidator from "email-validator"
59
57
  import crypto from "crypto"
58
+ import emailValidator from "email-validator"
59
+ import env from "../../../environment"
60
+ import * as userSdk from "../../../sdk/users"
61
+ import { isEmailConfigured } from "../../../utilities/email"
62
+ import { checkAnyUserExists } from "../../../utilities/users"
60
63
 
61
64
  const MAX_USERS_UPLOAD_LIMIT = 1000
62
65
 
@@ -505,45 +508,50 @@ export const getUserInvites = async (
505
508
  }
506
509
  }
507
510
 
508
- export const updateInvite = async (
509
- ctx: UserCtx<UpdateInviteRequest, UpdateInviteResponse>
511
+ export const addWorkspaceIdToInvite = async (
512
+ ctx: UserCtx<void, UpdateInviteResponse, { code: string; role: string }>
510
513
  ) => {
511
- const { code } = ctx.params
512
- let updateBody = { ...ctx.request.body }
514
+ const { code, role } = ctx.params
513
515
 
514
- delete updateBody.email
516
+ const workspaceId = await backendCoreUtils.getAppIdFromCtx(ctx)
517
+ if (!workspaceId) {
518
+ ctx.throw(400, "Workspace id not set")
519
+ }
520
+ const prodWorkspaceId = db.getProdWorkspaceID(workspaceId)
515
521
 
516
- let invite
517
522
  try {
518
- invite = await cache.invite.getCode(code)
523
+ const invite = await cache.invite.getCode(code)
524
+ invite.info.apps ??= {}
525
+ invite.info.apps[prodWorkspaceId] = role
526
+
527
+ await cache.invite.updateCode(code, invite)
528
+ ctx.body = { ...invite }
519
529
  } catch (e) {
520
- ctx.throw(400, "There was a problem with the invite")
530
+ ctx.throw(400, "Invitation is not valid or has expired.")
521
531
  }
532
+ }
522
533
 
523
- let updated = {
524
- ...invite,
525
- }
534
+ export const removeWorkspaceIdFromInvite = async (
535
+ ctx: UserCtx<void, UpdateInviteResponse, { code: string }>
536
+ ) => {
537
+ const { code } = ctx.params
526
538
 
527
- if (!updateBody?.builder?.apps && updated.info?.builder?.apps) {
528
- updated.info.builder.apps = []
529
- } else if (updateBody?.builder) {
530
- updated.info.builder = updateBody.builder
539
+ const workspaceId = await backendCoreUtils.getAppIdFromCtx(ctx)
540
+ if (!workspaceId) {
541
+ ctx.throw(400, "Workspace id not set")
531
542
  }
543
+ const prodWorkspaceId = db.getProdWorkspaceID(workspaceId)
532
544
 
533
- if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
534
- updated.info.apps = []
535
- } else {
536
- updated.info = {
537
- ...invite.info,
538
- apps: {
539
- ...invite.info.apps,
540
- ...updateBody.apps,
541
- },
542
- }
543
- }
545
+ try {
546
+ const invite = await cache.invite.getCode(code)
547
+ invite.info.apps ??= {}
548
+ delete invite.info.apps[prodWorkspaceId]
544
549
 
545
- await cache.invite.updateCode(code, updated)
546
- ctx.body = { ...invite }
550
+ await cache.invite.updateCode(code, invite)
551
+ ctx.body = { ...invite }
552
+ } catch (e) {
553
+ ctx.throw(400, "Invitation is not valid or has expired.")
554
+ }
547
555
  }
548
556
 
549
557
  export const inviteAccept = async (
@@ -614,3 +622,73 @@ export const inviteAccept = async (
614
622
  ctx.throw(400, err || "Unable to create new user, invitation invalid.")
615
623
  }
616
624
  }
625
+
626
+ export const addUserToWorkspace = async (
627
+ ctx: UserCtx<
628
+ EditUserPermissionsResponse,
629
+ SaveUserResponse,
630
+ { userId: string; role: string }
631
+ >
632
+ ) => handleUserWorkspacePermission(ctx, ctx.params.userId, ctx.params.role)
633
+
634
+ export const removeUserFromWorkspace = async (
635
+ ctx: UserCtx<
636
+ EditUserPermissionsResponse,
637
+ SaveUserResponse,
638
+ { userId: string }
639
+ >
640
+ ) => handleUserWorkspacePermission(ctx, ctx.params.userId, undefined)
641
+
642
+ async function handleUserWorkspacePermission(
643
+ ctx: UserCtx<EditUserPermissionsResponse, SaveUserResponse>,
644
+ userId: string,
645
+ role: string | undefined
646
+ ) {
647
+ const { _rev } = ctx.request.body
648
+ if (!_rev) {
649
+ ctx.throw(400, "rev is required")
650
+ }
651
+
652
+ const currentUserId = ctx.user?._id
653
+
654
+ const workspaceId = await backendCoreUtils.getAppIdFromCtx(ctx)
655
+ if (!workspaceId) {
656
+ ctx.throw(400, "Workspace id not set")
657
+ }
658
+ const prodWorkspaceId = db.getProdWorkspaceID(workspaceId)
659
+
660
+ const existingUser = await users.getById(userId)
661
+ existingUser._rev = ctx.request.body._rev
662
+ if (role) {
663
+ existingUser.roles[prodWorkspaceId] = role
664
+ } else {
665
+ delete existingUser.roles[prodWorkspaceId]
666
+ }
667
+
668
+ if (role === "CREATOR" && !(await features.isAppBuildersEnabled())) {
669
+ throw new HTTPError("Feature not enabled, please check license", 400)
670
+ }
671
+
672
+ const appCreator = Object.entries(existingUser.roles)
673
+ .filter(([_appId, role]) => role === "CREATOR")
674
+ .map(([appId]) => appId)
675
+ if (!appCreator.length && existingUser.builder) {
676
+ delete existingUser.builder.creator
677
+ delete existingUser.builder.apps
678
+ } else if (appCreator.length) {
679
+ existingUser.builder ??= {}
680
+ existingUser.builder.creator = true
681
+ existingUser.builder.apps = appCreator
682
+ }
683
+
684
+ const user = await userSdk.db.save(existingUser, {
685
+ currentUserId,
686
+ hashPassword: false,
687
+ })
688
+
689
+ ctx.body = {
690
+ _id: user._id!,
691
+ _rev: user._rev!,
692
+ email: user.email,
693
+ }
694
+ }
@@ -1,7 +1,7 @@
1
1
  import { events } from "@budibase/backend-core"
2
2
  import { generator } from "@budibase/backend-core/tests"
3
- import { structures, TestConfiguration, mocks } from "../../../../tests"
4
3
  import { User, UserGroup } from "@budibase/types"
4
+ import { mocks, structures, TestConfiguration } from "../../../../tests"
5
5
 
6
6
  mocks.licenses.useGroups()
7
7
 
@@ -446,4 +446,92 @@ describe("/api/global/groups", () => {
446
446
  })
447
447
  })
448
448
  })
449
+
450
+ describe("creator role functionality", () => {
451
+ let group: UserGroup
452
+
453
+ beforeEach(async () => {
454
+ mocks.licenses.useAppBuilders()
455
+ const groupResponse = await config.api.groups.saveGroup(
456
+ structures.groups.UserGroup()
457
+ )
458
+ group = groupResponse.body
459
+ })
460
+
461
+ describe("updateGroupApps with CREATOR role", () => {
462
+ it("should successfully update group with CREATOR role", async () => {
463
+ const appId = "app_test123"
464
+
465
+ // This should succeed without throwing an error
466
+ await config.api.groups.updateGroupApps(group._id!, {
467
+ add: [{ appId, roleId: "CREATOR" }],
468
+ remove: [],
469
+ })
470
+
471
+ const updatedGroup = await config.api.groups.find(group._id!)
472
+ expect(updatedGroup.body._id).toBe(group._id)
473
+ expect(updatedGroup.body.builder.apps).toEqual([appId])
474
+ })
475
+
476
+ it("should fail to assign CREATOR role when feature is not enabled", async () => {
477
+ mocks.licenses.useCloudFree() // Disable app builders feature
478
+ const appId = "app_test123"
479
+
480
+ await config.api.groups.updateGroupApps(
481
+ group._id!,
482
+ {
483
+ add: [{ appId, roleId: "CREATOR" }],
484
+ remove: [],
485
+ },
486
+ { expect: 400 }
487
+ )
488
+ })
489
+
490
+ it("should handle multiple CREATOR apps update operation", async () => {
491
+ const appId1 = "app_test111"
492
+ const appId2 = "app_test222"
493
+ const appId3 = "app_test333"
494
+
495
+ // This should succeed without throwing an error
496
+ await config.api.groups.updateGroupApps(group._id!, {
497
+ add: [
498
+ { appId: appId1, roleId: "CREATOR" },
499
+ { appId: appId2, roleId: "CREATOR" },
500
+ { appId: appId3, roleId: "BASIC" }, // Not a creator role
501
+ ],
502
+ remove: [],
503
+ })
504
+
505
+ // Verify the operation completed successfully
506
+ const updatedGroup = await config.api.groups.find(group._id!)
507
+ expect(updatedGroup.body._id).toBe(group._id)
508
+ expect(updatedGroup.body.builder.apps).toEqual([appId1, appId2])
509
+ })
510
+
511
+ it("should handle removing apps from CREATOR group", async () => {
512
+ const appId1 = "app_test111"
513
+ const appId2 = "app_test222"
514
+
515
+ // First add CREATOR roles
516
+ await config.api.groups.updateGroupApps(group._id!, {
517
+ add: [
518
+ { appId: appId1, roleId: "CREATOR" },
519
+ { appId: appId2, roleId: "CREATOR" },
520
+ ],
521
+ remove: [],
522
+ })
523
+
524
+ // Then remove one app - this should succeed
525
+ await config.api.groups.updateGroupApps(group._id!, {
526
+ add: [],
527
+ remove: [{ appId: appId1 }],
528
+ })
529
+
530
+ // Verify the operation completed successfully
531
+ const updatedGroup = await config.api.groups.find(group._id!)
532
+ expect(updatedGroup.body._id).toBe(group._id)
533
+ expect(updatedGroup.body.builder.apps).toEqual([])
534
+ })
535
+ })
536
+ })
449
537
  })
@@ -1,6 +1,7 @@
1
1
  import { InviteUsersResponse, OIDCUser, User } from "@budibase/types"
2
2
 
3
3
  import { accounts as _accounts, events, tenancy } from "@budibase/backend-core"
4
+ import { mocks as featureMocks } from "@budibase/backend-core/tests"
4
5
  import * as userSdk from "../../../../sdk/users"
5
6
  import { TestConfiguration, mocks, structures } from "../../../../tests"
6
7
 
@@ -20,8 +21,11 @@ describe("/api/global/users", () => {
20
21
  await config.afterAll()
21
22
  })
22
23
 
23
- beforeEach(() => {
24
+ beforeEach(async () => {
24
25
  jest.clearAllMocks()
26
+ featureMocks.licenses.useCloudFree()
27
+ await mocks.licenses.setUsersQuota(1000)
28
+ await mocks.licenses.setCreatorsQuota(1000)
25
29
  })
26
30
 
27
31
  async function createBuilderUser() {
@@ -114,6 +118,222 @@ describe("/api/global/users", () => {
114
118
  })
115
119
  })
116
120
 
121
+ describe("POST /api/global/users/invite/:code/:role", () => {
122
+ it("should be able to add workspace id to invite", async () => {
123
+ const email = structures.users.newEmail()
124
+ const { code } = await config.api.users.sendUserInvite(
125
+ sendMailMock,
126
+ email
127
+ )
128
+ const appId = "app_123456789"
129
+ const role = "BASIC"
130
+
131
+ const res = await config.withApp(appId, () =>
132
+ config.api.users.addWorkspaceIdToInvite(code, role)
133
+ )
134
+
135
+ expect(res.body.info.apps).toBeDefined()
136
+ expect(res.body.info.apps[appId]).toBe(role)
137
+ })
138
+
139
+ it("should handle invalid invite code", async () => {
140
+ const appId = "app_123456789"
141
+ const role = "BASIC"
142
+
143
+ await config.withApp(appId, () =>
144
+ config.api.users.addWorkspaceIdToInvite("invalid_code", role, 400)
145
+ )
146
+ })
147
+
148
+ it("should not allow builders to edit invites for apps they don't have access to", async () => {
149
+ const { code } = await config.api.users.sendUserInvite(
150
+ sendMailMock,
151
+ structures.users.newEmail()
152
+ )
153
+
154
+ // Create a builder user with access to another app
155
+ const builderUser = await config.createUser({
156
+ ...structures.users.user(),
157
+ builder: {
158
+ global: false,
159
+ apps: ["app_allowed_123"],
160
+ },
161
+ admin: { global: false },
162
+ })
163
+
164
+ await config.withUser(builderUser, () =>
165
+ config.withApp("app_no_access", () =>
166
+ config.api.users.addWorkspaceIdToInvite(code, "BASIC", 403)
167
+ )
168
+ )
169
+ })
170
+
171
+ it("should allow builders to edit invites for apps they have access to", async () => {
172
+ const { code } = await config.api.users.sendUserInvite(
173
+ sendMailMock,
174
+ structures.users.newEmail()
175
+ )
176
+ const appId = "app_allowed_123"
177
+ const role = "BASIC"
178
+
179
+ // Create a builder user with access to the specific app
180
+ const builderUser = await config.createUser({
181
+ ...structures.users.user(),
182
+ builder: {
183
+ global: false,
184
+ apps: [appId], // Has access to this specific app
185
+ },
186
+ admin: { global: false },
187
+ })
188
+
189
+ await config.login(builderUser)
190
+ const res = await config.withUser(builderUser, () =>
191
+ config.withApp(appId, () =>
192
+ config.api.users.addWorkspaceIdToInvite(code, role, 200)
193
+ )
194
+ )
195
+ expect(res.body.info.apps[appId]).toBe(role)
196
+ })
197
+
198
+ it("should allow global builders to edit invites for any app", async () => {
199
+ const { code } = await config.api.users.sendUserInvite(
200
+ sendMailMock,
201
+ structures.users.newEmail()
202
+ )
203
+ const appId = "app_any_123"
204
+ const role = "BASIC"
205
+
206
+ // Create a global builder user
207
+ const builderUser = await config.createUser({
208
+ ...structures.users.user(),
209
+ builder: {
210
+ global: true,
211
+ },
212
+ admin: { global: false },
213
+ })
214
+
215
+ await config.login(builderUser)
216
+ const res = await config.withUser(builderUser, async () =>
217
+ config.withApp(appId, () =>
218
+ config.api.users.addWorkspaceIdToInvite(code, role, 200)
219
+ )
220
+ )
221
+ expect(res.body.info.apps[appId]).toBe(role)
222
+ })
223
+ })
224
+
225
+ describe("DELETE /api/global/users/invite/:code", () => {
226
+ it("should be able to remove workspace id from invite", async () => {
227
+ const email = structures.users.newEmail()
228
+ const { code } = await config.api.users.sendUserInvite(
229
+ sendMailMock,
230
+ email
231
+ )
232
+ const appId = "app_123456789"
233
+ const role = "BASIC"
234
+
235
+ // First add the workspace
236
+ await config.withApp(appId, () =>
237
+ config.api.users.addWorkspaceIdToInvite(code, role)
238
+ )
239
+
240
+ // Then remove it
241
+ const res = await config.withApp(appId, () =>
242
+ config.api.users.removeWorkspaceIdFromInvite(code)
243
+ )
244
+
245
+ expect(res.body.info.apps).toBeDefined()
246
+ expect(res.body.info.apps[appId]).toBeUndefined()
247
+ })
248
+
249
+ it("should handle removing non-existent workspace id", async () => {
250
+ const email = structures.users.newEmail()
251
+ const { code } = await config.api.users.sendUserInvite(
252
+ sendMailMock,
253
+ email
254
+ )
255
+ const appId = "app_nonexistent"
256
+
257
+ const res = await config.withApp(appId, () =>
258
+ config.api.users.removeWorkspaceIdFromInvite(code)
259
+ )
260
+
261
+ expect(res.body.info.apps).toBeDefined()
262
+ expect(res.body.info.apps[appId]).toBeUndefined()
263
+ })
264
+
265
+ it("should handle invalid invite code", async () => {
266
+ const appId = "app_123456789"
267
+
268
+ await config.withApp(appId, () =>
269
+ config.api.users.removeWorkspaceIdFromInvite("invalid_code", 400)
270
+ )
271
+ })
272
+
273
+ it("should not allow builders to delete invites for apps they don't have access to", async () => {
274
+ const { code } = await config.api.users.sendUserInvite(
275
+ sendMailMock,
276
+ structures.users.newEmail()
277
+ )
278
+ const appId = "app_no_access"
279
+ const role = "BASIC"
280
+
281
+ // First add the workspace as admin
282
+ await config.withApp(appId, () =>
283
+ config.api.users.addWorkspaceIdToInvite(code, role)
284
+ )
285
+
286
+ // Create a builder user with specific app access
287
+ const builderUser = await config.createUser({
288
+ ...structures.users.user(),
289
+ builder: {
290
+ global: false,
291
+ apps: ["app_allowed_123"], // Different app than the one being tested
292
+ },
293
+ admin: { global: false },
294
+ })
295
+
296
+ await config.login(builderUser)
297
+ await config.withUser(builderUser, () =>
298
+ config.withApp(appId, () =>
299
+ config.api.users.removeWorkspaceIdFromInvite(code, 403)
300
+ )
301
+ )
302
+ })
303
+
304
+ it("should allow builders to delete invites for apps they have access to", async () => {
305
+ const { code } = await config.api.users.sendUserInvite(
306
+ sendMailMock,
307
+ structures.users.newEmail()
308
+ )
309
+ const appId = "app_allowed_456"
310
+ const role = "BASIC"
311
+
312
+ // First add the workspace as admin
313
+ await config.withApp(appId, () =>
314
+ config.api.users.addWorkspaceIdToInvite(code, role)
315
+ )
316
+
317
+ // Create a builder user with access to the specific app
318
+ const builderUser = await config.createUser({
319
+ ...structures.users.user(),
320
+ builder: {
321
+ global: false,
322
+ apps: [appId], // Has access to this specific app
323
+ },
324
+ admin: { global: false },
325
+ })
326
+
327
+ await config.login(builderUser)
328
+ const res = await config.withUser(builderUser, () =>
329
+ config.withApp(appId, () =>
330
+ config.api.users.removeWorkspaceIdFromInvite(code, 200)
331
+ )
332
+ )
333
+ expect(res.body.info.apps[appId]).toBeUndefined()
334
+ })
335
+ })
336
+
117
337
  describe("POST /api/global/users/multi/invite", () => {
118
338
  it("should be able to generate an invitation", async () => {
119
339
  const newUserInvite = () => ({
@@ -539,15 +759,17 @@ describe("/api/global/users", () => {
539
759
  expect(response.body.message).toBe("Email address cannot be changed")
540
760
  })
541
761
 
542
- it("should allow a non-admin user to update an existing user", async () => {
762
+ it("should not allow a builder users to update an existing user", async () => {
543
763
  const existingUser = await config.createUser(structures.users.user())
544
- const nonAdmin = await config.createUser(structures.users.builderUser())
545
- await config.createSession(nonAdmin)
764
+ const builderUser = await config.createUser(
765
+ structures.users.builderUser()
766
+ )
767
+ await config.createSession(builderUser)
546
768
 
547
769
  await config.api.users.saveUser(
548
770
  existingUser,
549
- 200,
550
- config.authHeaders(nonAdmin)
771
+ 403,
772
+ config.authHeaders(builderUser)
551
773
  )
552
774
  })
553
775
 
@@ -763,6 +985,135 @@ describe("/api/global/users", () => {
763
985
  })
764
986
  })
765
987
 
988
+ describe("POST /api/global/users/:userId/permission/:role", () => {
989
+ it("should fail to assign CREATOR role when feature is not enabled", async () => {
990
+ const user = await config.createUser()
991
+ const workspaceId = "app_123456789"
992
+
993
+ const res = await config.withApp(workspaceId, () =>
994
+ config.api.users.addUserToWorkspace(
995
+ user._id!,
996
+ user._rev!,
997
+ "CREATOR",
998
+ 400
999
+ )
1000
+ )
1001
+ expect(res.body.message).toBe("Feature not enabled, please check license")
1002
+ })
1003
+
1004
+ it("should assign CREATOR role and set builder properties", async () => {
1005
+ featureMocks.licenses.useAppBuilders()
1006
+
1007
+ const user = await config.createUser()
1008
+ const workspaceId = "app_123456789"
1009
+
1010
+ await config.withApp(workspaceId, () =>
1011
+ config.api.users.addUserToWorkspace(user._id!, user._rev!, "CREATOR")
1012
+ )
1013
+
1014
+ const updatedUser = await config.getUser(user.email)
1015
+ expect(updatedUser.roles[workspaceId]).toBe("CREATOR")
1016
+ expect(updatedUser.builder?.creator).toBe(true)
1017
+ expect(updatedUser.builder?.apps).toEqual([workspaceId])
1018
+ })
1019
+
1020
+ it("should remove CREATOR role and clean up builder properties when no creator apps remain", async () => {
1021
+ mocks.licenses.useAppBuilders()
1022
+ const user = await config.createUser()
1023
+ const workspaceId = "app_123456789"
1024
+
1025
+ // First assign CREATOR role
1026
+ const res = await config.withApp(workspaceId, () =>
1027
+ config.api.users.addUserToWorkspace(user._id!, user._rev!, "CREATOR")
1028
+ )
1029
+ user._rev = res.body._rev
1030
+
1031
+ // Then remove the user from workspace
1032
+ await config.withApp(workspaceId, () =>
1033
+ config.api.users.removeUserFromWorkspace(user._id!, user._rev!)
1034
+ )
1035
+
1036
+ const updatedUser = await config.getUser(user.email)
1037
+ expect(updatedUser.roles[workspaceId]).toBeUndefined()
1038
+ expect(updatedUser.builder?.creator).toBeUndefined()
1039
+ expect(updatedUser.builder?.apps).toBeUndefined()
1040
+ })
1041
+
1042
+ it("should maintain builder properties when user has multiple CREATOR roles", async () => {
1043
+ mocks.licenses.useAppBuilders()
1044
+ const user = await config.createUser()
1045
+ const workspaceId1 = "app_111111111"
1046
+ const workspaceId2 = "app_222222222"
1047
+
1048
+ // Assign CREATOR role to two workspaces
1049
+ let res = await config.withApp(workspaceId1, () =>
1050
+ config.api.users.addUserToWorkspace(user._id!, user._rev!, "CREATOR")
1051
+ )
1052
+ user._rev = res.body._rev
1053
+ res = await config.withApp(workspaceId2, () =>
1054
+ config.api.users.addUserToWorkspace(user._id!, user._rev!, "CREATOR")
1055
+ )
1056
+ user._rev = res.body._rev
1057
+
1058
+ let updatedUser = await config.getUser(user.email)
1059
+ expect(updatedUser.roles[workspaceId1]).toBe("CREATOR")
1060
+ expect(updatedUser.roles[workspaceId2]).toBe("CREATOR")
1061
+ expect(updatedUser.builder?.creator).toBe(true)
1062
+ expect(updatedUser.builder?.apps).toEqual(
1063
+ expect.arrayContaining([workspaceId1, workspaceId2])
1064
+ )
1065
+
1066
+ // Remove from one workspace
1067
+ await config.withApp(workspaceId1, () =>
1068
+ config.api.users.removeUserFromWorkspace(user._id!, user._rev!)
1069
+ )
1070
+
1071
+ updatedUser = await config.getUser(user.email)
1072
+ expect(updatedUser.roles[workspaceId1]).toBeUndefined()
1073
+ expect(updatedUser.roles[workspaceId2]).toBe("CREATOR")
1074
+ expect(updatedUser.builder?.creator).toBe(true)
1075
+ expect(updatedUser.builder?.apps).toEqual([workspaceId2])
1076
+ })
1077
+
1078
+ it("should handle non-CREATOR role assignments without affecting builder properties", async () => {
1079
+ const user = await config.createUser()
1080
+ const workspaceId = "app_123456789"
1081
+
1082
+ const res = await config.withApp(workspaceId, () =>
1083
+ config.api.users.addUserToWorkspace(user._id!, user._rev!, "BASIC")
1084
+ )
1085
+
1086
+ expect(res.body._id).toBe(user._id)
1087
+
1088
+ const updatedUser = await config.getUser(user.email)
1089
+ expect(updatedUser.roles[workspaceId]).toBe("BASIC")
1090
+ expect(updatedUser.builder?.creator).toBeUndefined()
1091
+ expect(updatedUser.builder?.apps).toBeUndefined()
1092
+ })
1093
+
1094
+ it("should not allow non-admin users to modify workspace permissions", async () => {
1095
+ const regularUser = await config.createUser()
1096
+ const targetUser = await config.createUser()
1097
+ const workspaceId = "app_123456789"
1098
+
1099
+ await config.login(regularUser)
1100
+
1101
+ const res = await config.withUser(regularUser, () =>
1102
+ config.withApp(workspaceId, () =>
1103
+ config.api.users.addUserToWorkspace(
1104
+ targetUser._id!,
1105
+ targetUser._rev!,
1106
+ "CREATOR",
1107
+ 403
1108
+ )
1109
+ )
1110
+ )
1111
+ expect(res.body.message).toBe(
1112
+ "Workspace Admin/Builder user only endpoint."
1113
+ )
1114
+ })
1115
+ })
1116
+
766
1117
  describe("DELETE /api/global/users/:userId", () => {
767
1118
  it("should be able to destroy a basic user", async () => {
768
1119
  const user = await config.createUser()
@@ -44,14 +44,6 @@ function buildInviteMultipleValidation() {
44
44
  ))
45
45
  }
46
46
 
47
- const createUserAdminOnly = (ctx: any, next: any) => {
48
- if (!ctx.request.body._id) {
49
- return auth.adminOnly(ctx, next)
50
- } else {
51
- return auth.builderOrAdmin(ctx, next)
52
- }
53
- }
54
-
55
47
  function buildInviteAcceptValidation() {
56
48
  // prettier-ignore
57
49
  return auth.joiValidator.body(Joi.object({
@@ -102,14 +94,30 @@ builderOrAdminRoutes
102
94
  .get("/api/global/users/count/:appId", controller.countByApp)
103
95
  .get("/api/global/users/invites", controller.getUserInvites)
104
96
  .get("/api/global/users/:id", controller.find)
105
-
106
- adminRoutes
107
- .post("/api/global/users/invite", buildInviteValidation(), controller.invite)
97
+ .post(
98
+ "/api/global/users/invite/:code/:role",
99
+ controller.addWorkspaceIdToInvite
100
+ )
101
+ .delete(
102
+ "/api/global/users/invite/:code",
103
+ controller.removeWorkspaceIdFromInvite
104
+ )
108
105
  .post(
109
106
  "/api/global/users/onboard",
110
107
  buildInviteMultipleValidation(),
111
108
  controller.onboardUsers
112
109
  )
110
+ .post(
111
+ "/api/global/users/:userId/permission/:role",
112
+ controller.addUserToWorkspace
113
+ )
114
+ .delete(
115
+ "/api/global/users/:userId/permission",
116
+ controller.removeUserFromWorkspace
117
+ )
118
+
119
+ adminRoutes
120
+ .post("/api/global/users/invite", buildInviteValidation(), controller.invite)
113
121
  .post(
114
122
  "/api/global/users/multi/invite",
115
123
  buildInviteMultipleValidation(),
@@ -119,17 +127,11 @@ adminRoutes
119
127
  "/api/global/users/multi/invite/delete",
120
128
  controller.removeMultipleInvites
121
129
  )
122
- .post("/api/global/users/invite/update/:code", controller.updateInvite)
130
+ .post("/api/global/users", users.buildUserSaveValidation(), controller.save)
123
131
 
124
132
  loggedInRoutes
125
133
  // search can be used by any user now, to retrieve users for user column
126
134
  .post("/api/global/users/search", controller.search)
127
- .post(
128
- "/api/global/users",
129
- createUserAdminOnly,
130
- users.buildUserSaveValidation(),
131
- controller.save
132
- )
133
135
  // non-global endpoints
134
136
  .get("/api/global/users/invite/:code", controller.checkInvite)
135
137
  .post(
@@ -1,23 +1,23 @@
1
- import Router from "@koa/router"
2
1
  import { api as pro } from "@budibase/pro"
2
+ import Router from "@koa/router"
3
3
  import { endpointGroupList } from "./endpointGroups"
4
4
 
5
5
  // import to register the routes with the endpoint groups
6
6
  import "./global/auth"
7
- import "./global/users"
8
7
  import "./global/configs"
9
- import "./global/templates"
10
8
  import "./global/email"
11
- import "./global/roles"
12
9
  import "./global/events"
13
- import "./system/environment"
14
- import "./system/tenants"
15
- import "./system/status"
16
- import "./global/self"
17
10
  import "./global/license"
11
+ import "./global/roles"
12
+ import "./global/self"
13
+ import "./global/templates"
14
+ import "./global/users"
18
15
  import "./system/accounts"
19
- import "./system/restore"
16
+ import "./system/environment"
20
17
  import "./system/logs"
18
+ import "./system/restore"
19
+ import "./system/status"
20
+ import "./system/tenants"
21
21
 
22
22
  const endpointGroupsRouter = new Router()
23
23
  for (let endpoint of endpointGroupList.listAllEndpoints()) {
@@ -26,7 +26,6 @@ for (let endpoint of endpointGroupList.listAllEndpoints()) {
26
26
 
27
27
  export const routes: Router[] = [
28
28
  endpointGroupsRouter,
29
- pro.users,
30
29
  pro.groups,
31
30
  pro.auditLogs,
32
31
  pro.scim,
package/src/index.ts CHANGED
@@ -3,31 +3,31 @@ if (process.env.DD_APM_ENABLED) {
3
3
  }
4
4
 
5
5
  // need to load environment first
6
- import env from "./environment"
7
- import Application, { Middleware } from "koa"
8
- import { bootstrap } from "global-agent"
9
- import * as db from "./db"
10
- import { sdk as proSdk } from "@budibase/pro"
11
6
  import {
12
7
  auth,
13
- logging,
8
+ cache,
9
+ env as coreEnv,
14
10
  events,
11
+ features,
12
+ logging,
15
13
  middleware,
16
14
  queue,
17
- env as coreEnv,
18
- timers,
19
15
  redis,
20
- cache,
21
- features,
16
+ timers,
22
17
  } from "@budibase/backend-core"
18
+ import { sdk as proSdk } from "@budibase/pro"
19
+ import { bootstrap } from "global-agent"
20
+ import http from "http"
21
+ import gracefulShutdown from "http-graceful-shutdown"
22
+ import Application, { Middleware } from "koa"
23
+ import koaBody from "koa-body"
23
24
  import RedisStore from "koa-redis"
25
+ import api from "./api"
24
26
  import { loadTemplateConfig } from "./constants/templates"
27
+ import * as db from "./db"
28
+ import env from "./environment"
25
29
 
26
30
  db.init()
27
- import koaBody from "koa-body"
28
- import http from "http"
29
- import api from "./api"
30
- import gracefulShutdown from "http-graceful-shutdown"
31
31
 
32
32
  const koaSession = require("koa-session")
33
33
 
@@ -52,7 +52,12 @@ app.proxy = true
52
52
 
53
53
  // set up top level koa middleware
54
54
  app.use(handleScimBody)
55
- app.use(koaBody({ multipart: true }))
55
+ app.use(
56
+ koaBody({
57
+ multipart: true,
58
+ parsedMethods: ["POST", "PUT", "PATCH", "DELETE"],
59
+ })
60
+ )
56
61
 
57
62
  let store: any
58
63
 
@@ -51,6 +51,7 @@ class TestConfiguration {
51
51
  apiKey?: string
52
52
  userPassword = "password123!"
53
53
  sessions: string[] = []
54
+ appId?: string
54
55
 
55
56
  constructor(opts: { openServer: boolean } = { openServer: true }) {
56
57
  // default to cloud hosting
@@ -233,6 +234,16 @@ class TestConfiguration {
233
234
  }
234
235
  }
235
236
 
237
+ async withApp<T>(appId: string, f: () => Promise<T>): Promise<T> {
238
+ const oldAppId = this.appId
239
+ this.appId = appId
240
+ try {
241
+ return await f()
242
+ } finally {
243
+ this.appId = oldAppId
244
+ }
245
+ }
246
+
236
247
  async login(user: User) {
237
248
  await this.createSession(user)
238
249
  return this.authHeaders(user)
@@ -245,11 +256,15 @@ class TestConfiguration {
245
256
  tenantId: user.tenantId,
246
257
  }
247
258
  const authCookie = jwt.sign(authToken, coreEnv.JWT_SECRET as Secret)
248
- return {
259
+ const headers: Record<string, string> = {
249
260
  Accept: "application/json",
250
261
  ...this.cookieHeader([`${constants.Cookie.Auth}=${authCookie}`]),
251
262
  [constants.Header.CSRF_TOKEN]: CSRF_TOKEN,
252
263
  }
264
+ if (this.appId) {
265
+ headers[constants.Header.APP_ID] = this.appId
266
+ }
267
+ return headers
253
268
  }
254
269
 
255
270
  defaultHeaders() {
@@ -1,14 +1,14 @@
1
+ import { generator } from "@budibase/backend-core/tests"
1
2
  import {
2
- BulkUserResponse,
3
3
  BulkUserRequest,
4
- InviteUsersRequest,
5
- User,
4
+ BulkUserResponse,
6
5
  CreateAdminUserRequest,
7
- SearchFilters,
6
+ InviteUsersRequest,
8
7
  InviteUsersResponse,
8
+ SearchFilters,
9
+ User,
9
10
  } from "@budibase/types"
10
11
  import structures from "../structures"
11
- import { generator } from "@budibase/backend-core/tests"
12
12
  import { TestAPI, TestAPIOpts } from "./base"
13
13
 
14
14
  export class UserAPI extends TestAPI {
@@ -179,22 +179,6 @@ export class UserAPI extends TestAPI {
179
179
  .expect(opts?.status ? opts.status : 200)
180
180
  }
181
181
 
182
- grantBuilderToApp = (userId: string, appId: string, statusCode = 200) => {
183
- return this.request
184
- .post(`/api/global/users/${userId}/app/${appId}/builder`)
185
- .set(this.config.defaultHeaders())
186
- .expect("Content-Type", /json/)
187
- .expect(statusCode)
188
- }
189
-
190
- revokeBuilderFromApp = (userId: string, appId: string) => {
191
- return this.request
192
- .delete(`/api/global/users/${userId}/app/${appId}/builder`)
193
- .set(this.config.defaultHeaders())
194
- .expect("Content-Type", /json/)
195
- .expect(200)
196
- }
197
-
198
182
  onboardUser = async (
199
183
  req: InviteUsersRequest
200
184
  ): Promise<InviteUsersResponse> => {
@@ -227,4 +211,43 @@ export class UserAPI extends TestAPI {
227
211
  .set(this.config.internalAPIHeaders())
228
212
  .expect(status)
229
213
  }
214
+
215
+ addWorkspaceIdToInvite = (code: string, role: string, status = 200) => {
216
+ return this.request
217
+ .post(`/api/global/users/invite/${code}/${role}`)
218
+ .set(this.config.defaultHeaders())
219
+ .expect("Content-Type", /json/)
220
+ .expect(status)
221
+ }
222
+
223
+ removeWorkspaceIdFromInvite = (code: string, status = 200) => {
224
+ return this.request
225
+ .delete(`/api/global/users/invite/${code}`)
226
+ .set(this.config.defaultHeaders())
227
+ .expect("Content-Type", /json/)
228
+ .expect(status)
229
+ }
230
+
231
+ addUserToWorkspace = (
232
+ userId: string,
233
+ _rev: string,
234
+ role: string,
235
+ status = 200
236
+ ) => {
237
+ return this.request
238
+ .post(`/api/global/users/${userId}/permission/${role}`)
239
+ .send({ _rev })
240
+ .set(this.config.defaultHeaders())
241
+ .expect("Content-Type", /json/)
242
+ .expect(status)
243
+ }
244
+
245
+ removeUserFromWorkspace = (userId: string, _rev: string, status = 200) => {
246
+ return this.request
247
+ .delete(`/api/global/users/${userId}/permission`)
248
+ .send({ _rev })
249
+ .set(this.config.defaultHeaders())
250
+ .expect("Content-Type", /json/)
251
+ .expect(status)
252
+ }
230
253
  }
@@ -1,64 +0,0 @@
1
- import { TestConfiguration, structures } from "../../../../tests"
2
- import { mocks } from "@budibase/backend-core/tests"
3
- import { User } from "@budibase/types"
4
-
5
- const MOCK_APP_ID = "app_a"
6
-
7
- describe("/api/global/users/:userId/app/builder", () => {
8
- const config = new TestConfiguration()
9
-
10
- beforeAll(async () => {
11
- await config.beforeAll()
12
- })
13
-
14
- afterAll(async () => {
15
- await config.afterAll()
16
- })
17
-
18
- async function newUser() {
19
- const base = structures.users.user()
20
- return await config.createUser(base)
21
- }
22
-
23
- async function getUser(userId: string) {
24
- const response = await config.api.users.getUser(userId)
25
- return response.body as User
26
- }
27
-
28
- describe("Confirm pro license", () => {
29
- it("should 400 with licensing", async () => {
30
- const user = await newUser()
31
- const resp = await config.api.users.grantBuilderToApp(
32
- user._id!,
33
- MOCK_APP_ID,
34
- 400
35
- )
36
- expect(resp.body.message).toContain(
37
- "appBuilders are not currently enabled"
38
- )
39
- })
40
- })
41
-
42
- describe("PATCH /api/global/users/:userId/app/:appId/builder", () => {
43
- it("should be able to grant a user access to a particular app", async () => {
44
- mocks.licenses.useAppBuilders()
45
- const user = await newUser()
46
- await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
47
- const updated = await getUser(user._id!)
48
- expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID)
49
- })
50
- })
51
-
52
- describe("DELETE /api/global/users/:userId/app/:appId/builder", () => {
53
- it("should allow revoking access", async () => {
54
- mocks.licenses.useAppBuilders()
55
- const user = await newUser()
56
- await config.api.users.grantBuilderToApp(user._id!, MOCK_APP_ID)
57
- let updated = await getUser(user._id!)
58
- expect(updated.builder?.apps![0]).toBe(MOCK_APP_ID)
59
- await config.api.users.revokeBuilderFromApp(user._id!, MOCK_APP_ID)
60
- updated = await getUser(user._id!)
61
- expect(updated.builder?.apps!.length).toBe(0)
62
- })
63
- })
64
- })