Compare commits

...

11 Commits

Author SHA1 Message Date
Dave Horton
2324890b72 add ability to create service_provider level apikeys 2022-05-06 20:30:38 -04:00
Dave Horton
4097ca2125 bugfix: createCall accepts call_hook that is a ws(s) url 2022-05-02 08:21:03 -04:00
Dave Horton
9a126f396e remove orphaned webhooks when deleting accounts and applications 2022-04-30 12:17:37 -04:00
Dave Horton
d32a042c5d lint 2022-04-21 13:35:39 -04:00
Dave Horton
a129c3c927 bugfix: add ability to edit a speech credential by changing the region (aws or azure) 2022-04-21 13:31:22 -04:00
Dave Horton
a3403de45a bugfix: parse json when retrieving speech credential 2022-04-18 19:24:48 -04:00
Dave Horton
e2408b2511 retrieve aws_region when getting an AWS speech credential 2022-04-18 09:39:58 -04:00
Dave Horton
c432b71a64 bump version 2022-04-06 08:19:42 -04:00
Dave Horton
77f945bc6b Merge branch 'main' of github.com:jambonz/jambonz-api-server into main 2022-04-05 17:30:14 -04:00
Dave Horton
8c54e80d46 better env name RATE_LIMIT_WINDOWS_MINS 2022-04-05 17:29:58 -04:00
dependabot[bot]
815aea5c75 Bump node-forge from 1.2.1 to 1.3.0 (#42)
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.2.1 to 1.3.0.
- [Release notes](https://github.com/digitalbazaar/forge/releases)
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.2.1...v1.3.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-03-25 21:56:00 -04:00
11 changed files with 202 additions and 72 deletions

2
app.js
View File

@@ -92,7 +92,7 @@ const unless = (paths, middleware) => {
};
const limiter = rateLimit({
windowMs: (process.env.RATE_LIMIT_WINDOWS_MS || 5) * 60 * 1000, // 5 minutes
windowMs: (process.env.RATE_LIMIT_WINDOWS_MINS || 5) * 60 * 1000, // 5 minutes
max: process.env.RATE_LIMIT_MAX_PER_WINDOW || 600, // Limit each IP to 600 requests per `window`
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers

View File

@@ -27,6 +27,25 @@ class ApiKey extends Model {
});
}
/**
* list all api keys for a service provider
*/
static retrieveAllForSP(service_provider_sid) {
const sql = 'SELECT * from api_keys WHERE service_provider_sid = ?';
const args = [service_provider_sid];
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(sql, args, (err, results) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
/**
* update last_used api key for an account
*/

View File

@@ -240,10 +240,10 @@ async function validateCreateCall(logger, sid, req) {
if (typeof obj.call_status_hook === 'object' && typeof obj.call_status_hook.url != 'string') {
throw new DbErrorBadRequest('call_status_hook must be string or an object containing a url property');
}
if (obj.call_hook && !/^https?:/.test(obj.call_hook.url)) {
if (obj.call_hook && !/^https?:/.test(obj.call_hook.url) && !/^wss?:/.test(obj.call_hook.url)) {
throw new DbErrorBadRequest('call_hook url be an absolute url');
}
if (obj.call_status_hook && !/^https?:/.test(obj.call_status_hook.url)) {
if (obj.call_status_hook && !/^https?:/.test(obj.call_status_hook.url) && !/^wss?:/.test(obj.call_status_hook.url)) {
throw new DbErrorBadRequest('call_status_hook url be an absolute url');
}
}
@@ -519,7 +519,7 @@ router.delete('/:sid', async(req, res) => {
await validateDelete(req, sid);
const [account] = await promisePool.query('SELECT * FROM accounts WHERE account_sid = ?', sid);
const {sip_realm, stripe_customer_id} = account[0];
const {sip_realm, stripe_customer_id, registration_hook_sid} = account[0];
/* remove dns records */
if (process.env.NODE_ENV !== 'test' || process.env.DME_API_KEY) {
@@ -560,6 +560,15 @@ account_subscriptions WHERE account_sid = ?)
await promisePool.execute('DELETE from applications where account_sid = ?', [sid]);
await promisePool.execute('DELETE from accounts where account_sid = ?', [sid]);
if (registration_hook_sid) {
/* remove registration hook if only used by this account */
const sql = 'SELECT COUNT(*) as count FROM accounts WHERE registration_hook_sid = ?';
const [r] = await promisePool.query(sql, registration_hook_sid);
if (r[0]?.count === 0) {
await promisePool.execute('DELETE from webhooks where webhook_sid = ?', [registration_hook_sid]);
}
}
if (stripe_customer_id) {
const response = await deleteCustomer(logger, stripe_customer_id);
logger.info({response}, `deleted stripe customer_id ${stripe_customer_id} for account_si ${sid}`);

View File

@@ -1,10 +1,10 @@
const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const PredefinedCarrier = require('../../models/predefined-carrier');
const VoipCarrier = require('../../models/voip-carrier');
const SipGateway = require('../../models/sip-gateway');
const SmppGateway = require('../../models/smpp-gateway');
const {parseServiceProviderSid} = require('./utils');
const short = require('short-uuid');
const {promisePool} = require('../../db');
const sysError = require('../error');
@@ -43,8 +43,7 @@ router.post('/:sid', async(req, res) => {
await promisePool.query(sqlSelectCarrierByNameForSP, [service_provider_sid, template.name]);
if (r2.length > 0) {
logger.info({account_sid}, `Failed to add carrier with name ${template.name}, carrier of that name exists`);
throw new DbErrorBadRequest(`A carrier with name ${template.name} already exists`);
template.name = `${template.name}-${short.generate()}`;
}
/* retrieve all the sip gateways */

View File

@@ -3,12 +3,12 @@ const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/er
const Application = require('../../models/application');
const Account = require('../../models/account');
const Webhook = require('../../models/webhook');
const {promisePool} = require('../../db');
const decorate = require('./decorate');
const sysError = require('../error');
const preconditions = {
'add': validateAdd,
'update': validateUpdate,
'delete': validateDelete
'update': validateUpdate
};
/* only user-level tokens can add applications */
@@ -59,7 +59,7 @@ async function validateDelete(req, sid) {
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete application with phone numbers');
}
decorate(router, Application, ['delete'], preconditions);
decorate(router, Application, [], preconditions);
/* add */
router.post('/', async(req, res) => {
@@ -111,6 +111,47 @@ router.get('/:sid', async(req, res) => {
}
});
/* delete */
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
await validateDelete(req, sid);
const [application] = await promisePool.query('SELECT * FROM applications WHERE application_sid = ?', sid);
const {call_hook_sid, call_status_hook_sid, messaging_hook_sid} = application[0];
logger.info({call_hook_sid, call_status_hook_sid, messaging_hook_sid, sid}, 'deleting application');
await promisePool.execute('DELETE from applications where application_sid = ?', [sid]);
if (call_hook_sid) {
/* remove call hook if only used by this app */
const sql = 'SELECT COUNT(*) as count FROM applications WHERE call_hook_sid = ?';
const [r] = await promisePool.query(sql, call_hook_sid);
if (r[0]?.count === 0) {
await promisePool.execute('DELETE from webhooks where webhook_sid = ?', [call_hook_sid]);
}
}
if (call_status_hook_sid) {
const sql = 'SELECT COUNT(*) as count FROM applications WHERE call_status_hook_sid = ?';
const [r] = await promisePool.query(sql, call_status_hook_sid);
if (r[0]?.count === 0) {
await promisePool.execute('DELETE from webhooks where webhook_sid = ?', [call_status_hook_sid]);
}
}
if (messaging_hook_sid) {
const sql = 'SELECT COUNT(*) as count FROM applications WHERE messaging_hook_sid = ?';
const [r] = await promisePool.query(sql, messaging_hook_sid);
if (r[0]?.count === 0) {
await promisePool.execute('DELETE from webhooks where webhook_sid = ?', [messaging_hook_sid]);
}
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
});
/* update */
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;

View File

@@ -7,9 +7,16 @@ const isAdminScope = (req, res, next) => {
message: 'insufficient privileges'
});
};
const isAdminOrSPScope = (req, res, next) => {
if (req.user.hasScope('admin') || req.user.hasScope('service_provider')) return next();
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
};
api.use('/BetaInviteCodes', isAdminScope, require('./beta-invite-codes'));
api.use('/ServiceProviders', isAdminScope, require('./service-providers'));
api.use('/ServiceProviders', isAdminOrSPScope, require('./service-providers'));
api.use('/VoipCarriers', require('./voip-carriers'));
api.use('/Webhooks', require('./webhooks'));
api.use('/SipGateways', require('./sip-gateways'));

View File

@@ -7,6 +7,7 @@ const Account = require('../../models/account');
const VoipCarrier = require('../../models/voip-carrier');
const Application = require('../../models/application');
const PhoneNumber = require('../../models/phone-number');
const ApiKey = require('../../models/api-key');
const {hasServiceProviderPermissions, parseServiceProviderSid} = require('./utils');
const sysError = require('../error');
const decorate = require('./decorate');
@@ -114,6 +115,18 @@ router.get(':sid/Acccounts', async(req, res) => {
sysError(logger, res, err);
}
});
router.get('/:sid/ApiKeys', async(req, res) => {
const logger = req.app.locals.logger;
const {sid} = req.params;
try {
const results = await ApiKey.retrieveAllForSP(sid);
res.status(200).json(results);
await ApiKey.updateLastUsed(sid);
} catch (err) {
sysError(logger, res, err);
}
});
/* add */
router.post('/', async(req, res) => {

View File

@@ -1,9 +1,10 @@
const router = require('express').Router();
const assert = require('assert');
const SpeechCredential = require('../../models/speech-credential');
const sysError = require('../error');
const {decrypt, encrypt} = require('../../utils/encrypt-decrypt');
const {parseAccountSid, parseServiceProviderSid} = require('./utils');
const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/errors');
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const {
testGoogleTts,
testGoogleStt,
@@ -14,11 +15,9 @@ const {
testWellSaidTts
} = require('../../utils/speech-utils');
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const encryptCredential = (obj) => {
const {
use_for_stt,
use_for_tts,
vendor,
service_key,
access_key_id,
@@ -26,6 +25,49 @@ router.post('/', async(req, res) => {
aws_region,
api_key,
region
} = obj;
switch (vendor) {
case 'google':
assert(service_key, 'invalid json key: service_key is required');
try {
const o = JSON.parse(service_key);
assert(o.client_email && o.private_key, 'invalid google service account key');
}
catch (err) {
assert(false, 'invalid google service account key - not JSON');
}
return encrypt(service_key);
case 'aws':
assert(access_key_id, 'invalid aws speech credential: access_key_id is required');
assert(secret_access_key, 'invalid aws speech credential: secret_access_key is required');
assert(aws_region, 'invalid aws speech credential: aws_region is required');
const awsData = JSON.stringify({aws_region, access_key_id, secret_access_key});
return encrypt(awsData);
case 'microsoft':
assert(region, 'invalid azure speech credential: region is required');
assert(api_key, 'invalid azure speech credential: api_key is required');
const azureData = JSON.stringify({region, api_key});
return encrypt(azureData);
case 'wellsaid':
assert(api_key, 'invalid wellsaid speech credential: api_key is required');
const wsData = JSON.stringify({api_key});
return encrypt(wsData);
default:
assert(false, `invalid or missing vendor: ${vendor}`);
}
};
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
const {
use_for_stt,
use_for_tts,
vendor,
} = req.body;
const account_sid = req.user.account_sid || req.body.account_sid;
let service_provider_sid;
@@ -37,43 +79,7 @@ router.post('/', async(req, res) => {
service_provider_sid = parseServiceProviderSid(req);
}
try {
let encrypted_credential;
if (vendor === 'google') {
let obj;
if (!service_key) throw new DbErrorBadRequest('invalid json key: service_key is required');
try {
obj = JSON.parse(service_key);
if (!obj.client_email || !obj.private_key) {
throw new DbErrorBadRequest('invalid google service account key');
}
}
catch (err) {
throw new DbErrorBadRequest('invalid google service account key - not JSON');
}
encrypted_credential = encrypt(service_key);
}
else if (vendor === 'aws') {
const data = JSON.stringify({
aws_region: aws_region || 'us-east-1',
access_key_id,
secret_access_key
});
encrypted_credential = encrypt(data);
}
else if (vendor === 'microsoft') {
const data = JSON.stringify({
region,
api_key
});
encrypted_credential = encrypt(data);
}
else if (vendor === 'wellsaid') {
const data = JSON.stringify({
api_key
});
encrypted_credential = encrypt(data);
}
else throw new DbErrorBadRequest(`invalid speech vendor ${vendor}`);
const encrypted_credential = encryptCredential(req.body);
const uuid = await SpeechCredential.make({
account_sid,
service_provider_sid,
@@ -104,20 +110,23 @@ router.get('/', async(req, res) => {
res.status(200).json(creds.map((c) => {
const {credential, ...obj} = c;
if ('google' === obj.vendor) {
obj.service_key = decrypt(credential);
obj.service_key = JSON.parse(decrypt(credential));
}
else if ('aws' === obj.vendor) {
const o = decrypt(credential);
const o = JSON.parse(decrypt(credential));
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
obj.aws_region = o.aws_region;
logger.info({obj, o}, 'retrieving aws speech credential');
}
else if ('microsoft' === obj.vendor) {
const o = decrypt(credential);
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
logger.info({obj, o}, 'retrieving azure speech credential');
}
else if ('wellsaid' === obj.vendor) {
const o = decrypt(credential);
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
}
return obj;
@@ -144,6 +153,7 @@ router.get('/:sid', async(req, res) => {
const o = JSON.parse(decrypt(credential));
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
obj.aws_region = o.aws_region;
}
else if ('microsoft' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
@@ -191,6 +201,11 @@ router.put('/:sid', async(req, res) => {
obj.use_for_stt = use_for_stt;
}
/* update the credential if provided */
try {
obj.credential = encryptCredential(req.body);
} catch (err) {}
const rowsAffected = await SpeechCredential.update(sid, obj);
if (rowsAffected === 0) {
return res.sendStatus(404);

28
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jambonz-api-server",
"version": "v0.7.4",
"version": "v0.7.5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "jambonz-api-server",
"version": "v0.7.4",
"version": "v0.7.5",
"license": "MIT",
"dependencies": {
"@google-cloud/speech": "^4.10.0",
@@ -4804,9 +4804,9 @@
}
},
"node_modules/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
},
"node_modules/mkdirp": {
"version": "0.5.5",
@@ -4925,9 +4925,9 @@
}
},
"node_modules/node-forge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.2.1.tgz",
"integrity": "sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.0.tgz",
"integrity": "sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA==",
"engines": {
"node": ">= 6.13.0"
}
@@ -10658,9 +10658,9 @@
}
},
"minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
"integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
},
"mkdirp": {
"version": "0.5.5",
@@ -10754,9 +10754,9 @@
}
},
"node-forge": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.2.1.tgz",
"integrity": "sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w=="
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.0.tgz",
"integrity": "sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA=="
},
"node-gyp-build": {
"version": "4.2.3",

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-api-server",
"version": "v0.7.4",
"version": "v0.7.5",
"description": "",
"main": "app.js",
"scripts": {

View File

@@ -106,6 +106,33 @@ test('service provider tests', async(t) => {
});
t.ok(result.statusCode === 204, 'successfully updated service provider');
/* add an api key for a service provider */
result = await request.post(`/ApiKeys`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
body: {
service_provider_sid: sid
}
});
t.ok(result.statusCode === 201, 'successfully added an api_key for a service provider');
const apiKeySid = result.body.sid;
/* query all api keys for a service provider */
result = await request.get(`/ServiceProviders/${sid}/ApiKeys`, {
auth: authAdmin,
json: true,
});
t.ok(result.length === 1 , 'successfully queried all service provider keys');
/* delete an api key */
result = await request.delete(`/ApiKeys/${apiKeySid}`, {
auth: authAdmin,
json: true,
resolveWithFullResponse: true,
});
t.ok(result.statusCode === 204, 'successfully deleted an api_key for a service provider');
/* add a predefined carrier for a service provider */
result = await request.post(`/ServiceProviders/${sid}/PredefinedCarriers/7d509a18-bbff-4c5d-b21e-b99bf8f8c49a`, {
auth: authAdmin,