@budibase/worker 3.23.23 → 3.23.25

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.23.23",
4
+ "version": "3.23.25",
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": "e04af85aab2242716869fa9a8f62cd99f8d5e0da"
112
+ "gitHead": "aaca0ff0f2cf886148a72826d4ecd9c8b90a62b2"
113
113
  }
@@ -5,6 +5,7 @@ import {
5
5
  events,
6
6
  utils as utilsCore,
7
7
  configs,
8
+ cache,
8
9
  } from "@budibase/backend-core"
9
10
  import {
10
11
  ConfigType,
@@ -37,6 +38,44 @@ const { setCookie, getCookie, clearCookie } = utilsCore
37
38
 
38
39
  // LOGIN / LOGOUT
39
40
 
41
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
42
+ const failKey = (email: string) => `auth:login:fail:${normalizeEmail(email)}`
43
+ const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
44
+ const isLocked = async (email: string) => {
45
+ return !!(await cache.get(lockKey(email)))
46
+ }
47
+
48
+ const handleLockoutResponse = (ctx: Ctx, email: string) => {
49
+ ctx.set("X-Account-Locked", "1")
50
+ ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
51
+ console.log(
52
+ `[auth] login blocked (post-failure) due to lock email=${normalizeEmail(email)}`
53
+ )
54
+ return ctx.throw(403, "Account temporarily locked. Try again later.")
55
+ }
56
+ const onFailed = async (email: string) => {
57
+ if (!email) return
58
+ const key = failKey(email)
59
+ const currentAttempt = Number((await cache.get(key)) || 0) || 0
60
+ const nextAttempt = currentAttempt + 1
61
+ await cache.store(key, nextAttempt, env.LOGIN_LOCKOUT_SECONDS)
62
+ console.log(
63
+ `[auth] failed login email=${normalizeEmail(email)} count=${nextAttempt}`
64
+ )
65
+ if (nextAttempt >= env.LOGIN_MAX_FAILED_ATTEMPTS) {
66
+ await cache.store(lockKey(email), "1", env.LOGIN_LOCKOUT_SECONDS)
67
+ await cache.destroy(key)
68
+ console.log(
69
+ `[auth] account locked email=${normalizeEmail(email)} for ${env.LOGIN_LOCKOUT_SECONDS}s`
70
+ )
71
+ }
72
+ }
73
+ const clearFailureState = async (email: string) => {
74
+ if (!email) return
75
+ await cache.destroy(failKey(email))
76
+ await cache.destroy(lockKey(email))
77
+ }
78
+
40
79
  async function passportCallback(
41
80
  ctx: Ctx,
42
81
  user: User,
@@ -75,14 +114,37 @@ export const login = async (
75
114
  ) => {
76
115
  const email = ctx.request.body.username
77
116
 
78
- const user = await userSdk.db.getUserByEmail(email)
79
- if (user && (await userSdk.db.isPreventPasswordActions(user))) {
117
+ const dbUser = await userSdk.db.getUserByEmail(email)
118
+ if (dbUser && (await userSdk.db.isPreventPasswordActions(dbUser))) {
119
+ console.log(
120
+ `[auth] login prevented due to sso enforcement email=${normalizeEmail(email)}`
121
+ )
80
122
  ctx.throw(403, "Invalid credentials")
81
123
  }
82
124
 
83
125
  return passport.authenticate(
84
126
  "local",
85
127
  async (err: any, user: User, info: any) => {
128
+ if (err || !user) {
129
+ if (dbUser) {
130
+ await onFailed(email)
131
+ }
132
+ if (await isLocked(email)) {
133
+ return handleLockoutResponse(ctx, email)
134
+ }
135
+ const reason =
136
+ (info && info.message) || (err && err.message) || "unknown"
137
+ console.log(
138
+ `[auth] password auth failed email=${normalizeEmail(email)} reason=${reason}`
139
+ )
140
+ // delegate to shared passport failure handling to preserve specific messages (e.g. expired)
141
+ return passportCallback(ctx, user as any, err, info)
142
+ }
143
+
144
+ await clearFailureState(email)
145
+ console.log(
146
+ `[auth] password auth success email=${normalizeEmail(user.email)}`
147
+ )
86
148
  await passportCallback(ctx, user, err, info)
87
149
  await context.identity.doInUserContext(user, ctx, async () => {
88
150
  await events.auth.login("local", user.email)
@@ -133,6 +195,60 @@ export const reset = async (
133
195
  ) => {
134
196
  const { email } = ctx.request.body
135
197
 
198
+ const lcEmail = (email || "").toLowerCase()
199
+ const ip = (ctx.ip || "").toString()
200
+
201
+ // rate limit keys
202
+ const emailKey = `auth:pwdreset:email:${lcEmail}`
203
+ const ipKey = `auth:pwdreset:ip:${ip}`
204
+
205
+ const increment = async (key: string, windowSeconds: number) => {
206
+ const currentAttempt = Number((await cache.get(key)) || 0) || 0
207
+ const nextAttempt = currentAttempt + 1
208
+ await cache.store(key, nextAttempt, windowSeconds)
209
+ return nextAttempt
210
+ }
211
+
212
+ // apply per-email and per-ip rate limits
213
+ const nextEmail = await increment(
214
+ emailKey,
215
+ env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS
216
+ )
217
+ const nextIp = await increment(
218
+ ipKey,
219
+ env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
220
+ )
221
+
222
+ const emailLimited = nextEmail > env.PASSWORD_RESET_RATE_EMAIL_LIMIT
223
+ const ipLimited = nextIp > env.PASSWORD_RESET_RATE_IP_LIMIT
224
+
225
+ if (emailLimited || ipLimited) {
226
+ // surfaced for ui to display
227
+ ctx.set(
228
+ "X-RateLimit-Email-Limit",
229
+ String(env.PASSWORD_RESET_RATE_EMAIL_LIMIT)
230
+ )
231
+ ctx.set(
232
+ "X-RateLimit-Email-Remaining",
233
+ String(Math.max(env.PASSWORD_RESET_RATE_EMAIL_LIMIT - nextEmail, 0))
234
+ )
235
+ ctx.set("X-RateLimit-IP-Limit", String(env.PASSWORD_RESET_RATE_IP_LIMIT))
236
+ ctx.set(
237
+ "X-RateLimit-IP-Remaining",
238
+ String(Math.max(env.PASSWORD_RESET_RATE_IP_LIMIT - nextIp, 0))
239
+ )
240
+ // best-effort retry window
241
+ const retryAfter = Math.max(
242
+ env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS,
243
+ env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS
244
+ )
245
+ ctx.set("Retry-After", String(retryAfter))
246
+ console.log(
247
+ `[auth] password reset rate limited email=${lcEmail} ip=${ip} emailCount=${nextEmail} ipCount=${nextIp}`
248
+ )
249
+ return ctx.throw(429, "Too many password reset requests. Try again later.")
250
+ }
251
+
136
252
  await authSdk.reset(email)
137
253
 
138
254
  ctx.body = {
@@ -10,6 +10,7 @@ import {
10
10
  tenancy,
11
11
  } from "@budibase/backend-core"
12
12
  import * as pro from "@budibase/pro"
13
+ import { BUILDER_URLS } from "@budibase/shared-core"
13
14
  import {
14
15
  AIInnerConfig,
15
16
  Config,
@@ -676,23 +677,23 @@ export async function configChecklist(ctx: Ctx<void, ConfigChecklistResponse>) {
676
677
  apps: {
677
678
  checked: workspaces.length > 0,
678
679
  label: "Create your first app",
679
- link: "/builder/portal/workspaces",
680
+ link: BUILDER_URLS.WORKSPACES,
680
681
  },
681
682
  smtp: {
682
683
  checked: !!smtpConfig,
683
684
  label: "Set up email",
684
- link: "/builder/portal/settings/email",
685
+ link: BUILDER_URLS.SETTINGS_EMAIL,
685
686
  fallback: smtpConfig?.fallback || false,
686
687
  },
687
688
  adminUser: {
688
689
  checked: userExists,
689
690
  label: "Create your first user",
690
- link: "/builder/portal/users/users",
691
+ link: BUILDER_URLS.SETTINGS_PEOPLE_USERS,
691
692
  },
692
693
  sso: {
693
694
  checked: !!googleConfig || !!oidcConfig,
694
695
  label: "Set up single sign-on",
695
- link: "/builder/portal/settings/auth",
696
+ link: BUILDER_URLS.SETTINGS_AUTH,
696
697
  },
697
698
  branding,
698
699
  }
@@ -1,4 +1,5 @@
1
1
  import { GetTenantInfoResponse, UserCtx } from "@budibase/types"
2
+ import { LockRequest, LockReason } from "@budibase/types"
2
3
  import * as tenantSdk from "../../../sdk/tenants"
3
4
 
4
5
  export async function destroy(ctx: UserCtx<void, void>) {
@@ -18,6 +19,34 @@ export async function destroy(ctx: UserCtx<void, void>) {
18
19
  }
19
20
  }
20
21
 
22
+ export async function lock(ctx: UserCtx<LockRequest, void>) {
23
+ if (!ctx.internal) {
24
+ ctx.throw(403, "Only internal user can lock a tenant")
25
+ }
26
+
27
+ const { reason } = ctx.request.body
28
+ if (reason !== undefined && !Object.values(LockReason).includes(reason)) {
29
+ ctx.throw(
30
+ 400,
31
+ `Invalid lock reason. Valid values: ${Object.values(LockReason).join(", ")}`
32
+ )
33
+ }
34
+
35
+ const tenantId = ctx.params.tenantId
36
+
37
+ try {
38
+ if (reason) {
39
+ await tenantSdk.lockTenant(tenantId, reason)
40
+ } else {
41
+ await tenantSdk.unlockTenant(tenantId)
42
+ }
43
+ ctx.status = 204
44
+ } catch (err) {
45
+ ctx.log.error(err)
46
+ throw err
47
+ }
48
+ }
49
+
21
50
  export async function info(ctx: UserCtx<void, GetTenantInfoResponse>) {
22
51
  ctx.body = await tenantSdk.tenantInfo(ctx.params.tenantId)
23
52
  }
@@ -2,6 +2,7 @@ import * as authController from "../../controllers/global/auth"
2
2
  import { auth } from "@budibase/backend-core"
3
3
  import Joi from "joi"
4
4
  import { loggedInRoutes } from "../endpointGroups"
5
+ import { lockout } from "../../../middleware"
5
6
 
6
7
  function buildAuthValidation() {
7
8
  // prettier-ignore
@@ -31,6 +32,7 @@ loggedInRoutes
31
32
  .post(
32
33
  "/api/global/auth/:tenantId/login",
33
34
  buildAuthValidation(),
35
+ lockout,
34
36
  authController.login
35
37
  )
36
38
  .post("/api/global/auth/logout", authController.logout)
@@ -140,6 +140,59 @@ describe("/api/global/auth", () => {
140
140
  })
141
141
  })
142
142
  })
143
+
144
+ describe("lockout", () => {
145
+ it("locks the account after 5 failed attempts and unlocks after TTL", async () => {
146
+ const tenantId = config.tenantId!
147
+ const email = config.user!.email!
148
+ const correctPassword = config.userPassword
149
+ const wrongPassword = "incorrect123"
150
+ const { withEnv } = require("../../../../environment")
151
+
152
+ await withEnv(
153
+ {
154
+ LOGIN_MAX_FAILED_ATTEMPTS: 5,
155
+ LOGIN_LOCKOUT_SECONDS: 2,
156
+ },
157
+ async () => {
158
+ // 5 consecutive wrong attempts
159
+ for (let i = 0; i < 5; i++) {
160
+ await config.api.auth.login(tenantId, email, wrongPassword, {
161
+ status: 403,
162
+ })
163
+ }
164
+
165
+ // lock should be active now - further attempts (wrong or right) return 403
166
+ await config.api.auth.login(tenantId, email, wrongPassword, {
167
+ status: 403,
168
+ })
169
+
170
+ await config.api.auth.login(tenantId, email, correctPassword, {
171
+ status: 403,
172
+ })
173
+
174
+ // wait for TTL to expire (add buffer for redis expiration granularity)
175
+ await new Promise(r => setTimeout(r, 3000))
176
+
177
+ // Clear any remaining lockout state to ensure clean test
178
+ await config.doInTenant(async () => {
179
+ const { cache } = require("@budibase/backend-core")
180
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
181
+ const lockKey = (email: string) =>
182
+ `auth:login:lock:${normalizeEmail(email)}`
183
+ await cache.destroy(lockKey(email))
184
+ })
185
+
186
+ const response = await config.api.auth.login(
187
+ tenantId,
188
+ email,
189
+ correctPassword
190
+ )
191
+ expectSetAuthCookie(response)
192
+ }
193
+ )
194
+ })
195
+ })
143
196
  })
144
197
 
145
198
  describe("POST /api/global/auth/logout", () => {
@@ -2,3 +2,4 @@ import * as controller from "../../controllers/system/tenants"
2
2
  import { adminRoutes } from "../endpointGroups"
3
3
 
4
4
  adminRoutes.delete("/api/system/tenants/:tenantId", controller.destroy)
5
+ adminRoutes.put("/api/system/tenants/:tenantId/lock", controller.lock)
@@ -1,5 +1,13 @@
1
1
  import { TestConfiguration } from "../../../../tests"
2
2
  import { tenancy } from "@budibase/backend-core"
3
+ import { LockReason } from "@budibase/types"
4
+ import * as tenantSdk from "../../../../sdk/tenants"
5
+
6
+ jest.mock("../../../../sdk/tenants", () => ({
7
+ lockTenant: jest.fn(),
8
+ unlockTenant: jest.fn(),
9
+ deleteTenant: jest.fn(),
10
+ }))
3
11
 
4
12
  describe("/api/global/tenants", () => {
5
13
  const config = new TestConfiguration()
@@ -58,4 +66,85 @@ describe("/api/global/tenants", () => {
58
66
  expect(res.body).toEqual(config.adminOnlyResponse())
59
67
  })
60
68
  })
69
+
70
+ describe("PUT /api/system/tenants/:tenantId/lock", () => {
71
+ it("allows locking tenant with valid reason", async () => {
72
+ const user = await config.createTenant()
73
+
74
+ await config.api.tenants.lock(
75
+ user.tenantId,
76
+ {
77
+ reason: LockReason.FREE_TIER,
78
+ },
79
+ {
80
+ headers: config.internalAPIHeaders(),
81
+ }
82
+ )
83
+
84
+ expect(tenantSdk.lockTenant).toHaveBeenCalledWith(
85
+ user.tenantId,
86
+ LockReason.FREE_TIER
87
+ )
88
+ expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
89
+ })
90
+
91
+ it("unlocks tenant when no reason provided", async () => {
92
+ const user = await config.createTenant()
93
+
94
+ await config.api.tenants.lock(
95
+ user.tenantId,
96
+ {},
97
+ {
98
+ headers: config.internalAPIHeaders(),
99
+ }
100
+ )
101
+
102
+ expect(tenantSdk.unlockTenant).toHaveBeenCalledWith(user.tenantId)
103
+ expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it("rejects invalid lock reason", async () => {
107
+ const user = await config.createTenant()
108
+
109
+ const status = 400
110
+ const res = await config.api.tenants.lock(
111
+ user.tenantId,
112
+ {
113
+ reason: "invalid_reason" as any,
114
+ },
115
+ {
116
+ status,
117
+ headers: config.internalAPIHeaders(),
118
+ }
119
+ )
120
+
121
+ expect(res.body.message).toContain("Invalid lock reason. Valid values:")
122
+ expect(res.body.message).toContain(LockReason.FREE_TIER)
123
+ expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
124
+ expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
125
+ })
126
+
127
+ it("rejects non-internal user", async () => {
128
+ const user = await config.createTenant()
129
+
130
+ const status = 403
131
+ const res = await config.api.tenants.lock(
132
+ user.tenantId,
133
+ {
134
+ reason: LockReason.FREE_TIER,
135
+ },
136
+ {
137
+ status,
138
+ headers: config.authHeaders(user),
139
+ }
140
+ )
141
+
142
+ expect(res.body).toEqual({
143
+ message: "Only internal user can lock a tenant",
144
+ status,
145
+ })
146
+ expect(tenantSdk.lockTenant).not.toHaveBeenCalled()
147
+ expect(tenantSdk.unlockTenant).not.toHaveBeenCalled()
148
+ })
149
+ })
61
150
  })
@@ -87,6 +87,20 @@ const environment = {
87
87
  PASSPORT_OIDCAUTH_FAILURE_REDIRECT:
88
88
  process.env.PASSPORT_OIDCAUTH_FAILURE_REDIRECT || "/error",
89
89
 
90
+ LOGIN_MAX_FAILED_ATTEMPTS:
91
+ parseIntSafe(process.env.LOGIN_MAX_FAILED_ATTEMPTS) || 5,
92
+ LOGIN_LOCKOUT_SECONDS: parseIntSafe(process.env.LOGIN_LOCKOUT_SECONDS) || 900,
93
+
94
+ // password reset rate limiting
95
+ PASSWORD_RESET_RATE_EMAIL_LIMIT:
96
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_LIMIT) || 3,
97
+ PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS:
98
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_EMAIL_WINDOW_SECONDS) || 900,
99
+ PASSWORD_RESET_RATE_IP_LIMIT:
100
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_LIMIT) || 20,
101
+ PASSWORD_RESET_RATE_IP_WINDOW_SECONDS:
102
+ parseIntSafe(process.env.PASSWORD_RESET_RATE_IP_WINDOW_SECONDS) || 900,
103
+
90
104
  // Budibase AI
91
105
  BUDIBASE_AI_API_KEY: process.env.BUDIBASE_AI_API_KEY,
92
106
  BUDIBASE_AI_DEFAULT_MODEL: process.env.BUDIBASE_AI_DEFAULT_MODEL,
@@ -0,0 +1,3 @@
1
+ export { default as cloudRestricted } from "./cloudRestricted"
2
+ export { handleScimBody } from "./handleScimBody"
3
+ export { default as lockout } from "./lockout"
@@ -0,0 +1,36 @@
1
+ import { cache } from "@budibase/backend-core"
2
+ import { Ctx } from "@budibase/types"
3
+ import { Next } from "koa"
4
+ import env from "../environment"
5
+ import * as userSdk from "../sdk/users"
6
+
7
+ const normalizeEmail = (e: string) => (e || "").toLowerCase()
8
+ const lockKey = (email: string) => `auth:login:lock:${normalizeEmail(email)}`
9
+
10
+ const isLocked = async (email: string) => {
11
+ return !!(await cache.get(lockKey(email)))
12
+ }
13
+
14
+ /**
15
+ * Middleware to check if an account is locked due to failed login attempts.
16
+ * If locked, returns 403 with appropriate headers.
17
+ */
18
+ export default async (ctx: Ctx, next: Next) => {
19
+ const email = ctx.request.body.username
20
+
21
+ if (!email) {
22
+ return await next()
23
+ }
24
+
25
+ const dbUser = await userSdk.db.getUserByEmail(email)
26
+ if (dbUser && (await isLocked(email))) {
27
+ console.log(
28
+ `[auth] login blocked due to lock email=${normalizeEmail(email)}`
29
+ )
30
+ ctx.set("X-Account-Locked", "1")
31
+ ctx.set("Retry-After", String(env.LOGIN_LOCKOUT_SECONDS))
32
+ ctx.throw(403, "Account temporarily locked. Try again later.")
33
+ }
34
+
35
+ return await next()
36
+ }
@@ -0,0 +1,95 @@
1
+ import lockout from "../lockout"
2
+ import { cache } from "@budibase/backend-core"
3
+ import * as userSdk from "../../sdk/users"
4
+ import env from "../../environment"
5
+
6
+ jest.mock("@budibase/backend-core", () => ({
7
+ cache: {
8
+ get: jest.fn(),
9
+ },
10
+ }))
11
+
12
+ jest.mock("../../sdk/users", () => ({
13
+ db: {
14
+ getUserByEmail: jest.fn(),
15
+ },
16
+ }))
17
+
18
+ describe("lockout middleware", () => {
19
+ let ctx: any
20
+ let next: jest.Mock
21
+
22
+ beforeEach(() => {
23
+ ctx = {
24
+ request: {
25
+ body: {},
26
+ },
27
+ set: jest.fn(),
28
+ throw: jest.fn(),
29
+ }
30
+ next = jest.fn()
31
+ jest.clearAllMocks()
32
+ })
33
+
34
+ it("should call next if no email provided", async () => {
35
+ ctx.request.body = {}
36
+
37
+ await lockout(ctx, next)
38
+
39
+ expect(next).toHaveBeenCalled()
40
+ expect(ctx.throw).not.toHaveBeenCalled()
41
+ })
42
+
43
+ it("should call next if user not found", async () => {
44
+ ctx.request.body = { username: "test@example.com" }
45
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue(null)
46
+
47
+ await lockout(ctx, next)
48
+
49
+ expect(next).toHaveBeenCalled()
50
+ expect(ctx.throw).not.toHaveBeenCalled()
51
+ })
52
+
53
+ it("should call next if user exists but not locked", async () => {
54
+ ctx.request.body = { username: "test@example.com" }
55
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
56
+ email: "test@example.com",
57
+ })
58
+ ;(cache.get as jest.Mock).mockResolvedValue(null)
59
+
60
+ await lockout(ctx, next)
61
+
62
+ expect(next).toHaveBeenCalled()
63
+ expect(ctx.throw).not.toHaveBeenCalled()
64
+ })
65
+
66
+ it("should throw 403 if user is locked", async () => {
67
+ ctx.request.body = { username: "test@example.com" }
68
+ ;(userSdk.db.getUserByEmail as jest.Mock).mockResolvedValue({
69
+ email: "test@example.com",
70
+ })
71
+ ;(cache.get as jest.Mock).mockResolvedValue("1")
72
+
73
+ // Mock ctx.throw to actually throw
74
+ ctx.throw = jest.fn().mockImplementation((status, message) => {
75
+ const error = new Error(message)
76
+ ;(error as any).status = status
77
+ throw error
78
+ })
79
+
80
+ await expect(lockout(ctx, next)).rejects.toThrow(
81
+ "Account temporarily locked. Try again later."
82
+ )
83
+
84
+ expect(next).not.toHaveBeenCalled()
85
+ expect(ctx.set).toHaveBeenCalledWith("X-Account-Locked", "1")
86
+ expect(ctx.set).toHaveBeenCalledWith(
87
+ "Retry-After",
88
+ String(env.LOGIN_LOCKOUT_SECONDS)
89
+ )
90
+ expect(ctx.throw).toHaveBeenCalledWith(
91
+ 403,
92
+ "Account temporarily locked. Try again later."
93
+ )
94
+ })
95
+ })
@@ -1,5 +1,11 @@
1
- import { db as dbCore, platform, tenancy } from "@budibase/backend-core"
1
+ import {
2
+ configs,
3
+ db as dbCore,
4
+ platform,
5
+ tenancy,
6
+ } from "@budibase/backend-core"
2
7
  import { quotas } from "@budibase/pro"
8
+ import { ConfigType, LockReason, SettingsConfig } from "@budibase/types"
3
9
 
4
10
  export async function deleteTenant(tenantId: string) {
5
11
  await quotas.bustCache()
@@ -8,6 +14,28 @@ export async function deleteTenant(tenantId: string) {
8
14
  await removeGlobalDB(tenantId)
9
15
  }
10
16
 
17
+ export async function lockTenant(tenantId: string, lockReason: LockReason) {
18
+ return await setLock(tenantId, lockReason)
19
+ }
20
+
21
+ export async function unlockTenant(tenantId: string) {
22
+ return await setLock(tenantId)
23
+ }
24
+
25
+ async function setLock(tenantId: string, lockReason?: LockReason) {
26
+ const db = tenancy.getTenantDB(tenantId)
27
+ const settingsConfig = await db.tryGet<SettingsConfig>(
28
+ configs.generateConfigID(ConfigType.SETTINGS)
29
+ )
30
+ if (!settingsConfig?.config) {
31
+ throw new Error(
32
+ `Cannot lock. Settings config not found for tenant ${tenantId}`
33
+ )
34
+ }
35
+ settingsConfig.config.lockedBy = lockReason
36
+ await db.put(settingsConfig)
37
+ }
38
+
11
39
  async function removeGlobalDB(tenantId: string) {
12
40
  try {
13
41
  const db = tenancy.getTenantDB(tenantId)
@@ -0,0 +1,162 @@
1
+ import { structures } from "../../../tests"
2
+ import { lockTenant, unlockTenant } from "../tenants"
3
+ import { configs, tenancy } from "@budibase/backend-core"
4
+ import { LockReason, ConfigType, SettingsConfig } from "@budibase/types"
5
+
6
+ // Mock the backend-core modules
7
+ jest.mock("@budibase/backend-core", () => {
8
+ const actual = jest.requireActual("@budibase/backend-core")
9
+ return {
10
+ ...actual,
11
+ tenancy: {
12
+ ...actual.tenancy,
13
+ getTenantDB: jest.fn(),
14
+ getGlobalDBName: jest.fn(),
15
+ },
16
+ configs: {
17
+ ...actual.configs,
18
+ generateConfigID: jest.fn(),
19
+ },
20
+ db: {
21
+ ...actual.db,
22
+ getAllWorkspaces: jest.fn(),
23
+ getDB: jest.fn(),
24
+ dbExists: jest.fn(),
25
+ getGlobalUserParams: jest.fn(),
26
+ },
27
+ platform: {
28
+ ...actual.platform,
29
+ getPlatformDB: jest.fn(),
30
+ },
31
+ }
32
+ })
33
+
34
+ // Mock the pro module
35
+ jest.mock("@budibase/pro", () => ({
36
+ quotas: {
37
+ bustCache: jest.fn(),
38
+ },
39
+ constants: {
40
+ licenses: {
41
+ CLOUD_FREE_LICENSE: "CLOUD_FREE_LICENSE",
42
+ UNLIMITED_LICENSE: "UNLIMITED_LICENSE",
43
+ },
44
+ },
45
+ licensing: {
46
+ cache: {
47
+ getCachedLicense: jest.fn(),
48
+ },
49
+ },
50
+ }))
51
+
52
+ const mockDb = {
53
+ put: jest.fn(),
54
+ tryGet: jest.fn(),
55
+ }
56
+
57
+ const mockedTenancy = jest.mocked(tenancy)
58
+ const mockedConfigs = jest.mocked(configs)
59
+
60
+ describe("tenants", () => {
61
+ beforeEach(() => {
62
+ jest.clearAllMocks()
63
+ // Setup mock database
64
+ mockedTenancy.getTenantDB.mockReturnValue(mockDb as any)
65
+ // Setup mock config ID generation
66
+ mockedConfigs.generateConfigID.mockReturnValue("config_settings")
67
+ })
68
+
69
+ describe("lockTenant", () => {
70
+ it("should lock tenant with provided reason", async () => {
71
+ const tenantId = structures.tenant.id()
72
+ const lockReason = LockReason.FREE_TIER
73
+
74
+ const settingsConfig: SettingsConfig = {
75
+ _id: "config_settings",
76
+ type: ConfigType.SETTINGS,
77
+ config: {},
78
+ }
79
+
80
+ mockDb.tryGet.mockResolvedValue(settingsConfig)
81
+
82
+ await lockTenant(tenantId, lockReason)
83
+
84
+ expect(mockDb.tryGet).toHaveBeenCalledWith(
85
+ configs.generateConfigID(ConfigType.SETTINGS)
86
+ )
87
+ expect(mockDb.put).toHaveBeenCalledWith({
88
+ ...settingsConfig,
89
+ config: {
90
+ ...settingsConfig.config,
91
+ lockedBy: lockReason,
92
+ },
93
+ })
94
+ })
95
+
96
+ it("should throw error when settings config not found", async () => {
97
+ const tenantId = structures.tenant.id()
98
+ const lockReason = LockReason.FREE_TIER
99
+
100
+ mockDb.tryGet.mockResolvedValue(null)
101
+
102
+ await expect(lockTenant(tenantId, lockReason)).rejects.toThrow(
103
+ `Cannot lock. Settings config not found for tenant ${tenantId}`
104
+ )
105
+ })
106
+
107
+ it("should throw error when settings config has no config property", async () => {
108
+ const tenantId = structures.tenant.id()
109
+ const lockReason = LockReason.FREE_TIER
110
+
111
+ const settingsConfig = {
112
+ _id: "config_settings",
113
+ type: ConfigType.SETTINGS,
114
+ }
115
+
116
+ mockDb.tryGet.mockResolvedValue(settingsConfig)
117
+
118
+ await expect(lockTenant(tenantId, lockReason)).rejects.toThrow(
119
+ `Cannot lock. Settings config not found for tenant ${tenantId}`
120
+ )
121
+ })
122
+ })
123
+
124
+ describe("unlockTenant", () => {
125
+ it("should unlock tenant by removing lock reason", async () => {
126
+ const tenantId = structures.tenant.id()
127
+
128
+ const settingsConfig: SettingsConfig = {
129
+ _id: "config_settings",
130
+ type: ConfigType.SETTINGS,
131
+ config: {
132
+ lockedBy: LockReason.FREE_TIER,
133
+ },
134
+ }
135
+
136
+ mockDb.tryGet.mockResolvedValue(settingsConfig)
137
+
138
+ await unlockTenant(tenantId)
139
+
140
+ expect(mockDb.tryGet).toHaveBeenCalledWith(
141
+ configs.generateConfigID(ConfigType.SETTINGS)
142
+ )
143
+ expect(mockDb.put).toHaveBeenCalledWith({
144
+ ...settingsConfig,
145
+ config: {
146
+ ...settingsConfig.config,
147
+ lockedBy: undefined,
148
+ },
149
+ })
150
+ })
151
+
152
+ it("should throw error when settings config not found", async () => {
153
+ const tenantId = structures.tenant.id()
154
+
155
+ mockDb.tryGet.mockResolvedValue(null)
156
+
157
+ await expect(unlockTenant(tenantId)).rejects.toThrow(
158
+ `Cannot lock. Settings config not found for tenant ${tenantId}`
159
+ )
160
+ })
161
+ })
162
+ })
@@ -1,5 +1,6 @@
1
1
  import TestConfiguration from "../TestConfiguration"
2
2
  import { TestAPI, TestAPIOpts } from "./base"
3
+ import { LockRequest } from "@budibase/types"
3
4
 
4
5
  export class TenantAPI extends TestAPI {
5
6
  config: TestConfiguration
@@ -14,4 +15,12 @@ export class TenantAPI extends TestAPI {
14
15
  .set(opts?.headers)
15
16
  .expect(opts?.status ? opts.status : 204)
16
17
  }
18
+
19
+ lock = (tenantId: string, body: LockRequest, opts?: TestAPIOpts) => {
20
+ return this.request
21
+ .put(`/api/system/tenants/${tenantId}/lock`)
22
+ .send(body)
23
+ .set(opts?.headers)
24
+ .expect(opts?.status ? opts.status : 204)
25
+ }
17
26
  }