Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions doc/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
}
```

Expand Down Expand Up @@ -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"]
}
]
```
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
63 changes: 39 additions & 24 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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',
Expand All @@ -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: {
Expand All @@ -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'
},
Expand All @@ -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
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/models/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ module.exports = (sequelize, DataTypes) => {
this.setDataValue('authFailureRedirectUrls', value);
}
}
}, {
classMethods: {
associate: db => {
Client.belongsToMany(db.Permissions, { through: 'ClientPermissions' });
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quite a good idea to have it here instead of defining it below, so that it's closer to the definition.

});

return Client;
}
14 changes: 11 additions & 3 deletions src/models/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -77,6 +78,13 @@ export default function() {
deferreds.pop().resolve(db);
}
state = READY;

// Load default permissions.
const permissions = config.get('permissions').map(permission => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this really belong in config ?

I'm not sure what you have in mind for the future. How will the permissions be required ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just though the config would be a good place cause it allows overriding the list of default permissions (for future testing purposes for example) and it provides an easy way to access the list (config.get('permissions').

I don't have a strong about this, so we could use an independent json file if you think is more appropriate.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think it's better; so that we can also ship it in git.

Alternatively you can provide the default list in the default property for this, but I find it's clumsy.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, I missed it. I still find it clumsy but up to you :) maybe just file an issue if you prefer to move faster.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll file a follow-up.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#74

return { model: 'Permissions', data: { name: permission }};
});
return sequelizeFixtures.loadFixtures(permissions, db);
}).then(() => {
return db;
}).catch(e => {
console.error(e);
Expand Down
13 changes: 13 additions & 0 deletions src/models/permissions.js
Original file line number Diff line number Diff line change
@@ -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;
};
56 changes: 46 additions & 10 deletions src/routes/clients.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import express from 'express';

import db from '../models/db';
import db from '../models/db';
import {
ApiError,
BAD_REQUEST,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -61,32 +71,58 @@ 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;
}
return ApiError(res, 400, errno, BAD_REQUEST);
}

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);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can simplify with a create with include, see http://docs.sequelizejs.com/en/latest/docs/associations/#creating-elements-of-a-hasmany-or-belongstomany-association
so that we don't need to be explicit with the transaction

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was my first approach, but somehow I failed to apply it... so if you don't mind, I'll stick with the current approach.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oki

});
}).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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really needed to use dataValues here ? Can't you just assign to client.permissions ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, unfortunately we need to modify dataValues. Modifying client.permissions directly didn't work for me.

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);
});
Expand Down
5 changes: 5 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}));
Expand Down
Loading