From 4c935c7fda32eaf38740c91de2d65341ff93e95b Mon Sep 17 00:00:00 2001 From: rammohan-y <37395033+rammohan-y@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:59:06 +0530 Subject: [PATCH] Feat/371 view only user implementation using user_permissions (#381) * https://github.com/jambonz/jambonz-api-server/issues/371 Implemented view_only permission feature * calling prepare-permissions in create-test-db.js * check if there is only 1 permission and if it is VIEW_ONLY then consider user as read-only user * setting is_view_only flag for view user by userid --- app.js | 14 ++ db/prepare-permissions-test.sql | 11 ++ lib/auth/index.js | 5 +- lib/middleware.js | 19 +- lib/models/permissions.js | 45 +++++ lib/models/user-permissions.js | 53 ++++++ lib/routes/api/login.js | 7 +- lib/routes/api/users.js | 58 ++++++- lib/utils/errors.js | 8 +- test/create-test-db.js | 8 + test/index.js | 1 + test/users-view-only.js | 296 ++++++++++++++++++++++++++++++++ test/users.js | 8 +- test/webapp_tests.js | 8 +- 14 files changed, 529 insertions(+), 12 deletions(-) create mode 100644 db/prepare-permissions-test.sql create mode 100644 lib/models/permissions.js create mode 100644 lib/models/user-permissions.js create mode 100644 test/users-view-only.js diff --git a/app.js b/app.js index de75bf8..1e61e89 100644 --- a/app.js +++ b/app.js @@ -7,6 +7,7 @@ const nocache = require('nocache'); const rateLimit = require('express-rate-limit'); const cors = require('cors'); const passport = require('passport'); +const {verifyViewOnlyUser} = require('./lib/middleware'); const routes = require('./lib/routes'); const Registrar = require('@jambonz/mw-registrar'); @@ -172,6 +173,19 @@ app.use('/v1', unless( '/InviteCodes', '/PredefinedCarriers' ], passport.authenticate('bearer', {session: false}))); +app.use('/v1', unless( + [ + '/register', + '/forgot-password', + '/signin', + '/login', + '/messaging', + '/outboundSMS', + '/AccountTest', + '/InviteCodes', + '/PredefinedCarriers', + '/logout' + ], verifyViewOnlyUser)); app.use('/', routes); app.use((err, req, res, next) => { logger.error(err, 'burped error'); diff --git a/db/prepare-permissions-test.sql b/db/prepare-permissions-test.sql new file mode 100644 index 0000000..56d0a1d --- /dev/null +++ b/db/prepare-permissions-test.sql @@ -0,0 +1,11 @@ +/* remove VIEW_ONLY permission for admin user as it will prevent write operations*/ +delete from user_permissions; + +delete from permissions; + +insert into permissions (permission_sid, name, description) +values +('ffbc342a-546a-11ed-bdc3-0242ac120002', 'VIEW_ONLY', 'Can view data but not make changes'), +('ffbc3a10-546a-11ed-bdc3-0242ac120002', 'PROVISION_SERVICES', 'Can provision services'), +('ffbc3c5e-546a-11ed-bdc3-0242ac120002', 'PROVISION_USERS', 'Can provision users'); + diff --git a/lib/auth/index.js b/lib/auth/index.js index 8f905cf..8aa9f2b 100644 --- a/lib/auth/index.js +++ b/lib/auth/index.js @@ -35,8 +35,8 @@ function makeStrategy(logger) { debug(err); logger.info({err}, 'Error checking redis for jwt'); } - const { user_sid, service_provider_sid, account_sid, email, name, scope, permissions } = decoded; - + const { user_sid, service_provider_sid, account_sid, email, + name, scope, permissions, is_view_only } = decoded; const user = { service_provider_sid, account_sid, @@ -45,6 +45,7 @@ function makeStrategy(logger) { email, name, permissions, + is_view_only, hasScope: (s) => s === scope, hasAdminAuth: scope === 'admin', hasServiceProviderAuth: scope === 'service_provider', diff --git a/lib/middleware.js b/lib/middleware.js index 538e081..245deb6 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -1,4 +1,5 @@ const logger = require('./logger'); +const {UserPermissionError} = require('./utils/errors'); function delayLoginMiddleware(req, res, next) { if (req.path.includes('/login') || req.path.includes('/signin')) { @@ -27,6 +28,22 @@ function delayLoginMiddleware(req, res, next) { next(); } +function verifyViewOnlyUser(req, res, next) { + // Skip check for GET requests + if (req.method === 'GET') { + return next(); + } + // Check if user is read-only + if (req.user && !!req.user.is_view_only) { + const upError = new UserPermissionError('User has view-only access'); + upError.status = 403; + throw upError; + } + + next(); +} + module.exports = { - delayLoginMiddleware + delayLoginMiddleware, + verifyViewOnlyUser }; diff --git a/lib/models/permissions.js b/lib/models/permissions.js new file mode 100644 index 0000000..978427c --- /dev/null +++ b/lib/models/permissions.js @@ -0,0 +1,45 @@ +const Model = require('./model'); +const {promisePool} = require('../db'); +const sqlAll = ` +SELECT * from permissions +`; +const sqlByName = ` +SELECT * from permissions where name = ? +`; + +class Permissions extends Model { + constructor() { + super(); + } + + static async retrieveAll() { + const [rows] = await promisePool.query(sqlAll); + return rows; + } + + static async retrieveByName(name) { + const [rows] = await promisePool.query(sqlByName, [name]); + return rows; + } +} + +Permissions.table = 'permissions'; +Permissions.fields = [ + { + name: 'permission_sid', + type: 'string', + primaryKey: true + }, + { + name: 'name', + type: 'string', + required: true + }, + { + name: 'description', + type: 'string', + required: true + } +]; + +module.exports = Permissions; diff --git a/lib/models/user-permissions.js b/lib/models/user-permissions.js new file mode 100644 index 0000000..2d29dc4 --- /dev/null +++ b/lib/models/user-permissions.js @@ -0,0 +1,53 @@ +const Model = require('./model'); +const {promisePool} = require('../db'); +const sqlAll = ` +SELECT * from user_permissions +`; +const sqlByUserIdPermissionSid = ` +SELECT * from user_permissions where user_sid = ? and permission_sid = ? +`; +const sqlByUserId = ` +SELECT * from user_permissions where user_sid = ? +`; + +class UserPermissions extends Model { + constructor() { + super(); + } + + static async retrieveAll() { + const [rows] = await promisePool.query(sqlAll); + return rows; + } + + static async retrieveByUserIdPermissionSid(user_sid, permission_sid) { + const [rows] = await promisePool.query(sqlByUserIdPermissionSid, [user_sid, permission_sid]); + return rows; + } + static async retrieveByUserId(user_sid) { + const [rows] = await promisePool.query(sqlByUserId, [user_sid]); + return rows; + } + +} + +UserPermissions.table = 'user_permissions'; +UserPermissions.fields = [ + { + name: 'user_permissions_sid', + type: 'string', + primaryKey: true + }, + { + name: 'user_sid', + type: 'string', + required: true + }, + { + name: 'permission_sid', + type: 'string', + required: true + } +]; + +module.exports = UserPermissions; diff --git a/lib/routes/api/login.js b/lib/routes/api/login.js index 6a90634..508ec92 100644 --- a/lib/routes/api/login.js +++ b/lib/routes/api/login.js @@ -71,9 +71,13 @@ router.post('/', async(req, res) => { obj.service_provider_sid = r[0].service_provider_sid; obj.service_provider_name = service_provider[0].name; } + // if there is only one permission and it is VIEW_ONLY, then the user is view only + // this is to prevent write operations on the API + const is_view_only = permissions.length === 1 && permissions.includes('VIEW_ONLY'); const payload = { scope: obj.scope, permissions, + is_view_only, ...(obj.service_provider_sid && { service_provider_sid: obj.service_provider_sid, service_provider_name: obj.service_provider_name @@ -83,7 +87,8 @@ router.post('/', async(req, res) => { account_name: obj.account_name, service_provider_name: obj.service_provider_name }), - user_sid: obj.user_sid + user_sid: obj.user_sid, + name: username }; const expiresIn = parseInt(process.env.JWT_EXPIRES_IN || 60) * 60; diff --git a/lib/routes/api/users.js b/lib/routes/api/users.js index 655627f..8dff77b 100644 --- a/lib/routes/api/users.js +++ b/lib/routes/api/users.js @@ -1,5 +1,7 @@ const router = require('express').Router(); const User = require('../../models/user'); +const UserPermissions = require('../../models/user-permissions'); +const Permissions = require('../../models/permissions'); const {DbErrorBadRequest, BadRequestError, DbErrorForbidden} = require('../../utils/errors'); const {generateHashedPassword, verifyPassword} = require('../../utils/password-utils'); const {promisePool} = require('../../db'); @@ -38,7 +40,8 @@ const validateRequest = async(user_sid, req) => { email, email_activation_code, force_change, - is_active + is_active, + is_view_only } = payload; const [r] = await promisePool.query(retrieveSql, user_sid); @@ -93,7 +96,8 @@ const validateRequest = async(user_sid, req) => { if (email_activation_code && !email) { throw new DbErrorBadRequest('email and email_activation_code both required'); } - if (!name && !new_password && !email && !initial_password && !force_change && !is_active) + if (!name && !new_password && !email && !initial_password && !force_change && !is_active && + is_view_only === undefined) throw new DbErrorBadRequest('no updates requested'); return user; @@ -140,7 +144,35 @@ const ensureUserRetrievalIsAllowed = (req, user) => { throw error; } }; +async function updateViewOnlyUserPermission(is_view_only, user_sid) { + try { + const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY'); + if (!viewOnlyPermission) { + throw new Error('VIEW_ONLY permission not found'); + } + const existingPermissions = await UserPermissions.retrieveByUserIdPermissionSid( + user_sid, + viewOnlyPermission.permission_sid + ); + if (is_view_only && existingPermissions.length === 0) { + await UserPermissions.make({ + user_sid, + permission_sid: viewOnlyPermission.permission_sid, + }); + } else if (!is_view_only && existingPermissions.length > 0) { + await UserPermissions.remove(existingPermissions[0].user_permissions_sid); + } + } catch (err) { + throw new Error(`Failed to update user permissions: ${err.message}`); + } +} +async function removeViewOnlyUserPermission(user_id) { + const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY'); + if (viewOnlyPermission) { + await UserPermissions.remove(user_id, viewOnlyPermission.permission_sid); + } +} router.get('/', async(req, res) => { const logger = req.app.locals.logger; @@ -308,7 +340,14 @@ router.get('/:user_sid', async(req, res) => { } ensureUserRetrievalIsAllowed(req, user); - + const [viewOnlyPermission] = await Permissions.retrieveByName('VIEW_ONLY'); + const existingPermissions = await UserPermissions.retrieveByUserId( + user_sid + ); + logger.debug(`existingPermissions of ${user_sid}: ${JSON.stringify(existingPermissions)}`); + user.is_view_only = existingPermissions.length === 1 && + existingPermissions[0].permission_sid === viewOnlyPermission.permission_sid; + logger.debug(`User ${user_sid} is view-only user: ${user.is_view_only}`); // eslint-disable-next-line no-unused-vars const { hashed_password, ...rest } = user; return res.status(200).json(rest); @@ -332,9 +371,9 @@ router.put('/:user_sid', async(req, res) => { is_active, force_change, account_sid, - service_provider_sid + service_provider_sid, + is_view_only } = req.body; - //if (req.user.user_sid && req.user.user_sid !== user_sid) return res.sendStatus(403); if (!hasAdminAuth && @@ -427,6 +466,8 @@ router.put('/:user_sid', async(req, res) => { //TODO: send email with activation code } } + // update user permissions + await updateViewOnlyUserPermission(is_view_only, user_sid); res.sendStatus(204); } catch (err) { sysError(logger, res, err); @@ -443,6 +484,8 @@ router.post('/', async(req, res) => { }; const allUsers = await User.retrieveAll(); delete payload.initial_password; + const is_view_only = payload.is_view_only; + delete payload.is_view_only; try { if (req.body.initial_password) { @@ -464,6 +507,7 @@ router.post('/', async(req, res) => { if (req.user.hasAdminAuth) { logger.debug({payload}, 'POST /users'); const uuid = await User.make(payload); + await updateViewOnlyUserPermission(is_view_only, uuid); res.status(201).json({user_sid: uuid}); } else if (req.user.hasAccountAuth) { @@ -472,6 +516,7 @@ router.post('/', async(req, res) => { ...payload, account_sid: req.user.account_sid, }); + await updateViewOnlyUserPermission(is_view_only, uuid); res.status(201).json({user_sid: uuid}); } else if (req.user.hasServiceProviderAuth) { @@ -480,6 +525,7 @@ router.post('/', async(req, res) => { ...payload, service_provider_sid: req.user.service_provider_sid, }); + await updateViewOnlyUserPermission(is_view_only, uuid); res.status(201).json({user_sid: uuid}); } } catch (err) { @@ -497,6 +543,8 @@ router.delete('/:user_sid', async(req, res) => { const user = allUsers.filter((user) => user.user_sid === user_sid); ensureUserDeletionIsAllowed(req, activeAdminUsers, user); + logger.debug(`Removing view-only permission for user ${user_sid}`); + await removeViewOnlyUserPermission(user_sid); await User.remove(user_sid); /* invalidate the jwt of the deleted user */ diff --git a/lib/utils/errors.js b/lib/utils/errors.js index f5b4fc0..85ff736 100644 --- a/lib/utils/errors.js +++ b/lib/utils/errors.js @@ -27,11 +27,17 @@ class DbErrorForbidden extends DbError { super(msg); } } +class UserPermissionError extends Error { + constructor(msg) { + super(msg); + } +} module.exports = { BadRequestError, DbError, DbErrorBadRequest, DbErrorUnprocessableRequest, - DbErrorForbidden + DbErrorForbidden, + UserPermissionError }; diff --git a/test/create-test-db.js b/test/create-test-db.js index cba00dc..d998e92 100644 --- a/test/create-test-db.js +++ b/test/create-test-db.js @@ -32,3 +32,11 @@ test('add predefined carriers', (t) => { t.end(); }); }); + +test('prepare permissions', (t) => { + exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => { + if (err) return t.end(err); + t.pass('permissions prepared'); + t.end(); + }); +}); diff --git a/test/index.js b/test/index.js index f92d984..beba303 100644 --- a/test/index.js +++ b/test/index.js @@ -13,6 +13,7 @@ require('./ms-teams'); require('./speech-credentials'); require('./recent-calls'); require('./users'); +require('./users-view-only'); require('./login'); require('./webapp_tests'); // require('./homer'); diff --git a/test/users-view-only.js b/test/users-view-only.js new file mode 100644 index 0000000..989076d --- /dev/null +++ b/test/users-view-only.js @@ -0,0 +1,296 @@ +const test = require('tape') ; +const jwt = require('jsonwebtoken'); +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); +const exec = require('child_process').exec ; +const {generateHashedPassword} = require('../lib/utils/password-utils'); +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); +test('add an admin user', (t) => { + exec(`${__dirname}/../db/reset_admin_password.js`, (err, stdout, stderr) => { + console.log(stderr); + console.log(stdout); + if (err) return t.end(err); + t.pass('successfully added admin user'); + t.end(); + }); +}); + + +test('prepare permissions', (t) => { + exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => { + if (err) return t.end(err); + t.pass('permissions prepared'); + t.end(); + }); +}); + +test('view-only user tests', async(t) => { + const app = require('../app'); + const password = 'abcde12345-'; + try { + let result; + /* login as admin to get a jwt */ + result = await request.post('/login', { + resolveWithFullResponse: true, + json: true, + body: { + username: 'admin', + password: 'admin', + } + }); + t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as admin'); + const authAdmin = {bearer: result.body.token}; + const decodedJwt = jwt.verify(result.body.token, process.env.JWT_SECRET); + /* add admin user */ + result = await request.post(`/Users`, { + resolveWithFullResponse: true, + json: true, + auth: authAdmin, + body: { + name: 'admin2', + email: 'admin2@jambonz.com', + is_active: true, + force_change: true, + initial_password: password, + } + }); + t.ok(result.statusCode === 201 && result.body.user_sid, 'admin user created'); + const admin_user_sid = result.body.user_sid; + /* add a service provider */ + result = await request.post('/ServiceProviders', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'sp1', + } + }); + t.ok(result.statusCode === 201, 'successfully created service provider'); + const sp_sid = result.body.sid; + /* add service_provider read only user */ + result = await request.post(`/Users`, { + resolveWithFullResponse: true, + json: true, + auth: authAdmin, + body: { + name: 'service_provider', + email: 'sp@jambonz.com', + is_active: true, + force_change: true, + initial_password: password, + service_provider_sid: sp_sid, + is_view_only: true + } + }); + t.ok(result.statusCode === 201 && result.body.user_sid, 'service_provider scope view-only user created'); + + // login as service_provider read only user + result = await request.post('/login', { + resolveWithFullResponse: true, + json: true, + body: { + username: 'service_provider', + password: password, + } + }); + t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as service provider view-only user'); + const spToken = {bearer: result.body.token}; + const spDecodedJwt = jwt.verify(result.body.token, process.env.JWT_SECRET); + try { + result = await request.post('/Accounts', { + resolveWithFullResponse: true, + auth: spToken, + json: true, + body: { + name: 'sample_account', + service_provider_sid: sp_sid, + registration_hook: { + url: 'http://example.com/reg', + method: 'get' + }, + webhook_secret: 'foobar' + } + }) + } catch(err) { + t.ok(err.statusCode === 403, 'As a view-only user, you cannot create an account'); + } + result = await request.post('/Accounts', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'sample_account', + service_provider_sid: sp_sid, + registration_hook: { + url: 'http://example.com/reg', + method: 'get' + }, + webhook_secret: 'foobar' + } + }) + t.ok(result.statusCode === 201, 'successfully created account using admin token'); + const account_sid = result.body.sid; + /* add account scope view-only user */ + result = await request.post(`/Users`, { + resolveWithFullResponse: true, + json: true, + auth: authAdmin, + body: { + name: 'account', + email: 'account@jambonz.com', + is_active: true, + force_change: true, + initial_password: password, + service_provider_sid: sp_sid, + account_sid: account_sid, + is_view_only: true + } + }); + t.ok(result.statusCode === 201 && result.body.user_sid, 'account scope user created'); + const account_user_sid = result.body.user_sid; + // login as account read only user + result = await request.post('/login', { + resolveWithFullResponse: true, + json: true, + body: { + username: 'account', + password: password, + } + }); + t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account view-only user'); + let userToken = {bearer: result.body.token}; + /* add an application which should fail as the logged in user is a view-only user */ + try { + result = await request.post('/Applications', { + resolveWithFullResponse: true, + auth: userToken, + json: true, + body: { + name: 'daveh', + account_sid, + call_hook: { + url: 'http://example.com' + }, + call_status_hook: { + url: 'http://example.com/status', + method: 'POST' + }, + messaging_hook: { + url: 'http://example.com/sms' + }, + app_json : '[\ + {\ + "verb": "play",\ + "url": "https://example.com/example.mp3",\ + "timeoutSecs": 10,\ + "seekOffset": 8000,\ + "actionHook": "/play/action"\ + }\ + ]', + use_for_fallback_speech: 1, + fallback_speech_synthesis_vendor: 'google', + fallback_speech_synthesis_language: 'en-US', + fallback_speech_synthesis_voice: 'man', + fallback_speech_synthesis_label: 'label1', + fallback_speech_recognizer_vendor: 'google', + fallback_speech_recognizer_language: 'en-US', + fallback_speech_recognizer_label: 'label1' + } + }); + } catch(err) { + t.ok(err.statusCode === 403, 'As a view-only user, you cannot create an application'); + } + // change user as read/write user and create an application - it should succeed + result = await request.put(`/Users/${account_user_sid}`, { + resolveWithFullResponse: true, + json: true, + auth: authAdmin, + body: { + is_view_only: false + } + }); + t.ok(result.statusCode === 204, 'successfully updated user to read/write permissions'); + // login as account read only user + result = await request.post('/login', { + resolveWithFullResponse: true, + json: true, + body: { + username: 'account', + password: password, + } + }); + t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account read-write user'); + userToken = {bearer: result.body.token}; + /* add an application which should succeed as the logged in user is a read-write user */ + result = await request.post('/Applications', { + resolveWithFullResponse: true, + auth: userToken, + json: true, + body: { + name: 'daveh', + account_sid, + call_hook: { + url: 'http://example.com' + }, + call_status_hook: { + url: 'http://example.com/status', + method: 'POST' + }, + messaging_hook: { + url: 'http://example.com/sms' + }, + app_json : '[\ + {\ + "verb": "play",\ + "url": "https://example.com/example.mp3",\ + "timeoutSecs": 10,\ + "seekOffset": 8000,\ + "actionHook": "/play/action"\ + }\ + ]', + use_for_fallback_speech: 1, + fallback_speech_synthesis_vendor: 'google', + fallback_speech_synthesis_language: 'en-US', + fallback_speech_synthesis_voice: 'man', + fallback_speech_synthesis_label: 'label1', + fallback_speech_recognizer_vendor: 'google', + fallback_speech_recognizer_language: 'en-US', + fallback_speech_recognizer_label: 'label1' + } + }); + t.ok(result.statusCode === 201, 'successfully created an application'); + // change user back to view-only and query the application - it should succeed + result = await request.put(`/Users/${account_user_sid}`, { + resolveWithFullResponse: true, + json: true, + auth: authAdmin, + body: { + is_view_only: true + } + }); + t.ok(result.statusCode === 204, 'successfully updated user permission to view-only'); + // login as account read only user + result = await request.post('/login', { + resolveWithFullResponse: true, + json: true, + body: { + username: 'account', + password: password, + } + }); + t.ok(result.statusCode === 200 && result.body.token, 'successfully logged in as account view-only user'); + userToken = {bearer: result.body.token}; + result = await request.get('/Applications', { + auth: userToken, + json: true, + }); + //console.log(`result: ${JSON.stringify(result)}`); + t.ok(result.length === 1 , 'successfully queried all applications with view-only user'); + } catch (err) { + console.error(err); + t.end(err); + } +}); \ No newline at end of file diff --git a/test/users.js b/test/users.js index 1d81d9a..deeee4d 100644 --- a/test/users.js +++ b/test/users.js @@ -20,7 +20,13 @@ test('add an admin user', (t) => { t.end(); }); }); - +test('prepare permissions', (t) => { + exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => { + if (err) return t.end(err); + t.pass('permissions prepared'); + t.end(); + }); +}); test('user tests', async(t) => { const app = require('../app'); const password = 'abcde12345-'; diff --git a/test/webapp_tests.js b/test/webapp_tests.js index d7535e1..b655df4 100644 --- a/test/webapp_tests.js +++ b/test/webapp_tests.js @@ -24,7 +24,13 @@ test('seeding database for webapp tests', (t) => { t.end(); }); }); - +test('prepare permissions', (t) => { + exec(`mysql -h 127.0.0.1 -u root --protocol=tcp --port=3360 -D jambones_test < ${__dirname}/../db/prepare-permissions-test.sql`, (err, stdout, stderr) => { + if (err) return t.end(err); + t.pass('permissions prepared'); + t.end(); + }); +}); test('webapp tests', async(t) => { const app = require('../app'); let sid;