From 30924a692258f1ef1b42318943df18345b32a478 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Thu, 5 Jul 2018 08:27:25 +1000 Subject: [PATCH] Added user permissions, delete user --- src/backend/internal/user.js | 76 +++++++++- src/backend/lib/access/users-permissions.json | 7 + .../migrations/20180618015850_initial.js | 118 +++++++++++++++ src/backend/models/user.js | 21 ++- src/backend/models/user_permission.js | 30 ++++ src/backend/routes/api/users.js | 16 +- src/backend/schema/endpoints/users.json | 43 ++++++ src/backend/setup.js | 28 +++- src/frontend/js/app/api.js | 10 ++ src/frontend/js/app/controller.js | 26 ++++ src/frontend/js/app/main.js | 2 +- src/frontend/js/app/user/delete.ejs | 19 +++ src/frontend/js/app/user/delete.js | 38 +++++ src/frontend/js/app/user/form.ejs | 32 ++-- src/frontend/js/app/user/form.js | 7 +- src/frontend/js/app/user/permissions.ejs | 140 ++++++++++++++++++ src/frontend/js/app/user/permissions.js | 99 +++++++++++++ src/frontend/js/app/users/list/item.ejs | 5 +- src/frontend/js/app/users/list/item.js | 16 +- src/frontend/js/app/users/main.js | 2 +- src/frontend/js/models/user.js | 3 +- webpack.config.js | 3 +- 22 files changed, 690 insertions(+), 51 deletions(-) create mode 100644 src/backend/lib/access/users-permissions.json create mode 100644 src/backend/models/user_permission.js create mode 100644 src/frontend/js/app/user/delete.ejs create mode 100644 src/frontend/js/app/user/delete.js create mode 100644 src/frontend/js/app/user/permissions.ejs create mode 100644 src/frontend/js/app/user/permissions.js diff --git a/src/backend/internal/user.js b/src/backend/internal/user.js index 9d6abb8..7a487b6 100644 --- a/src/backend/internal/user.js +++ b/src/backend/internal/user.js @@ -1,11 +1,12 @@ 'use strict'; -const _ = require('lodash'); -const error = require('../lib/error'); -const userModel = require('../models/user'); -const authModel = require('../models/auth'); -const gravatar = require('gravatar'); -const internalToken = require('./token'); +const _ = require('lodash'); +const error = require('../lib/error'); +const userModel = require('../models/user'); +const userPermissionModel = require('../models/user_permission'); +const authModel = require('../models/auth'); +const gravatar = require('gravatar'); +const internalToken = require('./token'); function omissions () { return ['is_deleted']; @@ -56,7 +57,23 @@ const internalUser = { } }) .then(user => { - return internalUser.get(access, {id: user.id}); + // Create permissions row as well + let is_admin = data.roles.indexOf('admin') !== -1; + + return userPermissionModel + .query() + .insert({ + user_id: user.id, + visibility: is_admin ? 'all' : 'user', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + streams: 'manage', + access_lists: 'manage' + }) + .then(() => { + return internalUser.get(access, {id: user.id, expand: ['permissions']}); + }); }); }, @@ -145,6 +162,7 @@ const internalUser = { .query() .where('is_deleted', 0) .andWhere('id', data.id) + .allowEager('[permissions]') .first(); // Custom omissions @@ -377,6 +395,50 @@ const internalUser = { }); }, + /** + * @param {Access} access + * @param {Object} data + * @return {Promise} + */ + setPermissions: (access, data) => { + return access.can('users:permissions', data.id) + .then(() => { + return internalUser.get(access, {id: data.id}); + }) + .then(user => { + if (user.id !== data.id) { + // Sanity check that something crazy hasn't happened + throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id); + } + + return user; + }) + .then(user => { + // Get perms row, patch if it exists + return userPermissionModel + .query() + .where('user_id', user.id) + .first() + .then(existing_auth => { + if (existing_auth) { + // patch + return userPermissionModel + .query() + .where('user_id', user.id) + .patchAndFetchById(existing_auth.id, _.assign({user_id: user.id}, data)); + } else { + // insert + return userPermissionModel + .query() + .insertAndFetch(_.assign({user_id: user.id}, data)); + } + }) + .then(permissions => { + return true; + }); + }); + }, + /** * @param {Access} access * @param {Object} data diff --git a/src/backend/lib/access/users-permissions.json b/src/backend/lib/access/users-permissions.json new file mode 100644 index 0000000..d2709fd --- /dev/null +++ b/src/backend/lib/access/users-permissions.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/migrations/20180618015850_initial.js b/src/backend/migrations/20180618015850_initial.js index 9ecb22c..03ee069 100644 --- a/src/backend/migrations/20180618015850_initial.js +++ b/src/backend/migrations/20180618015850_initial.js @@ -43,6 +43,124 @@ exports.up = function (knex/*, Promise*/) { }) .then(() => { logger.info('[' + migrate_name + '] user Table created'); + + return knex.schema.createTable('user_permission', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('user_id').notNull().unsigned(); + table.string('visibility').notNull(); + table.string('proxy_hosts').notNull(); + table.string('redirection_hosts').notNull(); + table.string('dead_hosts').notNull(); + table.string('streams').notNull(); + table.string('access_lists').notNull(); + table.unique('user_id'); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] user_permission Table created'); + + return knex.schema.createTable('proxy_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('domain_name').notNull(); + table.string('forward_ip').notNull(); + table.integer('forward_port').notNull().unsigned(); + table.integer('access_list_id').notNull().unsigned().defaultTo(0); + table.integer('ssl_enabled').notNull().unsigned().defaultTo(0); + table.string('ssl_provider').notNull().defaultTo(''); + table.integer('ssl_forced').notNull().unsigned().defaultTo(0); + table.integer('caching_enabled').notNull().unsigned().defaultTo(0); + table.integer('block_exploits').notNull().unsigned().defaultTo(0); + table.json('meta').notNull(); + table.unique(['domain_name', 'is_deleted']); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] proxy_host Table created'); + + return knex.schema.createTable('redirection_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('domain_name').notNull(); + table.string('forward_domain_name').notNull(); + table.integer('preserve_path').notNull().unsigned().defaultTo(0); + table.integer('ssl_enabled').notNull().unsigned().defaultTo(0); + table.string('ssl_provider').notNull().defaultTo(''); + table.integer('block_exploits').notNull().unsigned().defaultTo(0); + table.json('meta').notNull(); + table.unique(['domain_name', 'is_deleted']); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] redirection_host Table created'); + + return knex.schema.createTable('dead_host', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('domain_name').notNull(); + table.integer('ssl_enabled').notNull().unsigned().defaultTo(0); + table.string('ssl_provider').notNull().defaultTo(''); + table.json('meta').notNull(); + table.unique(['domain_name', 'is_deleted']); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] dead_host Table created'); + + return knex.schema.createTable('stream', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.integer('incoming_port').notNull().unsigned(); + table.string('forward_ip').notNull(); + table.integer('forwarding_port').notNull().unsigned(); + table.integer('tcp_forwarding').notNull().unsigned().defaultTo(0); + table.integer('udp_forwarding').notNull().unsigned().defaultTo(0); + table.json('meta').notNull(); + table.unique(['incoming_port', 'is_deleted']); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] stream Table created'); + + return knex.schema.createTable('access_list', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('owner_user_id').notNull().unsigned(); + table.integer('is_deleted').notNull().unsigned().defaultTo(0); + table.string('name').notNull(); + table.json('meta').notNull(); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] access_list Table created'); + + return knex.schema.createTable('access_list_auth', table => { + table.increments().primary(); + table.dateTime('created_on').notNull(); + table.dateTime('modified_on').notNull(); + table.integer('access_list_id').notNull().unsigned(); + table.string('username').notNull(); + table.string('password').notNull(); + table.json('meta').notNull(); + }); + }) + .then(() => { + logger.info('[' + migrate_name + '] access_list_auth Table created'); }); }; diff --git a/src/backend/models/user.js b/src/backend/models/user.js index b9a9a20..3a2ab10 100644 --- a/src/backend/models/user.js +++ b/src/backend/models/user.js @@ -3,8 +3,9 @@ 'use strict'; -const db = require('../db'); -const Model = require('objection').Model; +const db = require('../db'); +const Model = require('objection').Model; +const UserPermission = require('./user_permission'); Model.knex(db); @@ -30,6 +31,22 @@ class User extends Model { return ['roles']; } + static get relationMappings () { + return { + permissions: { + relation: Model.HasOneRelation, + modelClass: UserPermission, + join: { + from: 'user.id', + to: 'user_permission.user_id' + }, + modify: function (qb) { + qb.omit(['id', 'created_on', 'modified_on', 'user_id']); + } + } + }; + } + } module.exports = User; diff --git a/src/backend/models/user_permission.js b/src/backend/models/user_permission.js new file mode 100644 index 0000000..5848a9e --- /dev/null +++ b/src/backend/models/user_permission.js @@ -0,0 +1,30 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +'use strict'; + +const db = require('../db'); +const Model = require('objection').Model; + +Model.knex(db); + +class UserPermission extends Model { + $beforeInsert () { + this.created_on = Model.raw('NOW()'); + this.modified_on = Model.raw('NOW()'); + } + + $beforeUpdate () { + this.modified_on = Model.raw('NOW()'); + } + + static get name () { + return 'UserPermission'; + } + + static get tableName () { + return 'user_permission'; + } +} + +module.exports = UserPermission; diff --git a/src/backend/routes/api/users.js b/src/backend/routes/api/users.js index a42d512..070709c 100644 --- a/src/backend/routes/api/users.js +++ b/src/backend/routes/api/users.js @@ -183,12 +183,12 @@ router }); /** - * Specific user service settings + * Specific user permissions * - * /api/users/123/services + * /api/users/123/permissions */ router - .route('/:user_id/services') + .route('/:user_id/permissions') .options((req, res) => { res.sendStatus(204); }) @@ -196,18 +196,18 @@ router .all(userIdFromMe) /** - * POST /api/users/123/services + * PUT /api/users/123/permissions * - * Sets Service Settings for a user + * Set some or all permissions for a user */ - .post((req, res, next) => { + .put((req, res, next) => { apiValidator({$ref: 'endpoints/users#/links/5/schema'}, req.body) .then(payload => { payload.id = req.params.user_id; - return internalUser.setServiceSettings(res.locals.access, payload); + return internalUser.setPermissions(res.locals.access, payload); }) .then(result => { - res.status(200) + res.status(201) .send(result); }) .catch(next); diff --git a/src/backend/schema/endpoints/users.json b/src/backend/schema/endpoints/users.json index 3d82e63..1202713 100644 --- a/src/backend/schema/endpoints/users.json +++ b/src/backend/schema/endpoints/users.json @@ -206,6 +206,49 @@ "targetSchema": { "type": "boolean" } + }, + { + "title": "Set Permissions", + "description": "Sets Permissions for a User", + "href": "/users/{definitions.identity.example}/permissions", + "access": "private", + "method": "PUT", + "rel": "update", + "http_header": { + "$ref": "../examples.json#/definitions/auth_header" + }, + "schema": { + "type": "object", + "properties": { + "visibility": { + "type": "string", + "pattern": "^(all|user)$" + }, + "access_lists": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "dead_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "proxy_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "redirection_hosts": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + }, + "streams": { + "type": "string", + "pattern": "^(hidden|view|manage)$" + } + } + }, + "targetSchema": { + "type": "boolean" + } } ], "properties": { diff --git a/src/backend/setup.js b/src/backend/setup.js index b6c8f97..8a253c5 100644 --- a/src/backend/setup.js +++ b/src/backend/setup.js @@ -1,11 +1,12 @@ 'use strict'; -const fs = require('fs'); -const NodeRSA = require('node-rsa'); -const config = require('config'); -const logger = require('./logger').global; -const userModel = require('./models/user'); -const authModel = require('./models/auth'); +const fs = require('fs'); +const NodeRSA = require('node-rsa'); +const config = require('config'); +const logger = require('./logger').global; +const userModel = require('./models/user'); +const userPermissionModel = require('./models/user_permission'); +const authModel = require('./models/auth'); module.exports = function () { return new Promise((resolve, reject) => { @@ -54,7 +55,7 @@ module.exports = function () { .select(userModel.raw('COUNT(`id`) as `count`')) .where('is_deleted', 0) .first('count') - .then((row) => { + .then(row => { if (!row.count) { // Create a new user and set password logger.info('Creating a new user: admin@example.com with password: changeme'); @@ -79,6 +80,19 @@ module.exports = function () { type: 'password', secret: 'changeme', meta: {} + }) + .then(() => { + return userPermissionModel + .query() + .insert({ + user_id: user.id, + visibility: 'all', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + dead_hosts: 'manage', + streams: 'manage', + access_lists: 'manage' + }); }); }); } diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js index 1f23e16..b689136 100644 --- a/src/frontend/js/app/api.js +++ b/src/frontend/js/app/api.js @@ -224,6 +224,16 @@ module.exports = { */ loginAs: function (id) { return fetch('post', 'users/' + id + '/login'); + }, + + /** + * + * @param {Integer} id + * @param {Object} perms + * @returns {Promise} + */ + setPermissions: function (id, perms) { + return fetch('put', 'users/' + id + '/permissions', perms); } }, diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js index 7e02d9c..d7ea292 100644 --- a/src/frontend/js/app/controller.js +++ b/src/frontend/js/app/controller.js @@ -52,6 +52,19 @@ module.exports = { } }, + /** + * User Permissions Form + * + * @param model + */ + showUserPermissions: function (model) { + if (Cache.User.isAdmin()) { + require(['./main', './user/permissions'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + /** * User Password Form * @@ -65,6 +78,19 @@ module.exports = { } }, + /** + * User Delete Confirm + * + * @param model + */ + showUserDeleteConfirm: function (model) { + if (Cache.User.isAdmin() && model.get('id') !== Cache.User.get('id')) { + require(['./main', './user/delete'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } + }, + /** * Error * diff --git a/src/frontend/js/app/main.js b/src/frontend/js/app/main.js index 3fe2210..b3d80bb 100644 --- a/src/frontend/js/app/main.js +++ b/src/frontend/js/app/main.js @@ -110,7 +110,7 @@ const App = Mn.Application.extend({ * @returns {Promise} */ bootstrap: function () { - return Api.Users.getById('me') + return Api.Users.getById('me', ['permissions']) .then(response => { Cache.User.set(response); Tokens.setCurrentName(response.nickname || response.name); diff --git a/src/frontend/js/app/user/delete.ejs b/src/frontend/js/app/user/delete.ejs new file mode 100644 index 0000000..b743fa9 --- /dev/null +++ b/src/frontend/js/app/user/delete.ejs @@ -0,0 +1,19 @@ + diff --git a/src/frontend/js/app/user/delete.js b/src/frontend/js/app/user/delete.js new file mode 100644 index 0000000..a680611 --- /dev/null +++ b/src/frontend/js/app/user/delete.js @@ -0,0 +1,38 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./delete.ejs'); +const Controller = require('../controller'); +const Api = require('../api'); +const App = require('../main'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + Api.Users.delete(this.model.get('id')) + .then(() => { + Controller.showUsers(); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/src/frontend/js/app/user/form.ejs b/src/frontend/js/app/user/form.ejs index aa6c042..33828a3 100644 --- a/src/frontend/js/app/user/form.ejs +++ b/src/frontend/js/app/user/form.ejs @@ -25,25 +25,27 @@
- <% if (!isSelf()) { %>
+
Roles
+
+
-
Switches
-
- - -
+ +
+
+
+
+
- <% } %> diff --git a/src/frontend/js/app/user/form.js b/src/frontend/js/app/user/form.js index 5f0b11b..7adc871 100644 --- a/src/frontend/js/app/user/form.js +++ b/src/frontend/js/app/user/form.js @@ -58,7 +58,12 @@ module.exports = Mn.View.extend({ } view.model.set(result); - App.UI.closeModal(); + App.UI.closeModal(function () { + if (method === Api.Users.create) { + // Show permissions dialog immediately + Controller.showUserPermissions(view.model); + } + }); }) .catch(err => { this.ui.error.text(err.message).show(); diff --git a/src/frontend/js/app/user/permissions.ejs b/src/frontend/js/app/user/permissions.ejs new file mode 100644 index 0000000..043e71d --- /dev/null +++ b/src/frontend/js/app/user/permissions.ejs @@ -0,0 +1,140 @@ + diff --git a/src/frontend/js/app/user/permissions.js b/src/frontend/js/app/user/permissions.js new file mode 100644 index 0000000..267059d --- /dev/null +++ b/src/frontend/js/app/user/permissions.js @@ -0,0 +1,99 @@ +'use strict'; + +const Mn = require('backbone.marionette'); +const template = require('./permissions.ejs'); +const Controller = require('../controller'); +const Cache = require('../cache'); +const Api = require('../api'); +const App = require('../main'); +const UserModel = require('../../models/user'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + error: '.secret-error' + }, + + events: { + + 'click @ui.save': function (e) { + e.preventDefault(); + + let view = this; + let data = this.ui.form.serializeJSON(); + + // Manipulate + if (view.model.isAdmin()) { + // Force some attributes for admin + data = _.assign({}, data, { + access_lists: 'manage', + dead_hosts: 'manage', + proxy_hosts: 'manage', + redirection_hosts: 'manage', + streams: 'manage' + }); + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + + Api.Users.setPermissions(view.model.get('id'), data) + .then(() => { + if (view.model.get('id') === Cache.User.get('id')) { + Cache.User.set({permissions: data}); + } + + view.model.set({permissions: data}); + App.UI.closeModal(); + }) + .catch(err => { + this.ui.error.text(err.message).show(); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + }, + + templateContext: function () { + let perms = this.model.get('permissions'); + let is_admin = this.model.isAdmin(); + + return { + getPerm: function (key) { + if (perms !== null && typeof perms[key] !== 'undefined') { + return perms[key]; + } + + return null; + }, + + getPermProps: function (key, item, forced_admin) { + if (forced_admin && is_admin) { + return 'checked disabled'; + } else if (is_admin) { + return 'disabled'; + } else if (perms !== null && typeof perms[key] !== 'undefined' && perms[key] === item) { + return 'checked'; + } + + return ''; + }, + + isAdmin: function () { + return is_admin; + } + }; + }, + + initialize: function (options) { + if (typeof options.model === 'undefined' || !options.model) { + this.model = new UserModel.Model(); + } + } +}); diff --git a/src/frontend/js/app/users/list/item.ejs b/src/frontend/js/app/users/list/item.ejs index 2b1e3dd..bd4d19e 100644 --- a/src/frontend/js/app/users/list/item.ejs +++ b/src/frontend/js/app/users/list/item.ejs @@ -19,8 +19,9 @@