diff --git a/hosting/envoy.dev.yaml.hbs b/hosting/envoy.dev.yaml.hbs index f7f642a244..c2795fdb5f 100644 --- a/hosting/envoy.dev.yaml.hbs +++ b/hosting/envoy.dev.yaml.hbs @@ -26,6 +26,10 @@ static_resources: cluster: redis-service prefix_rewrite: "/" + - match: { prefix: "/api/admin" } + route: + cluster: worker-dev + - match: { prefix: "/api/" } route: cluster: server-dev @@ -123,3 +127,17 @@ static_resources: address: {{ address }} port_value: 3000 + - name: worker-dev + connect_timeout: 0.25s + type: strict_dns + lb_policy: round_robin + load_assignment: + cluster_name: worker-dev + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: {{ address }} + port_value: 4002 + diff --git a/hosting/envoy.yaml b/hosting/envoy.yaml index 8c6081d1a7..1fbd2070ff 100644 --- a/hosting/envoy.yaml +++ b/hosting/envoy.yaml @@ -25,6 +25,11 @@ static_resources: - match: { path: "/" } route: cluster: app-service + + # special case for worker admin API + - match: { path: "/api/admin" } + route: + cluster: worker-service # special case for when API requests are made, can just forward, not to minio - match: { prefix: "/api/" } diff --git a/packages/auth/src/db/utils.js b/packages/auth/src/db/utils.js index 17d09ceaeb..9b6f5d7103 100644 --- a/packages/auth/src/db/utils.js +++ b/packages/auth/src/db/utils.js @@ -32,3 +32,7 @@ exports.getUserParams = (email = "", otherProps = {}) => { endkey: `${DocumentTypes.USER}${SEPARATOR}${email}${UNICODE_MAX}`, } } + +exports.getEmailFromUserID = id => { + return id.split(`${DocumentTypes.USER}${SEPARATOR}`)[1] +} \ No newline at end of file diff --git a/packages/auth/src/index.js b/packages/auth/src/index.js index 51431e4241..b65690b064 100644 --- a/packages/auth/src/index.js +++ b/packages/auth/src/index.js @@ -8,7 +8,7 @@ const { jwt, local, google } = require("./middleware") const { Cookies, UserStatus } = require("./constants") const { hash, compare } = require("./hashing") const { getAppId, setCookie } = require("./utils") -const { generateUserID, getUserParams } = require("./db/utils") +const { generateUserID, getUserParams, getEmailFromUserID } = require("./db/utils") // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) @@ -36,6 +36,7 @@ module.exports = { StaticDatabases, generateUserID, getUserParams, + getEmailFromUserID, hash, compare, getAppId, diff --git a/packages/server/package.json b/packages/server/package.json index 42ecbe39e0..3ff5997985 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -79,6 +79,7 @@ "author": "Budibase", "license": "AGPL-3.0-or-later", "dependencies": { + "@budibase/auth": "^0.0.1", "@budibase/client": "^0.8.9", "@budibase/string-templates": "^0.8.9", "@elastic/elasticsearch": "7.10.0", diff --git a/packages/server/src/api/controllers/auth.js b/packages/server/src/api/controllers/auth.js index 43f7878108..da68aa485b 100644 --- a/packages/server/src/api/controllers/auth.js +++ b/packages/server/src/api/controllers/auth.js @@ -7,7 +7,7 @@ const { generateUserID } = require("../../db/utils") const { setCookie } = require("../../utilities") const { outputProcessing } = require("../../utilities/rowProcessor") const { ViewNames } = require("../../db/utils") -const { UserStatus } = require("../../constants") +const { UserStatus } = require("@budibase/auth") const setBuilderToken = require("../../utilities/builder/setBuilderToken") const INVALID_ERR = "Invalid Credentials" diff --git a/packages/server/src/api/controllers/row.js b/packages/server/src/api/controllers/row.js index 3f531d7e5c..1b3e795f83 100644 --- a/packages/server/src/api/controllers/row.js +++ b/packages/server/src/api/controllers/row.js @@ -48,7 +48,7 @@ async function findRow(db, appId, tableId, rowId) { appId, }, } - await usersController.find(ctx) + await usersController.findMetadata(ctx) row = ctx.body } else { row = await db.get(rowId) @@ -103,7 +103,7 @@ exports.patch = async function(ctx) { ...row, password: ctx.request.body.password, } - await usersController.update(ctx) + await usersController.updateMetadata(ctx) return } @@ -179,7 +179,7 @@ exports.save = async function(ctx) { if (row.tableId === ViewNames.USERS) { // the row has been updated, need to put it into the ctx ctx.request.body = row - await usersController.create(ctx) + await usersController.createMetadata(ctx) return } @@ -310,7 +310,7 @@ exports.fetchTableRows = async function(ctx) { let rows, table = await db.get(ctx.params.tableId) if (ctx.params.tableId === ViewNames.USERS) { - await usersController.fetch(ctx) + await usersController.fetchMetadata(ctx) rows = ctx.body } else { const response = await db.allDocs( diff --git a/packages/server/src/api/controllers/user.js b/packages/server/src/api/controllers/user.js index d9a4af9719..92b038d05e 100644 --- a/packages/server/src/api/controllers/user.js +++ b/packages/server/src/api/controllers/user.js @@ -1,109 +1,137 @@ const CouchDB = require("../../db") -const bcrypt = require("../../utilities/bcrypt") -const { generateUserID, getUserParams, ViewNames } = require("../../db/utils") +const { + generateUserID, + getUserParams, + getEmailFromUserID, +} = require("@budibase/auth") +const { InternalTables } = require("../../db/utils") const { getRole } = require("../../utilities/security/roles") -const { UserStatus } = require("../../constants") +const { checkSlashesInUrl } = require("../../utilities") +const env = require("../../environment") +const fetch = require("node-fetch") -exports.fetch = async function(ctx) { +async function deleteGlobalUser(email) { + const endpoint = `/api/admin/users/${email}` + const reqCfg = { method: "DELETE" } + const response = await fetch( + checkSlashesInUrl(env.WORKER_URL + endpoint), + reqCfg + ) + return response.json() +} + +async function getGlobalUsers(email = null) { + const endpoint = email ? `/api/admin/users/${email}` : `/api/admin/users` + const reqCfg = { method: "GET" } + const response = await fetch( + checkSlashesInUrl(env.WORKER_URL + endpoint), + reqCfg + ) + return response.json() +} + +async function saveGlobalUser(appId, email, body) { + const globalUser = await getGlobalUsers(email) + const roles = globalUser.roles || {} + if (body.roleId) { + roles.appId = body.roleId + } + const endpoint = `/api/admin/users` + const reqCfg = { + method: "POST", + body: { + ...globalUser, + email, + password: body.password, + status: body.status, + roles, + }, + } + + const response = await fetch( + checkSlashesInUrl(env.WORKER_URL + endpoint), + reqCfg + ) + await response.json() + delete body.email + delete body.password + delete body.roleId + delete body.status + return body +} + +exports.fetchMetadata = async function(ctx) { const database = new CouchDB(ctx.appId) - const users = ( + const global = await getGlobalUsers() + const metadata = ( await database.allDocs( getUserParams(null, { include_docs: true, }) ) ).rows.map(row => row.doc) - // user hashed password shouldn't ever be returned - for (let user of users) { - delete user.password + const users = [] + for (let user of global) { + const info = metadata.find(meta => meta._id.includes(user.email)) + users.push({ + ...user, + ...info, + }) } ctx.body = users } -// TODO: need to replace this with something that purely manages metadata -exports.create = async function(ctx) { - const db = new CouchDB(ctx.appId) - const { email, password, roleId } = ctx.request.body - - if (!email || !password) { - ctx.throw(400, "email and Password Required.") - } - - const role = await getRole(ctx.appId, roleId) +exports.createMetadata = async function(ctx) { + const appId = ctx.appId + const db = new CouchDB(appId) + const { email, roleId } = ctx.request.body + // check role valid + const role = await getRole(appId, roleId) if (!role) ctx.throw(400, "Invalid Role") - const hashedPassword = await bcrypt.hash(password) + const metadata = await saveGlobalUser(appId, email, ctx.request.body) + const user = { - ...ctx.request.body, - // these must all be after the object spread, make sure - // any values are overwritten, generateUserID will always - // generate the same ID for the user as it is not UUID based + ...metadata, _id: generateUserID(email), type: "user", - password: hashedPassword, - tableId: ViewNames.USERS, - } - // add the active status to a user if its not provided - if (user.status == null) { - user.status = UserStatus.ACTIVE + tableId: InternalTables.USER_METADATA, } - try { - const response = await db.post(user) - ctx.status = 200 - ctx.message = "User created successfully." - ctx.userId = response.id - ctx.body = { - _rev: response.rev, - email, - } - } catch (err) { - if (err.status === 409) { - ctx.throw(400, "User exists already") - } else { - ctx.throw(err.status, err) - } + const response = await db.post(user) + ctx.body = { + _rev: response.rev, + email, } } -exports.update = async function(ctx) { - const db = new CouchDB(ctx.appId) +exports.updateMetadata = async function(ctx) { + const appId = ctx.appId + const db = new CouchDB(appId) const user = ctx.request.body - let dbUser - if (user.email && !user._id) { - user._id = generateUserID(user.email) - } - // get user incase password removed - if (user._id) { - dbUser = await db.get(user._id) - } - if (user.password) { - user.password = await bcrypt.hash(user.password) - } else { - delete user.password - } + let email = user.email || getEmailFromUserID(user._id) + const metadata = await saveGlobalUser(appId, email, ctx.request.body) - const response = await db.put({ - password: dbUser.password, - ...user, + if (!metadata._id) { + user._id = generateUserID(email) + } + ctx.body = await db.put({ + ...metadata, }) - user._rev = response.rev - - ctx.status = 200 - ctx.body = response } -exports.destroy = async function(ctx) { - const database = new CouchDB(ctx.appId) - await database.destroy(generateUserID(ctx.params.email)) +exports.destroyMetadata = async function(ctx) { + const db = new CouchDB(ctx.appId) + const email = ctx.params.email + await deleteGlobalUser(email) + await db.destroy(generateUserID(email)) ctx.body = { message: `User ${ctx.params.email} deleted.`, } - ctx.status = 200 } -exports.find = async function(ctx) { +exports.findMetadata = async function(ctx) { const database = new CouchDB(ctx.appId) let lookup = ctx.params.email ? generateUserID(ctx.params.email) diff --git a/packages/server/src/api/routes/user.js b/packages/server/src/api/routes/user.js index cdaab0cc5b..b0450b72cc 100644 --- a/packages/server/src/api/routes/user.js +++ b/packages/server/src/api/routes/user.js @@ -11,31 +11,31 @@ const router = Router() router .get( - "/api/users", + "/api/users/metadata", authorized(PermissionTypes.USER, PermissionLevels.READ), - controller.fetch + controller.fetchMetadata ) .get( - "/api/users/:email", + "/api/users/metadata/:email", authorized(PermissionTypes.USER, PermissionLevels.READ), - controller.find + controller.findMetadata ) .put( - "/api/users", + "/api/users/metadata", authorized(PermissionTypes.USER, PermissionLevels.WRITE), - controller.update + controller.updateMetadata ) .post( - "/api/users", + "/api/users/metadata", authorized(PermissionTypes.USER, PermissionLevels.WRITE), usage, - controller.create + controller.createMetadata ) .delete( - "/api/users/:email", + "/api/users/metadata/:email", authorized(PermissionTypes.USER, PermissionLevels.WRITE), usage, - controller.destroy + controller.destroyMetadata ) module.exports = router diff --git a/packages/server/src/automations/steps/createUser.js b/packages/server/src/automations/steps/createUser.js index 147a3f7868..8849415c5a 100644 --- a/packages/server/src/automations/steps/createUser.js +++ b/packages/server/src/automations/steps/createUser.js @@ -75,7 +75,7 @@ module.exports.run = async function({ inputs, appId, apiKey, emitter }) { if (env.isProd()) { await usage.update(apiKey, usage.Properties.USER, 1) } - await userController.create(ctx) + await userController.createMetadata(ctx) return { response: ctx.body, // internal property not returned through the API diff --git a/packages/server/src/constants/index.js b/packages/server/src/constants/index.js index 1e8ebb2721..940c1100dd 100644 --- a/packages/server/src/constants/index.js +++ b/packages/server/src/constants/index.js @@ -1,4 +1,5 @@ const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles") +const { UserStatus } = require("@budibase/auth") exports.LOGO_URL = "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" @@ -27,11 +28,6 @@ exports.AuthTypes = { EXTERNAL: "external", } -exports.UserStatus = { - ACTIVE: "active", - INACTIVE: "inactive", -} - exports.USERS_TABLE_SCHEMA = { _id: "ta_users", type: "table", @@ -68,7 +64,7 @@ exports.USERS_TABLE_SCHEMA = { constraints: { type: exports.FieldTypes.STRING, presence: false, - inclusion: Object.values(exports.UserStatus), + inclusion: Object.values(UserStatus), }, }, }, diff --git a/packages/server/src/db/utils.js b/packages/server/src/db/utils.js index 4c31f0398e..8623b99f2c 100644 --- a/packages/server/src/db/utils.js +++ b/packages/server/src/db/utils.js @@ -34,7 +34,10 @@ const DocumentTypes = { const ViewNames = { LINK: "by_link", ROUTING: "screen_routes", - USERS: "ta_users", +} + +const InternalTables = { + USER_METADATA: "ta_users", } const SearchIndexes = { @@ -43,6 +46,7 @@ const SearchIndexes = { exports.StaticDatabases = StaticDatabases exports.ViewNames = ViewNames +exports.InternalTables = InternalTables exports.DocumentTypes = DocumentTypes exports.SEPARATOR = SEPARATOR exports.UNICODE_MAX = UNICODE_MAX diff --git a/packages/server/src/tests/utilities/TestConfiguration.js b/packages/server/src/tests/utilities/TestConfiguration.js index 433cec4a0a..002cf4d004 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.js +++ b/packages/server/src/tests/utilities/TestConfiguration.js @@ -279,7 +279,7 @@ class TestConfiguration { roleId, }, null, - controllers.user.create + controllers.user.createMetadata ) } @@ -289,7 +289,7 @@ class TestConfiguration { { email, }, - controllers.user.find + controllers.user.findMetadata ) return this._req( { @@ -297,7 +297,7 @@ class TestConfiguration { status: "inactive", }, null, - controllers.user.update + controllers.user.updateMetadata ) } diff --git a/packages/worker/package.json b/packages/worker/package.json index 5ab414dab1..37b7435066 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -25,7 +25,7 @@ "bcryptjs": "^2.4.3", "dotenv": "^8.2.0", "got": "^11.8.1", - "joi": "^17.2.1", + "joi": "^17.4.0", "koa": "^2.7.0", "koa-body": "^4.2.0", "koa-compress": "^4.0.1", diff --git a/packages/worker/src/api/routes/admin/index.js b/packages/worker/src/api/routes/admin/index.js index 1c64110d2a..97924ce7cb 100644 --- a/packages/worker/src/api/routes/admin/index.js +++ b/packages/worker/src/api/routes/admin/index.js @@ -3,11 +3,28 @@ const passport = require("@budibase/auth") const controller = require("../../controllers/admin") const authController = require("../../controllers/admin/auth") const authenticated = require("../../../middleware/authenticated") +const joiValidator = require("../../../middleware/joi-validator") +const Joi = require("joi") const router = Router() +function buildUserSaveValidation() { + // prettier-ignore + return joiValidator.body(Joi.object({ + _id: Joi.string(), + _rev: Joi.string(), + email: Joi.string(), + password: Joi.string(), + // maps appId -> roleId for the user + roles: Joi.object() + .pattern(/.*/, Joi.string()) + .required() + .unknown(true) + }).required().unknown(true)) +} + router - .post("/api/admin/users", authenticated, controller.userSave) + .post("/api/admin/users", buildUserSaveValidation(), authenticated, controller.userSave) .post("/api/admin/authenticate", authController.authenticate) .delete("/api/admin/users/:email", authenticated, controller.userDelete) .get("/api/admin/users", authenticated, controller.userFetch) diff --git a/packages/worker/src/middleware/joi-validator.js b/packages/worker/src/middleware/joi-validator.js new file mode 100644 index 0000000000..1686b0e727 --- /dev/null +++ b/packages/worker/src/middleware/joi-validator.js @@ -0,0 +1,28 @@ +function validate(schema, property) { + // Return a Koa middleware function + return (ctx, next) => { + if (!schema) { + return next() + } + let params = null + if (ctx[property] != null) { + params = ctx[property] + } else if (ctx.request[property] != null) { + params = ctx.request[property] + } + const { error } = schema.validate(params) + if (error) { + ctx.throw(400, `Invalid ${property} - ${error.message}`) + return + } + return next() + } +} + +module.exports.body = schema => { + return validate(schema, "body") +} + +module.exports.params = schema => { + return validate(schema, "params") +} diff --git a/packages/worker/yarn.lock b/packages/worker/yarn.lock index df8965f515..6515c5967f 100644 --- a/packages/worker/yarn.lock +++ b/packages/worker/yarn.lock @@ -1261,10 +1261,10 @@ jmespath@0.15.0, jmespath@^0.15.0: resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= -joi@^17.2.1: - version "17.3.0" - resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2" - integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg== +joi@^17.4.0: + version "17.4.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.0.tgz#b5c2277c8519e016316e49ababd41a1908d9ef20" + integrity sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg== dependencies: "@hapi/hoek" "^9.0.0" "@hapi/topo" "^5.0.0"