First pass of global user configuration through existing user API with role mappings.

This commit is contained in:
mike12345567 2021-04-08 16:58:33 +01:00
parent 4abe6192dc
commit 8cde219db9
17 changed files with 206 additions and 104 deletions

View File

@ -26,6 +26,10 @@ static_resources:
cluster: redis-service cluster: redis-service
prefix_rewrite: "/" prefix_rewrite: "/"
- match: { prefix: "/api/admin" }
route:
cluster: worker-dev
- match: { prefix: "/api/" } - match: { prefix: "/api/" }
route: route:
cluster: server-dev cluster: server-dev
@ -123,3 +127,17 @@ static_resources:
address: {{ address }} address: {{ address }}
port_value: 3000 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

View File

@ -26,6 +26,11 @@ static_resources:
route: route:
cluster: app-service 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 # special case for when API requests are made, can just forward, not to minio
- match: { prefix: "/api/" } - match: { prefix: "/api/" }
route: route:

View File

@ -32,3 +32,7 @@ exports.getUserParams = (email = "", otherProps = {}) => {
endkey: `${DocumentTypes.USER}${SEPARATOR}${email}${UNICODE_MAX}`, endkey: `${DocumentTypes.USER}${SEPARATOR}${email}${UNICODE_MAX}`,
} }
} }
exports.getEmailFromUserID = id => {
return id.split(`${DocumentTypes.USER}${SEPARATOR}`)[1]
}

View File

@ -8,7 +8,7 @@ const { jwt, local, google } = require("./middleware")
const { Cookies, UserStatus } = require("./constants") const { Cookies, UserStatus } = require("./constants")
const { hash, compare } = require("./hashing") const { hash, compare } = require("./hashing")
const { getAppId, setCookie } = require("./utils") const { getAppId, setCookie } = require("./utils")
const { generateUserID, getUserParams } = require("./db/utils") const { generateUserID, getUserParams, getEmailFromUserID } = require("./db/utils")
// Strategies // Strategies
passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new LocalStrategy(local.options, local.authenticate))
@ -36,6 +36,7 @@ module.exports = {
StaticDatabases, StaticDatabases,
generateUserID, generateUserID,
getUserParams, getUserParams,
getEmailFromUserID,
hash, hash,
compare, compare,
getAppId, getAppId,

View File

@ -79,6 +79,7 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.0.1",
"@budibase/client": "^0.8.9", "@budibase/client": "^0.8.9",
"@budibase/string-templates": "^0.8.9", "@budibase/string-templates": "^0.8.9",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View File

@ -7,7 +7,7 @@ const { generateUserID } = require("../../db/utils")
const { setCookie } = require("../../utilities") const { setCookie } = require("../../utilities")
const { outputProcessing } = require("../../utilities/rowProcessor") const { outputProcessing } = require("../../utilities/rowProcessor")
const { ViewNames } = require("../../db/utils") const { ViewNames } = require("../../db/utils")
const { UserStatus } = require("../../constants") const { UserStatus } = require("@budibase/auth")
const setBuilderToken = require("../../utilities/builder/setBuilderToken") const setBuilderToken = require("../../utilities/builder/setBuilderToken")
const INVALID_ERR = "Invalid Credentials" const INVALID_ERR = "Invalid Credentials"

View File

@ -48,7 +48,7 @@ async function findRow(db, appId, tableId, rowId) {
appId, appId,
}, },
} }
await usersController.find(ctx) await usersController.findMetadata(ctx)
row = ctx.body row = ctx.body
} else { } else {
row = await db.get(rowId) row = await db.get(rowId)
@ -103,7 +103,7 @@ exports.patch = async function(ctx) {
...row, ...row,
password: ctx.request.body.password, password: ctx.request.body.password,
} }
await usersController.update(ctx) await usersController.updateMetadata(ctx)
return return
} }
@ -179,7 +179,7 @@ exports.save = async function(ctx) {
if (row.tableId === ViewNames.USERS) { if (row.tableId === ViewNames.USERS) {
// the row has been updated, need to put it into the ctx // the row has been updated, need to put it into the ctx
ctx.request.body = row ctx.request.body = row
await usersController.create(ctx) await usersController.createMetadata(ctx)
return return
} }
@ -310,7 +310,7 @@ exports.fetchTableRows = async function(ctx) {
let rows, let rows,
table = await db.get(ctx.params.tableId) table = await db.get(ctx.params.tableId)
if (ctx.params.tableId === ViewNames.USERS) { if (ctx.params.tableId === ViewNames.USERS) {
await usersController.fetch(ctx) await usersController.fetchMetadata(ctx)
rows = ctx.body rows = ctx.body
} else { } else {
const response = await db.allDocs( const response = await db.allDocs(

View File

@ -1,109 +1,137 @@
const CouchDB = require("../../db") const CouchDB = require("../../db")
const bcrypt = require("../../utilities/bcrypt") const {
const { generateUserID, getUserParams, ViewNames } = require("../../db/utils") generateUserID,
getUserParams,
getEmailFromUserID,
} = require("@budibase/auth")
const { InternalTables } = require("../../db/utils")
const { getRole } = require("../../utilities/security/roles") 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 database = new CouchDB(ctx.appId)
const users = ( const global = await getGlobalUsers()
const metadata = (
await database.allDocs( await database.allDocs(
getUserParams(null, { getUserParams(null, {
include_docs: true, include_docs: true,
}) })
) )
).rows.map(row => row.doc) ).rows.map(row => row.doc)
// user hashed password shouldn't ever be returned const users = []
for (let user of users) { for (let user of global) {
delete user.password const info = metadata.find(meta => meta._id.includes(user.email))
users.push({
...user,
...info,
})
} }
ctx.body = users ctx.body = users
} }
// TODO: need to replace this with something that purely manages metadata exports.createMetadata = async function(ctx) {
exports.create = async function(ctx) { const appId = ctx.appId
const db = new CouchDB(ctx.appId) const db = new CouchDB(appId)
const { email, password, roleId } = ctx.request.body const { email, roleId } = ctx.request.body
if (!email || !password) {
ctx.throw(400, "email and Password Required.")
}
const role = await getRole(ctx.appId, roleId)
// check role valid
const role = await getRole(appId, roleId)
if (!role) ctx.throw(400, "Invalid Role") if (!role) ctx.throw(400, "Invalid Role")
const hashedPassword = await bcrypt.hash(password) const metadata = await saveGlobalUser(appId, email, ctx.request.body)
const user = { const user = {
...ctx.request.body, ...metadata,
// 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
_id: generateUserID(email), _id: generateUserID(email),
type: "user", type: "user",
password: hashedPassword, tableId: InternalTables.USER_METADATA,
tableId: ViewNames.USERS,
}
// add the active status to a user if its not provided
if (user.status == null) {
user.status = UserStatus.ACTIVE
} }
try {
const response = await db.post(user) const response = await db.post(user)
ctx.status = 200
ctx.message = "User created successfully."
ctx.userId = response.id
ctx.body = { ctx.body = {
_rev: response.rev, _rev: response.rev,
email, email,
} }
} catch (err) {
if (err.status === 409) {
ctx.throw(400, "User exists already")
} else {
ctx.throw(err.status, err)
}
}
} }
exports.update = async function(ctx) { exports.updateMetadata = async function(ctx) {
const db = new CouchDB(ctx.appId) const appId = ctx.appId
const db = new CouchDB(appId)
const user = ctx.request.body const user = ctx.request.body
let dbUser let email = user.email || getEmailFromUserID(user._id)
if (user.email && !user._id) { const metadata = await saveGlobalUser(appId, email, ctx.request.body)
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
}
const response = await db.put({ if (!metadata._id) {
password: dbUser.password, user._id = generateUserID(email)
...user, }
ctx.body = await db.put({
...metadata,
}) })
user._rev = response.rev
ctx.status = 200
ctx.body = response
} }
exports.destroy = async function(ctx) { exports.destroyMetadata = async function(ctx) {
const database = new CouchDB(ctx.appId) const db = new CouchDB(ctx.appId)
await database.destroy(generateUserID(ctx.params.email)) const email = ctx.params.email
await deleteGlobalUser(email)
await db.destroy(generateUserID(email))
ctx.body = { ctx.body = {
message: `User ${ctx.params.email} deleted.`, 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) const database = new CouchDB(ctx.appId)
let lookup = ctx.params.email let lookup = ctx.params.email
? generateUserID(ctx.params.email) ? generateUserID(ctx.params.email)

View File

@ -11,31 +11,31 @@ const router = Router()
router router
.get( .get(
"/api/users", "/api/users/metadata",
authorized(PermissionTypes.USER, PermissionLevels.READ), authorized(PermissionTypes.USER, PermissionLevels.READ),
controller.fetch controller.fetchMetadata
) )
.get( .get(
"/api/users/:email", "/api/users/metadata/:email",
authorized(PermissionTypes.USER, PermissionLevels.READ), authorized(PermissionTypes.USER, PermissionLevels.READ),
controller.find controller.findMetadata
) )
.put( .put(
"/api/users", "/api/users/metadata",
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),
controller.update controller.updateMetadata
) )
.post( .post(
"/api/users", "/api/users/metadata",
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),
usage, usage,
controller.create controller.createMetadata
) )
.delete( .delete(
"/api/users/:email", "/api/users/metadata/:email",
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),
usage, usage,
controller.destroy controller.destroyMetadata
) )
module.exports = router module.exports = router

View File

@ -75,7 +75,7 @@ module.exports.run = async function({ inputs, appId, apiKey, emitter }) {
if (env.isProd()) { if (env.isProd()) {
await usage.update(apiKey, usage.Properties.USER, 1) await usage.update(apiKey, usage.Properties.USER, 1)
} }
await userController.create(ctx) await userController.createMetadata(ctx)
return { return {
response: ctx.body, response: ctx.body,
// internal property not returned through the API // internal property not returned through the API

View File

@ -1,4 +1,5 @@
const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles") const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles")
const { UserStatus } = require("@budibase/auth")
exports.LOGO_URL = exports.LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
@ -27,11 +28,6 @@ exports.AuthTypes = {
EXTERNAL: "external", EXTERNAL: "external",
} }
exports.UserStatus = {
ACTIVE: "active",
INACTIVE: "inactive",
}
exports.USERS_TABLE_SCHEMA = { exports.USERS_TABLE_SCHEMA = {
_id: "ta_users", _id: "ta_users",
type: "table", type: "table",
@ -68,7 +64,7 @@ exports.USERS_TABLE_SCHEMA = {
constraints: { constraints: {
type: exports.FieldTypes.STRING, type: exports.FieldTypes.STRING,
presence: false, presence: false,
inclusion: Object.values(exports.UserStatus), inclusion: Object.values(UserStatus),
}, },
}, },
}, },

View File

@ -34,7 +34,10 @@ const DocumentTypes = {
const ViewNames = { const ViewNames = {
LINK: "by_link", LINK: "by_link",
ROUTING: "screen_routes", ROUTING: "screen_routes",
USERS: "ta_users", }
const InternalTables = {
USER_METADATA: "ta_users",
} }
const SearchIndexes = { const SearchIndexes = {
@ -43,6 +46,7 @@ const SearchIndexes = {
exports.StaticDatabases = StaticDatabases exports.StaticDatabases = StaticDatabases
exports.ViewNames = ViewNames exports.ViewNames = ViewNames
exports.InternalTables = InternalTables
exports.DocumentTypes = DocumentTypes exports.DocumentTypes = DocumentTypes
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR
exports.UNICODE_MAX = UNICODE_MAX exports.UNICODE_MAX = UNICODE_MAX

View File

@ -279,7 +279,7 @@ class TestConfiguration {
roleId, roleId,
}, },
null, null,
controllers.user.create controllers.user.createMetadata
) )
} }
@ -289,7 +289,7 @@ class TestConfiguration {
{ {
email, email,
}, },
controllers.user.find controllers.user.findMetadata
) )
return this._req( return this._req(
{ {
@ -297,7 +297,7 @@ class TestConfiguration {
status: "inactive", status: "inactive",
}, },
null, null,
controllers.user.update controllers.user.updateMetadata
) )
} }

View File

@ -25,7 +25,7 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"got": "^11.8.1", "got": "^11.8.1",
"joi": "^17.2.1", "joi": "^17.4.0",
"koa": "^2.7.0", "koa": "^2.7.0",
"koa-body": "^4.2.0", "koa-body": "^4.2.0",
"koa-compress": "^4.0.1", "koa-compress": "^4.0.1",

View File

@ -3,11 +3,28 @@ const passport = require("@budibase/auth")
const controller = require("../../controllers/admin") const controller = require("../../controllers/admin")
const authController = require("../../controllers/admin/auth") const authController = require("../../controllers/admin/auth")
const authenticated = require("../../../middleware/authenticated") const authenticated = require("../../../middleware/authenticated")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const router = Router() 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 router
.post("/api/admin/users", authenticated, controller.userSave) .post("/api/admin/users", buildUserSaveValidation(), authenticated, controller.userSave)
.post("/api/admin/authenticate", authController.authenticate) .post("/api/admin/authenticate", authController.authenticate)
.delete("/api/admin/users/:email", authenticated, controller.userDelete) .delete("/api/admin/users/:email", authenticated, controller.userDelete)
.get("/api/admin/users", authenticated, controller.userFetch) .get("/api/admin/users", authenticated, controller.userFetch)

View File

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

View File

@ -1261,10 +1261,10 @@ jmespath@0.15.0, jmespath@^0.15.0:
resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217" resolved "https://registry.yarnpkg.com/jmespath/-/jmespath-0.15.0.tgz#a3f222a9aae9f966f5d27c796510e28091764217"
integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc= integrity sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=
joi@^17.2.1: joi@^17.4.0:
version "17.3.0" version "17.4.0"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2" resolved "https://registry.yarnpkg.com/joi/-/joi-17.4.0.tgz#b5c2277c8519e016316e49ababd41a1908d9ef20"
integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg== integrity sha512-F4WiW2xaV6wc1jxete70Rw4V/VuMd6IN+a5ilZsxG4uYtUXWu2kq9W5P2dz30e7Gmw8RCbY/u/uk+dMPma9tAg==
dependencies: dependencies:
"@hapi/hoek" "^9.0.0" "@hapi/hoek" "^9.0.0"
"@hapi/topo" "^5.0.0" "@hapi/topo" "^5.0.0"