diff --git a/migrations/20210921022843-apps-add-operatorsJson.js b/migrations/20210921022843-apps-add-operatorsJson.js new file mode 100644 index 000000000..69c54e26d --- /dev/null +++ b/migrations/20210921022843-apps-add-operatorsJson.js @@ -0,0 +1,9 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN operatorsJson TEXT', callback); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN operatorsJson', callback); +}; diff --git a/src/apps.js b/src/apps.js index 6e639f51b..2578f4a95 100644 --- a/src/apps.js +++ b/src/apps.js @@ -2,6 +2,7 @@ exports = module.exports = { canAccess, + isOperator, removeInternalFields, removeRestrictedFields, @@ -23,6 +24,7 @@ exports = module.exports = { uninstall, setAccessRestriction, + setOperators, setLabel, setIcon, setTags, @@ -124,6 +126,7 @@ exports = module.exports = { _validatePortBindings: validatePortBindings, _validateAccessRestriction: validateAccessRestriction, _translatePortBindings: translatePortBindings, + _accessLevel: accessLevel, _clear: clear }; @@ -164,7 +167,7 @@ const appstore = require('./appstore.js'), const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState', 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuShares', - 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', + 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', 'apps.operatorsJson', 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.creationTime', 'apps.updateTime', 'apps.enableMailbox', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime', '(apps.icon IS NOT NULL) AS hasIcon', '(apps.appStoreIcon IS NOT NULL) AS hasAppStoreIcon' ].join(','); @@ -253,6 +256,7 @@ function translatePortBindings(portBindings, manifest) { return result; } +// also validates operators function validateAccessRestriction(accessRestriction) { assert.strictEqual(typeof accessRestriction, 'object'); @@ -435,8 +439,8 @@ function getDataDir(app, dataDir) { function removeInternalFields(app) { return _.pick(app, 'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', - 'location', 'domain', 'fqdn', 'mailboxName', 'mailboxDomain', - 'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', + 'location', 'domain', 'fqdn', 'mailboxName', 'mailboxDomain', 'accessLevel', + 'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'operators', 'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags', 'label', 'alternateDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate', 'dataDir', 'mounts', 'enableMailbox'); } @@ -445,7 +449,7 @@ function removeInternalFields(app) { function removeRestrictedFields(app) { return _.pick(app, 'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction', 'alternateDomains', 'aliasDomains', 'sso', - 'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup'); + 'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup', 'accessLevel'); } async function getIcon(app, options) { @@ -511,6 +515,10 @@ function postProcess(result) { if (result.accessRestriction && !result.accessRestriction.users) result.accessRestriction.users = []; delete result.accessRestrictionJson; + result.operators = safe.JSON.parse(result.operatorsJson); + if (result.operators && !result.operators.users) result.operators.users = []; + delete result.operatorsJson; + result.sso = !!result.sso; result.enableBackup = !!result.enableBackup; result.enableAutomaticUpdate = !!result.enableAutomaticUpdate; @@ -581,22 +589,42 @@ function attachProperties(app, domainObjectMap) { app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); } +function isAdmin(user) { + assert.strictEqual(typeof user, 'object'); + + return users.compareRoles(user.role, users.ROLE_ADMIN) >= 0; +} + +function isOperator(app, user) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof user, 'object'); + + if (!app.operators) return isAdmin(user); + + if (app.operators.users.some(function (e) { return e === user.id; })) return true; + if (!app.operators.groups) return isAdmin(user); + if (app.operators.groups.some(function (gid) { return Array.isArray(user.groupIds) && user.groupIds.indexOf(gid) !== -1; })) return true; + + return isAdmin(user); +} + function canAccess(app, user) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof user, 'object'); if (app.accessRestriction === null) return true; - // check user access if (app.accessRestriction.users.some(function (e) { return e === user.id; })) return true; - - if (users.compareRoles(user.role, users.ROLE_ADMIN) >= 0) return true; // admins can always access any app - - if (!app.accessRestriction.groups) return false; - + if (!app.accessRestriction.groups) return isOperator(app, user); if (app.accessRestriction.groups.some(function (gid) { return Array.isArray(user.groupIds) && user.groupIds.indexOf(gid) !== -1; })) return true; - return false; + return isOperator(app, user); +} + +function accessLevel(app, user) { + if (isAdmin(user)) return 'admin'; + if (isOperator(app, user)) return 'operator'; + return canAccess(app, user) ? 'user' : null; } async function add(id, appStoreId, manifest, location, domain, portBindings, data) { @@ -900,8 +928,16 @@ async function getByFqdn(fqdn) { async function listByUser(user) { assert.strictEqual(typeof user, 'object'); - const result = await list(); - return result.filter((app) => canAccess(app, user)); + const allApps = await list(); + const result = []; + for (const app of allApps) { + const al = accessLevel(app, user); + if (!al) continue; + app.accessLevel = al; + result.push(app); + } + + return result; } async function downloadManifest(appStoreId, manifest) { @@ -1172,6 +1208,19 @@ async function setAccessRestriction(app, accessRestriction, auditSource) { await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, accessRestriction }); } +async function setOperators(app, operators, auditSource) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof operators, 'object'); + assert.strictEqual(typeof auditSource, 'object'); + + const appId = app.id; + let error = validateAccessRestriction(operators); // not a typo. same structure for operators and accessRestriction + if (error) throw error; + + await update(appId, { operators }); + await eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, operators }); +} + async function setLabel(app, label, auditSource) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof label, 'string'); diff --git a/src/routes/accesscontrol.js b/src/routes/accesscontrol.js index 79fcd5565..6fa98addc 100644 --- a/src/routes/accesscontrol.js +++ b/src/routes/accesscontrol.js @@ -5,10 +5,12 @@ exports = module.exports = { tokenAuth, authorize, + authorizeOperator, websocketAuth }; const accesscontrol = require('../accesscontrol.js'), + apps = require('../apps.js'), assert = require('assert'), BoxError = require('../boxerror.js'), externalLdap = require('../externalldap.js'), @@ -105,3 +107,13 @@ async function websocketAuth(requiredRole, req, res, next) { next(); } + +async function authorizeOperator(req, res, next) { + assert.strictEqual(typeof req.params.id, 'string'); + assert.strictEqual(typeof req.user, 'object'); + assert.strictEqual(typeof req.app, 'object'); + + if (apps.isOperator(req.app, req.user)) return next(); + + return next(new HttpError(403, 'user is not an operator')); +} diff --git a/src/routes/apps.js b/src/routes/apps.js index b1a3f9403..dcab0954e 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -17,6 +17,7 @@ exports = module.exports = { repair, setAccessRestriction, + setOperators, setLabel, setTags, setIcon, @@ -44,7 +45,6 @@ exports = module.exports = { uploadFile, downloadFile, - load }; @@ -171,6 +171,18 @@ async function setAccessRestriction(req, res, next) { next(new HttpSuccess(200, {})); } +async function setOperators(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + assert.strictEqual(typeof req.app, 'object'); + + if (typeof req.body.operators !== 'object') return next(new HttpError(400, 'operators must be an object')); + + const [error] = await safe(apps.setOperators(req.app, req.body.operators, auditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} + async function setLabel(req, res, next) { assert.strictEqual(typeof req.body, 'object'); assert.strictEqual(typeof req.app, 'object'); diff --git a/src/server.js b/src/server.js index 41822cd7a..729ec0ffe 100644 --- a/src/server.js +++ b/src/server.js @@ -85,6 +85,7 @@ function initializeExpressSync() { const token = routes.accesscontrol.tokenAuth; const authorizeOwner = routes.accesscontrol.authorize(users.ROLE_OWNER); const authorizeAdmin = routes.accesscontrol.authorize(users.ROLE_ADMIN); + const authorizeOperator = routes.accesscontrol.authorizeOperator; const authorizeUserManager = routes.accesscontrol.authorize(users.ROLE_USER_MANAGER); // public routes @@ -197,44 +198,45 @@ function initializeExpressSync() { router.get ('/api/v1/appstore/apps/:appstoreId/versions/:versionId', token, authorizeAdmin, routes.appstore.getAppVersion); // app routes - router.post('/api/v1/apps/install', json, token, authorizeAdmin, routes.apps.install); - router.get ('/api/v1/apps', token, routes.apps.listByUser); - router.get ('/api/v1/apps/:id', token, authorizeAdmin, routes.apps.load, routes.apps.getApp); - router.get ('/api/v1/apps/:id/icon', token, routes.apps.load, routes.apps.getAppIcon); - router.post('/api/v1/apps/:id/uninstall', json, token, authorizeAdmin, routes.apps.load, routes.apps.uninstall); - router.post('/api/v1/apps/:id/configure/access_restriction', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAccessRestriction); - router.post('/api/v1/apps/:id/configure/label', json, token, authorizeAdmin, routes.apps.load, routes.apps.setLabel); - router.post('/api/v1/apps/:id/configure/tags', json, token, authorizeAdmin, routes.apps.load, routes.apps.setTags); - router.post('/api/v1/apps/:id/configure/icon', json, token, authorizeAdmin, routes.apps.load, routes.apps.setIcon); - router.post('/api/v1/apps/:id/configure/memory_limit', json, token, authorizeAdmin, routes.apps.load, routes.apps.setMemoryLimit); - router.post('/api/v1/apps/:id/configure/cpu_shares', json, token, authorizeAdmin, routes.apps.load, routes.apps.setCpuShares); - router.post('/api/v1/apps/:id/configure/automatic_backup', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticBackup); - router.post('/api/v1/apps/:id/configure/automatic_update', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAutomaticUpdate); - router.post('/api/v1/apps/:id/configure/reverse_proxy', json, token, authorizeAdmin, routes.apps.load, routes.apps.setReverseProxyConfig); - router.post('/api/v1/apps/:id/configure/cert', json, token, authorizeAdmin, routes.apps.load, routes.apps.setCertificate); - router.post('/api/v1/apps/:id/configure/debug_mode', json, token, authorizeAdmin, routes.apps.load, routes.apps.setDebugMode); - router.post('/api/v1/apps/:id/configure/mailbox', json, token, authorizeAdmin, routes.apps.load, routes.apps.setMailbox); - router.post('/api/v1/apps/:id/configure/env', json, token, authorizeAdmin, routes.apps.load, routes.apps.setEnvironment); - router.post('/api/v1/apps/:id/configure/data_dir', json, token, authorizeAdmin, routes.apps.load, routes.apps.setDataDir); - router.post('/api/v1/apps/:id/configure/location', json, token, authorizeAdmin, routes.apps.load, routes.apps.setLocation); - router.post('/api/v1/apps/:id/configure/mounts', json, token, authorizeAdmin, routes.apps.load, routes.apps.setMounts); - router.post('/api/v1/apps/:id/repair', json, token, authorizeAdmin, routes.apps.load, routes.apps.repair); - router.post('/api/v1/apps/:id/update', json, token, authorizeAdmin, routes.apps.load, routes.apps.update); - router.post('/api/v1/apps/:id/restore', json, token, authorizeAdmin, routes.apps.load, routes.apps.restore); - router.post('/api/v1/apps/:id/import', json, token, authorizeAdmin, routes.apps.load, routes.apps.importApp); - router.post('/api/v1/apps/:id/export', json, token, authorizeAdmin, routes.apps.load, routes.apps.exportApp); - router.post('/api/v1/apps/:id/backup', json, token, authorizeAdmin, routes.apps.load, routes.apps.backup); - router.get ('/api/v1/apps/:id/backups', token, authorizeAdmin, routes.apps.load, routes.apps.listBackups); - router.post('/api/v1/apps/:id/start', json, token, authorizeAdmin, routes.apps.load, routes.apps.start); - router.post('/api/v1/apps/:id/stop', json, token, authorizeAdmin, routes.apps.load, routes.apps.stop); - router.post('/api/v1/apps/:id/restart', json, token, authorizeAdmin, routes.apps.load, routes.apps.restart); - router.get ('/api/v1/apps/:id/logstream', token, authorizeAdmin, routes.apps.load, routes.apps.getLogStream); - router.get ('/api/v1/apps/:id/logs', token, authorizeAdmin, routes.apps.load, routes.apps.getLogs); - router.post('/api/v1/apps/:id/clone', json, token, authorizeAdmin, routes.apps.load, routes.apps.clone); - router.get ('/api/v1/apps/:id/download', token, authorizeAdmin, routes.apps.load, routes.apps.downloadFile); - router.post('/api/v1/apps/:id/upload', json, token, authorizeAdmin, multipart, routes.apps.load, routes.apps.uploadFile); - router.use ('/api/v1/apps/:id/files/*', token, authorizeAdmin, routes.filemanager.proxy); - router.get ('/api/v1/apps/:id/exec', token, authorizeAdmin, routes.apps.load, routes.apps.exec); + router.post('/api/v1/apps/install', json, token, authorizeAdmin, routes.apps.install); + router.get ('/api/v1/apps', token, routes.apps.listByUser); + router.get ('/api/v1/apps/:id', token, authorizeAdmin, routes.apps.load, routes.apps.getApp); + router.get ('/api/v1/apps/:id/icon', token, routes.apps.load, routes.apps.getAppIcon); + router.post('/api/v1/apps/:id/uninstall', json, token, authorizeAdmin, routes.apps.load, routes.apps.uninstall); + router.post('/api/v1/apps/:id/configure/access_restriction', json, token, authorizeAdmin, routes.apps.load, routes.apps.setAccessRestriction); + router.post('/api/v1/apps/:id/configure/operators', json, token, authorizeAdmin, routes.apps.load, routes.apps.setOperators); + router.post('/api/v1/apps/:id/configure/label', json, token, routes.apps.load, authorizeOperator, routes.apps.setLabel); + router.post('/api/v1/apps/:id/configure/tags', json, token, routes.apps.load, authorizeOperator, routes.apps.setTags); + router.post('/api/v1/apps/:id/configure/icon', json, token, routes.apps.load, authorizeOperator, routes.apps.setIcon); + router.post('/api/v1/apps/:id/configure/memory_limit', json, token, routes.apps.load, authorizeOperator, routes.apps.setMemoryLimit); + router.post('/api/v1/apps/:id/configure/cpu_shares', json, token, routes.apps.load, authorizeOperator, routes.apps.setCpuShares); + router.post('/api/v1/apps/:id/configure/automatic_backup', json, token, routes.apps.load, authorizeOperator, routes.apps.setAutomaticBackup); + router.post('/api/v1/apps/:id/configure/automatic_update', json, token, routes.apps.load, authorizeOperator, routes.apps.setAutomaticUpdate); + router.post('/api/v1/apps/:id/configure/reverse_proxy', json, token, routes.apps.load, authorizeOperator, routes.apps.setReverseProxyConfig); + router.post('/api/v1/apps/:id/configure/cert', json, token, routes.apps.load, authorizeOperator, routes.apps.setCertificate); + router.post('/api/v1/apps/:id/configure/debug_mode', json, token, routes.apps.load, authorizeOperator, routes.apps.setDebugMode); + router.post('/api/v1/apps/:id/configure/mailbox', json, token, routes.apps.load, authorizeOperator, routes.apps.setMailbox); + router.post('/api/v1/apps/:id/configure/env', json, token, routes.apps.load, authorizeOperator, routes.apps.setEnvironment); + router.post('/api/v1/apps/:id/configure/data_dir', json, token, routes.apps.load, authorizeAdmin, routes.apps.setDataDir); + router.post('/api/v1/apps/:id/configure/location', json, token, routes.apps.load, authorizeAdmin, routes.apps.setLocation); + router.post('/api/v1/apps/:id/configure/mounts', json, token, routes.apps.load, authorizeAdmin, routes.apps.setMounts); + router.post('/api/v1/apps/:id/repair', json, token, routes.apps.load, authorizeOperator, routes.apps.repair); + router.post('/api/v1/apps/:id/update', json, token, routes.apps.load, authorizeOperator, routes.apps.update); + router.post('/api/v1/apps/:id/restore', json, token, routes.apps.load, authorizeOperator, routes.apps.restore); + router.post('/api/v1/apps/:id/import', json, token, routes.apps.load, authorizeOperator, routes.apps.importApp); + router.post('/api/v1/apps/:id/export', json, token, routes.apps.load, authorizeOperator, routes.apps.exportApp); + router.post('/api/v1/apps/:id/backup', json, token, routes.apps.load, authorizeOperator, routes.apps.backup); + router.get ('/api/v1/apps/:id/backups', token, routes.apps.load, authorizeOperator, routes.apps.listBackups); + router.post('/api/v1/apps/:id/start', json, token, routes.apps.load, authorizeOperator, routes.apps.start); + router.post('/api/v1/apps/:id/stop', json, token, routes.apps.load, authorizeOperator, routes.apps.stop); + router.post('/api/v1/apps/:id/restart', json, token, routes.apps.load, authorizeOperator, routes.apps.restart); + router.get ('/api/v1/apps/:id/logstream', token, routes.apps.load, authorizeOperator, routes.apps.getLogStream); + router.get ('/api/v1/apps/:id/logs', token, routes.apps.load, authorizeOperator, routes.apps.getLogs); + router.post('/api/v1/apps/:id/clone', json, token, routes.apps.load, authorizeAdmin, routes.apps.clone); + router.get ('/api/v1/apps/:id/download', token, routes.apps.load, authorizeOperator, routes.apps.downloadFile); + router.post('/api/v1/apps/:id/upload', json, token, multipart, routes.apps.load, authorizeOperator, routes.apps.uploadFile); + router.use ('/api/v1/apps/:id/files/*', token, routes.apps.load, authorizeOperator, routes.filemanager.proxy); + router.get ('/api/v1/apps/:id/exec', token, routes.apps.load, authorizeOperator, routes.apps.exec); // websocket cannot do bearer authentication router.get ('/api/v1/apps/:id/execws', routes.accesscontrol.websocketAuth.bind(null, users.ROLE_ADMIN), routes.apps.load, routes.apps.execWebSocket); diff --git a/src/test/apps-test.js b/src/test/apps-test.js index f7f2c4f6e..3ecfa4d37 100644 --- a/src/test/apps-test.js +++ b/src/test/apps-test.js @@ -108,6 +108,63 @@ describe('Apps', function () { }); }); + describe('isOperator', function () { + const someuser = { id: 'someuser', groupIds: [], role: 'user' }; + const adminuser = { id: 'adminuser', groupIds: [ 'groupie' ], role: 'admin' }; + + it('returns false for unrestricted access', function () { + expect(apps.isOperator({ operators: null }, someuser)).to.be(false); + }); + + it('returns true for allowed user', function () { + expect(apps.isOperator({ operators: { users: [ 'someuser' ] } }, someuser)).to.be(true); + }); + + it('returns true for allowed user with multiple allowed', function () { + expect(apps.isOperator({ operators: { users: [ 'foo', 'someuser', 'anotheruser' ] } }, someuser)).to.be(true); + }); + + it('returns false for not allowed user', function () { + expect(apps.isOperator({ operators: { users: [ 'foo' ] } }, someuser)).to.be(false); + }); + + it('returns false for not allowed user with multiple allowed', function () { + expect(apps.isOperator({ operators: { users: [ 'foo', 'anotheruser' ] } }, someuser)).to.be(false); + }); + + it('returns false for no group or user', function () { + expect(apps.isOperator({ operators: { users: [ ], groups: [ ] } }, someuser)).to.be(false); + }); + + it('returns false for invalid group or user', function () { + expect(apps.isOperator({ operators: { users: [ ], groups: [ 'nop' ] } }, someuser)).to.be(false); + }); + + it('returns true for admin user', function () { + expect(apps.isOperator({ operators: { users: [ ], groups: [ 'nop' ] } }, adminuser)).to.be(true); + }); + }); + + describe('accessLevel', function () { + const someuser = { id: 'someuser', groupIds: [ 'ops' ], role: 'user' }; + const adminuser = { id: 'adminuser', groupIds: [ 'groupie' ], role: 'admin' }; + + it('return user for normal user', function () { + expect(apps._accessLevel({ accessRestriction: null, operators: null }, someuser)).to.be('user'); + expect(apps._accessLevel({ accessRestriction: null, operators: { users: [ ], groups: [ 'groupie' ] } }, someuser)).to.be('user'); + }); + + it('returns operator for operator user', function () { + expect(apps._accessLevel({ accessRestriction: null, operators: { users: [ 'someuser' ], groups: [ 'groupie' ] } }, someuser)).to.be('operator'); + expect(apps._accessLevel({ accessRestriction: null, operators: { users: [], groups: [ 'ops' ] } }, someuser)).to.be('operator'); + }); + + it('returns admin for admin user', function () { + expect(apps._accessLevel({ accessRestriction: null, operators: null }, adminuser)).to.be('admin'); + expect(apps._accessLevel({ accessRestriction: null, operators: { users: [], groups: [] } }, adminuser)).to.be('admin'); + }); + }); + describe('crud', function () { it('cannot get invalid app', async function () { const result = await apps.get('nope'); diff --git a/src/users.js b/src/users.js index 65b81a082..3d05a4430 100644 --- a/src/users.js +++ b/src/users.js @@ -690,7 +690,6 @@ async function sendInvite(user, options, auditSource) { assert.strictEqual(typeof options, 'object'); if (user.source) throw new BoxError(BoxError.CONFLICT, 'User is from an external directory'); - // if (!user.resetToken) throw new BoxError(BoxError.CONFLICT, 'Must generate resetToken to send invitation'); const resetToken = hat(256); const resetTokenCreationTime = new Date();