diff --git a/doc/API.md b/doc/API.md index aad011d..e33be2d 100644 --- a/doc/API.md +++ b/doc/API.md @@ -186,6 +186,7 @@ ___Parameters___ * name - API client name. * authRedirectUrls (optional) - API client user authentication redirection URLs. * authFailureRedirectUrls (optional) - API client user authentication failure redirection URLs. +* permissions (optional) - List of permissions the client is allowed to request. ```ssh POST /api/clients HTTP/1.1 @@ -194,7 +195,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImFkbWluIiwic { "name": "SensorWebClient", "authRedirectUrls": ["https://domain.org/auth/success"], - "authFailureRedirectUrls": ["https://domain.org/auth/error"] + "authFailureRedirectUrls": ["https://domain.org/auth/error"], + "permissions": ["sensorthings-api"] } ``` @@ -233,7 +235,10 @@ Content-Type: application/json; charset=utf-8 [ { "name": "SensorWebClient", - "key": "766a06dab7358b6aec17891df1fe8555" + "key": "766a06dab7358b6aec17891df1fe8555", + "authRedirectUrls": ["https://domain.org/auth/success"], + "authFailureRedirectUrls": ["https://domain.org/auth/error"], + "permissions": ["sensorthings-api"] } ] ``` diff --git a/package.json b/package.json index 4576fad..ab0411a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "passport-http": "^0.3.0", "pg": "^6.1.0", "sensorthings": "^0.5.0", - "sequelize": "^3.24.5" + "sequelize": "^3.24.5", + "sequelize-fixtures": "^0.5.6" } } diff --git a/src/config.js b/src/config.js index 9c912bb..d86be93 100644 --- a/src/config.js +++ b/src/config.js @@ -22,8 +22,8 @@ const password = value => { convict.addFormat({ name: 'dbport', - validate: (val) => (val === null || val >= 0 && val <= 65535), - coerce: (val) => (val === null ? null : parseInt(val)) + validate: val => (val === null || val >= 0 && val <= 65535), + coerce: val => (val === null ? null : parseInt(val)) }); convict.addFormat({ @@ -35,6 +35,14 @@ convict.addFormat({ } }); +convict.addFormat({ + name: 'arrayOfStrings', + validate: val => ( + Array.isArray(val) && val.every(item => typeof item === 'string') + ) +}); + +// Note: Alphabetically ordered, please. const conf = convict({ adminPass: { doc: 'The password for the admin user. Follow OWASP guidelines for passwords', @@ -46,17 +54,15 @@ const conf = convict({ format: avoidDefault, default: defaultValue }, - env: { - doc: 'The application environment.', - format: ['dev', 'test', 'stage', 'prod', 'circleci'], - default: 'dev', - env: 'NODE_ENV' - }, - port: { - doc: 'The port to bind.', - format: 'port', - default: 8080, - env: 'PORT' + behindProxy: { + doc: `Set this to true if the server runs behind a reverse proxy. This is + especially important if the proxy implements HTTPS with + userAuth.cookieSecure. Also with this Express will trust the + X-Forwarded-For header. Set to 1 or auto if you're behind a proxy.`, + default: false, + // Format is "*" because otherwise convict infers it's a boolean from the + // default value and will refuse 1 or "auto". + format: '*', }, db: { host: { @@ -81,6 +87,24 @@ const conf = convict({ default: '', } }, + env: { + doc: 'The application environment.', + format: ['dev', 'test', 'stage', 'prod', 'circleci'], + default: 'dev', + env: 'NODE_ENV' + }, + // XXX Define list of scopes Issue #53 + permissions: { + doc: 'List of allowed client permissions', + format: 'arrayOfStrings', + default: ['admin'] + }, + port: { + doc: 'The port to bind.', + format: 'port', + default: 8080, + env: 'PORT' + }, publicHost: { doc: 'Public host for this server, especially for auth callback' }, @@ -105,16 +129,6 @@ const conf = convict({ } } }, - behindProxy: { - doc: `Set this to true if the server runs behind a reverse proxy. This is - especially important if the proxy implements HTTPS with - userAuth.cookieSecure. Also with this Express will trust the - X-Forwarded-For header. Set to 1 or auto if you're behind a proxy.`, - default: false, - // Format is "*" because otherwise convict infers it's a boolean from the - // default value and will refuse 1 or "auto". - format: '*', - }, userAuth: { cookieSecure: { doc: `This configures whether the cookie should be set and sent for @@ -139,7 +153,8 @@ const conf = convict({ }, }, version: { - doc: 'API version. We follow SensorThing\'s versioning format as described at http://docs.opengeospatial.org/is/15-078r6/15-078r6.html#34', + doc: `API version. We follow SensorThing\'s versioning format as described + at http://docs.opengeospatial.org/is/15-078r6/15-078r6.html#34`, format: value => { const pattern = /^v(\d+\.)?(\d)$/g; const match = pattern.exec(value); diff --git a/src/errors.js b/src/errors.js index 75680e5..4b36702 100644 --- a/src/errors.js +++ b/src/errors.js @@ -2,6 +2,7 @@ const errnos = { ERRNO_INVALID_API_CLIENT_NAME : 100, ERRNO_INVALID_API_CLIENT_REDIRECT_URL : 101, + ERRNO_INVALID_API_CLIENT_PERMISSION : 102, ERRNO_BAD_REQUEST : 400, ERRNO_UNAUTHORIZED : 401, ERRNO_FORBIDDEN : 403, diff --git a/src/models/clients.js b/src/models/clients.js index 74da587..4d332e5 100644 --- a/src/models/clients.js +++ b/src/models/clients.js @@ -42,6 +42,13 @@ module.exports = (sequelize, DataTypes) => { this.setDataValue('authFailureRedirectUrls', value); } } + }, { + classMethods: { + associate: db => { + Client.belongsToMany(db.Permissions, { through: 'ClientPermissions' }); + } + } }); + return Client; } diff --git a/src/models/db.js b/src/models/db.js index 1a32826..8e7a8f1 100644 --- a/src/models/db.js +++ b/src/models/db.js @@ -4,10 +4,11 @@ 'use strict'; -import config from '../config'; -import fs from 'fs'; -import path from 'path'; +import config from '../config'; +import fs from 'fs'; +import path from 'path'; import Sequelize from 'sequelize'; +import sequelizeFixtures from 'sequelize-fixtures'; const IDLE = 0 const INITIALIZING = 1; @@ -77,6 +78,13 @@ export default function() { deferreds.pop().resolve(db); } state = READY; + + // Load default permissions. + const permissions = config.get('permissions').map(permission => { + return { model: 'Permissions', data: { name: permission }}; + }); + return sequelizeFixtures.loadFixtures(permissions, db); + }).then(() => { return db; }).catch(e => { console.error(e); diff --git a/src/models/permissions.js b/src/models/permissions.js new file mode 100644 index 0000000..31b9934 --- /dev/null +++ b/src/models/permissions.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +module.exports = (sequelize, DataTypes) => { + const Permission = sequelize.define('Permissions', { + name: { type: DataTypes.STRING, primaryKey: true } + }, { timestamps: false }); + + return Permission; +}; diff --git a/src/routes/clients.js b/src/routes/clients.js index 8625876..3c433bc 100644 --- a/src/routes/clients.js +++ b/src/routes/clients.js @@ -8,7 +8,7 @@ import express from 'express'; -import db from '../models/db'; +import db from '../models/db'; import { ApiError, BAD_REQUEST, @@ -17,6 +17,7 @@ import { ERRNO_FORBIDDEN, ERRNO_INTERNAL_ERROR, ERRNO_INVALID_API_CLIENT_NAME, + ERRNO_INVALID_API_CLIENT_PERMISSION, ERRNO_INVALID_API_CLIENT_REDIRECT_URL, INTERNAL_ERROR, modelErrors, @@ -50,6 +51,15 @@ router.post('/', (req, res) => { .isArrayOfUrls({ require_valid_protocol: true }); } + const permissions = req.body.permissions; + if (permissions) { + if (!Array.isArray(permissions)) { + req.body.permissions = [permissions]; + } + req.checkBody('permissions', 'invalid "permissions"') + .isArrayOfPermissions(); + } + const error = req.validationErrors()[0]; if (error) { let errno; @@ -61,6 +71,9 @@ router.post('/', (req, res) => { case 'name': errno = ERRNO_INVALID_API_CLIENT_NAME; break; + case 'permissions': + errno = ERRNO_INVALID_API_CLIENT_PERMISSION; + break; default: errno = ERRNO_BAD_REQUEST; } @@ -68,25 +81,48 @@ router.post('/', (req, res) => { } db().then(models => { - models.Clients.create(req.body).then(client => { - res.status(201).send(client); - }).catch(error => { - if (error.name && error.name === modelErrors[RECORD_ALREADY_EXISTS]) { - return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); - } - ApiError(res, 500, ERRNO_INTERNAL_ERROR, INTERNAL_ERROR); + return models.sequelize.transaction(transaction => { + return models.Clients.create(req.body, { transaction }).then(client => { + if (!req.body.permissions) { + return client; + } + return client.addPermissions(req.body.permissions, { + transaction + }).then(() => client); + }); }); + }).then(client => { + res.status(201).send(client); + }).catch(error => { + if (error.name && error.name === modelErrors[RECORD_ALREADY_EXISTS]) { + return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); + } + ApiError(res, 500, ERRNO_INTERNAL_ERROR, INTERNAL_ERROR); }); }); +const normalizeClient = client => { + if (client.Permissions) { + client.dataValues.permissions = client.Permissions.map( + permission => permission.name + ); + delete client.dataValues.Permissions; + } + return client; +}; + // Get the list of registered API clients. router.get('/', (req, res) => { db().then(models => { models.Clients.findAll({ attributes: ['key', 'name', 'authRedirectUrls', - 'authFailureRedirectUrls'] + 'authFailureRedirectUrls'], + include: [{ + model: models.Permissions, + attributes: ['name'], + }], }).then(clients => { - res.status(200).send(clients); + res.status(200).send(clients.map(normalizeClient)); }).catch(error => { ApiError(res, 500, ERRNO_INTERNAL_ERROR, INTERNAL_ERROR); }); diff --git a/src/server.js b/src/server.js index 8621267..7ace03e 100644 --- a/src/server.js +++ b/src/server.js @@ -26,6 +26,11 @@ app.use(expressValidator({ const validator = expressValidator.validator; return Array.isArray(value) && value.every(item => validator.isURL(item, options)); + }, + isArrayOfPermissions: (value) => { + const permissions = config.get('permissions'); + return Array.isArray(value) && + value.every(item => permissions.indexOf(item) !== -1); } } })); diff --git a/test/test_clients_api.js b/test/test_clients_api.js index c9d2092..a31f200 100644 --- a/test/test_clients_api.js +++ b/test/test_clients_api.js @@ -10,6 +10,7 @@ import { errnos, ERRNO_FORBIDDEN, ERRNO_INVALID_API_CLIENT_NAME, + ERRNO_INVALID_API_CLIENT_PERMISSION, ERRNO_INVALID_API_CLIENT_REDIRECT_URL, ERRNO_UNAUTHORIZED, errors, @@ -34,130 +35,165 @@ describe('Clients API', () => { }); }); + beforeEach(function*() { + const { Clients } = yield db(); + yield Clients.destroy({ where: {} }); + }); + describe('POST ' + endpointPrefix + '/clients', () => { - it('should respond 401 Unauthorized if there is no auth header', done => { + const postAndCheckSuccess = (done, body) => { server.post(endpointPrefix + '/clients') + .set('Authorization', 'Bearer ' + token) + .send(body) .expect('Content-type', /json/) - .expect(401) .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); + should.not.exist(err); + res.status.should.be.equal(201); + res.body.key.should.be.instanceof(String) + .and.have.lengthOf(16); + res.body.secret.should.be.instanceof(String) + .and.have.lengthOf(128); done(); }); - }); + }; - it('should respond 400 BadRequest if name param is missing', done => { + const postAndCheckError = (done, body, auth, error) => { server.post(endpointPrefix + '/clients') - .set('Authorization', 'Bearer ' + token) + .set('Authorization', auth ? 'Bearer ' + token : '') + .send(body) .expect('Content-type', /json/) - .expect(400) + .expect(error.code) .end((err, res) => { - res.status.should.be.equal(400); - res.body.code.should.be.equal(400); - res.body.errno.should.be.equal(errnos[ERRNO_INVALID_API_CLIENT_NAME]); - res.body.error.should.be.equal(errors[BAD_REQUEST]); + res.status.should.be.equal(error.code); + res.body.code.should.be.equal(error.code); + res.body.errno.should.be.equal( + errnos[error.errno]); + res.body.error.should.be.equal(errors[error.error]); done(); }); - }); + }; - it('should respond 400 BadRequest if name param is empty', done => { + it('should respond 401 Unauthorized if there is no auth header', done => { server.post(endpointPrefix + '/clients') - .set('Authorization', 'Bearer ' + token) - .send({ name: '' }) .expect('Content-type', /json/) - .expect(400) + .expect(401) .end((err, res) => { - res.status.should.be.equal(400); - res.body.code.should.be.equal(400); - res.body.errno.should.be.equal(errnos[ERRNO_INVALID_API_CLIENT_NAME]); - res.body.error.should.be.equal(errors[BAD_REQUEST]); + res.status.should.be.equal(401); + res.body.code.should.be.equal(401); + res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); + res.body.error.should.be.equal(errors[UNAUTHORIZED]); done(); }); }); [{ + reason: 'there is no auth header', + auth: false, + code: 401, + error: UNAUTHORIZED, + errno: ERRNO_UNAUTHORIZED + }, { + reason: 'name param is missing', + body: {}, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_NAME + }, { + reason: 'name param is empty', + body: { name: '' }, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_NAME + }, { reason: 'authRedirectUrls param is not an array of URLs', - body: { name: 'clientName', authRedirectUrls: 'notAnArrayOfUrls' } + body: { name: 'clientName', authRedirectUrls: 'notAnArrayOfUrls' }, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_REDIRECT_URL }, { reason: 'authRedirectUrls param is not an array of URLs', body: { name: 'clientName', authRedirectUrls: ['http://something.com'], - authFailureRedirectUrls: 'notAnArrayOfUrls' } + authFailureRedirectUrls: 'notAnArrayOfUrls' }, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_REDIRECT_URL }, { reason: 'authFailureRedirectUrls is present but authRedirectUrls is not', body: { name: 'clientName', - authFailureRedirectUrls: ['http://something.com'] } + authFailureRedirectUrls: ['http://something.com'] }, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_REDIRECT_URL + }, { + reason: 'permissions list has unknown permission', + body: { name: 'clientName', + permissions: ['banana'] }, + auth: true, + code: 400, + error: BAD_REQUEST, + errno: ERRNO_INVALID_API_CLIENT_PERMISSION }].forEach(test => { - it('should respond 400 BadRequest if ' + test.reason, done => { - server.post(endpointPrefix + '/clients') - .set('Authorization', 'Bearer ' + token) - .send(test.body) - .expect('Content-type', /json/) - .expect(400) - .end((err, res) => { - res.status.should.be.equal(400); - res.body.code.should.be.equal(400); - res.body.errno.should.be.equal( - errnos[ERRNO_INVALID_API_CLIENT_REDIRECT_URL]); - res.body.error.should.be.equal(errors[BAD_REQUEST]); - done(); - }); + it(`should respond ${test.code} if ${test.reason}`, done => { + postAndCheckError(done, test.body, test.auth, { + code: test.code, + error: test.error, + errno: test.errno + }); }); }); [{ reason: 'should respond 201 Created if request contains empty ' + 'redirect URLs', - clientName: 'clientName', body: { + name: 'name', authRedirectUrls: [], authFailureRedirectUrls: [] } }, { reason: 'should respond 201 Created if request has valid name ' + 'and redirect URLs', - clientName: 'anotherClientName', body: { + name: 'name', authRedirectUrls: ['http://something.com'], authFailureRedirectUrls: ['http://something.com'] } + }, { + reason: 'should respond 201 Created if request has valid name and ' + + 'valid permissions array', + body: { + name: 'name', + permissions: config.get('permissions') + } + }, { + reason: 'should respond 201 Created if request has valid name and ' + + 'valid permission string', + body: { + name: 'name', + permissions: config.get('permissions')[0] + } }].forEach(test => { it(test.reason, done => { - test.body.name = test.clientName; - server.post(endpointPrefix + '/clients') - .set('Authorization', 'Bearer ' + token) - .send(test.body) - .expect('Content-type', /json/) - .expect(201) - .end((err, res) => { - res.status.should.be.equal(201); - res.body.name.should.be.equal(test.clientName); - res.body.key.should.be.instanceof(String) - .and.have.lengthOf(16); - res.body.secret.should.be.instanceof(String) - .and.have.lengthOf(128); - done(); - }); + postAndCheckSuccess(done, test.body); }); }); it('should respond 403 Forbidden if API client is already registered', done => { const name = 'clientName'; - server.post(endpointPrefix + '/clients') - .set('Authorization', 'Bearer ' + token) - .send({ name: 'clientName' }) - .expect('Content-type', /json/) - .expect(403) - .end((err, res) => { - res.status.should.be.equal(403); - res.body.code.should.be.equal(403); - res.body.errno.should.be.equal(errnos[ERRNO_FORBIDDEN]); - res.body.error.should.be.equal(errors[FORBIDDEN]); - done(); - }); + postAndCheckSuccess(() => { + postAndCheckError(done, { name }, true /* auth */, { + code: 403, + error: FORBIDDEN, + errno: ERRNO_FORBIDDEN + }); + }, { name }); }); }); @@ -195,12 +231,14 @@ describe('Clients API', () => { it('should respond 200 OK with an array containing the registered client', done => { + const url = 'http://domain.org'; new Promise(resolve => { server.post(endpointPrefix + '/clients') .set('Authorization', 'Bearer ' + token) .send({ name: 'clientName', - authRedirectUrls: ['http://something.com'], - authFailureRedirectUrls: ['http://something.com'] }) + authRedirectUrls: [url], + authFailureRedirectUrls: [url], + permissions: config.get('permissions') }) .expect('Content-type', /json/) .expect(201) .end(resolve); @@ -215,11 +253,12 @@ describe('Clients API', () => { res.body.forEach(client => { client.should.have.properties('name'); client.should.have.properties('key'); - client['authRedirectUrls'].should.be.instanceof(Array) - .and.have.lengthOf(1); - client['authFailureRedirectUrls'].should.be.instanceof(Array) - .and.have.lengthOf(1); - client.should.not.have.properties('secret'); + client['authRedirectUrls'].should.be.deepEqual([url]); + client['authFailureRedirectUrls'].should.be.deepEqual([url]); + client['permissions'].should.be.deepEqual( + config.get('permissions') + ); + }); done(); });