diff --git a/README.md b/README.md index 824f20cb5..f81bd957e 100644 --- a/README.md +++ b/README.md @@ -534,9 +534,9 @@ Queries are created or replaced matching on query id. At this time the query ACL At this point SQLPad does not enforce referential integrity, so queries may be created with a `createdBy` containing an email address for a user that does not exist. -Example seed query JSON file: +Example seed query JSON file (comments only added for doc purposes): -```json +```js { "id": "seed-query-1", "name": "Seed query 1", @@ -544,8 +544,22 @@ Example seed query JSON file: "queryText": "SELECT * FROM seed_table", "createdBy": "admin@sqlpad.com", "acl": [ + // an ACL entry with write=false allows that user to read + // (and execute if they have connection permission) + // write=true allows user to save query + { + "userId": "some-userId-in-sqlpad", + "write": false + }, + // ACL entry can also be specified with a users email address. + // The user does not need to exist in SQLPad at this point + { + "userEmail": "someone@sqlpad.com", + "write": true + }, + // Alternatively a special __EVERYONE__ group may be used to share the query with all SQLPad users { - "userId": "__EVERYONE__", + "groupId": "__EVERYONE__", "write": true } ] diff --git a/client/src/queries/QueryListDrawer.js b/client/src/queries/QueryListDrawer.js index ab0371f3e..3ed9a8f00 100644 --- a/client/src/queries/QueryListDrawer.js +++ b/client/src/queries/QueryListDrawer.js @@ -137,9 +137,6 @@ function QueryListDrawer({ const chartUrl = `/query-chart/${query._id}`; const queryUrl = `/queries/${query._id}`; - const canDelete = - currentUser.role === 'admin' || currentUser.email === query.createdBy; - const hasChart = query && query.chartConfiguration && query.chartConfiguration.chartType; @@ -181,7 +178,7 @@ function QueryListDrawer({ key="del" confirmMessage={`Delete ${query.name}`} onConfirm={e => deleteQuery(query._id)} - disabled={!canDelete} + disabled={!query.canDelete} > Delete diff --git a/client/src/queryEditor/toolbar/ToolbarShareQueryButton.js b/client/src/queryEditor/toolbar/ToolbarShareQueryButton.js index 6099ea64b..f21d98c49 100644 --- a/client/src/queryEditor/toolbar/ToolbarShareQueryButton.js +++ b/client/src/queryEditor/toolbar/ToolbarShareQueryButton.js @@ -20,7 +20,7 @@ function ToolbarShareQueryButton({ shared, setQueryState }) { function handleClick() { setQueryState( 'acl', - shared ? [] : [{ userId: '__EVERYONE__', write: true }] + shared ? [] : [{ groupId: '__EVERYONE__', write: true }] ); } diff --git a/client/src/stores/queries.js b/client/src/stores/queries.js index 4c71a4996..08a3698ba 100644 --- a/client/src/stores/queries.js +++ b/client/src/stores/queries.js @@ -17,7 +17,10 @@ export const NEW_QUERY = { chartConfiguration: { chartType: '', fields: {} // key value for chart - } + }, + canRead: true, + canWrite: true, + canDelete: true }; export const initialState = { diff --git a/server/lib/decorateQueryUserAccess.js b/server/lib/decorateQueryUserAccess.js new file mode 100644 index 000000000..30cc4f618 --- /dev/null +++ b/server/lib/decorateQueryUserAccess.js @@ -0,0 +1,46 @@ +const consts = require('./consts'); + +// Not sure where to put utilities like these + +/** + * Returns a decorated query object with canRead, canWrite, and canDelete properties + * @param {object} query + * @param {object} user + */ +function decorateQueryUserAccess(query, user) { + const { ...clone } = query; + clone.canRead = false; + clone.canWrite = false; + clone.canDelete = false; + + if (user.role === 'admin' || user.email === clone.createdBy) { + clone.canRead = true; + clone.canWrite = true; + clone.canDelete = true; + } else if (clone.acl.length) { + const writeAcl = clone.acl + // filter acl records that match for this user + .filter( + acl => + acl.groupId === consts.EVERYONE_ID || + acl.userId === user._id || + acl.userEmail === user.email + ) + // and return first one that has write + .find(a => a.write === true); + clone.canWrite = Boolean(writeAcl); + + // A record in ACL allows read permissions + const canRead = query.acl.find( + acl => + acl.groupId === consts.EVERYONE_ID || + acl.userId === user._id || + acl.userEmail === user.email + ); + clone.canRead = Boolean(canRead); + } + + return clone; +} + +module.exports = decorateQueryUserAccess; diff --git a/server/migrations/04-00110-query-acl-data.js b/server/migrations/04-00110-query-acl-data.js index cdcd5fde8..325a9d202 100644 --- a/server/migrations/04-00110-query-acl-data.js +++ b/server/migrations/04-00110-query-acl-data.js @@ -1,5 +1,3 @@ -const consts = require('../lib/consts'); - /** * @param {import('sequelize').QueryInterface} queryInterface * @param {import('../lib/config')} config @@ -14,7 +12,7 @@ async function up(queryInterface, config, appLog, nedb) { const records = queries.map(query => { return { query_id: query._id, - user_id: consts.EVERYONE_ID, + user_id: '__EVERYONE__', // value in consts.EVERYONE_ID at time of migration write: true, created_at: new Date(), updated_at: new Date() diff --git a/server/migrations/04-00120-query-acl-remove-old-constraint.js b/server/migrations/04-00120-query-acl-remove-old-constraint.js new file mode 100644 index 000000000..e29702d59 --- /dev/null +++ b/server/migrations/04-00120-query-acl-remove-old-constraint.js @@ -0,0 +1,18 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + // Remove unique constraint on query_id_user_id (it'll be added again switched around later) + await queryInterface.removeConstraint( + 'query_acl', + 'query_acl_query_id_user_id_key' + ); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00121-query-acl-add-user-email.js b/server/migrations/04-00121-query-acl-add-user-email.js new file mode 100644 index 000000000..634aa6ebf --- /dev/null +++ b/server/migrations/04-00121-query-acl-add-user-email.js @@ -0,0 +1,18 @@ +const Sequelize = require('sequelize'); + +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + await queryInterface.addColumn('query_acl', 'user_email', { + type: Sequelize.STRING + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00122-query-acl-add-group-id.js b/server/migrations/04-00122-query-acl-add-group-id.js new file mode 100644 index 000000000..c1e5c9546 --- /dev/null +++ b/server/migrations/04-00122-query-acl-add-group-id.js @@ -0,0 +1,18 @@ +const Sequelize = require('sequelize'); + +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + await queryInterface.addColumn('query_acl', 'group_id', { + type: Sequelize.STRING + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00123-query-acl-remove-user-id-not-null.js b/server/migrations/04-00123-query-acl-remove-user-id-not-null.js new file mode 100644 index 000000000..486970176 --- /dev/null +++ b/server/migrations/04-00123-query-acl-remove-user-id-not-null.js @@ -0,0 +1,20 @@ +const Sequelize = require('sequelize'); + +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + // remove not-null constraint for user_id + await queryInterface.changeColumn('query_acl', 'user_id', { + type: Sequelize.STRING, + allowNull: true + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00124-query-acl-add-constraint-user-email-query.js b/server/migrations/04-00124-query-acl-add-constraint-user-email-query.js new file mode 100644 index 000000000..9e536cb15 --- /dev/null +++ b/server/migrations/04-00124-query-acl-add-constraint-user-email-query.js @@ -0,0 +1,18 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + // Add unique constraint for (user_email, query_id) and (group_id, query_id) + await queryInterface.addConstraint('query_acl', ['user_email', 'query_id'], { + type: 'unique', + name: 'query_acl_user_email_query_id_key' + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00125-query-acl-add-constraint-group-query.js b/server/migrations/04-00125-query-acl-add-constraint-group-query.js new file mode 100644 index 000000000..13ce75f1c --- /dev/null +++ b/server/migrations/04-00125-query-acl-add-constraint-group-query.js @@ -0,0 +1,17 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + await queryInterface.addConstraint('query_acl', ['group_id', 'query_id'], { + type: 'unique', + name: 'query_acl_group_id_query_id_key' + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00126-query-acl-add-constraint-user-id-query.js b/server/migrations/04-00126-query-acl-add-constraint-user-id-query.js new file mode 100644 index 000000000..ad88cf525 --- /dev/null +++ b/server/migrations/04-00126-query-acl-add-constraint-user-id-query.js @@ -0,0 +1,18 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + // Swap unique constraint around for (query_id, user_id) for index strategy, then add query_id index + await queryInterface.addConstraint('query_acl', ['user_id', 'query_id'], { + type: 'unique', + name: 'query_acl_user_id_query_id_key' + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00127-query-acl-add-index-query-id.js b/server/migrations/04-00127-query-acl-add-index-query-id.js new file mode 100644 index 000000000..017e6fc68 --- /dev/null +++ b/server/migrations/04-00127-query-acl-add-index-query-id.js @@ -0,0 +1,17 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + await queryInterface.addIndex('query_acl', { + fields: ['query_id'], + name: 'query_acl_query_id' + }); +} + +module.exports = { + up +}; diff --git a/server/migrations/04-00128-query-acl-move-everyone-id-to-group.js b/server/migrations/04-00128-query-acl-move-everyone-id-to-group.js new file mode 100644 index 000000000..a5f9e7f02 --- /dev/null +++ b/server/migrations/04-00128-query-acl-move-everyone-id-to-group.js @@ -0,0 +1,24 @@ +/** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('../lib/config')} config + * @param {import('../lib/logger')} appLog + * @param {object} nedb - collection of nedb objects created in /lib/db.js + */ +// eslint-disable-next-line no-unused-vars +async function up(queryInterface, config, appLog, nedb) { + // For any acl entries created in 04-00110, move the "__EVERYONE__" value to groupId + await queryInterface.bulkUpdate( + 'query_acl', + { + user_id: null, + group_id: '__EVERYONE__' // value in consts.EVERYONE_ID at time of migration + }, + { + user_id: '__EVERYONE__' // value in consts.EVERYONE_ID at time of migration + } + ); +} + +module.exports = { + up +}; diff --git a/server/models/index.js b/server/models/index.js index 8cd49ebbd..70b4f524e 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -7,6 +7,7 @@ const Queries = require('./queries'); const Connections = require('./connections'); const ConnectionAccesses = require('./connectionAccesses'); const QueryAcl = require('./queryAcl'); +const decorateQueryUserAccess = require('../lib/decorateQueryUserAccess'); class Models { constructor(nedb, sequelizeDb, config) { @@ -26,65 +27,38 @@ class Models { * If user is an admin, get all queries * If user is NOT an admin, get queries created by user or that are shared * - * This needs to merge queries, acl, and user data - * Fetching all query/user/acl data is not ideal, but is probably okay for now + * This needs to merge queries and acl + * Fetching both query and acl data is not ideal, but is probably okay for now * This will become problematic for large SQLPad environments * Eventually this can be a better SQL query once all data is moved to SQLite - * @param {string} userId + * @param {object} user */ - async findQueriesForUser(userId) { + async findQueriesForUser(user) { const queries = await this.queries.findAll(); - const user = await this.users.findOneById(userId); - - // If user is an admin return all queries and avoid extra work - if (user.role === 'admin') { - return queries; - } - - const queryAcls = await this.queryAcl.findAllByUserId(userId); + const queryAcls = await this.queryAcl.findAll(); const queryAclsByQueryId = _.groupBy(queryAcls, 'queryId'); - const usersQueries = queries.map(query => { - const acl = queryAclsByQueryId[query._id] || []; - - // If user is the owner return it - if (query.createdBy === user.email) { - return query; - } - - // If user has access via acl return it - if (acl.length > 0) { - return query; - } - - // Otherwise user does not have access - return null; - }); - - // The map() may have returned nulls, - // which represent queries the user does not have access to - return usersQueries.filter(query => Boolean(query)); + return ( + queries + // Join in query ACL info needed for decorateQueryUserAccess + .map(query => { + query.acl = queryAclsByQueryId[query._id] || []; + return query; + }) + // Decorate query with canRead/canWrite/canDelete + .map(query => decorateQueryUserAccess(query, user)) + // Only include queries user can read + .filter(query => query.canRead) + ); } /** - * Finds query + * Finds query and adds query.acl property * @param {string} id - query id */ async findQueryById(id) { const query = await this.queries.findOneById(id); - let queryAcls = await this.queryAcl.findAllByQueryId(id); - - // queryAcl has userId, not email address - // We need to get all user object and index for efficient lookups - const users = await this.users.findAll(); - const usersById = _.keyBy(users, '_id'); - - query.acl = queryAcls.map(queryAcl => { - // TODO everyone const isn't real user and won't show up here - queryAcl.user = usersById[queryAcls.userId]; - return queryAcl; - }); - + query.acl = await this.queryAcl.findAllByQueryId(id); return query; } @@ -108,6 +82,8 @@ class Models { return { queryId, userId: row.userId, + userEmail: row.userEmail, + groupId: row.groupId, write: row.write }; }); diff --git a/server/models/queryAcl.js b/server/models/queryAcl.js index b623a857a..c1b027a7d 100644 --- a/server/models/queryAcl.js +++ b/server/models/queryAcl.js @@ -1,4 +1,5 @@ const consts = require('../lib/consts'); +const { Op } = require('sequelize'); // NOTE - because QueryAcl is driven off of Sequelize ORM model // and not nedb (which is schemaless) I am skipping defining a Joi schema here. @@ -16,9 +17,19 @@ class QueryAcl { this.config = config; } - findAllByUserId(userId) { + findAll() { + return this.sequelizeDb.QueryAcl.findAll(); + } + + findAllByUser(user) { return this.sequelizeDb.QueryAcl.findAll({ - where: { userId: [userId, consts.EVERYONE_ID] } + where: { + [Op.or]: [ + { userId: user._id }, + { userEmail: user.email }, + { groupId: consts.EVERYONE_ID } + ] + } }); } @@ -32,12 +43,6 @@ class QueryAcl { return this.sequelizeDb.QueryAcl.findOne({ where: { id } }); } - findOneByQueryIdUserId(queryId, userId) { - return this.sequelizeDb.QueryAcl.findOne({ - where: { queryId, userId: [userId, consts.EVERYONE_ID] } - }); - } - removeByQueryId(queryId) { return this.sequelizeDb.QueryAcl.destroy({ where: { queryId } }); } diff --git a/server/routes/queries.js b/server/routes/queries.js index 30e860e48..498e0162a 100644 --- a/server/routes/queries.js +++ b/server/routes/queries.js @@ -4,7 +4,7 @@ const mustBeAuthenticated = require('../middleware/must-be-authenticated.js'); const mustBeAuthenticatedOrChartLink = require('../middleware/must-be-authenticated-or-chart-link-noauth.js'); const sendError = require('../lib/sendError'); const pushQueryToSlack = require('../lib/pushQueryToSlack'); -const consts = require('../lib/consts'); +const decorateQueryUserAccess = require('../lib/decorateQueryUserAccess'); // NOTE: this non-api route is special since it redirects legacy urls router.get('/queries/:_id', mustBeAuthenticatedOrChartLink, function( @@ -35,7 +35,9 @@ async function deleteQuery(req, res) { return sendError(res, null, 'Query not found'); } - if (user.role === 'admin' || user.email === query.createdBy) { + const decorated = decorateQueryUserAccess(query, user); + + if (decorated.canDelete) { await models.queries.removeById(params._id); await models.queryAcl.removeByQueryId(params._id); return res.json({}); @@ -57,8 +59,10 @@ router.delete('/api/queries/:_id', mustBeAuthenticated, deleteQuery); async function listQueries(req, res) { const { models, user } = req; try { - const queries = await models.findQueriesForUser(user._id); - return res.json({ queries }); + const queries = await models.findQueriesForUser(user); + return res.json({ + queries: queries.map(query => decorateQueryUserAccess(query, user)) + }); } catch (error) { sendError(res, error, 'Problem querying query database'); } @@ -83,18 +87,9 @@ async function getQuery(req, res) { }); } - // If user is admin or creator return it - if (user.role === 'admin' || query.createdBy === user.email) { - return res.json({ query }); - } - - // Otherwise user needs permission via ACL - const foundAccess = query.acl.find( - acl => acl.userId === consts.EVERYONE_ID || acl.userId === user._id - ); - - if (foundAccess) { - return res.json({ query }); + const decorated = decorateQueryUserAccess(query, user); + if (decorated.canRead) { + return res.json({ query: decorated }); } // TODO send 403 forbidden @@ -133,7 +128,7 @@ async function createQuery(req, res) { pushQueryToSlack(req.config, newQuery); return res.json({ - query: newQuery + query: decorateQueryUserAccess(newQuery, user) }); } catch (error) { sendError(res, error, 'Problem saving query'); @@ -147,24 +142,16 @@ router.post('/api/queries', mustBeAuthenticated, createQuery); * @param {*} res */ async function updateQuery(req, res) { - const { models, params, user } = req; + const { models, params, user, body } = req; try { - const query = await models.queries.findOneById(params._id); + const query = await models.findQueryById(params._id); if (!query) { return sendError(res, null, 'Query not found'); } - // Check to see if user has permission to do this - const queryUserAcl = await models.queryAcl.findOneByQueryIdUserId( - params._id, - user._id - ); - const hasAclWrite = queryUserAcl && queryUserAcl.write; - const isCreator = query.createdBy === user.email; - const isAdmin = user.role === 'admin'; - const hasPermission = hasAclWrite || isCreator || isAdmin; - - if (!hasPermission) { + const decorated = decorateQueryUserAccess(query, user); + + if (!decorated.canWrite) { // TODO send 403 forbidden return sendError(res, null, 'Access to query not permitted'); } @@ -176,8 +163,7 @@ async function updateQuery(req, res) { queryText, chartConfiguration, acl - } = req.body; - const { email } = req.user; + } = body; Object.assign(query, { name, @@ -185,13 +171,13 @@ async function updateQuery(req, res) { connectionId, queryText, chartConfiguration, - modifiedBy: email, + modifiedBy: user.email, acl }); const updatedQuery = await models.upsertQuery(query); - return res.json({ query: updatedQuery }); + return res.json({ query: decorateQueryUserAccess(updatedQuery, user) }); } catch (error) { sendError(res, error, 'Problem saving query'); } diff --git a/server/sequelizeDb/QueryAcl.js b/server/sequelizeDb/QueryAcl.js index b89828343..737c90bc4 100644 --- a/server/sequelizeDb/QueryAcl.js +++ b/server/sequelizeDb/QueryAcl.js @@ -1,7 +1,7 @@ const { DataTypes } = require('sequelize'); module.exports = function(sequelize) { - // An entry in this table gives access to a query for a user or user id constant + // An entry in this table gives access to a query for a user_id, user_email, or group_id const QueryAcl = sequelize.define( 'QueryAcl', { @@ -13,14 +13,25 @@ module.exports = function(sequelize) { // For historical reasons, queryId can be any string queryId: { type: DataTypes.STRING, - allowNull: false, - unique: 'query_acl_query_id_user_id_key' + allowNull: false }, // For historical reasons, userId can be any string userId: { type: DataTypes.STRING, - allowNull: false, - unique: 'query_acl_query_id_user_id_key' + allowNull: true + }, + // Email address can also be specified if userId is not known + userEmail: { + type: DataTypes.STRING, + allowNull: true + }, + // The "Group" data model does not exist yet today but some day maybe will + // It is intended to be a generic grouping mechanism + // For now it'll contain special group values like "__EVERYONE__" found in consts.EVERYONE_ID + // This prevents putting non-user-id values in userId column. + groupId: { + type: DataTypes.STRING, + allowNull: true }, write: { type: DataTypes.BOOLEAN, diff --git a/server/test/api/queries.js b/server/test/api/queries.js index 5fb3f92a9..d3981c21a 100644 --- a/server/test/api/queries.js +++ b/server/test/api/queries.js @@ -55,10 +55,18 @@ describe('api/queries', function() { assert.equal(body.query.name, 'test query2'); }); + it('Requires authentication', function() { + return utils.get(null, `/api/queries/${query._id}`, 302); + }); + it('Owner can get own query', async function() { const body = await utils.get('editor', `/api/queries/${query._id}`); assert(!body.error, 'no error'); assert.equal(body.query.name, 'test query2'); + // can represents what user that made API call can do + assert.strictEqual(body.query.canRead, true); + assert.strictEqual(body.query.canWrite, true); + assert.strictEqual(body.query.canDelete, true); }); it('Editor2 cannot get query without permission', async function() { @@ -66,55 +74,171 @@ describe('api/queries', function() { assert(body.error); }); - it('Editor2 can get query with specific userId permission', async function() { + it('Permissions for other users are not used', async function() { const body1 = await utils.put('editor', `/api/queries/${query._id}`, { ...createQueryBody, name: 'test query2', - acl: [{ userId: utils.users.editor2._id, write: false }] + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true } + ] }); assert(!body1.error); const body2 = await utils.get('editor2', `/api/queries/${query._id}`); - assert(!body2.error); + assert(body2.error); + + // Add read access for editor 2 + const body3 = await utils.put('editor', `/api/queries/${query._id}`, { + ...createQueryBody, + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { userId: utils.users.editor2._id, write: false } + ] + }); + assert(!body3.error); + + // Editor 2 can read at this point + const body4 = await utils.get('editor2', `/api/queries/${query._id}`); + assert(!body4.error); + + // But not write + const body5 = await utils.put('editor2', `/api/queries/${query._id}`, { + ...createQueryBody, + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { groupId: '__EVERYONE__', write: false }, + { userId: utils.users.editor2._id, write: false } + ] + }); + assert(body5.error); + + // Add write access for editor 2 + const body6 = await utils.put('editor', `/api/queries/${query._id}`, { + ...createQueryBody, + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { groupId: '__EVERYONE__', write: false }, + { userId: utils.users.editor2._id, write: true } + ] + }); + assert(!body6.error); }); - it('Editor2 can only view without write permission', async function() { + it('honors max matching permission only', async function() { const body1 = await utils.put('editor', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: utils.users.editor2._id, write: false }] + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { groupId: '__EVERYONE__', write: false }, + { userId: utils.users.editor2._id, write: false } + ] }); assert(!body1.error); - const body2 = await utils.get('editor2', `/api/queries/${query._id}`); - assert(!body2.error); + const body2 = await utils.put( + 'editor2', + `/api/queries/${query._id}`, + createQueryBody + ); + assert(body2.error); - const body3 = await utils.put('editor2', `/api/queries/${query._id}`, { + const body3 = await utils.put('editor', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: utils.users.editor2._id, write: false }] + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { groupId: '__EVERYONE__', write: true }, + { userId: utils.users.editor2._id, write: false } + ] }); - assert(body3.error); + assert(!body3.error); + + const body4 = await utils.put( + 'editor2', + `/api/queries/${query._id}`, + createQueryBody + ); + assert(!body4.error); }); - it('Editor2 can update with write permission', async function() { + it('ACL userId permissions work as expected', async function() { const body1 = await utils.put('editor', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: utils.users.editor2._id, write: true }] + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { userId: utils.users.editor2._id, write: false } + ] }); assert(!body1.error); const body2 = await utils.get('editor2', `/api/queries/${query._id}`); - assert(body2.query); + assert(!body2.error); const body3 = await utils.put('editor2', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: utils.users.editor2._id, write: true }] + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { groupId: '__EVERYONE__', write: false }, + { userId: utils.users.editor2._id, write: false } + ] }); - assert(!body3.error); + assert(body3.error); + + // Now use editor to give access, editor 2 should be update to update + const body4 = await utils.put('editor', `/api/queries/${query._id}`, { + ...createQueryBody, + name: 'test query2', + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { userId: utils.users.editor2._id, write: true } + ] + }); + assert(!body4.error); + + const body5 = await utils.put('editor2', `/api/queries/${query._id}`, { + ...createQueryBody, + acl: [ + { userId: 'fakeUser', write: true }, + { userEmail: 'fakeEmail', write: true }, + { groupId: 'fakeGroup', write: true }, + { userId: utils.users.editor2._id, write: false } + ] + }); + assert(!body5.error); + + const delBody = await utils.del('editor2', `/api/queries/${query._id}`); + assert(delBody.error); }); it('Admin is exempt from query ACL', async function() { const body1 = await utils.get('admin', `/api/queries/${query._id}`); assert(body1.query); + // can represents what user that made API call can do + assert.strictEqual(body1.query.canRead, true); + assert.strictEqual(body1.query.canWrite, true); + assert.strictEqual(body1.query.canDelete, true); const body2 = await utils.get('admin', '/api/queries'); assert.equal(body2.queries.length, 1); @@ -126,25 +250,54 @@ describe('api/queries', function() { assert(!body3.error); }); - it('Special __EVERYONE__ gives access like expected', async function() { + it('ACL userEmail gives access like expected', async function() { const body1 = await utils.put('editor', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: consts.EVERYONE_ID, write: true }] + acl: [{ userEmail: 'editor2@test.com', write: true }] }); assert(!body1.error); + // can represents what user that made API call can do + assert.strictEqual(body1.query.canRead, true); + assert.strictEqual(body1.query.canWrite, true); + assert.strictEqual(body1.query.canDelete, true); const body2 = await utils.get('editor2', `/api/queries/${query._id}`); assert(body2.query); + assert.strictEqual(body2.query.canRead, true); + assert.strictEqual(body2.query.canWrite, true); + assert.strictEqual(body2.query.canDelete, false); const body3 = await utils.put('editor2', `/api/queries/${query._id}`, { ...createQueryBody, - acl: [{ userId: consts.EVERYONE_ID, write: true }] + acl: [{ userEmail: 'editor2@test.com', write: false }] }); assert(!body3.error); + assert.strictEqual(body3.query.canRead, true); + assert.strictEqual(body3.query.canWrite, false); + assert.strictEqual(body3.query.canDelete, false); }); - it('Requires authentication', function() { - return utils.get(null, `/api/queries/${query._id}`, 302); + it('ACL groupId __EVERYONE__ gives access like expected', async function() { + const body1 = await utils.put('editor', `/api/queries/${query._id}`, { + ...createQueryBody, + acl: [{ groupId: consts.EVERYONE_ID, write: true }] + }); + assert(!body1.error); + + const body2 = await utils.get('editor2', `/api/queries/${query._id}`); + assert(body2.query); + assert.strictEqual(body2.query.canRead, true); + assert.strictEqual(body2.query.canWrite, true); + assert.strictEqual(body2.query.canDelete, false); + + const body3 = await utils.put('editor2', `/api/queries/${query._id}`, { + ...createQueryBody, + acl: [{ groupId: consts.EVERYONE_ID, write: false }] + }); + assert(!body3.error); + assert.strictEqual(body3.query.canRead, true); + assert.strictEqual(body3.query.canWrite, false); + assert.strictEqual(body3.query.canDelete, false); }); it('Owner can delete query', async function() { diff --git a/server/test/fixtures/seedData/queries/seed-query-1.json b/server/test/fixtures/seedData/queries/seed-query-1.json index 731fb557c..e393b226b 100644 --- a/server/test/fixtures/seedData/queries/seed-query-1.json +++ b/server/test/fixtures/seedData/queries/seed-query-1.json @@ -6,7 +6,15 @@ "createdBy": "admin@sqlpad.com", "acl": [ { - "userId": "__EVERYONE__", + "userId": "some-userId-in-sqlpad", + "write": false + }, + { + "userEmail": "someone@sqlpad.com", + "write": true + }, + { + "groupId": "__EVERYONE__", "write": true } ] diff --git a/server/test/lib/seedDataPath.js b/server/test/lib/seedDataPath.js index 663de4158..665f55524 100644 --- a/server/test/lib/seedDataPath.js +++ b/server/test/lib/seedDataPath.js @@ -10,6 +10,10 @@ describe('seedDataPath', function() { const queries = await utils.models.queries.findAll(); assert.strictEqual(queries.length, 2); assert(queries.find(q => q._id === 'seed-query-1')); + const queryAcls = await utils.models.queryAcl.findAllByQueryId( + 'seed-query-1' + ); + assert.equal(queryAcls.length, 3); const connections = await utils.models.connections.findAll(); assert.equal(connections.length, 1); assert.strictEqual(connections[0]._id, 'seed-connection-1'); diff --git a/server/test/sequelize/QueryAcl.js b/server/test/sequelize/QueryAcl.js index 87c0a68db..f98364764 100644 --- a/server/test/sequelize/QueryAcl.js +++ b/server/test/sequelize/QueryAcl.js @@ -2,25 +2,47 @@ const assert = require('assert'); const TestUtils = require('../utils'); describe('QueryAcl', function() { - const utils = new TestUtils(); + it('write expected results', async function() { + const utils = new TestUtils(); + await utils.init(); - before(function() { - return utils.init(); - }); - - it('write defaults to false', async function() { const sdb = utils.sequelizeDb; - const queryAcl = await sdb.QueryAcl.create({ - queryId: 'foo', - userId: 'bar' + const queryId = 'query1'; + const qa1 = await sdb.QueryAcl.create({ + queryId, + userId: 'test' + }); + assert(qa1.id); + assert.strictEqual(qa1.queryId, queryId); + assert.strictEqual(qa1.userId, 'test'); + assert.strictEqual(qa1.write, false); + + const qa2 = await sdb.QueryAcl.create({ + queryId, + userEmail: 'test@sqlpad.com' }); - assert(queryAcl.id); - assert.strictEqual(queryAcl.queryId, 'foo'); - assert.strictEqual(queryAcl.userId, 'bar'); - assert.strictEqual(queryAcl.write, false); + assert.strictEqual(qa2.queryId, queryId); + assert(!qa2.userId); + assert.strictEqual(qa2.userEmail, 'test@sqlpad.com'); + assert(!qa2.groupId); + assert.strictEqual(qa2.write, false); + + const qa3 = await sdb.QueryAcl.create({ + queryId, + groupId: 'group', + write: true + }); + assert.strictEqual(qa3.queryId, queryId); + assert(!qa3.userId); + assert(!qa3.userEmail); + assert.strictEqual(qa3.groupId, 'group'); + assert.strictEqual(qa3.write, true); }); - it('honors unique constraint on queryId & userId', async function() { + it('honors unique constraints', async function() { + const utils = new TestUtils(); + await utils.init(); + await assert.rejects(async () => { await utils.models.queryAcl.create({ queryId: 'q1', @@ -31,5 +53,27 @@ describe('QueryAcl', function() { userId: 'u1' }); }); + + await assert.rejects(async () => { + await utils.models.queryAcl.create({ + queryId: 'q1', + userEmail: 'e1' + }); + await utils.models.queryAcl.create({ + queryId: 'q1', + userEmail: 'e1' + }); + }); + + await assert.rejects(async () => { + await utils.models.queryAcl.create({ + queryId: 'q1', + groupId: 'group1' + }); + await utils.models.queryAcl.create({ + queryId: 'q1', + groupId: 'group1' + }); + }); }); });