mirror of
https://github.com/jambonz/jambonz-api-server.git
synced 2026-10-09 21:14:00 +00:00
major updates and test suite
This commit is contained in:
+12
-8
@@ -27,18 +27,22 @@ function makeStrategy(logger) {
|
||||
}
|
||||
|
||||
// found api key
|
||||
logger.info(results, 'api key lookup');
|
||||
const scope = [];
|
||||
if (results[0].account_sid === null && results[0].service_provider_sid === null) {
|
||||
scope.push.apply(scope, ['admin', 'service_provider', 'account']);
|
||||
}
|
||||
else if (results[0].service_provider_sid) {
|
||||
scope.push.apply(scope, ['service_provider', 'account']);
|
||||
}
|
||||
else {
|
||||
scope.push('account');
|
||||
}
|
||||
|
||||
const user = {
|
||||
account_sid: results[0].account_sid,
|
||||
service_provider_sid: results[0].service_provider_sid,
|
||||
isAdmin: results[0].account_sid === null && results[0].service_provider_sid === null,
|
||||
isServiceProvider: results[0].service_provider_sid !== null,
|
||||
isUser: results[0].account_sid != null
|
||||
hasScope: (s) => scope.includes(s)
|
||||
};
|
||||
const scope = [];
|
||||
if (user.isAdmin) scope.push('admin');
|
||||
else if (user.isServiceProvider) scope.push('service_provider');
|
||||
else scope.push('user');
|
||||
logger.info(user, `successfully validated with scope ${scope}`);
|
||||
return done(null, user, {scope});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ pool.getConnection((err, conn) => {
|
||||
if (err) return console.error(err, 'Error testing pool');
|
||||
conn.ping((err) => {
|
||||
if (err) return console.error(err, `Error pinging mysql at ${JSON.stringify(config.get('mysql'))}`);
|
||||
console.log('successfully pinged mysql database');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const Model = require('./model');
|
||||
|
||||
class Account extends Model {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
Account.table = 'accounts';
|
||||
Account.fields = [
|
||||
{
|
||||
name: 'account_sid',
|
||||
type: 'string',
|
||||
primaryKey: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'service_provider_sid',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'sip_realm',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'registration_hook',
|
||||
type: 'string',
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = Account;
|
||||
@@ -0,0 +1,77 @@
|
||||
const Model = require('./model');
|
||||
const {getMysqlConnection} = require('../db');
|
||||
const serviceProviderSql = `
|
||||
SELECT * from ${this.table}
|
||||
WHERE account_sid in (
|
||||
SELECT account_sid from accounts
|
||||
WHERE service_provider_sid = ?
|
||||
)`;
|
||||
|
||||
class Application extends Model {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieve all applications for an account
|
||||
*/
|
||||
static retrieveAllForAccount(account_sid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
getMysqlConnection((err, conn) => {
|
||||
if (err) return reject(err);
|
||||
conn.query(`SELECT * from ${this.table} WHERE account_sid = ?`, [account_sid], (err, results, fields) => {
|
||||
conn.release();
|
||||
if (err) return reject(err);
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieve all applications for a service provider
|
||||
*/
|
||||
static retrieveAllForServiceProvider(service_provider_sid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
getMysqlConnection((err, conn) => {
|
||||
if (err) return reject(err);
|
||||
conn.query(serviceProviderSql, [service_provider_sid], (err, results, fields) => {
|
||||
conn.release();
|
||||
if (err) return reject(err);
|
||||
resolve(results);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Application.table = 'applications';
|
||||
Application.fields = [
|
||||
{
|
||||
name: 'application_sid',
|
||||
type: 'string',
|
||||
primaryKey: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'account_sid',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'call_hook',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'call_status_hook',
|
||||
type: 'string',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = Application;
|
||||
+2
-2
@@ -30,11 +30,11 @@ class Model extends Emitter {
|
||||
static make(obj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pk = this.getPrimaryKey();
|
||||
const uuid = uuidv4();
|
||||
obj[pk.name] = uuid;
|
||||
this.checkIsInsertable(obj);
|
||||
getMysqlConnection((err, conn) => {
|
||||
if (err) return reject(err);
|
||||
const uuid = uuidv4();
|
||||
obj[pk.name] = uuid;
|
||||
conn.query(`INSERT into ${this.table} SET ?`,
|
||||
obj,
|
||||
(err, results, fields) => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const Model = require('./model');
|
||||
|
||||
class PhoneNumber extends Model {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
PhoneNumber.table = 'phone_numbers';
|
||||
PhoneNumber.fields = [
|
||||
{
|
||||
name: 'phone_number_sid',
|
||||
type: 'string',
|
||||
primaryKey: true
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'voip_carrier_sid',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'account_sid',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
name: 'application_sid',
|
||||
type: 'string',
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = PhoneNumber;
|
||||
@@ -0,0 +1,27 @@
|
||||
const Model = require('./model');
|
||||
|
||||
class VoipCarrier extends Model {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
VoipCarrier.table = 'voip_carriers';
|
||||
VoipCarrier.fields = [
|
||||
{
|
||||
name: 'voip_carrier_sid',
|
||||
type: 'string',
|
||||
primaryKey: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'string'
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = VoipCarrier;
|
||||
@@ -0,0 +1,29 @@
|
||||
const router = require('express').Router();
|
||||
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
|
||||
const Account = require('../../models/account');
|
||||
const ServiceProvider = require('../../models/service-provider');
|
||||
const decorate = require('./decorate');
|
||||
const preconditions = {
|
||||
'add': validateAdd,
|
||||
'update': validateUpdate,
|
||||
'delete': validateDelete
|
||||
};
|
||||
|
||||
async function validateAdd(req) {
|
||||
/* check that service provider exists */
|
||||
const result = await ServiceProvider.retrieve(req.body.service_provider_sid);
|
||||
if (!result || result.length === 0) {
|
||||
throw new DbErrorBadRequest(`service_provider not found for sid ${req.body.service_provider_sid}`);
|
||||
}
|
||||
}
|
||||
async function validateUpdate(req, sid) {
|
||||
if (req.body.service_provider_sid) throw new DbErrorBadRequest('service_provider_sid may not be modified')
|
||||
}
|
||||
async function validateDelete(req, sid) {
|
||||
const assignedPhoneNumbers = await Account.getForeignKeyReferences('phone_numbers.account_sid', sid);
|
||||
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete account with phone numbers');
|
||||
}
|
||||
|
||||
decorate(router, Account, ['*'], preconditions);
|
||||
|
||||
module.exports = router;
|
||||
+10
-21
@@ -1,7 +1,6 @@
|
||||
const router = require('express').Router();
|
||||
const {DbErrorBadRequest} = require('../../utils/errors');
|
||||
const ApiKey = require('../../models/api-key');
|
||||
const {isAdmin, isServiceProvider, isUser} = require('../../utils/scopes');
|
||||
const decorate = require('./decorate');
|
||||
const uuidv4 = require('uuid/v4');
|
||||
const assert = require('assert');
|
||||
@@ -11,38 +10,28 @@ const preconditions = {
|
||||
'delete': validateDeleteToken
|
||||
};
|
||||
|
||||
/**
|
||||
* if user scope, add to the associated account
|
||||
* if admin scope, only admin-level tokens may be created
|
||||
*/
|
||||
function validateAddToken(req) {
|
||||
if (isAdmin(req) && ('account_sid' in req.body)) {
|
||||
throw new DbErrorBadRequest('admin users may not create account-level tokens');
|
||||
if (req.user.hasScope('admin') && ('account_sid' in req.body)) {
|
||||
// ok
|
||||
}
|
||||
else if (isServiceProvider(req) && (!('account_sid' in req.body) && !('service_provider_sid' in req.body))) {
|
||||
req.body['service_provider_sid'] = req.user.service_provider_sid
|
||||
else if (req.user.hasScope('service_provider') &&
|
||||
(!('account_sid' in req.body) && !('service_provider_sid' in req.body))) {
|
||||
req.body['service_provider_sid'] = req.user.service_provider_sid;
|
||||
}
|
||||
else if (isUser(req)) {
|
||||
else if (req.user.hasScope('account') && !req.user.hasScope('service_provider')) {
|
||||
delete req.body['service_provider_sid'];
|
||||
req.body['account_sid'] = req.user.account_sid;
|
||||
}
|
||||
req.body.token = uuidv4();
|
||||
}
|
||||
|
||||
/**
|
||||
* admin users can only delete admin tokens or service provider tokens
|
||||
* service_provider users can delete service provider or user tokens
|
||||
* user-scope may only delete their own tokens
|
||||
*/
|
||||
async function validateDeleteToken(req, sid) {
|
||||
const results = await ApiKey.retrieve(sid);
|
||||
if (0 == results.length) return;
|
||||
if (isAdmin(req)) {
|
||||
if (results[0].account_sid) {
|
||||
throw new DbErrorBadRequest('an admin user may not delete account level api keys');
|
||||
}
|
||||
if (req.user.hasScope('admin')) {
|
||||
// can do anything
|
||||
}
|
||||
else if (isServiceProvider(req)) {
|
||||
else if (req.user.hasScope('service_provider')) {
|
||||
if (results[0].service_provider_sid === null && results[0].account_sid === null) {
|
||||
throw new DbErrorBadRequest('a service provider user may not delete an admin token');
|
||||
}
|
||||
@@ -50,7 +39,7 @@ async function validateDeleteToken(req, sid) {
|
||||
throw new DbErrorBadRequest('a service provider user may not delete api key from another service provider');
|
||||
}
|
||||
}
|
||||
else if (isUser(req)) {
|
||||
else {
|
||||
if (results[0].account_sid !== req.user.account_sid) {
|
||||
throw new DbErrorBadRequest('a user may not delete a token associated with a different account');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const router = require('express').Router();
|
||||
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
|
||||
const Application = require('../../models/application');
|
||||
const decorate = require('./decorate');
|
||||
const sysError = require('./error');
|
||||
const preconditions = {
|
||||
'add': validateAdd,
|
||||
'update': validateUpdate,
|
||||
'delete': validateDelete
|
||||
};
|
||||
|
||||
/* only user-level tokens can add applications */
|
||||
async function validateAdd(req) {
|
||||
if (req.user.account_sid) {
|
||||
req.body.account_sid = req.user.account_sid;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUpdate(req, sid) {
|
||||
if (req.user.account_sid && sid !== req.user.account_sid) {
|
||||
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDelete(req, sid) {
|
||||
if (req.user.account_sid && sid !== req.user.account_sid) {
|
||||
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
|
||||
}
|
||||
const assignedPhoneNumbers = await Application.getForeignKeyReferences('phone_numbers.application_sid', sid);
|
||||
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete application with phone numbers');
|
||||
}
|
||||
|
||||
decorate(router, Application, ['*'], preconditions);
|
||||
|
||||
/**
|
||||
* if account-level privileges, retrieve only applications for that account
|
||||
* ditto if service provider
|
||||
*/
|
||||
router.get('/', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
try {
|
||||
let results;
|
||||
if (req.user.account_sid) results = Application.retrieveAllForAccount(req.user.account_sid);
|
||||
else if (req.user.service_provider_sid) {
|
||||
results = Application.retrieveAllForServiceProvider(req.user.service_provider_sid);
|
||||
}
|
||||
else results = Application.Application.retrieveAll();
|
||||
res.status(200).json(results);
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -19,12 +19,16 @@ function decorate(router, klass, methods, preconditions) {
|
||||
});
|
||||
}
|
||||
|
||||
function list(router, klass) {
|
||||
function list(router, klass, preconditions) {
|
||||
router.get('/', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
//logger.info(`user: ${JSON.stringify(req.user)}`);
|
||||
//logger.info(`scope: ${JSON.stringify(req.authInfo.scope)}`);
|
||||
try {
|
||||
if ('list' in preconditions) {
|
||||
assert(typeof preconditions.list === 'function');
|
||||
await preconditions.list(req);
|
||||
}
|
||||
const results = await klass.retrieveAll();
|
||||
res.status(200).json(results);
|
||||
} catch (err) {
|
||||
@@ -63,11 +67,15 @@ function retrieve(router, klass) {
|
||||
});
|
||||
}
|
||||
|
||||
function update(router, klass) {
|
||||
function update(router, klass, preconditions) {
|
||||
router.put('/:sid', async(req, res) => {
|
||||
const sid = req.params.sid;
|
||||
const logger = req.app.locals.logger;
|
||||
try {
|
||||
if ('update' in preconditions) {
|
||||
assert(typeof preconditions.update === 'function');
|
||||
await preconditions.update(req, sid);
|
||||
}
|
||||
const rowsAffected = await klass.update(sid, req.body);
|
||||
if (rowsAffected === 0) {
|
||||
return res.status(404).end();
|
||||
|
||||
@@ -2,15 +2,15 @@ const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/er
|
||||
|
||||
function sysError(logger, res, err) {
|
||||
if (err instanceof DbErrorBadRequest) {
|
||||
logger.error(err, 'invalid client request');
|
||||
logger.info(err, 'invalid client request');
|
||||
return res.status(400).json({msg: err.message});
|
||||
}
|
||||
if (err instanceof DbErrorUnprocessableRequest) {
|
||||
logger.error(err, 'unprocessable request');
|
||||
logger.info(err, 'unprocessable request');
|
||||
return res.status(422).json({msg: err.message});
|
||||
}
|
||||
if (err.message.includes('ER_DUP_ENTRY')) {
|
||||
logger.error(err, 'duplicate entry on insert');
|
||||
logger.info(err, 'duplicate entry on insert');
|
||||
return res.status(422).json({msg: err.message});
|
||||
}
|
||||
logger.error(err, 'Database error');
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
const api = require('express').Router();
|
||||
const {isAdmin} = require('../../utils/scopes');
|
||||
|
||||
function isAdminScope(req, res, next) {
|
||||
if (isAdmin(req)) return next();
|
||||
if (req.user.hasScope('admin')) return next();
|
||||
res.status(403).json({
|
||||
status: 'fail',
|
||||
message: 'insufficient privileges'
|
||||
@@ -10,6 +9,10 @@ function isAdminScope(req, res, next) {
|
||||
}
|
||||
|
||||
api.use('/ServiceProviders', isAdminScope, require('./service-providers'));
|
||||
api.use('/VoipCarriers', isAdminScope, require('./voip-carriers'));
|
||||
api.use('/PhoneNumbers', isAdminScope, require('./phone-numbers'));
|
||||
api.use('/ApiKeys', require('./api-keys'));
|
||||
api.use('/Accounts', require('./accounts'));
|
||||
api.use('/Applications', require('./applications'));
|
||||
|
||||
module.exports = api;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const router = require('express').Router();
|
||||
const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/errors');
|
||||
const PhoneNumber = require('../../models/phone-number');
|
||||
const VoipCarrier = require('../../models/voip-carrier');
|
||||
const decorate = require('./decorate');
|
||||
const validateNumber = require('../../utils/phone-number-syntax');
|
||||
const preconditions = {
|
||||
'add': validateAdd,
|
||||
'delete': checkInUse,
|
||||
'update': validateUpdate
|
||||
};
|
||||
|
||||
/* check for required fields when adding */
|
||||
async function validateAdd(req) {
|
||||
try {
|
||||
if (!req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid is required');
|
||||
if (!req.body.number) throw new DbErrorBadRequest('number is required');
|
||||
validateNumber(req.body.number);
|
||||
} catch (err) {
|
||||
throw new DbErrorBadRequest(err.message);
|
||||
}
|
||||
|
||||
/* check that voip carrier exists */
|
||||
const result = await VoipCarrier.retrieve(req.body.voip_carrier_sid);
|
||||
if (!result || result.length === 0) {
|
||||
throw new DbErrorBadRequest(`voip_carrier not found for sid ${req.body.voip_carrier_sid}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* can not delete a phone number if it in use */
|
||||
async function checkInUse(req, sid) {
|
||||
const phoneNumber = await PhoneNumber.retrieve(sid);
|
||||
if (phoneNumber.account_sid) {
|
||||
throw new DbErrorUnprocessableRequest('cannot delete phone number that is assigned to an account');
|
||||
}
|
||||
}
|
||||
|
||||
/* can not change number or voip carrier */
|
||||
async function validateUpdate(req, sid) {
|
||||
const result = await PhoneNumber.retrieve(sid);
|
||||
if (req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid may not be modified');
|
||||
if (req.body.number) throw new DbErrorBadRequest('number may not be modified');
|
||||
|
||||
// TODO: if we are assigning to an account, verify it exists
|
||||
|
||||
// TODO: if we are assigning to an application, verify it is associated to the same account
|
||||
|
||||
// TODO: if we are removing from an account, verify we are also removing from application.
|
||||
}
|
||||
|
||||
decorate(router, PhoneNumber, ['*'], preconditions);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,17 @@
|
||||
const router = require('express').Router();
|
||||
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
|
||||
const VoipCarrier = require('../../models/voip-carrier');
|
||||
const decorate = require('./decorate');
|
||||
const preconditions = {
|
||||
'delete': noActiveAccounts
|
||||
};
|
||||
|
||||
/* can not delete a voip provider if it has any active phone numbers */
|
||||
async function noActiveAccounts(req, sid) {
|
||||
const activeAccounts = await VoipCarrier.getForeignKeyReferences('phone_numbers.voip_carrier_sid', sid);
|
||||
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete voip carrier with active phone numbers');
|
||||
}
|
||||
|
||||
decorate(router, VoipCarrier, ['*'], preconditions);
|
||||
|
||||
module.exports = router;
|
||||
+483
-349
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
function validate(number) {
|
||||
if (typeof number !== 'string') throw new Error('phone number must be a string');
|
||||
if (!/^\d+$/.test(number)) throw new Error('phone number must only include digits');
|
||||
if (number.length < 8) throw new Error('invalid phone number: insufficient digits');
|
||||
if (number[0] === '1' && number.length !== 11) throw new Error('invalid US phone number');
|
||||
}
|
||||
|
||||
module.exports = validate;
|
||||
@@ -1,17 +0,0 @@
|
||||
function isAdmin(req) {
|
||||
return req.authInfo.scope.includes('admin');
|
||||
}
|
||||
|
||||
function isServiceProvider(req) {
|
||||
return req.authInfo.scope.includes('service_provider');
|
||||
}
|
||||
|
||||
function isUser(req) {
|
||||
return req.authInfo.scope.includes('user');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAdmin,
|
||||
isServiceProvider,
|
||||
isUser
|
||||
};
|
||||
Reference in New Issue
Block a user