Merge pull request #5496 from ErisDS/api-public-perms

Add public API endpoint permission handling
This commit is contained in:
Sebastian Gierlinger 2015-08-04 11:05:20 +02:00
commit cfce197159
9 changed files with 504 additions and 158 deletions

View File

@ -38,20 +38,6 @@ posts = {
permittedOptions = utils.browseDefaultOptions.concat(extraOptions),
tasks;
/**
* ### Handle Permissions
* We need to either be an authorised user, or only return published posts.
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
if (!(options.context && options.context.user)) {
options.status = 'published';
}
return options;
}
/**
* ### Model Query
* Make the call to the Model layer
@ -65,7 +51,7 @@ posts = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: permittedOptions}),
handlePermissions,
utils.handlePublicPermissions(docName, 'browse'),
utils.convertOptions(allowedIncludes),
modelQuery
];
@ -86,19 +72,6 @@ posts = {
var attrs = ['id', 'slug', 'status', 'uuid'],
tasks;
/**
* ### Handle Permissions
* We need to either be an authorised user, or only return published posts.
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
if (!options.data.uuid && !(options.context && options.context.user)) {
options.data.status = 'published';
}
return options;
}
/**
* ### Model Query
* Make the call to the Model layer
@ -112,7 +85,7 @@ posts = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {attrs: attrs}),
handlePermissions,
utils.handlePublicPermissions(docName, 'read'),
utils.convertOptions(allowedIncludes),
modelQuery
];

View File

@ -26,20 +26,6 @@ tags = {
browse: function browse(options) {
var tasks;
/**
* ### Handle Permissions
* We need to be an authorised user to perform this action
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
return canThis(options.context).browse.tag().then(function permissionGranted() {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to browse tags.');
});
}
/**
* ### Model Query
* Make the call to the Model layer
@ -53,7 +39,7 @@ tags = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: utils.browseDefaultOptions}),
handlePermissions,
utils.handlePublicPermissions(docName, 'browse'),
utils.convertOptions(allowedIncludes),
doQuery
];
@ -71,20 +57,6 @@ tags = {
var attrs = ['id', 'slug'],
tasks;
/**
* ### Handle Permissions
* We need to be an authorised user to perform this action
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
return canThis(options.context).read.tag().then(function permissionGranted() {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to read tags.');
});
}
/**
* ### Model Query
* Make the call to the Model layer
@ -98,7 +70,7 @@ tags = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {attrs: attrs}),
handlePermissions,
utils.handlePublicPermissions(docName, 'read'),
utils.convertOptions(allowedIncludes),
doQuery
];

View File

@ -77,20 +77,6 @@ users = {
permittedOptions = utils.browseDefaultOptions.concat(extraOptions),
tasks;
/**
* ### Handle Permissions
* We need to either be an authorised user, or only return published posts.
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
return canThis(options.context).browse.user().then(function permissionGranted() {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error, 'You do not have permission to browse users.');
});
}
/**
* ### Model Query
* Make the call to the Model layer
@ -104,7 +90,7 @@ users = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {opts: permittedOptions}),
handlePermissions,
utils.handlePublicPermissions(docName, 'browse'),
utils.convertOptions(allowedIncludes),
doQuery
];
@ -122,18 +108,9 @@ users = {
var attrs = ['id', 'slug', 'status', 'email', 'role'],
tasks;
/**
* ### Handle Permissions
* Convert 'me' safely
* @param {Object} options
* @returns {Object} options
*/
function handlePermissions(options) {
if (options.data.id === 'me' && options.context && options.context.user) {
options.data.id = options.context.user;
}
return options;
// Special handling for id = 'me'
if (options.id === 'me' && options.context && options.context.user) {
options.id = options.context.user;
}
/**
@ -149,7 +126,7 @@ users = {
// Push all of our tasks into a `tasks` array in the correct order
tasks = [
utils.validate(docName, {attrs: attrs}),
handlePermissions,
utils.handlePublicPermissions(docName, 'read'),
utils.convertOptions(allowedIncludes),
doQuery
];

View File

@ -1,10 +1,12 @@
// # API Utils
// Shared helpers for working with the API
var Promise = require('bluebird'),
_ = require('lodash'),
path = require('path'),
errors = require('../errors'),
validation = require('../data/validation'),
var Promise = require('bluebird'),
_ = require('lodash'),
path = require('path'),
errors = require('../errors'),
permissions = require('../permissions'),
validation = require('../data/validation'),
utils;
utils = {
@ -131,13 +133,67 @@ utils = {
return errors;
},
/**
* ## Is Public Context?
* If this is a public context, return true
* @param {Object} options
* @returns {Boolean}
*/
isPublicContext: function isPublicContext(options) {
return permissions.parseContext(options.context).public;
},
/**
* ## Apply Public Permissions
* Update the options object so that the rules reflect what is permitted to be retrieved from a public request
* @param {String} docName
* @param {String} method (read || browse)
* @param {Object} options
* @returns {Object} options
*/
applyPublicPermissions: function applyPublicPermissions(docName, method, options) {
return permissions.applyPublicRules(docName, method, options);
},
/**
* ## Handle Public Permissions
* @param {String} docName
* @param {String} method (read || browse)
* @returns {Function}
*/
handlePublicPermissions: function handlePublicPermissions(docName, method) {
var singular = docName.replace(/s$/, '');
/**
* Check if this is a public request, if so use the public permissions, otherwise use standard canThis
* @param {Object} options
* @returns {Object} options
*/
return function doHandlePublicPermissions(options) {
var permsPromise;
if (utils.isPublicContext(options)) {
permsPromise = utils.applyPublicPermissions(docName, method, options);
} else {
permsPromise = permissions.canThis(options.context)[method][singular](options.data);
}
return permsPromise.then(function permissionGranted() {
return options;
}).catch(function handleError(error) {
return errors.handleAPIError(error);
});
};
},
prepareInclude: function prepareInclude(include, allowedIncludes) {
include = include || '';
include = _.intersection(include.split(','), allowedIncludes);
return include;
},
/**
* ## Convert Options
* @param {Array} allowedIncludes
* @returns {Function} doConversion
*/

View File

@ -20,30 +20,90 @@ function hasActionsMap() {
});
}
// TODO: Move this to its own file so others can use it?
function parseContext(context) {
// Parse what's passed to canThis.beginCheck for standard user and app scopes
var parsed = {
internal: false,
user: null,
app: null
app: null,
public: true
};
if (context && (context === 'internal' || context.internal)) {
parsed.internal = true;
parsed.public = false;
}
if (context && context.user) {
parsed.user = context.user;
parsed.public = false;
}
if (context && context.app) {
parsed.app = context.app;
parsed.public = false;
}
return parsed;
}
function applyStatusRules(docName, method, opts) {
var errorMsg = 'You do not have permission to retrieve ' + docName + ' with that status';
// Enforce status 'active' for users
if (docName === 'users') {
if (!opts.status) {
return 'active';
} else if (opts.status !== 'active') {
throw errorMsg;
}
}
// Enforce status 'published' for posts
if (docName === 'posts') {
if (!opts.status) {
return 'published';
} else if (
method === 'read'
&& (opts.status === 'draft' || opts.status === 'all')
&& _.isString(opts.uuid) && _.isUndefined(opts.id) && _.isUndefined(opts.slug)
) {
// public read requests can retrieve a draft, but only by UUID
return opts.status;
} else if (opts.status !== 'published') {
// any other parameter would make this a permissions error
throw errorMsg;
}
}
return opts.status;
}
/**
* API Public Permission Rules
* This method enforces the rules for public requests
* @param {String} docName
* @param {String} method (read || browse)
* @param {Object} options
* @returns {Object} options
*/
function applyPublicRules(docName, method, options) {
try {
// If this is a public context
if (parseContext(options.context).public === true) {
if (method === 'browse') {
options.status = applyStatusRules(docName, method, options);
} else if (method === 'read') {
options.data.status = applyStatusRules(docName, method, options.data);
}
}
return Promise.resolve(options);
} catch (err) {
return Promise.reject(err);
}
}
// Base class for canThis call results
CanThisResult = function () {
return;
@ -244,5 +304,7 @@ module.exports = exported = {
init: init,
refresh: refresh,
canThis: canThis,
parseContext: parseContext,
applyPublicRules: applyPublicRules,
actionsMap: {}
};

View File

@ -126,15 +126,35 @@ describe('Post API', function () {
it('without context.user cannot fetch all posts', function (done) {
PostAPI.browse({status: 'all'}).then(function (results) {
should.exist(results);
testUtils.API.checkResponse(results, 'posts');
should.exist(results.posts);
results.posts.length.should.eql(4);
results.posts[0].status.should.eql('published');
testUtils.API.checkResponse(results.posts[0], 'post');
should.not.exist(results);
done(new Error('should not provide results if invalid status provided'));
}).catch(function (err) {
err.errorType.should.eql('NoPermissionError');
done();
}).catch(done);
});
});
it('without context.user cannot fetch draft posts', function (done) {
PostAPI.browse({status: 'draft'}).then(function (results) {
should.not.exist(results);
done(new Error('should not provide results if invalid status provided'));
}).catch(function (err) {
err.errorType.should.eql('NoPermissionError');
done();
});
});
it('without context.user cannot use uuid to fetch draft posts in browse', function (done) {
PostAPI.browse({status: 'draft', uuid: 'imastring'}).then(function (results) {
should.not.exist(results);
done(new Error('should not provide results if invalid status provided'));
}).catch(function (err) {
err.errorType.should.eql('NoPermissionError');
done();
});
});
it('with context.user can fetch drafts', function (done) {
@ -230,15 +250,13 @@ describe('Post API', function () {
});
it('without context.user cannot fetch draft', function (done) {
PostAPI.read({slug: 'unfinished', status: 'draft'}).then(function (results) {
should.not.exist(results.posts);
done();
PostAPI.read({slug: 'unfinished', status: 'draft'}).then(function () {
done(new Error('Should not return a result with no permission'));
}).catch(function (err) {
should.exist(err);
err.message.should.eql('Post not found.');
err.errorType.should.eql('NoPermissionError');
done();
});
}).catch(done);
});
it('with context.user can fetch a draft', function (done) {
@ -260,13 +278,13 @@ describe('Post API', function () {
it('cannot fetch post with unknown id', function (done) {
PostAPI.read({context: {user: 1}, slug: 'not-a-post'}).then(function () {
done();
done(new Error('Should not return a result with unknown id'));
}).catch(function (err) {
should.exist(err);
err.message.should.eql('Post not found.');
done();
});
}).catch(done);
});
it('can fetch post with by id', function (done) {

View File

@ -49,15 +49,15 @@ describe('Users API', function () {
});
describe('Browse', function () {
function checkBrowseResponse(response, count) {
function checkBrowseResponse(response, count, additional, missing) {
should.exist(response);
testUtils.API.checkResponse(response, 'users');
should.exist(response.users);
response.users.should.have.length(count);
testUtils.API.checkResponse(response.users[0], 'user');
testUtils.API.checkResponse(response.users[1], 'user');
testUtils.API.checkResponse(response.users[2], 'user');
testUtils.API.checkResponse(response.users[3], 'user');
testUtils.API.checkResponse(response.users[0], 'user', additional, missing);
testUtils.API.checkResponse(response.users[1], 'user', additional, missing);
testUtils.API.checkResponse(response.users[2], 'user', additional, missing);
testUtils.API.checkResponse(response.users[3], 'user', additional, missing);
}
it('Owner can browse', function (done) {
@ -88,9 +88,16 @@ describe('Users API', function () {
}).catch(done);
});
it('No-auth CANNOT browse', function (done) {
UserAPI.browse().then(function () {
done(new Error('Browse users is not denied without authentication.'));
it('No-auth CAN browse, but only gets filtered active users', function (done) {
UserAPI.browse().then(function (response) {
checkBrowseResponse(response, 7, null, ['email']);
done();
}).catch(done);
});
it('No-auth CANNOT browse non-active users', function (done) {
UserAPI.browse({status: 'invited'}).then(function () {
done(new Error('Browse non-active users is not denied without authentication.'));
}, function () {
done();
}).catch(done);
@ -111,21 +118,6 @@ describe('Users API', function () {
});
});
it('Author can browse', function (done) {
UserAPI.browse(context.author).then(function (response) {
checkBrowseResponse(response, 7);
done();
}).catch(done);
});
it('No-auth CANNOT browse', function (done) {
UserAPI.browse().then(function () {
done(new Error('Browse users is not denied without authentication.'));
}, function () {
done();
}).catch(done);
});
it('Can browse all', function (done) {
UserAPI.browse(_.extend({}, testUtils.context.admin, {status: 'all'})).then(function (response) {
checkBrowseResponse(response, 7);

View File

@ -1,19 +1,16 @@
/*globals describe, it, beforeEach, afterEach */
/*globals describe, it, afterEach */
/*jshint expr:true*/
var should = require('should'),
sinon = require('sinon'),
_ = require('lodash'),
Promise = require('bluebird'),
apiUtils = require('../../server/api/utils');
permissions = require('../../server/permissions'),
apiUtils = require('../../server/api/utils'),
sandbox = sinon.sandbox.create();
describe('API Utils', function () {
var sandbox;
beforeEach(function () {
sandbox = sinon.sandbox.create();
});
afterEach(function () {
sandbox.restore();
});
@ -405,4 +402,74 @@ describe('API Utils', function () {
apiUtils.checkFileIsValid({name: 'test.txt', type: 'text'}, ['archive'], ['.txt']).should.be.false;
});
});
describe('isPublicContext', function () {
it('should call out to permissions', function () {
var permsStub = sandbox.stub(permissions, 'parseContext').returns({public: true});
apiUtils.isPublicContext({context: 'test'}).should.be.true;
permsStub.called.should.be.true;
permsStub.calledWith('test').should.be.true;
});
});
describe('applyPublicPermissions', function () {
it('should call out to permissions', function () {
var permsStub = sandbox.stub(permissions, 'applyPublicRules');
apiUtils.applyPublicPermissions('test', {});
permsStub.called.should.be.true;
permsStub.calledWith('test', {}).should.be.true;
});
});
describe('handlePublicPermissions', function () {
it('should return empty options if passed empty options', function (done) {
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
options.should.eql({});
done();
}).catch(done);
});
it('should treat no context as public', function (done) {
var aPPStub = sandbox.stub(apiUtils, 'applyPublicPermissions').returns(Promise.resolve({}));
apiUtils.handlePublicPermissions('tests', 'test')({}).then(function (options) {
aPPStub.calledOnce.should.eql(true);
options.should.eql({});
done();
}).catch(done);
});
it('should treat user context as NOT public', function (done) {
var cTMethodStub = {
test: {
test: sandbox.stub().returns(Promise.resolve())
}
},
cTStub = sandbox.stub(permissions, 'canThis').returns(cTMethodStub);
apiUtils.handlePublicPermissions('tests', 'test')({context: {user: 1}}).then(function (options) {
cTStub.calledOnce.should.eql(true);
cTMethodStub.test.test.calledOnce.should.eql(true);
options.should.eql({context: {user: 1}});
done();
}).catch(done);
});
it('should throw a permissions error if permission is not granted', function (done) {
var cTMethodStub = {
test: {
test: sandbox.stub().returns(Promise.reject())
}
},
cTStub = sandbox.stub(permissions, 'canThis').returns(cTMethodStub);
apiUtils.handlePublicPermissions('tests', 'test')({context: {user: 1}}).then(function () {
done(new Error('should throw error when no permissions'));
}).catch(function (err) {
cTStub.calledOnce.should.eql(true);
cTMethodStub.test.test.calledOnce.should.eql(true);
err.errorType.should.eql('NoPermissionError');
done();
}).catch(done);
});
});
});

View File

@ -14,39 +14,268 @@ var testUtils = require('../utils'),
sandbox = sinon.sandbox.create();
// TODO move to integrations or stub
describe('Permissions', function () {
before(function (done) {
Models.init().then(done).catch(done);
});
afterEach(function () {
sandbox.restore();
});
beforeEach(function () {
var permissions = _.map(testUtils.DataGenerator.Content.permissions, function (testPerm) {
return testUtils.DataGenerator.forKnex.createPermission(testPerm);
describe('actions map', function () {
before(function (done) {
Models.init().then(done).catch(done);
});
sandbox.stub(Models.Permission, 'findAll', function () {
return Promise.resolve(Models.Permissions.forge(permissions));
beforeEach(function () {
var permissions = _.map(testUtils.DataGenerator.Content.permissions, function (testPerm) {
return testUtils.DataGenerator.forKnex.createPermission(testPerm);
});
sandbox.stub(Models.Permission, 'findAll', function () {
return Promise.resolve(Models.Permissions.forge(permissions));
});
});
it('can load an actions map from existing permissions', function (done) {
permissions.init().then(function (actionsMap) {
should.exist(actionsMap);
actionsMap.edit.sort().should.eql(['post', 'tag', 'user', 'page'].sort());
actionsMap.should.equal(permissions.actionsMap);
done();
}).catch(done);
});
});
it('can load an actions map from existing permissions', function (done) {
permissions.init().then(function (actionsMap) {
should.exist(actionsMap);
describe('parseContext', function () {
it('should return public for no context', function () {
permissions.parseContext().should.eql({
internal: false,
user: null,
app: null,
public: true
});
permissions.parseContext({}).should.eql({
internal: false,
user: null,
app: null,
public: true
});
});
actionsMap.edit.sort().should.eql(['post', 'tag', 'user', 'page'].sort());
it('should return public for random context', function () {
permissions.parseContext('public').should.eql({
internal: false,
user: null,
app: null,
public: true
});
permissions.parseContext({client: 'thing'}).should.eql({
internal: false,
user: null,
app: null,
public: true
});
});
actionsMap.should.equal(permissions.actionsMap);
it('should return user if user populated', function () {
permissions.parseContext({user: 1}).should.eql({
internal: false,
user: 1,
app: null,
public: false
});
});
done();
}).catch(done);
it('should return app if app populated', function () {
permissions.parseContext({app: 5}).should.eql({
internal: false,
user: null,
app: 5,
public: false
});
});
it('should return internal if internal provided', function () {
permissions.parseContext({internal: true}).should.eql({
internal: true,
user: null,
app: null,
public: false
});
permissions.parseContext('internal').should.eql({
internal: true,
user: null,
app: null,
public: false
});
});
});
describe('applyPublicRules', function () {
it('should return empty object for docName with no rules', function (done) {
permissions.applyPublicRules('test', 'test', {}).then(function (result) {
result.should.eql({});
done();
});
});
it('should return unchanged object for non-public context', function (done) {
var internal = {context: 'internal'},
user = {context: {user: 1}},
app = {context: {app: 1}};
permissions.applyPublicRules('posts', 'browse', _.cloneDeep(internal)).then(function (result) {
result.should.eql(internal);
return permissions.applyPublicRules('posts', 'browse', _.cloneDeep(user));
}).then(function (result) {
result.should.eql(user);
return permissions.applyPublicRules('posts', 'browse', _.cloneDeep(app));
}).then(function (result) {
result.should.eql(app);
done();
}).catch(done);
});
it('should return unchanged object for post with public context', function (done) {
var public = {context: {}};
permissions.applyPublicRules('posts', 'browse', _.cloneDeep(public)).then(function (result) {
result.should.not.eql(public);
result.should.eql({
context: {},
status: 'published'
});
return permissions.applyPublicRules('posts', 'browse', _.extend({}, _.cloneDeep(public), {status: 'published'}));
}).then(function (result) {
result.should.eql({
context: {},
status: 'published'
});
done();
}).catch(done);
});
it('should throw an error for draft post without uuid (read)', function (done) {
var draft = {context: {}, data: {status: 'draft'}};
permissions.applyPublicRules('posts', 'read', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
it('should throw an error for draft post (browse)', function (done) {
var draft = {context: {}, status: 'draft'};
permissions.applyPublicRules('posts', 'browse', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
it('should permit post draft status with uuid (read)', function (done) {
var draft = {context: {}, data: {status: 'draft', uuid: '1234-abcd'}};
permissions.applyPublicRules('posts', 'read', _.cloneDeep(draft)).then(function (result) {
result.should.eql(draft);
done();
}).catch(done);
});
it('should permit post all status with uuid (read)', function (done) {
var draft = {context: {}, data: {status: 'all', uuid: '1234-abcd'}};
permissions.applyPublicRules('posts', 'read', _.cloneDeep(draft)).then(function (result) {
result.should.eql(draft);
done();
}).catch(done);
});
it('should NOT permit post draft status with uuid (browse)', function (done) {
var draft = {context: {}, status: 'draft', uuid: '1234-abcd'};
permissions.applyPublicRules('posts', 'browse', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
it('should NOT permit post all status with uuid (browse)', function (done) {
var draft = {context: {}, status: 'all', uuid: '1234-abcd'};
permissions.applyPublicRules('posts', 'browse', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
it('should throw an error for draft post with uuid and id or slug (read)', function (done) {
var draft = {context: {}, data: {status: 'draft', uuid: '1234-abcd', id: 1}};
permissions.applyPublicRules('posts', 'read', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
draft = {context: {}, data: {status: 'draft', uuid: '1234-abcd', slug: 'abcd'}};
return permissions.applyPublicRules('posts', 'read', _.cloneDeep(draft)).then(function () {
done('Did not throw an error for draft');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
});
it('should return unchanged object for user with public context', function (done) {
var public = {context: {}};
permissions.applyPublicRules('users', 'browse', _.cloneDeep(public)).then(function (result) {
result.should.not.eql(public);
result.should.eql({
context: {},
status: 'active'
});
return permissions.applyPublicRules('users', 'browse', _.extend({}, _.cloneDeep(public), {status: 'active'}));
}).then(function (result) {
result.should.eql({
context: {},
status: 'active'
});
done();
}).catch(done);
});
it('should throw an error for an inactive user', function (done) {
var inactive = {context: {}, status: 'inactive'};
permissions.applyPublicRules('users', 'browse', _.cloneDeep(inactive)).then(function () {
done('Did not throw an error for inactive');
}).catch(function (err) {
err.should.be.a.String;
done();
});
});
});
// @TODO: move to integrations or stub
// it('does not allow edit post without permission', function (done) {
// var fakePage = {
// id: 1