Enforce licensed developer count + error types/codes framework
This commit is contained in:
parent
515ade6bd3
commit
3b9303a1fb
|
@ -30,6 +30,7 @@ const UNICODE_MAX = "\ufff0"
|
||||||
|
|
||||||
exports.ViewNames = {
|
exports.ViewNames = {
|
||||||
USER_BY_EMAIL: "by_email",
|
USER_BY_EMAIL: "by_email",
|
||||||
|
USER_BY_BUILDERS: "by_builders",
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.StaticDatabases = StaticDatabases
|
exports.StaticDatabases = StaticDatabases
|
||||||
|
|
|
@ -31,3 +31,25 @@ exports.createUserEmailView = async db => {
|
||||||
}
|
}
|
||||||
await db.put(designDoc)
|
await db.put(designDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.createUserBuildersView = async db => {
|
||||||
|
let designDoc
|
||||||
|
try {
|
||||||
|
designDoc = await db.get("_design/database")
|
||||||
|
} catch (err) {
|
||||||
|
// no design doc, make one
|
||||||
|
designDoc = DesignDoc()
|
||||||
|
}
|
||||||
|
const view = {
|
||||||
|
map: `function(doc) {
|
||||||
|
if (doc.builder && doc.builder.global === true) {
|
||||||
|
emit(doc._id, doc._id)
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}
|
||||||
|
designDoc.views = {
|
||||||
|
...designDoc.views,
|
||||||
|
[ViewNames.USER_BY_BUILDERS]: view,
|
||||||
|
}
|
||||||
|
await db.put(designDoc)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
class BudibaseError extends Error {
|
||||||
|
constructor(message, type, code) {
|
||||||
|
super(message)
|
||||||
|
this.type = type
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BudibaseError,
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
const licensing = require("./licensing")
|
||||||
|
|
||||||
|
const codes = {
|
||||||
|
...licensing.codes,
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = {
|
||||||
|
...licensing.types,
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
codes,
|
||||||
|
types,
|
||||||
|
UsageLimitError: licensing.UsageLimitError,
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
const { BudibaseError } = require("./base")
|
||||||
|
|
||||||
|
const types = {
|
||||||
|
LICENSE_ERROR: "license_error",
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = {
|
||||||
|
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsageLimitError extends BudibaseError {
|
||||||
|
constructor(message, limitName) {
|
||||||
|
super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED)
|
||||||
|
this.limitName = limitName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
types,
|
||||||
|
codes,
|
||||||
|
UsageLimitError,
|
||||||
|
}
|
|
@ -16,4 +16,5 @@ module.exports = {
|
||||||
constants: require("../constants"),
|
constants: require("../constants"),
|
||||||
migrations: require("../migrations"),
|
migrations: require("../migrations"),
|
||||||
licensing: require("./licensing"),
|
licensing: require("./licensing"),
|
||||||
|
errors: require("./errors"),
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ exports.getLicense = async (tenantId, opts = { populateLicense: null }) => {
|
||||||
return license
|
return license
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.invalidateLicense = async tenantId => {
|
exports.invalidate = async tenantId => {
|
||||||
const client = await redis.getClient()
|
const client = await redis.getClient()
|
||||||
await client.delete(tenantId)
|
await client.delete(tenantId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
const middleware = require("./middleware")
|
const middleware = require("./middleware")
|
||||||
const cache = require("./cache")
|
const cache = require("./cache")
|
||||||
|
const usage = require("./usage")
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
middleware,
|
middleware,
|
||||||
cache,
|
cache,
|
||||||
|
usage,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
const { getTenantId } = require("../../context")
|
||||||
|
const cache = require("../cache")
|
||||||
|
const env = require("../../environment")
|
||||||
|
const utils = require("../../utils")
|
||||||
|
const { UsageLimitError } = require("../../errors")
|
||||||
|
|
||||||
|
const UsageType = {
|
||||||
|
MONTHLY: "monthly",
|
||||||
|
STATIC: "static",
|
||||||
|
}
|
||||||
|
|
||||||
|
const StaticUsageLimits = {
|
||||||
|
MAX_DEVELOPERS: "maxDevelopers",
|
||||||
|
QUERY_TIMEOUT_SECONDS: "queryTimeoutSeconds",
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const MonthlyUsageLimits = {
|
||||||
|
MAX_QUERY_COUNT: "maxQueryCount",
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.checkMaxDevelopers = async () => {
|
||||||
|
const developerCount = await utils.getBuildersCount()
|
||||||
|
await checkUsageLimit(
|
||||||
|
developerCount,
|
||||||
|
UsageType.STATIC,
|
||||||
|
StaticUsageLimits.MAX_DEVELOPERS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkUsageLimit = async (currentUsage, usageType, usageLimit) => {
|
||||||
|
const tenantId = getTenantId()
|
||||||
|
const license = await cache.getLicense(tenantId)
|
||||||
|
const limit = license.usageLimits[usageType][usageLimit]
|
||||||
|
if (currentUsage >= limit.value) {
|
||||||
|
throw new UsageLimitError(
|
||||||
|
`Licensed ${limit.name} has been exceeded. To upgrade, visit ${env.ACCOUNT_PORTAL_URL}/portal/plans`,
|
||||||
|
limit.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,7 +6,7 @@ const {
|
||||||
} = require("./db/utils")
|
} = require("./db/utils")
|
||||||
const jwt = require("jsonwebtoken")
|
const jwt = require("jsonwebtoken")
|
||||||
const { options } = require("./middleware/passport/jwt")
|
const { options } = require("./middleware/passport/jwt")
|
||||||
const { createUserEmailView } = require("./db/views")
|
const { createUserEmailView, createUserBuildersView } = require("./db/views")
|
||||||
const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants")
|
const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants")
|
||||||
const {
|
const {
|
||||||
getGlobalDB,
|
getGlobalDB,
|
||||||
|
@ -20,6 +20,7 @@ const { hash } = require("./hashing")
|
||||||
const userCache = require("./cache/user")
|
const userCache = require("./cache/user")
|
||||||
const env = require("./environment")
|
const env = require("./environment")
|
||||||
const { getUserSessions, invalidateSessions } = require("./security/sessions")
|
const { getUserSessions, invalidateSessions } = require("./security/sessions")
|
||||||
|
const { usage } = require("./licensing")
|
||||||
|
|
||||||
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
|
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
|
||||||
|
|
||||||
|
@ -160,12 +161,33 @@ exports.getGlobalUserByEmail = async email => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.getBuildersCount = async () => {
|
||||||
|
const db = getGlobalDB()
|
||||||
|
try {
|
||||||
|
let users = await db.query(`database/${ViewNames.USER_BY_BUILDERS}`)
|
||||||
|
return users.total_rows
|
||||||
|
} catch (err) {
|
||||||
|
if (err != null && err.name === "not_found") {
|
||||||
|
await createUserBuildersView(db)
|
||||||
|
return exports.getBuildersCount()
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exports.saveUser = async (
|
exports.saveUser = async (
|
||||||
user,
|
user,
|
||||||
tenantId,
|
tenantId,
|
||||||
hashPassword = true,
|
hashPassword = true,
|
||||||
requirePassword = true
|
requirePassword = true
|
||||||
) => {
|
) => {
|
||||||
|
// new user
|
||||||
|
// check license restrictions
|
||||||
|
if (!user._id && user.builder) {
|
||||||
|
await usage.checkMaxDevelopers()
|
||||||
|
}
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
throw "No tenancy specified."
|
throw "No tenancy specified."
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
notifications.success("Invitation accepted successfully")
|
notifications.success("Invitation accepted successfully")
|
||||||
$goto("../auth/login")
|
$goto("../auth/login")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error accepting invitation")
|
notifications.error(error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
})
|
})
|
||||||
notifications.success("Successfully created user")
|
notifications.success("Successfully created user")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error creating user")
|
notifications.error(error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
const { createUserBuildersView } = require("@budibase/backend-core/db")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date:
|
||||||
|
* March 2022
|
||||||
|
*
|
||||||
|
* Description:
|
||||||
|
* Create the builder users view.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const run = async (db: any) => {
|
||||||
|
await createUserBuildersView(db)
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ const {
|
||||||
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
|
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
|
||||||
import * as quota1 from "./functions/quotas1"
|
import * as quota1 from "./functions/quotas1"
|
||||||
import * as appUrls from "./functions/appUrls"
|
import * as appUrls from "./functions/appUrls"
|
||||||
|
import * as userBuildersView from "./functions/userBuildersView"
|
||||||
|
|
||||||
export interface Migration {
|
export interface Migration {
|
||||||
type: string
|
type: string
|
||||||
|
@ -49,6 +50,11 @@ export const MIGRATIONS: Migration[] = [
|
||||||
opts: { all: true },
|
opts: { all: true },
|
||||||
fn: appUrls.run,
|
fn: appUrls.run,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: MIGRATION_TYPES.GLOBAL,
|
||||||
|
name: "user_builders_view",
|
||||||
|
fn: userBuildersView.run,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const migrate = async (options?: MigrationOptions) => {
|
export const migrate = async (options?: MigrationOptions) => {
|
||||||
|
|
|
@ -2494,6 +2494,13 @@
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
safe-buffer "*"
|
safe-buffer "*"
|
||||||
|
|
||||||
|
"@types/redis@^2.8.0":
|
||||||
|
version "2.8.32"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11"
|
||||||
|
integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==
|
||||||
|
dependencies:
|
||||||
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/serve-static@*":
|
"@types/serve-static@*":
|
||||||
version "1.13.10"
|
version "1.13.10"
|
||||||
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
|
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
|
||||||
|
|
|
@ -24,6 +24,7 @@ const {
|
||||||
const { removeUserFromInfoDB } = require("@budibase/backend-core/deprovision")
|
const { removeUserFromInfoDB } = require("@budibase/backend-core/deprovision")
|
||||||
const env = require("../../../environment")
|
const env = require("../../../environment")
|
||||||
const { syncUserInApps } = require("../../../utilities/appService")
|
const { syncUserInApps } = require("../../../utilities/appService")
|
||||||
|
const { errors } = require("@budibase/backend-core")
|
||||||
|
|
||||||
async function allUsers() {
|
async function allUsers() {
|
||||||
const db = getGlobalDB()
|
const db = getGlobalDB()
|
||||||
|
@ -295,6 +296,10 @@ exports.inviteAccept = async ctx => {
|
||||||
info.tenantId
|
info.tenantId
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) {
|
||||||
|
// explicitly re-throw limit exceeded errors
|
||||||
|
ctx.throw(400, err)
|
||||||
|
}
|
||||||
ctx.throw(400, "Unable to create new user, invitation invalid.")
|
ctx.throw(400, "Unable to create new user, invitation invalid.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ const {
|
||||||
buildCsrfMiddleware,
|
buildCsrfMiddleware,
|
||||||
} = require("@budibase/backend-core/auth")
|
} = require("@budibase/backend-core/auth")
|
||||||
const { licensing } = require("@budibase/backend-core")
|
const { licensing } = require("@budibase/backend-core")
|
||||||
|
const { errors } = require("@budibase/backend-core")
|
||||||
|
|
||||||
const PUBLIC_ENDPOINTS = [
|
const PUBLIC_ENDPOINTS = [
|
||||||
// old deprecated endpoints kept for backwards compat
|
// old deprecated endpoints kept for backwards compat
|
||||||
|
@ -105,16 +106,32 @@ router
|
||||||
})
|
})
|
||||||
.use(auditLog)
|
.use(auditLog)
|
||||||
|
|
||||||
// error handling middleware
|
// error handling middleware - TODO: This could be moved to backend-core
|
||||||
router.use(async (ctx, next) => {
|
router.use(async (ctx, next) => {
|
||||||
try {
|
try {
|
||||||
await next()
|
await next()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.log.error(err)
|
ctx.log.error(err)
|
||||||
ctx.status = err.status || err.statusCode || 500
|
ctx.status = err.status || err.statusCode || 500
|
||||||
|
|
||||||
|
let error
|
||||||
|
if (err.code || err.type) {
|
||||||
|
// add generic error information
|
||||||
|
error = {
|
||||||
|
code: err.code,
|
||||||
|
type: err.type,
|
||||||
|
}
|
||||||
|
|
||||||
|
// add specific error information
|
||||||
|
if (error.code === errors.codes.USAGE_LIMIT_EXCEEDED) {
|
||||||
|
error.limitName = err.limitName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: err.message,
|
message: err.message,
|
||||||
status: ctx.status,
|
status: ctx.status,
|
||||||
|
error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue