revamped db schema and implemented some simple api

This commit is contained in:
Dave Horton
2019-12-02 16:49:25 -05:00
parent 8c287f06df
commit 47bb642854
19 changed files with 1005 additions and 592 deletions

View File

@@ -1,10 +1,9 @@
const Strategy = require('passport-http-bearer').Strategy;
const {getMysqlConnection} = require('../db');
const sql = `
SELECT api_keys.uuid, accounts.uuid
SELECT *
FROM api_keys
LEFT JOIN accounts
ON api_keys.account_id = accounts.id`;
WHERE api_keys.token = ?`;
function makeStrategy(logger) {
return new Strategy(
@@ -15,7 +14,7 @@ function makeStrategy(logger) {
logger.error(err, 'Error retrieving mysql connection');
return done(err);
}
conn.query({sql, nestTables: '_'}, [token], (err, results, fields) => {
conn.query(sql, [token], (err, results, fields) => {
conn.release();
if (err) {
logger.error(err, 'Error querying for api key');
@@ -28,9 +27,20 @@ function makeStrategy(logger) {
}
// found api key
return done(null,
{accountSid: results[0].accounts_uuid},
{scope: results[0].accounts_uuid ? ['user'] : ['admin']});
logger.info(results, 'api key lookup');
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
};
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});
});
});
});

31
lib/models/api-key.js Normal file
View File

@@ -0,0 +1,31 @@
const Model = require('./model');
class ApiKey extends Model {
constructor() {
super();
}
}
ApiKey.table = 'api_keys';
ApiKey.fields = [
{
name: 'api_key_sid',
type: 'string',
primaryKey: true
},
{
name: 'token',
type: 'string',
required: true
},
{
name: 'account_sid',
type: 'string'
},
{
name: 'service_provider_sid',
type: 'string'
}
];
module.exports = ApiKey;

141
lib/models/model.js Normal file
View File

@@ -0,0 +1,141 @@
const Emitter = require('events');
const uuidv4 = require('uuid/v4');
const assert = require('assert');
const {getMysqlConnection} = require('../db');
const {DbErrorBadRequest} = require('../utils/errors');
class Model extends Emitter {
constructor() {
super();
}
static getPrimaryKey() {
return this.fields.find((f) => f.primaryKey === true);
}
/**
* check validity of object to be inserted into db
*/
static checkIsInsertable(obj) {
// check all required fields are present
const required = this.fields.filter((f) => f.required === true);
const missing = required.find((f) => !(f.name in obj));
if (missing) throw new DbErrorBadRequest(`missing field ${missing.name}`);
return true;
}
/**
* insert object into the database
*/
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);
conn.query(`INSERT into ${this.table} SET ?`,
obj,
(err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(uuid);
});
});
});
}
/**
* delete object from database
*/
static remove(uuid) {
return new Promise((resolve, reject) => {
const pk = this.getPrimaryKey();
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`DELETE from ${this.table} WHERE ${pk.name} = ?`, uuid, (err, results) => {
conn.release();
if (err) return reject(err);
resolve(results.affectedRows);
});
});
});
}
/**
* retrieve all objects
*/
static retrieveAll() {
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`SELECT * from ${this.table}`, (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
/**
* retrieve a specific object
*/
static retrieve(sid) {
return new Promise((resolve, reject) => {
const pk = this.getPrimaryKey();
assert.ok(pk, 'field definitions must include the primary key');
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`SELECT * from ${this.table} WHERE ${pk.name} = ?`, sid, (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
/**
* update an object
*/
static update(sid, obj) {
const pk = this.getPrimaryKey();
assert.ok(pk, 'field definitions must include the primary key');
return new Promise((resolve, reject) => {
if (pk.name in obj) throw new DbErrorBadRequest(`primary key ${pk.name} is immutable`);
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`UPDATE ${this.table} SET ? WHERE ${pk.name} = '${sid}'`, obj, (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results.affectedRows);
});
});
});
}
static getForeignKeyReferences(fk, sid) {
return new Promise((resolve, reject) => {
const arr = /(.*)\.(.*)/.exec(fk);
assert.ok(arr, `foreign key must be written as table.column: ${fk}`);
const table = arr[1];
const column = arr[2];
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`SELECT COUNT(*) as count from ${table} WHERE ${column} = ?`,
sid, (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results[0].count);
});
});
});
}
}
Model.table = 'subclassResponsibility';
Model.fields = [];
module.exports = Model;

View File

@@ -1,23 +1,27 @@
const Emitter = require('events');
const {getMysqlConnection} = require('../db');
const scrubIds = require('../utils/scrub-ids');
const Model = require('./model');
class ServiceProvider extends Emitter {
class ServiceProvider extends Model {
constructor() {
super();
}
static retrieveAll() {
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query('SELECT * from service_providers', (err, results, fields) => {
if (err) return reject(err);
resolve(scrubIds(results));
});
});
});
}
}
ServiceProvider.table = 'service_providers';
ServiceProvider.fields = [
{
name: 'service_provider_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'description',
type: 'string'
}
];
module.exports = ServiceProvider;

View File

@@ -0,0 +1,80 @@
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');
const sysError = require('./error');
const preconditions = {
'add': validateAddToken,
'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');
}
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 (isUser(req)) {
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');
}
}
else if (isServiceProvider(req)) {
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');
}
if (results[0].service_provider_sid && results[0].service_provider_sid != req.user.service_provider_sid) {
throw new DbErrorBadRequest('a service provider user may not delete api key from another service provider');
}
}
else if (isUser(req)) {
if (results[0].account_sid !== req.user.account_sid) {
throw new DbErrorBadRequest('a user may not delete a token associated with a different account');
}
}
}
/**
* need to handle here because response is slightly different than standard for an insert
* (returning the token generated along with the sid)
*/
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
if ('add' in preconditions) {
assert(typeof preconditions.add === 'function');
await preconditions.add(req);
}
const uuid = await ApiKey.make(req.body);
res.status(201).json({sid: uuid, token: req.body.token});
} catch (err) {
sysError(logger, res, err);
}
});
decorate(router, ApiKey, ['delete'], preconditions);
module.exports = router;

101
lib/routes/api/decorate.js Normal file
View File

@@ -0,0 +1,101 @@
const assert = require('assert');
const sysError = require('./error');
module.exports = decorate;
const decorators = {
'list': list,
'add': add,
'retrieve': retrieve,
'update': update,
'delete': remove
};
function decorate(router, klass, methods, preconditions) {
const decs = methods && Array.isArray(methods) && methods[0] !== '*' ? methods : Object.keys(decorators);
decs.forEach((m) => {
assert(m in decorators);
decorators[m](router, klass, preconditions);
});
}
function list(router, klass) {
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 {
const results = await klass.retrieveAll();
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
}
function add(router, klass, preconditions) {
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
if ('add' in preconditions) {
assert(typeof preconditions.add === 'function');
await preconditions.add(req);
}
const uuid = await klass.make(req.body);
res.status(201).json({sid: uuid});
} catch (err) {
sysError(logger, res, err);
}
});
}
function retrieve(router, klass) {
router.get('/:sid', async(req, res) => {
const logger = req.app.locals.logger;
try {
const results = await klass.retrieve(req.params.sid);
if (results.length === 0) return res.status(404).end();
return res.status(200).json(results[0]);
}
catch (err) {
sysError(logger, res, err);
}
});
}
function update(router, klass) {
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
const rowsAffected = await klass.update(sid, req.body);
if (rowsAffected === 0) {
return res.status(404).end();
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
});
}
function remove(router, klass, preconditions) {
router.delete('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
if ('delete' in preconditions) {
assert(typeof preconditions.delete === 'function');
await preconditions.delete(req, sid);
}
const rowsAffected = await klass.remove(sid);
if (rowsAffected === 0) {
logger.info(`unable to delete ${klass.name} with sid ${sid}: not found`);
return res.status(404).end();
}
res.status(204).end();
} catch (err) {
sysError(logger, res, err);
}
});
}

20
lib/routes/api/error.js Normal file
View File

@@ -0,0 +1,20 @@
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
function sysError(logger, res, err) {
if (err instanceof DbErrorBadRequest) {
logger.error(err, 'invalid client request');
return res.status(400).json({msg: err.message});
}
if (err instanceof DbErrorUnprocessableRequest) {
logger.error(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');
return res.status(422).json({msg: err.message});
}
logger.error(err, 'Database error');
res.status(500).json({msg: err.message});
}
module.exports = sysError;

View File

@@ -1,21 +1,15 @@
const api = require('express').Router();
const {isAdmin} = require('../../utils/scopes');
function isAdmin(req, res, next) {
if (req.authInfo.scope.includes('admin')) return next();
function isAdminScope(req, res, next) {
if (isAdmin(req)) return next();
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
});
}
function isUser(req, res, next) {
if (req.authInfo.scope.includes('user')) return next();
res.status(403).json({
status: 'fail',
message: 'end-user data can not be modified with admin privileges'
});
}
api.use('/ServiceProviders', isAdmin, require('./service-providers'));
api.use('/ServiceProviders', isAdminScope, require('./service-providers'));
api.use('/ApiKeys', require('./api-keys'));
module.exports = api;

View File

@@ -1,28 +1,17 @@
const router = require('express').Router();
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const ServiceProvider = require('../../models/service-provider');
const decorate = require('./decorate');
const preconditions = {
'delete': noActiveAccounts
};
function sysError(logger, res, err) {
logger.error(err, 'Database error');
res.status(500).end();
/* can not delete a service provider if it has any active accounts */
async function noActiveAccounts(req, sid) {
const activeAccounts = await ServiceProvider.getForeignKeyReferences('accounts.service_provider_sid', sid);
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete service provider with active accounts');
}
/* return list of all service providers */
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 {
const results = await ServiceProvider.retrieveAll();
res.status(200).json(results);
} catch (err) {
logger.error(err, 'Error retrieving service providers');
sysError(logger, res, err);
}
});
/* add a service provider */
router.post('/', (req, res) => {
});
decorate(router, ServiceProvider, ['*'], preconditions);
module.exports = router;

View File

@@ -12,6 +12,58 @@ servers:
- url: /v1
description: development server
paths:
/Apikeys:
post:
summary: create an api key
operationId: createApikey
requestBody:
content:
application/json:
schema:
type: object
properties:
service_provider_sid:
type: string
description: service provider scope for the generated api key
account_sid:
type: string
description: account scope for the generated api key
responses:
201:
description: api key successfully created
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessfulApiKeyAdd'
400:
description: bad request
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
500:
description: bad request
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
/Apikeys/{ApiKeySid}:
parameters:
- name: ApiKeySid
in: path
required: true
schema:
type: string
delete:
summary: delete api key
operationId: deleteApiKey
responses:
200:
description: api key deleted
404:
description: api key or account not found
/ServiceProviders:
post:
summary: create service provider
@@ -35,17 +87,14 @@ paths:
content:
application/json:
schema:
required:
- serviceProviderSid
properties:
serviceProviderSid:
type: string
format: uuid
example: 2531329f-fb09-4ef7-887e-84e648214436
400:
description: bad request
409:
description: an existing service provider already exists with this name
$ref: '#/components/schemas/SuccessfulAdd'
422:
description: unprocessable entity
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
get:
summary: list service providers
operationId: listServiceProviders
@@ -71,12 +120,16 @@ paths:
summary: delete a service provider
operationId: deleteServiceProvider
responses:
200:
204:
description: service provider successfully deleted
404:
description: service provider not found
409:
description: service provider with active accounts can not be deleted
422:
description: unprocessable entity
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
get:
summary: retrieve service provider
operationId: getServiceProvider
@@ -98,14 +151,16 @@ paths:
schema:
$ref: '#/components/schemas/ServiceProvider'
responses:
200:
204:
description: service provider updated
404:
description: service provider not found
422:
description: unprocessable entity
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceProvider'
404:
description: service provider not found
$ref: '#/components/schemas/GeneralError'
/ServiceProviders/{ServiceProviderSid}/Accounts:
parameters:
- name: ServiceProviderSid
@@ -640,8 +695,8 @@ paths:
/Accounts/{AccountSid}/Apikeys:
post:
summary: create api key
operationId: createApikey
summary: create an account level api key
operationId: createAccountApikey
parameters:
- name: AccountSid
in: path
@@ -660,22 +715,22 @@ paths:
- apiKey
- apiKeySid
properties:
apiKeySid:
api_key_sid:
type: string
description: system identifier for api key that was created
format: uuid
example: 7531328e-eb08-4eff-887e-84e648214872
apiKey:
token:
type: string
description: api key
description: api key authorization token
format: uuid
example: 2531329f-fb09-4ef7-887e-84e648214436
404:
description: Account not found
/Accounts/{AccountSid}/Apikeys/{ApiKeySid}:
delete:
summary: delete api key
operationId: deleteApiKey
summary: delete account-level api key
operationId: deleteAccountApiKey
parameters:
- name: AccountSid
in: path
@@ -775,121 +830,171 @@ components:
scheme: bearer
bearerFormat: token
schemas:
SuccessfulApiKeyAdd:
type: object
required:
- sid
- token
properties:
sid:
type: string
token:
type: string
example:
sid: 9d26a637-1679-471f-8da8-7150266e1254
token: 589cead6-de24-4689-8ac3-08ffaf102811
SuccessfulAdd:
type: object
required:
- sid
properties:
sid:
type: string
example:
sid: 9d26a637-1679-471f-8da8-7150266e1254
GeneralError:
type: object
required:
- msg
properties:
msg:
type: string
example:
msg: cannot delete service provider with active accounts
ServiceProvider:
type: object
properties:
id:
type: integer
uuid:
service_provider_sid:
type: string
format: uuid
name:
type: string
description:
type: string
required:
- service_provider_sid
- name
VoipCarrier:
type: object
properties:
id:
type: integer
uuid:
voip_carrier_sid:
type: string
format: uuid
name:
type: string
description:
type: string
required:
- voip_carrier_sid
- name
Account:
type: object
properties:
id:
type: integer
uuid:
account_sid:
type: string
format: uuid
sipRealm:
name:
type: string
registrationHook:
sip_realm:
type: string
registration_hook:
type: string
format: url
serviceProvider:
service_provider:
$ref: '#/components/schemas/ServiceProvider'
required:
- account_sid
- name
- service_provider
Application:
type: object
properties:
id:
type: integer
uuid:
application_sid:
type: string
format: uuid
name:
type: string
account:
$ref: '#/components/schemas/Account'
callHook:
call_hook:
type: string
format: url
callBackupHook:
type: string
format: url
callStatusChangeHook:
call_status_hook:
type: string
format: url
required:
- application_sid
- name
- account
- call_hook
- call_status_hook
PhoneNumber:
type: object
properties:
id:
type: integer
uuid:
phone_number_sid:
type: string
format: uuid
number:
type: string
voipCarrier:
voip_carrier:
$ref: '#/components/schemas/VoipCarrier'
account:
$ref: '#/components/schemas/Account'
application:
$ref: '#/components/schemas/Application'
RegisteredUser:
required:
- phone_number_sid
- number
- voip_carrier
- account
Registration:
type: object
properties:
id:
type: integer
uuid:
registration_sid:
type: string
format: uuid
username:
type: string
domain:
type: string
sip_contact:
type: string
sip_user_agent:
type: string
required:
- registration_sid
- username
- domain
- sip_contact
Call:
type: object
properties:
id:
type: integer
uuid:
call_sid:
type: string
format: uuid
application:
$ref: '#/components/schemas/Application'
parentCall:
parent_call:
$ref: '#/components/schemas/Call'
direction:
type: string
enum:
- inbound
- outbound
phoneNumber:
phone_number:
$ref: '#/components/schemas/PhoneNumber'
inboundUser:
inbound_user:
$ref: '#/components/schemas/RegisteredUser'
outboundUser:
outbound_user:
$ref: '#/components/schemas/RegisteredUser'
callingNumber:
calling_number:
type: string
calledNumber:
called_number:
type: string
required:
- call_sid
- application
- direction
security:
- bearerAuth: []

23
lib/utils/errors.js Normal file
View File

@@ -0,0 +1,23 @@
class DbError extends Error {
constructor(msg) {
super(msg);
}
}
class DbErrorBadRequest extends DbError {
constructor(msg) {
super(msg);
}
}
class DbErrorUnprocessableRequest extends DbError {
constructor(msg) {
super(msg);
}
}
module.exports = {
DbError,
DbErrorBadRequest,
DbErrorUnprocessableRequest
};

17
lib/utils/scopes.js Normal file
View File

@@ -0,0 +1,17 @@
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
};

View File

@@ -1,6 +0,0 @@
module.exports = function(tuples) {
return tuples.map((t) => {
delete t.id;
return t;
});
};

23
lib/utils/transforms.js Normal file
View File

@@ -0,0 +1,23 @@
function scrubIds(tuples) {
return tuples.map((t) => {
delete t.id;
return t;
});
}
function rewriteKeys(tuples, obj) {
return tuples.map((t) => {
Object.keys(obj).forEach((k) => {
if (k in t) {
t[obj[k]] = t[k];
delete t[k];
}
});
});
}
module.exports = {
scrubIds,
rewriteKeys
};