'use strict';
/**
* @module controllers
*/
var util = require('util');
var utilExt = process.requireApi('lib/util.js');
var errors = process.requireApi('lib/controllers/httpErrors.js');
var EntityController = process.requireApi('lib/controllers/EntityController.js');
var ResourceFilter = process.requireApi('lib/storages/ResourceFilter.js');
/**
* Defines base controller for all controllers which need to provide HTTP route actions for all requests
* relative to content entities.
*
* A content entity is an entity owned by a user, consequently user must be authenticated to use ContentController
* actions. Content entities which belong to the anonymous user can be manipulated by all. Content entities which
* belong to a particular user can be manipulated by this particular user, the super administrator, the entity manager,
* and, if entity is inside a group, by all users which have enough privileges on this group.
*
* The authenticated user must have the following properties:
* - **String** id The user id
* - **Array** permissions An array of permissions in the following format: OPERATION-group-GROUP_ID, where OPERATION
* is one of ContentController.OPERATIONS and GROUP_ID the id of a group (e.g.
* ['get-group-Jekrn20Rl', 'update-group-Jekrn20Rl', 'delete-group-YldO3Jie3'])
*
* A content entity has a "metadata" property with:
* - **String** user The id of the content entity owner
* - **Array** groups The list of groups associated to the content entity
*
* @class ContentController
* @extends EntityController
* @constructor
*/
function ContentController() {
ContentController.super_.call(this);
}
module.exports = ContentController;
util.inherits(ContentController, EntityController);
// Operations on content entities
/**
* The list of operations used to manage privileges of a user.
*
* @property OPERATIONS
* @type Object
* @final
* @static
*/
ContentController.OPERATIONS = {
READ: 'get',
UPDATE: 'update',
DELETE: 'delete'
};
Object.freeze(ContentController.OPERATIONS);
/**
* Gets user permissions by groups.
*
* @example
*
* // Example of user permissions
* ['get-group-Jekrn20Rl', 'update-group-Jekrn20Rl', 'delete-group-YldO3Jie3']
*
* // Example of returned groups
* {
* 'Jekrn20Rl': ['get', 'update'], // User only has get / update permissions on group 'Jekrn20Rl'
* 'YldO3Jie3': ['delete'], // User only has delete permission on group 'YldO3Jie3'
* ...
* }
*
* @method getUserGroups
* @private
* @param {Object} user The user to extract groups from
* @return {Object} Groups organized by ids
*/
function getUserGroups(user) {
var groups = {};
if (user && user.permissions) {
user.permissions.forEach(function(permission) {
var reg = new RegExp('^(get|update|delete)-group-(.+)$');
var permissionChunks = reg.exec(permission);
if (permissionChunks) {
var operation = permissionChunks[1];
var groupId = permissionChunks[2];
if (!groups[groupId])
groups[groupId] = [];
groups[groupId].push(operation);
}
});
}
return groups;
}
/**
* Gets the list of groups of a user, with authorization on a certain operation.
*
* All user groups with authorization on the operation are returned.
*
* @method getUserAuthorizedGroups
* @private
* @param {Object} user The user
* @param {String} operation The operation (get, update or delete)
* @return {Array} The list of user groups which have authorization on the given operation
*/
function getUserAuthorizedGroups(user, operation) {
var userGroups = getUserGroups(user);
var groups = [];
for (var groupId in userGroups) {
if (userGroups[groupId].indexOf(operation) >= 0)
groups.push(groupId);
}
return groups;
}
/**
* Gets entities.
*
* If user does not have enough privilege to read a particular entity, the entity is not listed in the response.
*
* @example
*
* // Response example
* {
* "entities" : [ ... ],
* "pagination" : {
* "limit": ..., // The limit number of entities by page
* "page": ..., // The actual page
* "pages": ..., // The total number of pages
* "size": ... // The total number of entities
* }
*
* @method getEntitiesAction
* @async
* @param {Request} request ExpressJS HTTP Request
* @param {Object} request.query Request query
* @param {String|Array} [request.query.include] The list of fields to include from returned entities
* @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entities. Ignored if
* include is also specified.
* @param {Number} [request.query.limit] A limit number of entities to retrieve per page (default to 10)
* @param {Number} [request.query.page] The page number started at 0 for the first page (default to 0)
* @param {String} [request.query.sortBy] The entity field to sort by
* @param {String} [request.query.sortOrder] Either "asc" for ascendant or "desc" for descendant
* @param {Response} response ExpressJS HTTP Response
* @param {Function} next Function to defer execution to the next registered middleware
*/
ContentController.prototype.getEntitiesAction = function(request, response, next) {
var provider = this.getProvider();
var sort = {};
var query;
request.query = request.query || {};
try {
query = utilExt.shallowValidateObject(request.query, {
include: {type: 'array<string>'},
exclude: {type: 'array<string>'},
limit: {type: 'number', gt: 0, default: 10},
page: {type: 'number', gte: 0, default: 0},
sortBy: {type: 'string'},
sortOrder: {type: 'string', in: ['asc', 'desc'], default: 'desc'}
});
} catch (error) {
return next(errors.GET_ENTITIES_WRONG_PARAMETERS);
}
// Build sort description object
if (query.sortBy && query.sortOrder) sort[query.sortBy] = query.sortOrder;
provider.get(
this.addAccessFilter(null, request.user),
{
exclude: query.exclude,
include: query.include
},
query.limit,
query.page,
sort,
function(error, entities, pagination) {
if (error) {
process.logger.error(error.message, {error: error, method: 'getEntitiesAction'});
next(errors.GET_ENTITIES_ERROR);
} else {
response.send({
entities: entities,
pagination: pagination
});
}
}
);
};
/**
* Gets a specific entity.
*
* User must have permission to read the entity.
*
* @example
*
* // Response example
* {
* "entity" : { ... }
* }
*
* @method getEntityAction
* @async
* @param {Request} request ExpressJS HTTP Request
* @param {Object} request.params Request parameters
* @param {String} request.params.id The entity id to retrieve
* @param {Object} request.query Request query
* @param {String|Array} [request.query.include] The list of fields to include from returned entity
* @param {String|Array} [request.query.exclude] The list of fields to exclude from returned entity. Ignored if
* include is also specified.
* @param {Response} response ExpressJS HTTP Response
* @param {Function} next Function to defer execution to the next registered middleware
*/
ContentController.prototype.getEntityAction = function(request, response, next) {
if (request.params.id) {
var entityId = request.params.id;
var provider = this.getProvider();
var self = this;
var fields;
request.query = request.query || {};
try {
fields = utilExt.shallowValidateObject(request.query, {
include: {type: 'array<string>'},
exclude: {type: 'array<string>'}
});
} catch (error) {
return next(errors.GET_ENTITY_WRONG_PARAMETERS);
}
// Make sure "metadata" field is not excluded
fields = this.removeMetatadaFromFields(fields);
provider.getOne(
new ResourceFilter().equal('id', entityId),
fields,
function(error, entity) {
if (error) {
process.logger.error(error.message, {error: error, method: 'getEntityAction', entity: entityId});
next(errors.GET_ENTITY_ERROR);
} else if (!entity) {
process.logger.warn('Not found', {method: 'getEntityAction', entity: entityId});
next(errors.GET_ENTITY_NOT_FOUND);
} else if (!self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.READ)) {
process.logger.error(
'User "' + request.user.id + '" doesn\'t have access to entity "' + entityId + '"',
{method: 'getEntityAction'}
);
next(errors.GET_ENTITY_FORBIDDEN);
} else {
response.send({
entity: entity
});
}
}
);
} else {
// Missing id of the entity
next(errors.GET_ENTITY_MISSING_PARAMETERS);
}
};
/**
* Updates an entity.
*
* User must have permission to update the entity. If user doesn't have permission to update the entity an
* HTTP forbidden error will be sent as response.
*
* @example
*
* // Response example
* {
* "total": 1
* }
*
* @method updateEntityAction
* @async
* @param {Request} request ExpressJS HTTP Request
* @param {Object} request.params Request parameters
* @param {String} request.params.id The id of the entity to update
* @param {Object} request.body The fields to update with their values
* @param {Array} [request.body.groups] The list of groups the content entity belongs to
* @param {String} [request.body.user] The id of the entity owner. Only the owner can modify the entity owner.
* @param {Response} response ExpressJS HTTP Response
* @param {Function} next Function to defer execution to the next registered middleware
*/
ContentController.prototype.updateEntityAction = function(request, response, next) {
if (request.params.id && request.body) {
var self = this;
var entityId = request.params.id;
var provider = this.getProvider();
var data = request.body;
var metadatas;
try {
metadatas = utilExt.shallowValidateObject(request.body, {
groups: {type: 'array<string>'},
user: {type: 'string'}
});
} catch (error) {
return next(errors.UPDATE_ENTITY_WRONG_PARAMETERS);
}
if (metadatas.groups) {
data['metadata.groups'] = data.groups.filter(function(group) {
return group ? true : false;
});
}
if (metadatas.user) data['metadata.user'] = data.user;
// Get information on the entity which is about to be updated to validate that the user has enough permissions
// to update it
provider.getOne(
new ResourceFilter().equal('id', entityId),
{
include: ['id', 'metadata']
},
function(error, entity) {
if (error) return next(errors.UPDATE_ENTITY_GET_ONE_ERROR);
if (!entity) return next(errors.UPDATE_ENTITY_NOT_FOUND_ERROR);
// Make sure user is authorized to modify all the entities
if (self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.UPDATE)) {
// User has permission to update this entity
// User is authorized to update the entity but he must be owner to update the owner
if (!self.isUserOwner(entity, request.user) &&
!self.isUserAdmin(request.user) &&
!self.isUserManager(request.user)) {
delete data['user'];
}
provider.updateOne(new ResourceFilter().equal('id', entity.id), data, function(error, total) {
if (error) {
process.logger.error(
error.message || 'Fail updating',
{method: 'updateEntityAction', entity: entityId}
);
next(errors.UPDATE_ENTITY_ERROR);
} else if (!total) {
process.logger.error(
'The entity could not be updated',
{method: 'updateEntityAction', entity: entityId}
);
next(errors.UPDATE_ENTITY_ERROR);
} else {
response.send({total: total});
}
});
} else {
process.logger.error('The entity could not be updated', {method: 'updateEntityAction', entity: entityId});
next(errors.UPDATE_ENTITY_FORBIDDEN);
}
}
);
} else {
// Missing entity id or the datas
next(errors.UPDATE_ENTITY_MISSING_PARAMETERS);
}
};
/**
* Adds entities.
*
* Information about the user (which becomes the owner) is automatically added to the entities.
*
* @example
*
* // Response example
* {
* "entities": [ ... ],
* "total": 42
* }
*
* @method addEntitiesAction
* @async
* @param {Request} request ExpressJS HTTP Request
* @param {Array} request.body The list of entities to add with for each entity the fields with their values
* @param {Array} [request.body.groups] The list of groups the content entities belong to
* @param {Response} response ExpressJS HTTP Response
* @param {Function} next Function to defer execution to the next registered middleware
*/
ContentController.prototype.addEntitiesAction = function(request, response, next) {
if (request.body) {
var provider = this.getProvider();
var parsedRequest;
var datas;
try {
parsedRequest = utilExt.shallowValidateObject(request, {
body: {type: 'array<object>', required: true}
});
} catch (error) {
return next(errors.ADD_ENTITIES_WRONG_PARAMETERS);
}
// Set common content entities information
datas = parsedRequest.body;
datas.forEach(function(data) {
data.metadata = {
user: request.user && request.user.id,
groups: data.groups || []
};
});
provider.add(datas, function(error, total, entities) {
if (error) {
process.logger.error(error.message, {error: error, method: 'addEntitiesAction'});
next(errors.ADD_ENTITIES_ERROR);
} else
response.send({entities: entities, total: total});
});
} else {
// Missing body
next(errors.ADD_ENTITIES_MISSING_PARAMETERS);
}
};
/**
* Removes entities.
*
* User must have permission to remove the entities. If user doesn't have permission to remove a particular entity an
* HTTP forbidden error will be sent as response and there won't be any guarantee on the number of removed entities.
*
* @example
*
* // Response example
* {
* "total": 42
* }
*
* @method removeEntitiesAction
* @async
* @param {Request} request ExpressJS HTTP Request
* @param {Object} request.params Request parameters
* @param {String} request.params.id A comma separated list of entity ids to remove
* @param {Response} response ExpressJS HTTP Response
* @param {Function} next Function to defer execution to the next registered middleware
*/
ContentController.prototype.removeEntitiesAction = function(request, response, next) {
if (request.params.id) {
var self = this;
var entityIds = request.params.id.split(',');
var entityIdsToRemove = [];
var provider = this.getProvider();
// Get information on entities which are about to be removed to validate that the user has enough permissions
// to do it
provider.get(
new ResourceFilter().in('id', entityIds),
{
include: ['id', 'metadata']
},
entityIds.length,
null,
null,
function(error, entities, pagination) {
// Make sure user is authorized to modify all the entities
entities.forEach(function(entity) {
if (self.isUserAuthorized(request.user, entity, ContentController.OPERATIONS.DELETE))
entityIdsToRemove.push(entity.id);
});
if (entityIdsToRemove.length !== entityIds.length) {
process.logger.error(
'Some entities can\'t be removed : abort',
{method: 'removeEntitiesAction', entities: entityIds, removedEntities: entityIdsToRemove}
);
return next(errors.REMOVE_ENTITIES_FORBIDDEN);
}
provider.remove(new ResourceFilter().in('id', entityIdsToRemove), function(error, total) {
if (error) {
process.logger.error(error.message, {error: error, method: 'removeEntitiesAction'});
next(errors.REMOVE_ENTITIES_ERROR);
} else if (total != entityIdsToRemove.length) {
process.logger.error(
total + '/' + entityIds.length + ' removed',
{method: 'removeEntitiesAction', entities: entityIdsToRemove}
);
next(errors.REMOVE_ENTITIES_ERROR);
} else {
response.send({total: total});
}
});
}
);
} else {
// Missing entity ids
next(errors.REMOVE_ENTITIES_MISSING_PARAMETERS);
}
};
/**
* Adds access rules to the given filter reference.
*
* Access rules make sure that content entities belong to the user (owner or in the same group).
* If no filter is specified, a new filter is created.
*
* @method addAccessFilter
* @param {ResourceFilter} [filter] The filter to add the access rules to
* @param {Object} user The user information
* @param {String} user.id The user id
* @param {Array} user.permissions The user permissions
* @return {ResourceFilter} The modified filter or a new one if no filter specified
*/
ContentController.prototype.addAccessFilter = function(filter, user) {
if (user && !this.isUserAdmin(user) && !this.isUserManager(user)) {
var userGroups = getUserAuthorizedGroups(user, ContentController.OPERATIONS.READ);
if (!filter) filter = new ResourceFilter();
filter.or([
new ResourceFilter().in('metadata.user', [user.id, this.getAnonymousId()])
]);
if (userGroups.length) {
filter.or([
new ResourceFilter().in('metadata.groups', userGroups)
]);
}
}
return filter;
};
/**
* Tests if user is the administrator.
*
* @method isUserAdmin
* @param {Object} user The user to test
* @param {String} user.id The user's id
* @return {Boolean} true if the user is the administrator, false otherwise
*/
ContentController.prototype.isUserAdmin = function(user) {
return user && user.id === this.getSuperAdminId();
};
/**
* Tests if user is the anonymous user.
*
* @method isUserAnonymous
* @param {Object} user The user to test
* @param {String} user.id The user's id
* @return {Boolean} true if the user is the anonymous, false otherwise
*/
ContentController.prototype.isUserAnonymous = function(user) {
return user && user.id === this.getAnonymousId();
};
/**
* Tests if user is the owner of a content entity.
*
* @method isUserOwner
* @param {Object} entity The entity to test
* @param {Object} entity.metadata Entity information about associated user and groups
* @param {String} entity.metadata.user The id of the user the entity belongs to
* @param {Object} user The user to test
* @param {String} user.id The user's id
* @return {Boolean} true if the user is the owner, false otherwise
*/
ContentController.prototype.isUserOwner = function(entity, user) {
return user && entity.metadata && entity.metadata.user === user.id;
};
/**
* Validates that a user is authorized to manipulate a content entity.
*
* User is authorized to manipulate the entity if one of the following conditions is met:
* - The entity belongs to the anonymous user
* - User is the super administrator
* - User is the owner of the entity
* - User has permission to manage contents
* - Entity has associated groups and user has permission to perform the operation on one of these groups
*
* @method isUserAuthorized
* @param {Object} user The user
* @param {String} user.id The user's id
* @param {Array} user.permissions The user's permissions
* @param {Object} entity The entity to manipulate
* @param {Object} entity.metadata Entity information about associated user and groups
* @param {String} entity.metadata.user The id of the user the entity belongs to
* @param {Array} entity.metadata.groups The list of group ids the entity is part of
* @param {String} operation The operation to perform on the entity
* @return {Boolean} true if the user can manipulate the entity, false otherwise
*/
ContentController.prototype.isUserAuthorized = function(user, entity, operation) {
if (this.isUserAdmin(user) ||
this.isUserManager(user) ||
this.isUserOwner(entity, user) ||
(entity.metadata && this.isUserAnonymous({id: entity.metadata.user}))
) {
return true;
}
if (entity.metadata && entity.metadata.groups) {
var userGroups = getUserAuthorizedGroups(user, operation);
return utilExt.intersectArray(entity.metadata.groups, userGroups).length;
}
return false;
};
/**
* Removes "metadata" field from query fields.
*
* The "metadata" property of a content entity is used by ContentControllers to validate that a user
* has enough privileges to perform an action. "metadata" property contains the id of the user the content property
* belongs to and the list of groups the entity is part of.
* Consequently "metadata" property has to be fetched by the provider when getting an entity, however we authorize the
* user the exclude / include fields from provider response. removeMetadataFromFields makes sure "metadata" property
* is not excluded from returned fields.
*
* @method removeMetatadaFromFields
* @param {Object} fields The include and exclude fields
* @param {Array} [fields.include] The list of fields to include which may contain a "metadata" property
* @param {Array} [fields.exclude] The list of fields to exclude may contain a "metadata" property
* @return {Object} The same fields object with new include and exclude arrays
*/
ContentController.prototype.removeMetatadaFromFields = function(fields) {
if (fields.exclude) {
fields.exclude = fields.exclude.filter(function(text) {
return text.indexOf('metadata') < 0;
});
}
if (fields.include) fields.include.push('metadata');
return fields;
};
/**
* Gets the id of the super administrator.
*
* It must be overriden by the sub class.
*
* @method getSuperAdminId
* @return {String} The id of the super admin
* @throw {Error} getSuperAdminId is not implemented
*/
ContentController.prototype.getSuperAdminId = function() {
throw new Error('getSuperAdminId is not implemented for this ContentController');
};
/**
* Gets the id of the anonymous user.
*
* It must be overriden by the sub class.
*
* @method getAnonymousId
* @return {String} The id of the anonymous user
* @throw {Error} getAnonymousId is not implemented
*/
ContentController.prototype.getAnonymousId = function() {
throw new Error('getAnonymousId is not implemented for this ContentController');
};
/**
* Tests if user is a contents manager.
*
* A contents manager can perform CRUD operations on content entities.
* It must be overriden by the sub class.
*
* @method isUserManager
* @param {Object} user The user to test
* @param {Array} user.permissions The user's permissions
* @return {Boolean} true if the user has permission to manage contents, false otherwise
* @throw {Error} isUserManager is not implemented
*/
ContentController.prototype.isUserManager = function(user) {
throw new Error('isUserManager is not implemented for this ContentController');
};