diff --git a/packages/builder/src/components/database/ModelDataTable/api.js b/packages/builder/src/components/database/ModelDataTable/api.js index c573814e88..e9592074bf 100644 --- a/packages/builder/src/components/database/ModelDataTable/api.js +++ b/packages/builder/src/components/database/ModelDataTable/api.js @@ -1,6 +1,6 @@ import api from "builderStore/api" -export async function createUser(user, appId, instanceId) { +export async function createUser(user, instanceId) { const CREATE_USER_URL = `/api/${instanceId}/users` const response = await api.post(CREATE_USER_URL, user) return await response.json() @@ -15,20 +15,20 @@ export async function createDatabase(appname, instanceName) { } export async function deleteRecord(record, instanceId) { - const DELETE_RECORDS_URL = `/api/${instanceId}/records/${record._id}/${record._rev}` + const DELETE_RECORDS_URL = `/api/${instanceId}/${record.modelId}/records/${record._id}/${record._rev}` const response = await api.delete(DELETE_RECORDS_URL) return response } export async function saveRecord(record, instanceId) { - const SAVE_RECORDS_URL = `/api/${instanceId}/records` + const SAVE_RECORDS_URL = `/api/${instanceId}/${record.modelId}/records` const response = await api.post(SAVE_RECORDS_URL, record) return await response.json() } export async function fetchDataForView(viewName, instanceId) { - const FETCH_RECORDS_URL = `/api/${instanceId}/${viewName}/records` + const FETCH_RECORDS_URL = `/api/${instanceId}/views/${viewName}` const response = await api.get(FETCH_RECORDS_URL) return await response.json() diff --git a/packages/builder/src/components/database/ModelDataTable/modals/CreateUser.svelte b/packages/builder/src/components/database/ModelDataTable/modals/CreateUser.svelte index 842703af1c..77e0850b87 100644 --- a/packages/builder/src/components/database/ModelDataTable/modals/CreateUser.svelte +++ b/packages/builder/src/components/database/ModelDataTable/modals/CreateUser.svelte @@ -7,14 +7,15 @@ let username let password + let accessLevelId - $: valid = username && password + $: valid = username && password && accessLevelId $: instanceId = $backendUiStore.selectedDatabase._id $: appId = $store.appId async function createUser() { - const user = { name: username, username, password } - const response = await api.createUser(user, appId, instanceId) + const user = { name: username, username, password, accessLevelId } + const response = await api.createUser(user, instanceId) backendUiStore.actions.users.create(response) onClosed() } @@ -30,6 +31,14 @@ <label class="uk-form-label" for="form-stacked-text">Password</label> <input class="uk-input" type="password" bind:value={password} /> </div> + <div class="uk-margin"> + <label class="uk-form-label" for="form-stacked-text">Access Level</label> + <select class="uk-select" bind:value={accessLevelId}> + <option value=""></option> + <option value="POWER_USER">Power User</option> + <option value="ADMIN">Admin</option> + </select> + </div> </div> <footer> <ActionButton alert on:click={onClosed}>Cancel</ActionButton> diff --git a/packages/cli/src/commands/init/index.js b/packages/cli/src/commands/init/index.js index 4a25fbd8b2..f387a8d203 100644 --- a/packages/cli/src/commands/init/index.js +++ b/packages/cli/src/commands/init/index.js @@ -11,13 +11,6 @@ module.exports = { default: "~/.budibase", alias: "d", }) - yargs.positional("database", { - type: "string", - describe: "use a local (PouchDB) or remote (CouchDB) database", - alias: "b", - default: "local", - choices: ["local", "remote"], - }) yargs.positional("clientId", { type: "string", describe: "used to determine the name of the global databse", @@ -28,7 +21,7 @@ module.exports = { type: "string", describe: "connection string for couch db, format: https://username:password@localhost:5984", - alias: "x", + alias: "u", default: "", }) yargs.positional("quiet", { diff --git a/packages/cli/src/commands/init/initHandler.js b/packages/cli/src/commands/init/initHandler.js index 0467633ee8..c0c4ad1b9e 100644 --- a/packages/cli/src/commands/init/initHandler.js +++ b/packages/cli/src/commands/init/initHandler.js @@ -14,7 +14,6 @@ const run = async opts => { try { await ensureAppDir(opts) await setEnvironmentVariables(opts) - await prompts(opts) await createClientDatabase(opts) await createDevEnvFile(opts) console.log(chalk.green("Budibase successfully initialised.")) @@ -24,13 +23,13 @@ const run = async opts => { } const setEnvironmentVariables = async opts => { - if (opts.database === "local") { + if (opts.couchDbUrl) { + process.env.COUCH_DB_URL = opts.couchDbUrl + } else { const dataDir = join(opts.dir, ".data") await ensureDir(dataDir) process.env.COUCH_DB_URL = dataDir + (dataDir.endsWith("/") || dataDir.endsWith("\\") ? "" : "/") - } else { - process.env.COUCH_DB_URL = opts.couchDbUrl } } @@ -39,25 +38,6 @@ const ensureAppDir = async opts => { await ensureDir(opts.dir) } -const prompts = async opts => { - const questions = [ - { - type: "input", - name: "couchDbUrl", - message: - "CouchDB Connection String (e.g. https://user:password@localhost:5984): ", - validate: function(value) { - return !!value || "Please enter connection string" - }, - }, - ] - - if (opts.database === "remote" && !opts.couchDbUrl) { - const answers = await inquirer.prompt(questions) - opts.couchDbUrl = answers.couchDbUrl - } -} - const createClientDatabase = async opts => { // cannot be a top level require as it // will cause environment module to be loaded prematurely diff --git a/packages/server/.vscode/launch.json b/packages/server/.vscode/launch.json index 964e9297f4..4dc6b653cb 100644 --- a/packages/server/.vscode/launch.json +++ b/packages/server/.vscode/launch.json @@ -49,6 +49,19 @@ "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", } }, + { + "type": "node", + "request": "launch", + "name": "Jest - Access Levels", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["accesslevel.spec", "--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "windows": { + "program": "${workspaceFolder}/node_modules/jest-cli/bin/jest", + } + }, { "type": "node", "request": "launch", diff --git a/packages/server/src/api/controllers/accesslevel.js b/packages/server/src/api/controllers/accesslevel.js new file mode 100644 index 0000000000..9c9c7ff727 --- /dev/null +++ b/packages/server/src/api/controllers/accesslevel.js @@ -0,0 +1,108 @@ +const CouchDB = require("../../db") +const newid = require("../../db/newid") +const { + generateAdminPermissions, + generatePowerUserPermissions, + POWERUSER_LEVEL_ID, + ADMIN_LEVEL_ID, +} = require("../../utilities/accessLevels") + +exports.fetch = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + const body = await db.query("database/by_type", { + include_docs: true, + key: ["accesslevel"], + }) + const customAccessLevels = body.rows.map(row => row.doc) + + const staticAccessLevels = [ + { + _id: ADMIN_LEVEL_ID, + name: "Admin", + permissions: await generateAdminPermissions(ctx.params.instanceId), + }, + { + _id: POWERUSER_LEVEL_ID, + name: "Power User", + permissions: await generatePowerUserPermissions(ctx.params.instanceId), + }, + ] + + ctx.body = [...staticAccessLevels, ...customAccessLevels] +} + +exports.find = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + ctx.body = await db.get(ctx.params.levelId) +} + +exports.update = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + const level = await db.get(ctx.params.levelId) + level.name = ctx.body.name + level.permissions = ctx.request.body.permissions + const result = await db.put(level) + level._rev = result.rev + ctx.body = level + ctx.message = `Level ${level.name} updated successfully.` +} + +exports.patch = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + const level = await db.get(ctx.params.levelId) + const { removedPermissions, addedPermissions, _rev } = ctx.request.body + + if (!_rev) throw new Error("Must supply a _rev to update an access level") + + level._rev = _rev + + if (removedPermissions) { + level.permissions = level.permissions.filter( + p => + !removedPermissions.some( + rem => rem.name === p.name && rem.itemId === p.itemId + ) + ) + } + + if (addedPermissions) { + level.permissions = [ + ...level.permissions.filter( + p => + !addedPermissions.some( + add => add.name === p.name && add.itemId === p.itemId + ) + ), + ...addedPermissions, + ] + } + + const result = await db.put(level) + level._rev = result.rev + ctx.body = level + ctx.message = `Access Level ${level.name} updated successfully.` +} + +exports.create = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + + const level = { + name: ctx.request.body.name, + _rev: ctx.request.body._rev, + permissions: ctx.request.body.permissions || [], + _id: newid(), + type: "accesslevel", + } + + const result = await db.put(level) + level._rev = result.rev + ctx.body = level + ctx.message = `Access Level '${level.name}' created successfully.` +} + +exports.destroy = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + await db.remove(ctx.params.levelId, ctx.params.rev) + ctx.message = `Access Level ${ctx.params.id} deleted successfully` + ctx.status = 200 +} diff --git a/packages/server/src/api/controllers/auth.js b/packages/server/src/api/controllers/auth.js index 4cfc2f438a..fb00024d43 100644 --- a/packages/server/src/api/controllers/auth.js +++ b/packages/server/src/api/controllers/auth.js @@ -24,20 +24,21 @@ exports.authenticate = async ctx => { // Check the user exists in the instance DB by username const instanceDb = new CouchDB(instanceId) - const { rows } = await instanceDb.query("database/by_username", { - include_docs: true, - username, - }) - if (rows.length === 0) ctx.throw(500, `User does not exist.`) - - const dbUser = rows[0].doc + let dbUser + try { + dbUser = await instanceDb.get(`user_${username}`) + } catch (_) { + // do not want to throw a 404 - as this could be + // used to dtermine valid usernames + ctx.throw(401, "Invalid Credentials") + } // authenticate if (await bcrypt.compare(password, dbUser.password)) { const payload = { userId: dbUser._id, - accessLevel: "", + accessLevelId: dbUser.accessLevelId, instanceId: instanceId, } diff --git a/packages/server/src/api/controllers/record.js b/packages/server/src/api/controllers/record.js index ca1d4275f7..c4ac1deba3 100644 --- a/packages/server/src/api/controllers/record.js +++ b/packages/server/src/api/controllers/record.js @@ -8,6 +8,7 @@ exports.save = async function(ctx) { console.log("THIS INSTANCE", ctx.params.instanceId); const db = new CouchDB(ctx.params.instanceId) const record = ctx.request.body + record.modelId = ctx.params.modelId if (!record._rev && !record._id) { record._id = newid() @@ -45,13 +46,12 @@ exports.save = async function(ctx) { const response = await db.post(record) record._rev = response.rev // ctx.eventPublisher.emit("RECORD_CREATED", record) - ctx.body = record ctx.status = 200 ctx.message = `${model.name} created successfully` } -exports.fetch = async function(ctx) { +exports.fetchView = async function(ctx) { const db = new CouchDB(ctx.params.instanceId) const response = await db.query(`database/${ctx.params.viewName}`, { include_docs: true, @@ -59,13 +59,30 @@ exports.fetch = async function(ctx) { ctx.body = response.rows.map(row => row.doc) } +exports.fetchModel = async function(ctx) { + const db = new CouchDB(ctx.params.instanceId) + const response = await db.query(`database/all_${ctx.params.modelId}`, { + include_docs: true, + }) + ctx.body = response.rows.map(row => row.doc) +} + exports.find = async function(ctx) { const db = new CouchDB(ctx.params.instanceId) - ctx.body = await db.get(ctx.params.recordId) + const record = await db.get(ctx.params.recordId) + if (record.modelId !== ctx.params.modelId) { + ctx.throw(400, "Supplied modelId doe not match the record's modelId") + return + } + ctx.body = record } exports.destroy = async function(ctx) { - const databaseId = ctx.params.instanceId - const db = new CouchDB(databaseId) + const db = new CouchDB(ctx.params.instanceId) + const record = await db.get(ctx.params.recordId) + if (record.modelId !== ctx.params.modelId) { + ctx.throw(400, "Supplied modelId doe not match the record's modelId") + return + } ctx.body = await db.remove(ctx.params.recordId, ctx.params.revId) } diff --git a/packages/server/src/api/controllers/user.js b/packages/server/src/api/controllers/user.js index 2be0132a47..f654caa4c0 100644 --- a/packages/server/src/api/controllers/user.js +++ b/packages/server/src/api/controllers/user.js @@ -2,8 +2,11 @@ const CouchDB = require("../../db") const clientDb = require("../../db/clientDb") const bcrypt = require("../../utilities/bcrypt") const env = require("../../environment") - const getUserId = userName => `user_${userName}` +const { + POWERUSER_LEVEL_ID, + ADMIN_LEVEL_ID, +} = require("../../utilities/accessLevels") exports.fetch = async function(ctx) { const database = new CouchDB(ctx.params.instanceId) @@ -18,17 +21,26 @@ exports.fetch = async function(ctx) { exports.create = async function(ctx) { const database = new CouchDB(ctx.params.instanceId) const appId = (await database.get("_design/database")).metadata.applicationId - const { username, password, name } = ctx.request.body + const { username, password, name, accessLevelId } = ctx.request.body - if (!username || !password) ctx.throw(400, "Username and Password Required.") + if (!username || !password) { + ctx.throw(400, "Username and Password Required.") + } - const response = await database.post({ + const accessLevel = await checkAccessLevel(database, accessLevelId) + + if (!accessLevel) ctx.throw(400, "Invalid Access Level") + + const user = { _id: getUserId(username), username, password: await bcrypt.hash(password), name: name || username, type: "user", - }) + accessLevelId, + } + + const response = await database.post(user) // the clientDB needs to store a map of users against the app const db = new CouchDB(clientDb.name(env.CLIENT_ID)) @@ -49,6 +61,8 @@ exports.create = async function(ctx) { } } +exports.update = async function(ctx) {} + exports.destroy = async function(ctx) { const database = new CouchDB(ctx.params.instanceId) await database.destroy(getUserId(ctx.params.username)) @@ -65,3 +79,18 @@ exports.find = async function(ctx) { _rev: user._rev, } } + +const checkAccessLevel = async (db, accessLevelId) => { + if (!accessLevelId) return + if ( + accessLevelId === POWERUSER_LEVEL_ID || + accessLevelId === ADMIN_LEVEL_ID + ) { + return { + _id: accessLevelId, + name: accessLevelId, + permissions: [], + } + } + return await db.get(accessLevelId) +} diff --git a/packages/server/src/api/index.js b/packages/server/src/api/index.js index 360cf8f827..66c3168f23 100644 --- a/packages/server/src/api/index.js +++ b/packages/server/src/api/index.js @@ -7,7 +7,6 @@ const { authRoutes, pageRoutes, userRoutes, - recordRoutes, instanceRoutes, clientRoutes, applicationRoutes, @@ -15,7 +14,8 @@ const { viewRoutes, staticRoutes, componentRoutes, - workflowRoutes + workflowRoutes, + accesslevelRoutes, } = require("./routes") const router = new Router() @@ -71,9 +71,6 @@ router.use(modelRoutes.allowedMethods()) router.use(userRoutes.routes()) router.use(userRoutes.allowedMethods()) -router.use(recordRoutes.routes()) -router.use(recordRoutes.allowedMethods()) - router.use(instanceRoutes.routes()) router.use(instanceRoutes.allowedMethods()) @@ -93,6 +90,9 @@ router.use(componentRoutes.allowedMethods()) router.use(clientRoutes.routes()) router.use(clientRoutes.allowedMethods()) +router.use(accesslevelRoutes.routes()) +router.use(accesslevelRoutes.allowedMethods()) + router.use(staticRoutes.routes()) router.use(staticRoutes.allowedMethods()) diff --git a/packages/server/src/api/routes/accesslevel.js b/packages/server/src/api/routes/accesslevel.js new file mode 100644 index 0000000000..d34acab02c --- /dev/null +++ b/packages/server/src/api/routes/accesslevel.js @@ -0,0 +1,14 @@ +const Router = require("@koa/router") +const controller = require("../controllers/accesslevel") + +const router = Router() + +router + .post("/api/:instanceId/accesslevels", controller.create) + .put("/api/:instanceId/accesslevels", controller.update) + .get("/api/:instanceId/accesslevels", controller.fetch) + .get("/api/:instanceId/accesslevels/:levelId", controller.find) + .delete("/api/:instanceId/accesslevels/:levelId/:rev", controller.destroy) + .patch("/api/:instanceId/accesslevels/:levelId", controller.patch) + +module.exports = router diff --git a/packages/server/src/api/routes/application.js b/packages/server/src/api/routes/application.js index dddf64a710..60cc781ac6 100644 --- a/packages/server/src/api/routes/application.js +++ b/packages/server/src/api/routes/application.js @@ -1,11 +1,17 @@ const Router = require("@koa/router") const controller = require("../controllers/application") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() router - .get("/api/applications", controller.fetch) - .get("/api/:applicationId/appPackage", controller.fetchAppPackage) - .post("/api/applications", controller.create) + .get("/api/applications", authorized(BUILDER), controller.fetch) + .get( + "/api/:applicationId/appPackage", + authorized(BUILDER), + controller.fetchAppPackage + ) + .post("/api/applications", authorized(BUILDER), controller.create) module.exports = router diff --git a/packages/server/src/api/routes/client.js b/packages/server/src/api/routes/client.js index ff87b82e22..16acf1b7a3 100644 --- a/packages/server/src/api/routes/client.js +++ b/packages/server/src/api/routes/client.js @@ -1,8 +1,10 @@ const Router = require("@koa/router") const controller = require("../controllers/client") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() -router.get("/api/client/id", controller.getClientId) +router.get("/api/client/id", authorized(BUILDER), controller.getClientId) module.exports = router diff --git a/packages/server/src/api/routes/component.js b/packages/server/src/api/routes/component.js index 5df34381fa..8fbe7ac41a 100644 --- a/packages/server/src/api/routes/component.js +++ b/packages/server/src/api/routes/component.js @@ -1,10 +1,13 @@ const Router = require("@koa/router") const controller = require("../controllers/component") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() router.get( "/:appId/components/definitions", + authorized(BUILDER), controller.fetchAppComponentDefinitions ) diff --git a/packages/server/src/api/routes/index.js b/packages/server/src/api/routes/index.js index e0737ffa6c..c515d5f437 100644 --- a/packages/server/src/api/routes/index.js +++ b/packages/server/src/api/routes/index.js @@ -1,7 +1,6 @@ const authRoutes = require("./auth") const pageRoutes = require("./pages") const userRoutes = require("./user") -const recordRoutes = require("./record") const instanceRoutes = require("./instance") const clientRoutes = require("./client") const applicationRoutes = require("./application") @@ -9,13 +8,13 @@ const modelRoutes = require("./model") const viewRoutes = require("./view") const staticRoutes = require("./static") const componentRoutes = require("./component") -const workflowRoutes = require("./workflow"); +const workflowRoutes = require("./workflow") +const accesslevelRoutes = require("./accesslevel") module.exports = { authRoutes, pageRoutes, userRoutes, - recordRoutes, instanceRoutes, clientRoutes, applicationRoutes, @@ -23,5 +22,6 @@ module.exports = { viewRoutes, staticRoutes, componentRoutes, - workflowRoutes + workflowRoutes, + accesslevelRoutes, } diff --git a/packages/server/src/api/routes/instance.js b/packages/server/src/api/routes/instance.js index fd74a98bf1..9b7b3db511 100644 --- a/packages/server/src/api/routes/instance.js +++ b/packages/server/src/api/routes/instance.js @@ -1,10 +1,12 @@ const Router = require("@koa/router") const controller = require("../controllers/instance") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() router - .post("/api/:applicationId/instances", controller.create) - .delete("/api/instances/:instanceId", controller.destroy) + .post("/api/:applicationId/instances", authorized(BUILDER), controller.create) + .delete("/api/instances/:instanceId", authorized(BUILDER), controller.destroy) module.exports = router diff --git a/packages/server/src/api/routes/model.js b/packages/server/src/api/routes/model.js index d25d0d17cb..d9eb5cf798 100644 --- a/packages/server/src/api/routes/model.js +++ b/packages/server/src/api/routes/model.js @@ -1,12 +1,49 @@ const Router = require("@koa/router") -const controller = require("../controllers/model") +const modelController = require("../controllers/model") +const recordController = require("../controllers/record") +const authorized = require("../../middleware/authorized") +const { + READ_MODEL, + WRITE_MODEL, + BUILDER, +} = require("../../utilities/accessLevels") const router = Router() +// records + router - .get("/api/:instanceId/models", controller.fetch) - .post("/api/:instanceId/models", controller.create) + .get( + "/api/:instanceId/:modelId/records", + authorized(READ_MODEL, ctx => ctx.params.modelId), + recordController.fetchModel + ) + .get( + "/api/:instanceId/:modelId/records/:recordId", + authorized(READ_MODEL, ctx => ctx.params.modelId), + recordController.find + ) + .post( + "/api/:instanceId/:modelId/records", + authorized(WRITE_MODEL, ctx => ctx.params.modelId), + recordController.save + ) + .delete( + "/api/:instanceId/:modelId/records/:recordId/:revId", + authorized(WRITE_MODEL, ctx => ctx.params.modelId), + recordController.destroy + ) + +// models + +router + .get("/api/:instanceId/models", authorized(BUILDER), modelController.fetch) + .post("/api/:instanceId/models", authorized(BUILDER), modelController.create) // .patch("/api/:instanceId/models", controller.update) - .delete("/api/:instanceId/models/:modelId/:revId", controller.destroy) + .delete( + "/api/:instanceId/models/:modelId/:revId", + authorized(BUILDER), + modelController.destroy + ) module.exports = router diff --git a/packages/server/src/api/routes/pages.js b/packages/server/src/api/routes/pages.js index 88fd1239f4..98f0f08b92 100644 --- a/packages/server/src/api/routes/pages.js +++ b/packages/server/src/api/routes/pages.js @@ -7,63 +7,85 @@ const { renameScreen, deleteScreen, } = require("../../utilities/builder") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() -router.post("/_builder/api/:appId/pages/:pageName", async ctx => { - await buildPage( - ctx.config, - ctx.params.appId, - ctx.params.pageName, - ctx.request.body - ) - ctx.response.status = StatusCodes.OK -}) +router.post( + "/_builder/api/:appId/pages/:pageName", + authorized(BUILDER), + async ctx => { + await buildPage( + ctx.config, + ctx.params.appId, + ctx.params.pageName, + ctx.request.body + ) + ctx.response.status = StatusCodes.OK + } +) -router.get("/_builder/api/:appId/pages/:pagename/screens", async ctx => { - ctx.body = await listScreens( - ctx.config, - ctx.params.appId, - ctx.params.pagename - ) - ctx.response.status = StatusCodes.OK -}) +router.get( + "/_builder/api/:appId/pages/:pagename/screens", + authorized(BUILDER), + async ctx => { + ctx.body = await listScreens( + ctx.config, + ctx.params.appId, + ctx.params.pagename + ) + ctx.response.status = StatusCodes.OK + } +) -router.post("/_builder/api/:appId/pages/:pagename/screen", async ctx => { - ctx.body = await saveScreen( - ctx.config, - ctx.params.appId, - ctx.params.pagename, - ctx.request.body - ) - ctx.response.status = StatusCodes.OK -}) +router.post( + "/_builder/api/:appId/pages/:pagename/screen", + authorized(BUILDER), + async ctx => { + ctx.body = await saveScreen( + ctx.config, + ctx.params.appId, + ctx.params.pagename, + ctx.request.body + ) + ctx.response.status = StatusCodes.OK + } +) -router.patch("/_builder/api/:appname/pages/:pagename/screen", async ctx => { - await renameScreen( - ctx.config, - ctx.params.appname, - ctx.params.pagename, - ctx.request.body.oldname, - ctx.request.body.newname - ) - ctx.response.status = StatusCodes.OK -}) +router.patch( + "/_builder/api/:appname/pages/:pagename/screen", + authorized(BUILDER), + async ctx => { + await renameScreen( + ctx.config, + ctx.params.appname, + ctx.params.pagename, + ctx.request.body.oldname, + ctx.request.body.newname + ) + ctx.response.status = StatusCodes.OK + } +) -router.delete("/_builder/api/:appname/pages/:pagename/screen/*", async ctx => { - const name = ctx.request.path.replace( - `/_builder/api/${ctx.params.appname}/pages/${ctx.params.pagename}/screen/`, - "" - ) +router.delete( + "/_builder/api/:appname/pages/:pagename/screen/*", + authorized(BUILDER), + async ctx => { + const name = ctx.request.path.replace( + `/_builder/api/${ctx.params.appname}/pages/${ctx.params.pagename}/screen/`, + "" + ) - await deleteScreen( - ctx.config, - ctx.params.appname, - ctx.params.pagename, - decodeURI(name) - ) + await deleteScreen( + ctx.config, + ctx.params.appname, + ctx.params.pagename, + decodeURI(name) + ) - ctx.response.status = StatusCodes.OK -}) + ctx.response.status = StatusCodes.OK + } +) module.exports = router diff --git a/packages/server/src/api/routes/record.js b/packages/server/src/api/routes/record.js deleted file mode 100644 index fb0b543dc1..0000000000 --- a/packages/server/src/api/routes/record.js +++ /dev/null @@ -1,12 +0,0 @@ -const Router = require("@koa/router") -const controller = require("../controllers/record") - -const router = Router() - -router - .get("/api/:instanceId/:viewName/records", controller.fetch) - .get("/api/:instanceId/records/:recordId", controller.find) - .post("/api/:instanceId/records", controller.save) - .delete("/api/:instanceId/records/:recordId/:revId", controller.destroy) - -module.exports = router diff --git a/packages/server/src/api/routes/screen.js b/packages/server/src/api/routes/screen.js index 3a167b6ef6..19823aab68 100644 --- a/packages/server/src/api/routes/screen.js +++ b/packages/server/src/api/routes/screen.js @@ -1,11 +1,17 @@ const Router = require("@koa/router") const controller = require("../controllers/screen") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() router - .get("/api/:instanceId/screens", controller.fetch) - .post("/api/:instanceId/screens", controller.save) - .delete("/api/:instanceId/:screenId/:revId", controller.destroy) + .get("/api/:instanceId/screens", authorized(BUILDER), controller.fetch) + .post("/api/:instanceId/screens", authorized(BUILDER), controller.save) + .delete( + "/api/:instanceId/:screenId/:revId", + authorized(BUILDER), + controller.destroy + ) module.exports = router diff --git a/packages/server/src/api/routes/tests/accesslevel.spec.js b/packages/server/src/api/routes/tests/accesslevel.spec.js new file mode 100644 index 0000000000..ef2d1575cd --- /dev/null +++ b/packages/server/src/api/routes/tests/accesslevel.spec.js @@ -0,0 +1,184 @@ +const { + createInstance, + createClientDatabase, + createApplication, + createModel, + createView, + supertest, + defaultHeaders +} = require("./couchTestUtils") +const { + generateAdminPermissions, + generatePowerUserPermissions, + POWERUSER_LEVEL_ID, + ADMIN_LEVEL_ID, + READ_MODEL, + WRITE_MODEL, +} = require("../../../utilities/accessLevels") + +describe("/accesslevels", () => { + let appId + let server + let request + let instanceId + let model + let view + + beforeAll(async () => { + ({ request, server } = await supertest()) + await createClientDatabase(request); + appId = (await createApplication(request))._id + }); + + afterAll(async () => { + server.close(); + }) + + beforeEach(async () => { + instanceId = (await createInstance(request, appId))._id + model = await createModel(request, instanceId) + view = await createView(request, instanceId) + }) + + describe("create", () => { + + it("returns a success message when level is successfully created", async () => { + const res = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ name: "user" }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + expect(res.res.statusMessage).toEqual("Access Level 'user' created successfully.") + expect(res.body._id).toBeDefined() + expect(res.body._rev).toBeDefined() + expect(res.body.permissions).toEqual([]) + }) + + }); + + describe("fetch", () => { + + it("should list custom levels, plus 2 default levels", async () => { + const createRes = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ name: "user", permissions: [ { itemId: model._id, name: READ_MODEL }] }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const customLevel = createRes.body + + const res = await request + .get(`/api/${instanceId}/accesslevels`) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + expect(res.body.length).toBe(3) + + const adminLevel = res.body.find(r => r._id === ADMIN_LEVEL_ID) + expect(adminLevel).toBeDefined() + expect(adminLevel.permissions).toEqual(await generateAdminPermissions(instanceId)) + + const powerUserLevel = res.body.find(r => r._id === POWERUSER_LEVEL_ID) + expect(powerUserLevel).toBeDefined() + expect(powerUserLevel.permissions).toEqual(await generatePowerUserPermissions(instanceId)) + + const customLevelFetched = res.body.find(r => r._id === customLevel._id) + expect(customLevelFetched.permissions).toEqual(customLevel.permissions) + }) + + }); + + describe("destroy", () => { + it("should delete custom access level", async () => { + const createRes = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ name: "user", permissions: [ { itemId: model._id, name: READ_MODEL } ] }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const customLevel = createRes.body + + await request + .delete(`/api/${instanceId}/accesslevels/${customLevel._id}/${customLevel._rev}`) + .set(defaultHeaders) + .expect(200) + + await request + .get(`/api/${instanceId}/accesslevels/${customLevel._id}`) + .set(defaultHeaders) + .expect(404) + }) + }) + + describe("patch", () => { + it("should add given permissions", async () => { + const createRes = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ name: "user", permissions: [ { itemId: model._id, name: READ_MODEL }] }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const customLevel = createRes.body + + await request + .patch(`/api/${instanceId}/accesslevels/${customLevel._id}`) + .send({ + _rev: customLevel._rev, + addedPermissions: [ { itemId: model._id, name: WRITE_MODEL } ] + }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const finalRes = await request + .get(`/api/${instanceId}/accesslevels/${customLevel._id}`) + .set(defaultHeaders) + .expect(200) + + expect(finalRes.body.permissions.length).toBe(2) + expect(finalRes.body.permissions.some(p => p.name === WRITE_MODEL)).toBe(true) + expect(finalRes.body.permissions.some(p => p.name === READ_MODEL)).toBe(true) + }) + + it("should remove given permissions", async () => { + const createRes = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ + name: "user", + permissions: [ + { itemId: model._id, name: READ_MODEL }, + { itemId: model._id, name: WRITE_MODEL }, + ] + }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const customLevel = createRes.body + + await request + .patch(`/api/${instanceId}/accesslevels/${customLevel._id}`) + .send({ + _rev: customLevel._rev, + removedPermissions: [ { itemId: model._id, name: WRITE_MODEL }] + }) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + + const finalRes = await request + .get(`/api/${instanceId}/accesslevels/${customLevel._id}`) + .set(defaultHeaders) + .expect(200) + + expect(finalRes.body.permissions.length).toBe(1) + expect(finalRes.body.permissions.some(p => p.name === READ_MODEL)).toBe(true) + }) + }) +}); diff --git a/packages/server/src/api/routes/tests/application.spec.js b/packages/server/src/api/routes/tests/application.spec.js index 4e6766f71c..4c193613fd 100644 --- a/packages/server/src/api/routes/tests/application.spec.js +++ b/packages/server/src/api/routes/tests/application.spec.js @@ -1,9 +1,11 @@ const { createClientDatabase, createApplication, + createInstance, destroyClientDatabase, + builderEndpointShouldBlockNormalUsers, supertest, - defaultHeaders + defaultHeaders, } = require("./couchTestUtils") describe("/applications", () => { @@ -37,6 +39,18 @@ describe("/applications", () => { expect(res.res.statusMessage).toEqual("Application My App created successfully") expect(res.body._id).toBeDefined() }) + + it("should apply authorization to endpoint", async () => { + const otherApplication = await createApplication(request) + const instance = await createInstance(request, otherApplication._id) + await builderEndpointShouldBlockNormalUsers({ + request, + method: "POST", + url: `/api/applications`, + instanceId: instance._id, + body: { name: "My App" } + }) + }) }) describe("fetch", () => { @@ -53,6 +67,17 @@ describe("/applications", () => { expect(res.body.length).toBe(2) }) + + it("should apply authorization to endpoint", async () => { + const otherApplication = await createApplication(request) + const instance = await createInstance(request, otherApplication._id) + await builderEndpointShouldBlockNormalUsers({ + request, + method: "GET", + url: `/api/applications`, + instanceId: instance._id, + }) + }) }) }) diff --git a/packages/server/src/api/routes/tests/couchTestUtils.js b/packages/server/src/api/routes/tests/couchTestUtils.js index 85674e11d5..efec051ff8 100644 --- a/packages/server/src/api/routes/tests/couchTestUtils.js +++ b/packages/server/src/api/routes/tests/couchTestUtils.js @@ -2,6 +2,10 @@ const CouchDB = require("../../../db") const { create, destroy } = require("../../../db/clientDb") const supertest = require("supertest") const app = require("../../../app") +const { + POWERUSER_LEVEL_ID, + generateAdminPermissions, +} = require("../../../utilities/accessLevels") const TEST_CLIENT_ID = "test-client-id" @@ -17,7 +21,7 @@ exports.supertest = async () => { exports.defaultHeaders = { Accept: "application/json", - Authorization: "Basic test-admin-secret", + Cookie: ["builder:token=test-admin-secret"], } exports.createModel = async (request, instanceId, model) => { @@ -37,6 +41,31 @@ exports.createModel = async (request, instanceId, model) => { return res.body } +exports.createRecord = async (request, instanceId, modelId, record) => { + record = record || { + modelId, + name: "test name", + } + + const res = await request + .post(`/api/${instanceId}/${modelId}/records`) + .send(record) + .set(exports.defaultHeaders) + return res.body +} + +exports.createView = async (request, instanceId, view) => { + view = view || { + map: "function(doc) { emit(doc[doc.key], doc._id); } ", + } + + const res = await request + .post(`/api/${instanceId}/views`) + .set(exports.defaultHeaders) + .send(view) + return res.body +} + exports.createClientDatabase = async () => await create(TEST_CLIENT_ID) exports.createApplication = async (request, name = "test_application") => { @@ -64,21 +93,170 @@ exports.createInstance = async (request, appId) => { exports.createUser = async ( request, instanceId, - username = "bill", - password = "bills_password" + username = "babs", + password = "babs_password" ) => { const res = await request .post(`/api/${instanceId}/users`) .set(exports.defaultHeaders) - .send({ name: "Bill", username, password }) + .send({ + name: "Bill", + username, + password, + accessLevelId: POWERUSER_LEVEL_ID, + }) return res.body } +const createUser_WithOnePermission = async ( + request, + instanceId, + permName, + itemId +) => { + let permissions = await generateAdminPermissions(instanceId) + permissions = permissions.filter( + p => p.name === permName && p.itemId === itemId + ) + + return await createUserWithPermissions( + request, + instanceId, + permissions, + "onePermOnlyUser" + ) +} + +const createUser_WithAdminPermissions = async (request, instanceId) => { + let permissions = await generateAdminPermissions(instanceId) + + return await createUserWithPermissions( + request, + instanceId, + permissions, + "adminUser" + ) +} + +const createUser_WithAllPermissionExceptOne = async ( + request, + instanceId, + permName, + itemId +) => { + let permissions = await generateAdminPermissions(instanceId) + permissions = permissions.filter( + p => !(p.name === permName && p.itemId === itemId) + ) + + return await createUserWithPermissions( + request, + instanceId, + permissions, + "allPermsExceptOneUser" + ) +} + +const createUserWithPermissions = async ( + request, + instanceId, + permissions, + username +) => { + const accessRes = await request + .post(`/api/${instanceId}/accesslevels`) + .send({ name: "TestLevel", permissions }) + .set(exports.defaultHeaders) + + const password = `password_${username}` + await request + .post(`/api/${instanceId}/users`) + .set(exports.defaultHeaders) + .send({ + name: username, + username, + password, + accessLevelId: accessRes.body._id, + }) + + const db = new CouchDB(instanceId) + const designDoc = await db.get("_design/database") + + const loginResult = await request + .post(`/api/authenticate`) + .set("Referer", `http://localhost:4001/${designDoc.metadata.applicationId}`) + .send({ username, password }) + + // returning necessary request headers + return { + Accept: "application/json", + Cookie: loginResult.headers["set-cookie"], + } +} + +exports.testPermissionsForEndpoint = async ({ + request, + method, + url, + body, + instanceId, + permissionName, + itemId, +}) => { + const headers = await createUser_WithOnePermission( + request, + instanceId, + permissionName, + itemId + ) + + await createRequest(request, method, url, body) + .set(headers) + .expect(200) + + const noPermsHeaders = await createUser_WithAllPermissionExceptOne( + request, + instanceId, + permissionName, + itemId + ) + + await createRequest(request, method, url, body) + .set(noPermsHeaders) + .expect(403) +} + +exports.builderEndpointShouldBlockNormalUsers = async ({ + request, + method, + url, + body, + instanceId, +}) => { + const headers = await createUser_WithAdminPermissions(request, instanceId) + + await createRequest(request, method, url, body) + .set(headers) + .expect(403) +} + +const createRequest = (request, method, url, body) => { + let req + + if (method === "POST") req = request.post(url).send(body) + else if (method === "GET") req = request.get(url) + else if (method === "DELETE") req = request.delete(url) + else if (method === "PATCH") req = request.patch(url).send(body) + else if (method === "PUT") req = request.put(url).send(body) + + return req +} + exports.insertDocument = async (databaseId, document) => { const { id, ...documentFields } = document return await new CouchDB(databaseId).put({ _id: id, ...documentFields }) } exports.destroyDocument = async (databaseId, documentId) => { - return await new CouchDB(databaseId).destroy(documentId); -} \ No newline at end of file + return await new CouchDB(databaseId).destroy(documentId) +} diff --git a/packages/server/src/api/routes/tests/model.spec.js b/packages/server/src/api/routes/tests/model.spec.js index e32c94224f..65a44b677a 100644 --- a/packages/server/src/api/routes/tests/model.spec.js +++ b/packages/server/src/api/routes/tests/model.spec.js @@ -3,7 +3,9 @@ const { createModel, supertest, createClientDatabase, - createApplication + createApplication , + defaultHeaders, + builderEndpointShouldBlockNormalUsers } = require("./couchTestUtils") describe("/models", () => { @@ -38,7 +40,7 @@ describe("/models", () => { name: { type: "string" } } }) - .set("Accept", "application/json") + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) .end(async (err, res) => { @@ -47,6 +49,22 @@ describe("/models", () => { done(); }); }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "POST", + url: `/api/${instance._id}/models`, + instanceId: instance._id, + body: { + name: "TestModel", + key: "name", + schema: { + name: { type: "string" } + } + } + }) + }) }); describe("fetch", () => { @@ -60,7 +78,7 @@ describe("/models", () => { it("returns all the models for that instance in the response body", done => { request .get(`/api/${instance._id}/models`) - .set("Accept", "application/json") + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) .end(async (_, res) => { @@ -69,7 +87,17 @@ describe("/models", () => { expect(fetchedModel.type).toEqual("model"); done(); }); + }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "GET", + url: `/api/${instance._id}/models`, + instanceId: instance._id, + }) }) + }); describe("destroy", () => { @@ -83,7 +111,7 @@ describe("/models", () => { it("returns a success response when a model is deleted.", done => { request .delete(`/api/${instance._id}/models/${testModel._id}/${testModel._rev}`) - .set("Accept", "application/json") + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) .end(async (_, res) => { @@ -91,5 +119,15 @@ describe("/models", () => { done(); }); }) - }); + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "DELETE", + url: `/api/${instance._id}/models/${testModel._id}/${testModel._rev}`, + instanceId: instance._id, + }) + }) + + }); }); diff --git a/packages/server/src/api/routes/tests/record.spec.js b/packages/server/src/api/routes/tests/record.spec.js index b75b245135..c2ec2927f9 100644 --- a/packages/server/src/api/routes/tests/record.spec.js +++ b/packages/server/src/api/routes/tests/record.spec.js @@ -3,8 +3,15 @@ const { createClientDatabase, createInstance, createModel, - supertest + supertest, + defaultHeaders, + testPermissionsForEndpoint, + shouldReturn403WhenNoPermission, + shouldReturn200WithOnlyOnePermission, } = require("./couchTestUtils"); +const { + WRITE_MODEL, READ_MODEL +} = require("../../../utilities/accessLevels") describe("/records", () => { let request @@ -24,25 +31,25 @@ describe("/records", () => { server.close(); }) - describe("save, load, update, delete", () => { + beforeEach(async () => { + instance = await createInstance(request, app._id) + model = await createModel(request, instance._id) + record = { + name: "Test Contact", + status: "new", + modelId: model._id + } + }) - beforeEach(async () => { - instance = await createInstance(request, app._id) - model = await createModel(request, instance._id) - record = { - name: "Test Contact", - status: "new", - modelId: model._id - } - }) + const createRecord = async r => + await request + .post(`/api/${instance._id}/${model._id}/records`) + .send(r || record) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) - const createRecord = async r => - await request - .post(`/api/${instance._id}/records`) - .send(r || record) - .set("Accept", "application/json") - .expect('Content-Type', /json/) - .expect(200) + describe("save", () => { it("returns a success message when the record is created", async () => { const res = await createRecord() @@ -51,33 +58,47 @@ describe("/records", () => { expect(res.body._rev).toBeDefined() }) + it("should apply authorization to endpoint", async () => { + await testPermissionsForEndpoint({ + request, + method: "POST", + url: `/api/${instance._id}/${model._id}/records`, + body: record, + instanceId: instance._id, + permissionName: WRITE_MODEL, + itemId: model._id, + }) + }) + it("updates a record successfully", async () => { const rec = await createRecord() const existing = rec.body const res = await request - .post(`/api/${instance._id}/records`) + .post(`/api/${instance._id}/${model._id}/records`) .send({ _id: existing._id, _rev: existing._rev, modelId: model._id, name: "Updated Name", }) - .set("Accept", "application/json") + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) expect(res.res.statusMessage).toEqual(`${model.name} updated successfully.`) expect(res.body.name).toEqual("Updated Name") }) + }) + describe("find", () => { it("should load a record", async () => { const rec = await createRecord() const existing = rec.body const res = await request - .get(`/api/${instance._id}/records/${existing._id}`) - .set("Accept", "application/json") + .get(`/api/${instance._id}/${model._id}/records/${existing._id}`) + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) @@ -89,6 +110,31 @@ describe("/records", () => { }) }) + it("load should return 404 when record does not exist", async () => { + await createRecord() + await request + .get(`/api/${instance._id}/${model._id}/records/not-a-valid-id`) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(404) + }) + + it("should apply authorization to endpoint", async () => { + const rec = await createRecord() + await testPermissionsForEndpoint({ + request, + method: "GET", + url: `/api/${instance._id}/${model._id}/records/${rec.body._id}`, + instanceId: instance._id, + permissionName: READ_MODEL, + itemId: model._id, + }) + }) + + }) + + describe("fetch", () => { + it("should list all records for given modelId", async () => { const newRecord = { modelId: model._id, @@ -99,8 +145,8 @@ describe("/records", () => { await createRecord(newRecord) const res = await request - .get(`/api/${instance._id}/all_${newRecord.modelId}/records`) - .set("Accept", "application/json") + .get(`/api/${instance._id}/${model._id}/records`) + .set(defaultHeaders) .expect('Content-Type', /json/) .expect(200) @@ -109,13 +155,46 @@ describe("/records", () => { expect(res.body.find(r => r.name === record.name)).toBeDefined() }) - it("load should return 404 when record does not exist", async () => { - await createRecord() + it("should apply authorization to endpoint", async () => { + await testPermissionsForEndpoint({ + request, + method: "GET", + url: `/api/${instance._id}/${model._id}/records`, + instanceId: instance._id, + permissionName: READ_MODEL, + itemId: model._id, + }) + }) + + }) + + describe("delete", () => { + + it("should remove a record", async () => { + const createRes = await createRecord() + await request - .get(`/api/${instance._id}/records/not-a-valid-id`) - .set("Accept", "application/json") - .expect('Content-Type', /json/) + .delete(`/api/${instance._id}/${model._id}/records/${createRes.body._id}/${createRes.body._rev}`) + .set(defaultHeaders) + .expect(200) + + await request + .get(`/api/${instance._id}/${model._id}/records/${createRes.body._id}`) + .set(defaultHeaders) .expect(404) }) + + it("should apply authorization to endpoint", async () => { + const createRes = await createRecord() + await testPermissionsForEndpoint({ + request, + method: "DELETE", + url: `/api/${instance._id}/${model._id}/records/${createRes.body._id}/${createRes.body._rev}`, + instanceId: instance._id, + permissionName: WRITE_MODEL, + itemId: model._id, + }) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/user.spec.js b/packages/server/src/api/routes/tests/user.spec.js index bba748daff..d950f03c3d 100644 --- a/packages/server/src/api/routes/tests/user.spec.js +++ b/packages/server/src/api/routes/tests/user.spec.js @@ -5,7 +5,13 @@ const { supertest, defaultHeaders, createUser, + testPermissionsForEndpoint, } = require("./couchTestUtils") +const { + POWERUSER_LEVEL_ID, + LIST_USERS, + USER_MANAGEMENT +} = require("../../../utilities/accessLevels") describe("/users", () => { let request @@ -43,6 +49,17 @@ describe("/users", () => { expect(res.body.find(u => u.username === "pam")).toBeDefined() }) + it("should apply authorization to endpoint", async () => { + await createUser(request, instance._id, "brenda", "brendas_password") + await testPermissionsForEndpoint({ + request, + method: "GET", + url: `/api/${instance._id}/users`, + instanceId: instance._id, + permissionName: LIST_USERS, + }) + }) + }) describe("create", () => { @@ -51,12 +68,24 @@ describe("/users", () => { const res = await request .post(`/api/${instance._id}/users`) .set(defaultHeaders) - .send({ name: "Bill", username: "bill", password: "bills_password" }) + .send({ name: "Bill", username: "bill", password: "bills_password", accessLevelId: POWERUSER_LEVEL_ID }) .expect(200) .expect('Content-Type', /json/) expect(res.res.statusMessage).toEqual("User created successfully."); expect(res.body._id).toBeUndefined() }) + + it("should apply authorization to endpoint", async () => { + await testPermissionsForEndpoint({ + request, + method: "POST", + body: { name: "brandNewUser", username: "brandNewUser", password: "yeeooo", accessLevelId: POWERUSER_LEVEL_ID }, + url: `/api/${instance._id}/users`, + instanceId: instance._id, + permissionName: USER_MANAGEMENT, + }) + }) + }); }) diff --git a/packages/server/src/api/routes/tests/view.spec.js b/packages/server/src/api/routes/tests/view.spec.js index e2aeececce..df2c6e0048 100644 --- a/packages/server/src/api/routes/tests/view.spec.js +++ b/packages/server/src/api/routes/tests/view.spec.js @@ -3,9 +3,13 @@ const { createApplication, createInstance, createModel, + createRecord, supertest, - defaultHeaders + defaultHeaders, + testPermissionsForEndpoint, + builderEndpointShouldBlockNormalUsers, } = require("./couchTestUtils") +const { READ_VIEW } = require("../../../utilities/accessLevels") describe("/views", () => { let request @@ -34,11 +38,10 @@ describe("/views", () => { .send({ name: "TestView", map: `function(doc) { - if (doc.id) { + if (doc.type === 'record') { emit(doc.name, doc._id); } }`, - reduce: `function(keys, values) { }` }) .set(defaultHeaders) .expect('Content-Type', /json/) @@ -51,14 +54,28 @@ describe("/views", () => { expect(res.res.statusMessage).toEqual("View TestView created successfully."); expect(res.body.name).toEqual("TestView"); }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "POST", + url: `/api/${instance._id}/views`, + body: { + name: "TestView", + map: `function(doc) { + if (doc.id) { + emit(doc.name, doc._id); + } + }`, + reduce: `function(keys, values) { }` + }, + instanceId: instance._id, + }) + }) }); describe("fetch", () => { - beforeEach(async () => { - model = await createModel(request, instance._id); - }); - it("should only return custom views", async () => { const view = await createView() const res = await request @@ -69,5 +86,46 @@ describe("/views", () => { expect(res.body.length).toBe(1) expect(res.body.find(v => v.name === view.body.name)).toBeDefined() }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "GET", + url: `/api/${instance._id}/views`, + instanceId: instance._id, + }) + }) }); + + describe("query", () => { + + beforeEach(async () => { + model = await createModel(request, instance._id); + }); + + it("should return records from custom view", async () => { + await createView() + const rec1 = await createRecord(request, instance._id, model._id) + await createRecord(request, instance._id, model._id) + const res = await request + .get(`/api/${instance._id}/views/TestView`) + .set(defaultHeaders) + .expect('Content-Type', /json/) + .expect(200) + expect(res.body.length).toBe(2) + expect(res.body.find(r => r._id === rec1._id)).toBeDefined() + }) + + it("should apply authorization to endpoint", async () => { + await createView() + await testPermissionsForEndpoint({ + request, + method: "GET", + url: `/api/${instance._id}/views/TestView`, + instanceId: instance._id, + permissionName: READ_VIEW, + itemId: "TestView", + }) + }) + }) }); diff --git a/packages/server/src/api/routes/tests/workflow.spec.js b/packages/server/src/api/routes/tests/workflow.spec.js index 4a4d91228e..0e0ce6bfc6 100644 --- a/packages/server/src/api/routes/tests/workflow.spec.js +++ b/packages/server/src/api/routes/tests/workflow.spec.js @@ -5,7 +5,8 @@ const { defaultHeaders, supertest, insertDocument, - destroyDocument + destroyDocument, + builderEndpointShouldBlockNormalUsers } = require("./couchTestUtils") const TEST_WORKFLOW = { @@ -71,6 +72,16 @@ describe("/workflows", () => { expect(res.body.message).toEqual("Workflow created successfully"); expect(res.body.workflow.name).toEqual("My Workflow"); }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "POST", + url: `/api/${instance._id}/workflows`, + instanceId: instance._id, + body: TEST_WORKFLOW + }) + }) }) describe("update", () => { @@ -103,6 +114,15 @@ describe("/workflows", () => { expect(res.body[0]).toEqual(expect.objectContaining(TEST_WORKFLOW)); }) + + it("should apply authorization to endpoint", async () => { + await builderEndpointShouldBlockNormalUsers({ + request, + method: "GET", + url: `/api/${instance._id}/workflows`, + instanceId: instance._id, + }) + }) }) describe("find", () => { @@ -116,6 +136,16 @@ describe("/workflows", () => { expect(res.body).toEqual(expect.objectContaining(TEST_WORKFLOW)); }) + + it("should apply authorization to endpoint", async () => { + await createWorkflow(); + await builderEndpointShouldBlockNormalUsers({ + request, + method: "GET", + url: `/api/${instance._id}/workflows/${workflow.id}`, + instanceId: instance._id, + }) + }) }) describe("destroy", () => { @@ -129,5 +159,15 @@ describe("/workflows", () => { expect(res.body.id).toEqual(TEST_WORKFLOW._id); }) + + it("should apply authorization to endpoint", async () => { + await createWorkflow(); + await builderEndpointShouldBlockNormalUsers({ + request, + method: "DELETE", + url: `/api/${instance._id}/workflows/${workflow.id}/${workflow._rev}`, + instanceId: instance._id, + }) + }) }) }); diff --git a/packages/server/src/api/routes/user.js b/packages/server/src/api/routes/user.js index 61f4a096fb..20e17c7473 100644 --- a/packages/server/src/api/routes/user.js +++ b/packages/server/src/api/routes/user.js @@ -1,12 +1,26 @@ const Router = require("@koa/router") const controller = require("../controllers/user") +const authorized = require("../../middleware/authorized") +const { USER_MANAGEMENT, LIST_USERS } = require("../../utilities/accessLevels") const router = Router() router - .get("/api/:instanceId/users", controller.fetch) - .get("/api/:instanceId/users/:username", controller.find) - .post("/api/:instanceId/users", controller.create) - .delete("/api/:instanceId/users/:username", controller.destroy) + .get("/api/:instanceId/users", authorized(LIST_USERS), controller.fetch) + .get( + "/api/:instanceId/users/:username", + authorized(USER_MANAGEMENT), + controller.find + ) + .post( + "/api/:instanceId/users", + authorized(USER_MANAGEMENT), + controller.create + ) + .delete( + "/api/:instanceId/users/:username", + authorized(USER_MANAGEMENT), + controller.destroy + ) module.exports = router diff --git a/packages/server/src/api/routes/view.js b/packages/server/src/api/routes/view.js index f7ce9b41b5..193ece1cdf 100644 --- a/packages/server/src/api/routes/view.js +++ b/packages/server/src/api/routes/view.js @@ -1,12 +1,20 @@ const Router = require("@koa/router") -const controller = require("../controllers/view") +const viewController = require("../controllers/view") +const recordController = require("../controllers/record") +const authorized = require("../../middleware/authorized") +const { BUILDER, READ_VIEW } = require("../../utilities/accessLevels") const router = Router() router - .get("/api/:instanceId/views", controller.fetch) + .get( + "/api/:instanceId/views/:viewName", + authorized(READ_VIEW, ctx => ctx.params.viewName), + recordController.fetchView + ) + .get("/api/:instanceId/views", authorized(BUILDER), viewController.fetch) // .patch("/api/:databaseId/views", controller.update); // .delete("/api/:instanceId/views/:viewId/:revId", controller.destroy); - .post("/api/:instanceId/views", controller.create) + .post("/api/:instanceId/views", authorized(BUILDER), viewController.create) module.exports = router diff --git a/packages/server/src/api/routes/workflow.js b/packages/server/src/api/routes/workflow.js index 5bd501bd76..df69ca32f6 100644 --- a/packages/server/src/api/routes/workflow.js +++ b/packages/server/src/api/routes/workflow.js @@ -1,15 +1,28 @@ const Router = require("@koa/router") const controller = require("../controllers/workflow") +const authorized = require("../../middleware/authorized") +const { BUILDER } = require("../../utilities/accessLevels") const router = Router() router - .get("/api/:instanceId/workflows", controller.fetch) - .get("/api/:instanceId/workflows/:id", controller.find) - .get("/api/:instanceId/workflows/:id/:action", controller.fetchActionScript) - .post("/api/:instanceId/workflows", controller.create) - .post("/api/:instanceId/workflows/action", controller.executeAction) - .put("/api/:instanceId/workflows", controller.update) - .delete("/api/:instanceId/workflows/:id/:rev", controller.destroy) + .get("/api/:instanceId/workflows", authorized(BUILDER), controller.fetch) + .get("/api/:instanceId/workflows/:id", authorized(BUILDER), controller.find) + .get( + "/api/:instanceId/workflows/:id/:action", + authorized(BUILDER), + controller.fetchActionScript + ) + .put("/api/:instanceId/workflows", authorized(BUILDER), controller.update) + .post( + "/api/:instanceId/workflows/action", + authorized(BUILDER), + controller.executeAction + ) + .delete( + "/api/:instanceId/workflows/:id", + authorized(BUILDER), + controller.destroy + ) module.exports = router diff --git a/packages/server/src/middleware/authenticated.js b/packages/server/src/middleware/authenticated.js index 1bcc5575c3..9308d1b173 100644 --- a/packages/server/src/middleware/authenticated.js +++ b/packages/server/src/middleware/authenticated.js @@ -1,6 +1,11 @@ const jwt = require("jsonwebtoken") const STATUS_CODES = require("../utilities/statusCodes") const env = require("../environment") +const accessLevelController = require("../api/controllers/accesslevel") +const { + ADMIN_LEVEL_ID, + POWERUSER_LEVEL_ID, +} = require("../utilities/accessLevels") module.exports = async (ctx, next) => { if (ctx.path === "/_builder") { @@ -8,8 +13,9 @@ module.exports = async (ctx, next) => { return } - if (ctx.isDev && ctx.cookies.get("builder:token") === env.ADMIN_SECRET) { + if (ctx.cookies.get("builder:token") === env.ADMIN_SECRET) { ctx.isAuthenticated = true + ctx.isBuilder = true await next() return } @@ -23,7 +29,15 @@ module.exports = async (ctx, next) => { } try { - ctx.jwtPayload = jwt.verify(token, ctx.config.jwtSecret) + const jwtPayload = jwt.verify(token, ctx.config.jwtSecret) + + ctx.user = { + ...jwtPayload, + accessLevel: await getAccessLevel( + jwtPayload.instanceId, + jwtPayload.accessLevelId + ), + } ctx.isAuthenticated = true } catch (err) { ctx.throw(err.status || STATUS_CODES.FORBIDDEN, err.text) @@ -31,3 +45,25 @@ module.exports = async (ctx, next) => { await next() } + +const getAccessLevel = async (instanceId, accessLevelId) => { + if ( + accessLevelId === POWERUSER_LEVEL_ID || + accessLevelId === ADMIN_LEVEL_ID + ) { + return { + _id: accessLevelId, + name: accessLevelId, + permissions: [], + } + } + + const findAccessContext = { + params: { + levelId: accessLevelId, + instanceId, + }, + } + await accessLevelController.find(findAccessContext) + return findAccessContext.body +} diff --git a/packages/server/src/middleware/authorized.js b/packages/server/src/middleware/authorized.js new file mode 100644 index 0000000000..b07715af36 --- /dev/null +++ b/packages/server/src/middleware/authorized.js @@ -0,0 +1,58 @@ +const { + adminPermissions, + ADMIN_LEVEL_ID, + POWERUSER_LEVEL_ID, + BUILDER, +} = require("../utilities/accessLevels") + +module.exports = (permName, getItemId) => async (ctx, next) => { + if (!ctx.isAuthenticated) { + ctx.throw(403, "Session not authenticated") + } + + if (ctx.isBuilder) { + await next() + return + } + + if (permName === BUILDER) { + ctx.throw(403, "Not Authorized") + return + } + + if (!ctx.user) { + ctx.throw(403, "User not found") + } + + const permissionId = ({ name, itemId }) => name + (itemId ? `-${itemId}` : "") + + if (ctx.user.accessLevel._id === ADMIN_LEVEL_ID) { + await next() + return + } + + const thisPermissionId = permissionId({ + name: permName, + itemId: getItemId && getItemId(ctx), + }) + + // power user has everything, except the admin specific perms + if ( + ctx.user.accessLevel._id === POWERUSER_LEVEL_ID && + !adminPermissions.map(permissionId).includes(thisPermissionId) + ) { + await next() + return + } + + if ( + ctx.user.accessLevel.permissions + .map(permissionId) + .includes(thisPermissionId) + ) { + await next() + return + } + + ctx.throw(403, "Not Authorized") +} diff --git a/packages/server/src/utilities/accessLevels.js b/packages/server/src/utilities/accessLevels.js new file mode 100644 index 0000000000..b859d35f61 --- /dev/null +++ b/packages/server/src/utilities/accessLevels.js @@ -0,0 +1,79 @@ +const viewController = require("../api/controllers/view") +const modelController = require("../api/controllers/model") +const workflowController = require("../api/controllers/workflow") + +exports.ADMIN_LEVEL_ID = "ADMIN" +exports.POWERUSER_LEVEL_ID = "POWER_USER" + +exports.READ_MODEL = "read-model" +exports.WRITE_MODEL = "write-model" +exports.READ_VIEW = "read-view" +exports.EXECUTE_WORKFLOW = "execute-workflow" +exports.USER_MANAGEMENT = "user-management" +exports.BUILDER = "builder" +exports.LIST_USERS = "list-users" + +exports.adminPermissions = [ + { + name: exports.USER_MANAGEMENT, + }, +] + +exports.generateAdminPermissions = async instanceId => [ + ...exports.adminPermissions, + ...(await exports.generatePowerUserPermissions(instanceId)), +] + +exports.generatePowerUserPermissions = async instanceId => { + const fetchModelsCtx = { + params: { + instanceId, + }, + } + await modelController.fetch(fetchModelsCtx) + const models = fetchModelsCtx.body + + const fetchViewsCtx = { + params: { + instanceId, + }, + } + await viewController.fetch(fetchViewsCtx) + const views = fetchViewsCtx.body + + const fetchWorkflowsCtx = { + params: { + instanceId, + }, + } + await workflowController.fetch(fetchWorkflowsCtx) + const workflows = fetchWorkflowsCtx.body + + const readModelPermissions = models.map(m => ({ + itemId: m._id, + name: exports.READ_MODEL, + })) + + const writeModelPermissions = models.map(m => ({ + itemId: m._id, + name: exports.WRITE_MODEL, + })) + + const viewPermissions = views.map(v => ({ + itemId: v.name, + name: exports.READ_VIEW, + })) + + const executeWorkflowPermissions = workflows.map(w => ({ + itemId: w._id, + name: exports.EXECUTE_WORKFLOW, + })) + + return [ + ...readModelPermissions, + ...writeModelPermissions, + ...viewPermissions, + ...executeWorkflowPermissions, + { name: exports.LIST_USERS }, + ] +}