Enforce licensed developer count + error types/codes framework

This commit is contained in:
Rory Powell 2022-03-04 13:42:50 +00:00
parent a81041bc40
commit b686c19658
17 changed files with 190 additions and 5 deletions

View File

@ -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

View File

@ -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)
}

View File

@ -0,0 +1,11 @@
class BudibaseError extends Error {
constructor(message, type, code) {
super(message)
this.type = type
this.code = code
}
}
module.exports = {
BudibaseError,
}

View File

@ -0,0 +1,15 @@
const licensing = require("./licensing")
const codes = {
...licensing.codes,
}
const types = {
...licensing.types,
}
module.exports = {
codes,
types,
UsageLimitError: licensing.UsageLimitError,
}

View File

@ -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,
}

View File

@ -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"),
} }

View File

@ -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)
} }

View File

@ -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,
} }

View File

@ -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
)
}
}

View File

@ -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."
} }

View File

@ -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>

View File

@ -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>

View File

@ -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)
}

View File

@ -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) => {

View File

@ -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"

View File

@ -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.")
} }
} }

View File

@ -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,
} }
} }
}) })