apostrophe
Version:
Apostrophe is a user-friendly content management system. You'll need more than this core module. See apostrophenow.org to get started.
586 lines (515 loc) • 19.4 kB
JavaScript
var _ = require('lodash');
var async = require('async');
/**
* permissions
* @class Manages permissions of pages, files and other objects.
* Provides methods for building MongoDB criteria that fetch things
* the user is allowed to work with, and also for checking whether
* the user can carry out specific actions, both on individual
* objects and in the more general case (eg creating new objects).
*/
function Permissions(options) {
options = options || {};
var self = this;
// For access to the sanitize methods
var apos = options.apos;
// Permissions is an event emitter/receiver
require('events').EventEmitter.call(self);
// Determines whether the active user "can" carry out the
// action specified by "action". Returns true if the action
// is permitted, false if not permitted.
// This object emits a `can` event that provides an easy way to
// extend permissions. The `can` event receives the request object, the
// action, the object, and a `result` object with a `response` property
// which is what will be returned by `can` if no changes are made.
// To alter the result, just change `result.response`.
//
// Actions begin with a verb, followed by a hyphen and an
// object type. For example:
//
// `edit-page`, `view-page`, `publish-page`, `edit-file`
//
// If there is no third argument, the question is whether this user can
// perform the action in question to create a new object.
//
// If there is a third argument, this method checks whether the user can
// carry out the specified action on that particular object.
self.can = function(req, action, object) {
return self._check(req, action, 'can', true, false, object, function(req, permissions, verb, type, object, strategy) {
if (object) {
return strategy.specific(req, permissions, verb, type, object);
} else {
return strategy.generic(req, permissions, verb, type);
}
});
};
// Returns a MongoDB criteria object which will match only objects
// on which the current user is permitted to perform the
// specified action.
self.criteria = function(req, action) {
var result = self._check(req, action, 'criteria', {}, { _iNeverMatch: true }, undefined, function(req, permissions, verb, type, object, strategy) {
return strategy.criteria(req, permissions, verb, type);
});
return result;
};
// Add a permission everyone gets in the generic case, even if
// not logged in. It is useful to add edit-files, for instance, to
// allow file uploads by anonymous users for apostrophe-moderator.
//
// View permissions are handled separately.
self.addPublic = function(permission) {
self.publicPermissions[permission] = true;
};
// Set all the public permissions at once. Pass an array of
// actions, like this: [ 'edit-file' ]
//
// View permissions are handled separately.
self.setPublic = function(permissions) {
self.publicPermissions = {};
_.each(permissions, function(permission) {
self.publicPermissions[permission] = true;
});
};
// Given a request object for a user with suitable permissions, a data
// object with loginRequired, loginRequiredPropagate and pagePermissions
// properties, and a page object, this method will sanitize and apply
// those permissions settings to the page and also propagate them to
// descendant pages if it is requested.
//
// Propagation is only performed if a "propagator" function is passed.
// This function will be called like the update method of a mongodb
// collection, except without the first argument. You supply a
// wrapper function that does the actual MongoDB update call with
// criteria that match your descendant pages.
//
// The entries in the data.pagePermissions array may be strings such
// as "view-xxx" where xxx is a user or group ID, or they may be
// objects in which such a string is the "value" property, and the
// "removed" and "propagate" properties may also be present.
//
// This method does NOT actually save the page object itself, although
// it does update its properties, and it does directly modify
// descendant pages if propagation is requested. It is your responsibility
// to save the page object itself afterwards.
//
// "data" is usually req.body, however it may be convenient to call
// this method from tasks as well.
//
// This method is designed to work with the data property created
// by apos.permissions.debrief on the browser side.
self.apply = function(req, data, page, propagator, callback) {
// Only admins can change editing permissions.
//
// TODO I should be checking this as a named permission in its own right
var userPermissions = req.user && req.user.permissions;
var allowed = [ 'view' ];
if (userPermissions.admin) {
allowed = [ 'view', 'edit', 'publish' ];
}
var propagatePull;
var propagateAdd;
var propagateSet;
var propagateUnset;
var loginRequired = apos.sanitizeSelect(data.loginRequired, [ '', 'loginRequired', 'certainPeople' ], '');
if (loginRequired === '') {
delete page.loginRequired;
} else {
page.loginRequired = loginRequired;
}
if (apos.sanitizeBoolean(data.loginRequiredPropagate)) {
if (loginRequired !== '') {
propagateSet = { loginRequired: loginRequired };
} else {
propagateUnset = { loginRequired: 1 };
}
}
page.pagePermissions = page.pagePermissions || [];
var map = {};
var permissions = _.filter(page.pagePermissions, function(permission) {
var matches = permission.split(/\-/);
var verb = matches[0];
var id = matches[1];
if (!_.contains(allowed, verb)) {
// We are not authorized to adjust permissions for this verb,
// keep what is there
return true;
} else {
// Strip this; we'll put it back if the user still wants it below
return false;
}
});
_.each(data.pagePermissions || [], function(permission) {
// If we're not propagating (a new page), normalize
// the rule to look like what we get when we're propagating
if (typeof(permission) !== 'object') {
permission = {
value: apos.sanitizeString(permission),
propagate: false,
removed: false
};
}
permission.value = apos.sanitizeString(permission.value);
if (!permission.value.match(/^\w+\-([\w\-]+)$/)) {
return;
}
var removed = apos.sanitizeBoolean(permission.removed);
var propagate = apos.sanitizeBoolean(permission.propagate);
if (removed) {
if (propagate) {
if (!propagatePull) {
propagatePull = [];
}
propagatePull.push(permission.value);
}
} else {
if (propagate) {
if (!propagateAdd) {
propagateAdd = [];
}
propagateAdd.push(permission.value);
}
// Duplicates shouldn't happen but it's the server's job to
// watch out for the unlikely
if (!_.contains(permissions, permission.value)) {
permissions.push(permission.value);
}
}
});
page.pagePermissions = permissions;
if (!propagator) {
return setImmediate(callback);
}
if (propagatePull || propagateAdd || propagateSet || propagateUnset) {
var command = {};
if (propagatePull) {
command.$pull = { pagePermissions: { $in: propagatePull } };
}
if (propagateAdd) {
command.$addToSet = { pagePermissions: { $each: propagateAdd } };
}
if (propagateSet) {
command.$set = propagateSet;
}
if (propagateUnset) {
command.$unset = propagateUnset;
}
if (propagatePull && propagateAdd) {
// Oh brother, must do it in two passes
// https://jira.mongodb.org/browse/SERVER-1050
var pullCommand = { $pull: command.$pull };
delete command.$pull;
}
return async.series({
pull: function(callback) {
if (!pullCommand) {
return callback(null);
}
return propagator(pullCommand, { multi: true }, callback);
},
main: function(callback) {
return propagator(command, { multi: true }, callback);
}
}, callback);
} else {
return setImmediate(callback);
}
};
// For each object in the array, if the user is able to
// carry out the specified action, a property is added
// to the object. For instance, if the action is "edit-page",
// each page the user can edit gets a "._edit = true" property.
//
// Note the underscore.
//
// This is most often used when an array of objects the user
// can view have been retrieved and we wish to know which ones
// the user can also edit.
self.annotate = function(req, action, objects) {
var matches = action.match(/^(\w+)\-([\w\-]+)$/);
var property;
if (!matches) {
property = '_' + action;
} else {
property = '_' + matches[1];
}
_.each(objects, function(object) {
if (self.can(req, action, object)) {
object[property] = true;
}
});
};
// Returns a user ID which is unique for this logged-in user, or if the user
// is not logged in, an ID based on their session which will continue to be
// available for as long as their session lasts
self.getEffectiveUserId = function(req) {
return (req.user && req.user._id) || ('anon-' + req.sessionID);
};
// Adds the given permission to the given page for the
// user associated with the given request. Does not update
// the page in the database; you need to do that.
//
// Currently this only makes sense with things that use the "page"
// strategy (pretty much everything except files), and the
// verbs that will work are `view`, `edit` and `publish`.
//
// For things that use the "owner" strategy, just set ownerId.
self.add = function(req, page, permission) {
page.pagePermissions = page.pagePermissions || [];
// Allow 'edit-page' to be passed in, but we're really
// only interested in the verb when building a permissions
// array to store in the object
permission = permission.replace(/\-.*$/, '');
page.pagePermissions.push(permission + '-' + self.getEffectiveUserId(req));
};
self._options = options;
if (options.workflow) {
// publish permissions are superior to edit permissions
self.impliedBy = options.impliedBy || {
view: [ 'submit', 'edit', 'publish', 'admin' ],
submit: [ 'edit', 'publish', 'admin' ],
edit: [ 'publish', 'admin' ],
publish: [ 'admin' ]
};
} else {
// Without workflow there is no "can publish" checkbox and
// we should treat edit permissions as being just as good
// as publish permissions
self.impliedBy = options.impliedBy || {
view: [ 'submit', 'edit', 'publish', 'admin' ],
submit: [ 'edit', 'publish', 'admin' ],
publish: [ 'edit', 'admin' ],
// Make sure publish still implies edit so that
// publish permissions present before a site
// switches off workflow are still honored
edit: [ 'publish', 'admin' ]
};
}
// Permissions that everyone has in the generic case. Typically empty
// except on sites that allow anonymous uploads, submissions, etc.
// view is handled as a special case
self.publicPermissions = {};
self.types = options.types || {
page: {
strategy: 'page'
},
file: {
strategy: 'owner'
}
};
self.defaultStrategy = options.defaultStrategy || 'page';
self._check = function(req, action, event, _true, _false, object, then) {
function filter(response) {
// Post an event allowing an opportunity to change the result
var result = { response: response };
self.emit(event, req, action, object, result);
return result.response;
}
var permissions = {};
_.extend(permissions, self.publicPermissions);
_.extend(permissions, (req.user && req.user.permissions) || {});
if (permissions.admin) {
// Admins can do anything
return filter(_true);
}
var matches = action.match(/^(\w+)\-([\w\-]+)$/);
if (!matches) {
return filter(_false);
}
var verb = matches[1];
var type = matches[2];
if (permissions['admin-' + type]) {
// Admins of specific content types can do anything to them
return filter(_true);
}
if (verb === 'edit') {
if (!(permissions.edit || permissions['edit-' + type])) {
// Those without the generic edit permission for
// something may not gain access to do it
// via a specific permission attached to
// an object. In other words, you have to
// have the "edit" permission to edit any
// pages ever, and you have to have either
// "edit" or "edit-events" to edit events
return filter(_false);
}
}
var strategy = self.strategies[(self.types[type] && self.types[type].strategy) || self.defaultStrategy];
return filter(then(req, permissions, verb, type, object, strategy));
};
self.strategies = {
page: {
generic: function(req, permissions, verb, type) {
if (verb === 'view') {
return true;
}
if (permissions[verb]) {
return true;
}
if (permissions[verb + '-' + type]) {
return true;
}
if (_.find(self.impliedBy[verb] || [], function(implied) {
if (permissions[implied]) {
return true;
}
if (permissions[implied + '-' + type]) {
return true;
}
})) {
return true;
}
return false;
},
specific: function(req, permissions, verb, type, object) {
var clauses = [];
// view permissions have some niceties
if (verb === 'view') {
// Case #1: it is published and no login is required
if (object.published && (!object.loginRequired)) {
return true;
}
// Case #2: for logged-in users with the guest permission,
// it's OK to show objects with loginRequired set to `loginRequired` but not `certainPeople`
// (this is called "Login Required" on the front end)
if (permissions.guest) {
if (object.published && (object.loginRequired === 'loginRequired')) {
return true;
}
}
// Case #3: object is restricted to certain people
if (req.user && object.published && (object.loginRequired === 'certainPeople') && _.intersection(self.userPermissionNames(req.user, 'view'), object.pagePermissions).length) {
return true;
}
// Case #4: you can edit the object
if (req.user && _.intersection(self.userPermissionNames(req.user, 'edit'), object.pagePermissions).length) {
return true;
}
} else {
// Not view permissions
// Case #4: we are only interested in people with a
// specific permission.
if (req.user && _.intersection(self.userPermissionNames(req.user, verb), object.pagePermissions).length) {
return true;
}
}
return false;
},
criteria: function(req, permissions, verb) {
var clauses = [];
// view permissions have some niceties
if (verb === 'view') {
// Case #1: it is published and no login is required
clauses.push({
published: true,
loginRequired: { $exists: false }
});
if (req.user) {
// Case #2: for logged-in users with the guest permission,
// it's OK to show pages with loginRequired set to `loginRequired` but not `certainPeople`
// (this is called "Login Required" on the front end)
if (permissions.guest) {
clauses.push({
published: true,
loginRequired: 'loginRequired'
});
}
// Case #3: page is restricted to certain people
clauses.push({
published: true,
loginRequired: 'certainPeople',
pagePermissions: { $in: self.userPermissionNames(req.user, 'view') }
});
// Case #4: can edit the page
clauses.push({
pagePermissions: {
$in: self.userPermissionNames(req.user, 'edit')
}
});
}
} else {
// Not view permissions
if (!req.user) {
// We want to never match
return { _iNeverMatch: true };
}
// Case #4: we are only interested in people with a
// specific permission.
clauses.push({
pagePermissions: {
$in: self.userPermissionNames(req.user, verb)
}
});
}
if (!clauses.length) {
// Empty $or is an error in MongoDB 2.6
return {};
}
return { $or: clauses };
}
}
};
_.extend(self.strategies, {
owner: {
generic: self.strategies.page.generic,
specific: function(req, permissions, verb, type, object) {
if (verb === 'view') {
return true;
}
// Assume everything else is an editing operation
if (object.ownerId === self.getEffectiveUserId(req)) {
return true;
}
return false;
},
criteria: function(req, permissions, verb) {
if (verb === 'view') {
return {};
}
return {
ownerId: self.getEffectiveUserId(req)
};
}
}
});
// Given a permission name, this method appends the user ID and
// the user's group IDs to each one and returns the resulting
// array. For instance, if the user's ID is xyz and the user
// is in groups with IDs abc and def, and this method is invoked
// for the permission name "edit", the return value will be:
//
// [ "edit-xyz", "edit-abc", "edit-def" ]
//
// Permissions with such names are stored in the .pagePermissions
// property of each page.
//
// Permission names that imply "edit" are also included,
// for instance "publish-xyz" is also good enough.
//
// Used internally to implement apos.permissions.criteria().
self.userPermissionNames = function(user, names) {
if (!user) {
return [];
}
names = Array.isArray(names) ? names : [ names ];
var groupIds = (user && user.groupIds) ? user.groupIds : [];
_.each(names, function(name) {
if (_.has(self.impliedBy, name)) {
names = _.union(names, self.impliedBy[name]);
}
});
var permissionNames = [];
_.each(names, function(name) {
_.each(groupIds, function(groupId) {
permissionNames.push(name + '-' + groupId);
});
permissionNames.push(name + '-' + user._id);
});
return permissionNames;
};
}
// Required because EventEmitter is built on prototypal inheritance,
// calling the constructor is not enough
require('util').inherits(Permissions, require('events').EventEmitter);
module.exports = function(options) {
return new Permissions(options);
};