@budibase/worker 3.25.4 → 3.26.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.25.4",
4
+ "version": "3.26.0",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -108,5 +108,5 @@
108
108
  }
109
109
  }
110
110
  },
111
- "gitHead": "4a51aa9f4675ffa6555788aeaf6c31b522a952b3"
111
+ "gitHead": "94f6d3fcbeb2043d8144dc3be0cf560b71dd35dc"
112
112
  }
@@ -10,7 +10,7 @@ import {
10
10
  tenancy,
11
11
  users,
12
12
  } from "@budibase/backend-core"
13
- import { features } from "@budibase/pro"
13
+ import { features, groups as proGroups } from "@budibase/pro"
14
14
  import { BpmStatusKey, BpmStatusValue, utils } from "@budibase/shared-core"
15
15
  import {
16
16
  AcceptUserInviteRequest,
@@ -318,6 +318,18 @@ export const search = async (
318
318
  }
319
319
  }
320
320
 
321
+ const hasWorkspaceId =
322
+ body && Object.prototype.hasOwnProperty.call(body, "workspaceId")
323
+
324
+ if (hasWorkspaceId) {
325
+ let response = await searchWorkspaceUsers(body)
326
+ if (!users.hasBuilderPermissions(ctx.user)) {
327
+ response.data = stripUsers(response.data)
328
+ }
329
+ ctx.body = response
330
+ return
331
+ }
332
+
321
333
  let response: SearchUsersResponse = { data: [] }
322
334
 
323
335
  if (body.paginate === false) {
@@ -347,6 +359,111 @@ export const search = async (
347
359
  ctx.body = response
348
360
  }
349
361
 
362
+ const DEFAULT_USER_LIMIT = 8
363
+
364
+ const searchWorkspaceUsers = async (
365
+ body: SearchUsersRequest
366
+ ): Promise<SearchUsersResponse> => {
367
+ const workspaceId = body.workspaceId
368
+ if (!workspaceId) {
369
+ return { data: [], hasNextPage: false }
370
+ }
371
+
372
+ const limit = body.limit ?? DEFAULT_USER_LIMIT
373
+ const query = body.query
374
+ const filtered: User[] = []
375
+ let cursor = body.bookmark
376
+ let nextPage: string | undefined
377
+ const groupAccessCache = new Map<string, boolean>()
378
+
379
+ const getBookmarkValue = (user: User) => {
380
+ if (query?.string?.email && user.email) {
381
+ return user.email.toLowerCase()
382
+ }
383
+ return user._id
384
+ }
385
+
386
+ const hydrateGroupAccess = async (usersToCheck: User[]) => {
387
+ const missingGroupIds = new Set<string>()
388
+ for (const user of usersToCheck) {
389
+ for (const groupId of user.userGroups || []) {
390
+ if (!groupAccessCache.has(groupId)) {
391
+ missingGroupIds.add(groupId)
392
+ }
393
+ }
394
+ }
395
+
396
+ if (!missingGroupIds.size) {
397
+ return
398
+ }
399
+
400
+ const groupIdList = [...missingGroupIds]
401
+ const groups = await proGroups.getBulk(groupIdList, { enriched: false })
402
+ groups.forEach((group, index) => {
403
+ const groupId = group?._id || groupIdList[index]
404
+ const hasAccess = !!group?.roles?.[workspaceId]
405
+ groupAccessCache.set(groupId, hasAccess)
406
+ })
407
+ }
408
+
409
+ const hasWorkspaceAccess = (user: User) => {
410
+ if (users.isAdminOrBuilder(user, workspaceId)) {
411
+ return true
412
+ }
413
+ if (user.roles?.[workspaceId]) {
414
+ return true
415
+ }
416
+ return (user.userGroups || []).some(groupId =>
417
+ groupAccessCache.get(groupId)
418
+ )
419
+ }
420
+
421
+ while (filtered.length <= limit) {
422
+ const page = await userSdk.core.paginatedUsers({
423
+ bookmark: cursor,
424
+ query,
425
+ limit,
426
+ })
427
+
428
+ if (!page.data?.length) {
429
+ break
430
+ }
431
+
432
+ await hydrateGroupAccess(page.data)
433
+
434
+ for (const user of page.data) {
435
+ if (!hasWorkspaceAccess(user)) {
436
+ continue
437
+ }
438
+ filtered.push(user)
439
+ if (filtered.length === limit + 1) {
440
+ nextPage = getBookmarkValue(user)
441
+ break
442
+ }
443
+ }
444
+
445
+ if (filtered.length === limit + 1 || !page.hasNextPage) {
446
+ break
447
+ }
448
+ cursor = page.nextPage
449
+ }
450
+
451
+ const hasNextPage = filtered.length > limit
452
+ const data = hasNextPage ? filtered.slice(0, limit) : filtered
453
+
454
+ for (let user of data) {
455
+ if (user) {
456
+ delete user.password
457
+ }
458
+ }
459
+
460
+ return {
461
+ data,
462
+ hasNextPage,
463
+ nextPage: hasNextPage ? nextPage : undefined,
464
+ }
465
+ }
466
+
350
467
  // called internally by app server user fetch
351
468
  export const fetch = async (ctx: UserCtx<void, FetchUsersResponse>) => {
352
469
  const all = await userSdk.db.allUsers()
@@ -868,6 +868,52 @@ describe("/api/global/users", () => {
868
868
  expect(response.body.data[0].email).toBe(email)
869
869
  })
870
870
 
871
+ it("should filter by workspace access when workspaceId is provided", async () => {
872
+ const workspaceId = "app_workspace_filter"
873
+ const email = structures.users.newEmail()
874
+ await config.createUser({
875
+ email,
876
+ roles: { [workspaceId]: "BASIC" },
877
+ })
878
+
879
+ const response = await config.api.users.searchUsers({
880
+ workspaceId,
881
+ query: { string: { email } },
882
+ })
883
+
884
+ expect(response.body.data.length).toBe(1)
885
+ expect(response.body.data[0].email).toBe(email)
886
+ })
887
+
888
+ it("should exclude users without workspace access", async () => {
889
+ const workspaceId = "app_workspace_filter_exclude"
890
+ const email = structures.users.newEmail()
891
+ await config.createUser({
892
+ email,
893
+ roles: { app_other: "BASIC" },
894
+ })
895
+
896
+ const response = await config.api.users.searchUsers({
897
+ workspaceId,
898
+ query: { string: { email } },
899
+ })
900
+
901
+ expect(response.body.data.length).toBe(0)
902
+ })
903
+
904
+ it("should return no users when workspaceId is empty", async () => {
905
+ const email = structures.users.newEmail()
906
+ await config.createUser({ email })
907
+
908
+ const response = await config.api.users.searchUsers({
909
+ workspaceId: "",
910
+ query: { string: { email } },
911
+ })
912
+
913
+ expect(response.body.data.length).toBe(0)
914
+ expect(response.body.hasNextPage).toBe(false)
915
+ })
916
+
871
917
  it("should be able to search by email with numeric prefixing", async () => {
872
918
  const user = await config.createUser()
873
919
  const response = await config.api.users.searchUsers({
@@ -5,7 +5,7 @@ import {
5
5
  CreateAdminUserRequest,
6
6
  InviteUsersRequest,
7
7
  InviteUsersResponse,
8
- SearchFilters,
8
+ SearchUsersRequest,
9
9
  User,
10
10
  } from "@budibase/types"
11
11
  import structures from "../structures"
@@ -151,7 +151,7 @@ export class UserAPI extends TestAPI {
151
151
  }
152
152
 
153
153
  searchUsers = (
154
- { query }: { query?: SearchFilters },
154
+ body: SearchUsersRequest = {},
155
155
  opts?: {
156
156
  status?: number
157
157
  noHeaders?: boolean
@@ -160,7 +160,7 @@ export class UserAPI extends TestAPI {
160
160
  ) => {
161
161
  const req = this.request
162
162
  .post("/api/global/users/search")
163
- .send({ query })
163
+ .send(body)
164
164
  .expect("Content-Type", /json/)
165
165
  .expect(opts?.status ? opts.status : 200)
166
166
  if (opts?.useHeaders) {