@budibase/worker 2.3.18-alpha.15 → 2.3.18-alpha.17

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": "2.3.18-alpha.15",
4
+ "version": "2.3.18-alpha.17",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -36,10 +36,10 @@
36
36
  "author": "Budibase",
37
37
  "license": "GPL-3.0",
38
38
  "dependencies": {
39
- "@budibase/backend-core": "2.3.18-alpha.15",
40
- "@budibase/pro": "2.3.18-alpha.14",
41
- "@budibase/string-templates": "2.3.18-alpha.15",
42
- "@budibase/types": "2.3.18-alpha.15",
39
+ "@budibase/backend-core": "2.3.18-alpha.17",
40
+ "@budibase/pro": "2.3.18-alpha.16",
41
+ "@budibase/string-templates": "2.3.18-alpha.17",
42
+ "@budibase/types": "2.3.18-alpha.17",
43
43
  "@koa/router": "8.0.8",
44
44
  "@sentry/node": "6.17.7",
45
45
  "@techpass/passport-openidconnect": "0.3.2",
@@ -101,5 +101,5 @@
101
101
  "typescript": "4.7.3",
102
102
  "update-dotenv": "1.1.1"
103
103
  },
104
- "gitHead": "78287e11da6973374962afac59b35c598a54efc3"
104
+ "gitHead": "f703ebb73409290aa949c240c51b95c7ce3826c8"
105
105
  }
@@ -17,10 +17,15 @@ import {
17
17
  Ctx,
18
18
  GetPublicOIDCConfigResponse,
19
19
  GetPublicSettingsResponse,
20
+ GoogleInnerConfig,
20
21
  isGoogleConfig,
21
22
  isOIDCConfig,
22
23
  isSettingsConfig,
23
24
  isSMTPConfig,
25
+ OIDCConfigs,
26
+ SettingsInnerConfig,
27
+ SSOConfig,
28
+ SSOConfigType,
24
29
  UserCtx,
25
30
  } from "@budibase/types"
26
31
  import * as pro from "@budibase/pro"
@@ -119,6 +124,61 @@ const getEventFns = async (config: Config, existing?: Config) => {
119
124
  return fns
120
125
  }
121
126
 
127
+ type SSOConfigs = { [key in SSOConfigType]: SSOConfig | undefined }
128
+
129
+ async function getSSOConfigs(): Promise<SSOConfigs> {
130
+ const google = await configs.getGoogleConfig()
131
+ const oidc = await configs.getOIDCConfig()
132
+ return {
133
+ [ConfigType.GOOGLE]: google,
134
+ [ConfigType.OIDC]: oidc,
135
+ }
136
+ }
137
+
138
+ async function hasActivatedConfig(ssoConfigs?: SSOConfigs) {
139
+ if (!ssoConfigs) {
140
+ ssoConfigs = await getSSOConfigs()
141
+ }
142
+ return !!Object.values(ssoConfigs).find(c => c?.activated)
143
+ }
144
+
145
+ async function verifySettingsConfig(config: SettingsInnerConfig) {
146
+ if (config.isSSOEnforced) {
147
+ const valid = await hasActivatedConfig()
148
+ if (!valid) {
149
+ throw new Error("Cannot enforce SSO without an activated configuration")
150
+ }
151
+ }
152
+ }
153
+
154
+ async function verifySSOConfig(type: SSOConfigType, config: SSOConfig) {
155
+ const settings = await configs.getSettingsConfig()
156
+ if (settings.isSSOEnforced && !config.activated) {
157
+ // config is being saved as deactivated
158
+ // ensure there is at least one other activated sso config
159
+ const ssoConfigs = await getSSOConfigs()
160
+
161
+ // overwrite the config being updated
162
+ // to reflect the desired state
163
+ ssoConfigs[type] = config
164
+
165
+ const activated = await hasActivatedConfig(ssoConfigs)
166
+ if (!activated) {
167
+ throw new Error(
168
+ "Configuration cannot be deactivated while SSO is enforced"
169
+ )
170
+ }
171
+ }
172
+ }
173
+
174
+ async function verifyGoogleConfig(config: GoogleInnerConfig) {
175
+ await verifySSOConfig(ConfigType.GOOGLE, config)
176
+ }
177
+
178
+ async function verifyOIDCConfig(config: OIDCConfigs) {
179
+ await verifySSOConfig(ConfigType.OIDC, config.configs[0])
180
+ }
181
+
122
182
  export async function save(ctx: UserCtx<Config>) {
123
183
  const body = ctx.request.body
124
184
  const type = body.type
@@ -133,10 +193,19 @@ export async function save(ctx: UserCtx<Config>) {
133
193
 
134
194
  try {
135
195
  // verify the configuration
136
- switch (config.type) {
196
+ switch (type) {
137
197
  case ConfigType.SMTP:
138
198
  await email.verifyConfig(config)
139
199
  break
200
+ case ConfigType.SETTINGS:
201
+ await verifySettingsConfig(config)
202
+ break
203
+ case ConfigType.GOOGLE:
204
+ await verifyGoogleConfig(config)
205
+ break
206
+ case ConfigType.OIDC:
207
+ await verifyOIDCConfig(config)
208
+ break
140
209
  }
141
210
  } catch (err: any) {
142
211
  ctx.throw(400, err)
@@ -1,4 +1,9 @@
1
- import { checkInviteCode } from "../../../utilities/redis"
1
+ import {
2
+ checkInviteCode,
3
+ getInviteCodes,
4
+ updateInviteCode,
5
+ } from "../../../utilities/redis"
6
+ // import sdk from "../../../sdk"
2
7
  import * as userSdk from "../../../sdk/users"
3
8
  import env from "../../../environment"
4
9
  import {
@@ -28,6 +33,7 @@ import {
28
33
  platform,
29
34
  } from "@budibase/backend-core"
30
35
  import { checkAnyUserExists } from "../../../utilities/users"
36
+ import { isEmailConfigured } from "../../../utilities/email"
31
37
 
32
38
  const MAX_USERS_UPLOAD_LIMIT = 1000
33
39
 
@@ -179,16 +185,28 @@ export const destroy = async (ctx: any) => {
179
185
  }
180
186
  }
181
187
 
188
+ export const getAppUsers = async (ctx: any) => {
189
+ const body = ctx.request.body as SearchUsersRequest
190
+ const users = await userSdk.getUsersByAppAccess(body?.appId)
191
+
192
+ ctx.body = { data: users }
193
+ }
194
+
182
195
  export const search = async (ctx: any) => {
183
196
  const body = ctx.request.body as SearchUsersRequest
184
- const paginated = await userSdk.paginatedUsers(body)
185
- // user hashed password shouldn't ever be returned
186
- for (let user of paginated.data) {
187
- if (user) {
188
- delete user.password
197
+
198
+ if (body.paginated === false) {
199
+ await getAppUsers(ctx)
200
+ } else {
201
+ const paginated = await userSdk.paginatedUsers(body)
202
+ // user hashed password shouldn't ever be returned
203
+ for (let user of paginated.data) {
204
+ if (user) {
205
+ delete user.password
206
+ }
189
207
  }
208
+ ctx.body = paginated
190
209
  }
191
- ctx.body = paginated
192
210
  }
193
211
 
194
212
  // called internally by app server user fetch
@@ -218,9 +236,71 @@ export const tenantUserLookup = async (ctx: any) => {
218
236
  }
219
237
  }
220
238
 
239
+ /*
240
+ Encapsulate the app user onboarding flows here.
241
+ */
242
+ export const onboardUsers = async (ctx: any) => {
243
+ const request = ctx.request.body as InviteUsersRequest | BulkUserRequest
244
+ const isBulkCreate = "create" in request
245
+
246
+ const emailConfigured = await isEmailConfigured()
247
+
248
+ let onboardingResponse
249
+
250
+ if (isBulkCreate) {
251
+ // @ts-ignore
252
+ const { users, groups, roles } = request.create
253
+ const assignUsers = users.map((user: User) => (user.roles = roles))
254
+ onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
255
+ ctx.body = onboardingResponse
256
+ } else if (emailConfigured) {
257
+ onboardingResponse = await inviteMultiple(ctx)
258
+ } else if (!emailConfigured) {
259
+ const inviteRequest = ctx.request.body as InviteUsersRequest
260
+
261
+ let createdPasswords: any = {}
262
+
263
+ const users: User[] = inviteRequest.map(invite => {
264
+ let password = Math.random().toString(36).substring(2, 22)
265
+
266
+ // Temp password to be passed to the user.
267
+ createdPasswords[invite.email] = password
268
+
269
+ return {
270
+ email: invite.email,
271
+ password,
272
+ forceResetPassword: true,
273
+ roles: invite.userInfo.apps,
274
+ admin: { global: false },
275
+ builder: { global: false },
276
+ tenantId: tenancy.getTenantId(),
277
+ }
278
+ })
279
+ let bulkCreateReponse = await userSdk.bulkCreate(users, [])
280
+
281
+ // Apply temporary credentials
282
+ let createWithCredentials = {
283
+ ...bulkCreateReponse,
284
+ successful: bulkCreateReponse?.successful.map(user => {
285
+ return {
286
+ ...user,
287
+ password: createdPasswords[user.email],
288
+ }
289
+ }),
290
+ created: true,
291
+ }
292
+
293
+ ctx.body = createWithCredentials
294
+ } else {
295
+ ctx.throw(400, "User onboarding failed")
296
+ }
297
+ }
298
+
221
299
  export const invite = async (ctx: any) => {
222
300
  const request = ctx.request.body as InviteUserRequest
223
- const response = await userSdk.invite([request])
301
+
302
+ let multiRequest = [request] as InviteUsersRequest
303
+ const response = await userSdk.invite(multiRequest)
224
304
 
225
305
  // explicitly throw for single user invite
226
306
  if (response.unsuccessful.length) {
@@ -234,6 +314,8 @@ export const invite = async (ctx: any) => {
234
314
 
235
315
  ctx.body = {
236
316
  message: "Invitation has been sent.",
317
+ successful: response.successful,
318
+ unsuccessful: response.unsuccessful,
237
319
  }
238
320
  }
239
321
 
@@ -255,6 +337,53 @@ export const checkInvite = async (ctx: any) => {
255
337
  }
256
338
  }
257
339
 
340
+ export const getUserInvites = async (ctx: any) => {
341
+ let invites
342
+ try {
343
+ // Restricted to the currently authenticated tenant
344
+ invites = await getInviteCodes([ctx.user.tenantId])
345
+ } catch (e) {
346
+ ctx.throw(400, "There was a problem fetching invites")
347
+ }
348
+ ctx.body = invites
349
+ }
350
+
351
+ export const updateInvite = async (ctx: any) => {
352
+ const { code } = ctx.params
353
+ let updateBody = { ...ctx.request.body }
354
+
355
+ delete updateBody.email
356
+
357
+ let invite
358
+ try {
359
+ invite = await checkInviteCode(code, false)
360
+ if (!invite) {
361
+ throw new Error("The invite could not be retrieved")
362
+ }
363
+ } catch (e) {
364
+ ctx.throw(400, "There was a problem with the invite")
365
+ }
366
+
367
+ let updated = {
368
+ ...invite,
369
+ }
370
+
371
+ if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
372
+ updated.info.apps = []
373
+ } else {
374
+ updated.info = {
375
+ ...invite.info,
376
+ apps: {
377
+ ...invite.info.apps,
378
+ ...updateBody.apps,
379
+ },
380
+ }
381
+ }
382
+
383
+ await updateInviteCode(code, updated)
384
+ ctx.body = { ...invite }
385
+ }
386
+
258
387
  export const inviteAccept = async (
259
388
  ctx: Ctx<AcceptUserInviteRequest, AcceptUserInviteResponse>
260
389
  ) => {
@@ -263,13 +392,23 @@ export const inviteAccept = async (
263
392
  // info is an extension of the user object that was stored by global
264
393
  const { email, info }: any = await checkInviteCode(inviteCode)
265
394
  const user = await tenancy.doInTenant(info.tenantId, async () => {
266
- const saved = await userSdk.save({
395
+ let request = {
267
396
  firstName,
268
397
  lastName,
269
398
  password,
270
399
  email,
400
+ roles: info.apps,
401
+ tenantId: info.tenantId,
402
+ }
403
+
404
+ delete info.apps
405
+
406
+ request = {
407
+ ...request,
271
408
  ...info,
272
- })
409
+ }
410
+
411
+ const saved = await userSdk.save(request)
273
412
  const db = tenancy.getGlobalDB()
274
413
  const user = await db.get(saved._id)
275
414
  await events.user.inviteAccepted(user)
@@ -34,8 +34,8 @@ function settingValidation() {
34
34
  function googleValidation() {
35
35
  // prettier-ignore
36
36
  return Joi.object({
37
- clientID: Joi.when('activated', { is: true, then: Joi.string().required() }),
38
- clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }),
37
+ clientID: Joi.string().required(),
38
+ clientSecret: Joi.string().required(),
39
39
  activated: Joi.boolean().required(),
40
40
  }).unknown(true)
41
41
  }
@@ -45,12 +45,12 @@ function oidcValidation() {
45
45
  return Joi.object({
46
46
  configs: Joi.array().items(
47
47
  Joi.object({
48
- clientID: Joi.when('activated', { is: true, then: Joi.string().required() }),
49
- clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }),
50
- configUrl: Joi.when('activated', { is: true, then: Joi.string().required() }),
48
+ clientID: Joi.string().required(),
49
+ clientSecret: Joi.string().required(),
50
+ configUrl: Joi.string().required(),
51
51
  logo: Joi.string().allow("", null),
52
52
  name: Joi.string().allow("", null),
53
- uuid: Joi.when('activated', { is: true, then: Joi.string().required() }),
53
+ uuid: Joi.string().required(),
54
54
  activated: Joi.boolean().required(),
55
55
  scopes: Joi.array().optional()
56
56
  })
@@ -30,7 +30,11 @@ describe("/api/global/users", () => {
30
30
  email
31
31
  )
32
32
 
33
- expect(res.body).toEqual({ message: "Invitation has been sent." })
33
+ expect(res.body?.message).toBe("Invitation has been sent.")
34
+ expect(res.body?.unsuccessful.length).toBe(0)
35
+ expect(res.body?.successful.length).toBe(1)
36
+ expect(res.body?.successful[0].email).toBe(email)
37
+
34
38
  expect(sendMailMock).toHaveBeenCalled()
35
39
  expect(code).toBeDefined()
36
40
  expect(events.user.invited).toBeCalledTimes(1)
@@ -38,13 +38,6 @@ function buildInviteMultipleValidation() {
38
38
  ))
39
39
  }
40
40
 
41
- function buildInviteLookupValidation() {
42
- // prettier-ignore
43
- return auth.joiValidator.params(Joi.object({
44
- code: Joi.string().required()
45
- }).unknown(true))
46
- }
47
-
48
41
  const createUserAdminOnly = (ctx: any, next: any) => {
49
42
  if (!ctx.request.body._id) {
50
43
  return auth.adminOnly(ctx, next)
@@ -88,22 +81,34 @@ router
88
81
  .get("/api/global/roles/:appId")
89
82
  .post(
90
83
  "/api/global/users/invite",
91
- auth.adminOnly,
84
+ auth.builderOrAdmin,
92
85
  buildInviteValidation(),
93
86
  controller.invite
94
87
  )
88
+ .post(
89
+ "/api/global/users/onboard",
90
+ auth.builderOrAdmin,
91
+ buildInviteMultipleValidation(),
92
+ controller.onboardUsers
93
+ )
95
94
  .post(
96
95
  "/api/global/users/multi/invite",
97
- auth.adminOnly,
96
+ auth.builderOrAdmin,
98
97
  buildInviteMultipleValidation(),
99
98
  controller.inviteMultiple
100
99
  )
101
100
 
102
101
  // non-global endpoints
102
+ .get("/api/global/users/invite/:code", controller.checkInvite)
103
+ .post(
104
+ "/api/global/users/invite/update/:code",
105
+ auth.builderOrAdmin,
106
+ controller.updateInvite
107
+ )
103
108
  .get(
104
- "/api/global/users/invite/:code",
105
- buildInviteLookupValidation(),
106
- controller.checkInvite
109
+ "/api/global/users/invites",
110
+ auth.builderOrAdmin,
111
+ controller.getUserInvites
107
112
  )
108
113
  .post(
109
114
  "/api/global/users/invite/accept",
@@ -57,11 +57,22 @@ export const countUsersByApp = async (appId: string) => {
57
57
  }
58
58
  }
59
59
 
60
+ export const getUsersByAppAccess = async (appId?: string) => {
61
+ const opts: any = {
62
+ include_docs: true,
63
+ limit: 50,
64
+ }
65
+ let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
66
+ appId,
67
+ opts
68
+ )
69
+ return response
70
+ }
71
+
60
72
  export const paginatedUsers = async ({
61
73
  page,
62
74
  email,
63
75
  appId,
64
- userIds,
65
76
  }: SearchUsersRequest = {}) => {
66
77
  const db = tenancy.getGlobalDB()
67
78
  // get one extra document, to have the next page
@@ -234,7 +245,7 @@ export const save = async (
234
245
  const tenantId = tenancy.getTenantId()
235
246
  const db = tenancy.getGlobalDB()
236
247
 
237
- let { email, _id, userGroups = [] } = user
248
+ let { email, _id, userGroups = [], roles } = user
238
249
 
239
250
  if (!email && !_id) {
240
251
  throw new Error("_id or email is required")
@@ -276,6 +287,10 @@ export const save = async (
276
287
  builtUser.roles = dbUser.roles
277
288
  }
278
289
 
290
+ if (!dbUser && roles?.length) {
291
+ builtUser.roles = { ...roles }
292
+ }
293
+
279
294
  // make sure we set the _id field for a new user
280
295
  // Also if this is a new user, associate groups with them
281
296
  let groupPromises = []
@@ -203,7 +203,7 @@ export async function sendEmail(
203
203
  * @param {object} config an SMTP configuration - this is based on the nodemailer API.
204
204
  * @return {Promise<boolean>} returns true if the configuration is valid.
205
205
  */
206
- export async function verifyConfig(config: any) {
206
+ export async function verifyConfig(config: SMTPInnerConfig) {
207
207
  const transport = createSMTPTransport(config)
208
208
  await transport.verify()
209
209
  }
@@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) {
7
7
  return 3600
8
8
  case redis.utils.Databases.INVITATIONS:
9
9
  // a day
10
- return 86400
10
+ return 604800
11
11
  }
12
12
  }
13
13
 
@@ -29,6 +29,20 @@ async function writeACode(db: string, value: any) {
29
29
  return code
30
30
  }
31
31
 
32
+ async function updateACode(db: string, code: string, value: any) {
33
+ const client = await getClient(db)
34
+ await client.store(code, value, getExpirySecondsForDB(db))
35
+ }
36
+
37
+ /**
38
+ * Given an invite code and invite body, allow the update an existing/valid invite in redis
39
+ * @param {string} inviteCode The invite code for an invite in redis
40
+ * @param {object} value The body of the updated user invitation
41
+ */
42
+ export async function updateInviteCode(inviteCode: string, value: string) {
43
+ await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
44
+ }
45
+
32
46
  async function getACode(db: string, code: string, deleteCode = true) {
33
47
  const client = await getClient(db)
34
48
  const value = await client.get(code)
@@ -113,3 +127,27 @@ export async function checkInviteCode(
113
127
  throw "Invitation is not valid or has expired, please request a new one."
114
128
  }
115
129
  }
130
+
131
+ /**
132
+ Get all currently available user invitations.
133
+ @return {Object[]} A list of all objects containing invite metadata
134
+ **/
135
+ export async function getInviteCodes(tenantIds?: string[]) {
136
+ const client = await getClient(redis.utils.Databases.INVITATIONS)
137
+ const invites: any[] = await client.scan()
138
+
139
+ const results = invites.map(invite => {
140
+ return {
141
+ ...invite.value,
142
+ code: invite.key,
143
+ }
144
+ })
145
+ return results.reduce((acc, invite) => {
146
+ if (tenantIds?.length && tenantIds.includes(invite.info.tenantId)) {
147
+ acc.push(invite)
148
+ } else {
149
+ acc.push(invite)
150
+ }
151
+ return acc
152
+ }, [])
153
+ }