555 lines
14 KiB
JavaScript
555 lines
14 KiB
JavaScript
/*!
|
|
* Module dependencies.
|
|
*/
|
|
var async = require('async');
|
|
var pathToRegExp = require('path-to-regexp');
|
|
var debug = require('debug')('loopback:security:acl:route');
|
|
var loopback = require('../../lib/loopback');
|
|
var Principal = require('../../lib/access-context').Principal;
|
|
var Role;
|
|
var ACL;
|
|
|
|
/*!
|
|
* Export the middleware.
|
|
*/
|
|
|
|
module.exports = acl;
|
|
|
|
/**
|
|
* Normalize the http verb to lower case
|
|
* @param {String} verb HTTP verb/method
|
|
* @returns {String|*}
|
|
* @private
|
|
*/
|
|
function normalizeVerb(verb) {
|
|
verb = verb.toLowerCase();
|
|
if (verb === 'del') {
|
|
verb = 'delete';
|
|
}
|
|
return verb;
|
|
}
|
|
|
|
/**
|
|
* Normalize items to string[]
|
|
* @param {String|String[]} items
|
|
* @returns {String[]}
|
|
* @private
|
|
*/
|
|
function normalizeList(items) {
|
|
if (!items) {
|
|
return [];
|
|
}
|
|
var list;
|
|
if (Array.isArray(items)) {
|
|
list = [].concat(items);
|
|
} else if (typeof items === 'string') {
|
|
list = items.split(/[\s,]+/g).filter(Boolean);
|
|
} else {
|
|
throw new Error('Invalid items: ' + items);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function toLowerCase(m) {
|
|
return m && m.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Normalize the scopes object into an array of routes sorted by its
|
|
* path/methods
|
|
*
|
|
* ```json
|
|
* {
|
|
* "scope1": [{"methods": "get", path: "/:user/profile"}, "/order"],
|
|
* "scope2": [{"methods": "post", path: "/:user/profile"}]
|
|
* }
|
|
* ```
|
|
*
|
|
* @param {Object} scopes Scope mappings
|
|
* @returns {Array}
|
|
* @private
|
|
*/
|
|
function normalizeScopeMappings(scopes) {
|
|
var routes = [];
|
|
for (var s in scopes) {
|
|
var routeList = scopes[s];
|
|
debug('Scope: %s, routes: %j', s, routeList);
|
|
if (Array.isArray(routeList)) {
|
|
for (var i = 0, n = routeList.length; i < n; i++) {
|
|
var route = routeList[i];
|
|
var methods = normalizeList(route.methods);
|
|
if (methods.length === 0) {
|
|
methods = ['all'];
|
|
}
|
|
methods = methods.map(normalizeVerb);
|
|
var routeDef = {
|
|
scope: s,
|
|
methods: methods,
|
|
path: route.path,
|
|
regexp: pathToRegExp(route.path, [], {end: false})
|
|
};
|
|
debug('Route: %j', routeDef);
|
|
routes.push(routeDef);
|
|
}
|
|
}
|
|
}
|
|
routes.sort(sortRoutes);
|
|
return routes;
|
|
}
|
|
|
|
/**
|
|
* Normalize and sort ACL entries
|
|
* @param {Object[]} acls An array of ACLs
|
|
* @returns {*|Array}
|
|
* @private
|
|
*/
|
|
function normalizeACLs(acls) {
|
|
acls = acls || [];
|
|
for (var i = 0, n = acls.length; i < n; i++) {
|
|
var acl = acls[i];
|
|
if (acl.role) {
|
|
acl.principalType = Principal.ROLE;
|
|
acl.principalId = acl.role;
|
|
delete acl.role;
|
|
}
|
|
acl.scopes = normalizeList(acl.scopes);
|
|
}
|
|
acls.sort(sortACLs);
|
|
return acls;
|
|
}
|
|
/**
|
|
* Find matching ACLs for the given scopes
|
|
* @param {Object[]} acls An array of acl entries
|
|
* @param {Object[]} scopes An array of scopes
|
|
* @returns {Object[]} ACLs matching one of the scopes
|
|
* @private
|
|
*/
|
|
function matchACLs(acls, scopes) {
|
|
var matchedACLs = [];
|
|
if (Array.isArray(scopes) && Array.isArray(acls)) {
|
|
for (var i = 0, n = scopes.length; i < n; i++) {
|
|
var s = scopes[i];
|
|
for (var j = 0, k = acls.length; j < k; j++) {
|
|
var acl = acls[j];
|
|
debug('Checking ACL %j against scope %s', acl, s);
|
|
var aclScopes = acl.scopes || [];
|
|
if (typeof aclScopes === 'string') {
|
|
aclScopes = normalizeList(aclScopes);
|
|
}
|
|
if (aclScopes.indexOf(s) !== -1) {
|
|
debug('Matched ACL for scope: %j', s, acl);
|
|
matchedACLs.push(acl);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return matchedACLs;
|
|
}
|
|
|
|
/**
|
|
* Try to find out the protected resource scopes for the given request
|
|
* @param {Request} req loopback Request
|
|
* @param {Function} cb Callback function
|
|
* @returns {*}
|
|
* @private
|
|
*/
|
|
function identifyScopes(req, scopes, cb) {
|
|
var routes = normalizeScopeMappings(scopes);
|
|
var matchedScopes = findMatchedScopes(req, routes);
|
|
debug('Matched scopes: %j', matchedScopes);
|
|
return process.nextTick(function() {
|
|
cb(null, matchedScopes);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Try to find out the principals for the given request
|
|
* @param {Request} req HTTP request object
|
|
* @private
|
|
*/
|
|
function identifyPrincipals(req) {
|
|
var principals = [{
|
|
principalType: Principal.ROLE,
|
|
principalId: '$everyone'
|
|
}];
|
|
if (req.accessToken) {
|
|
if (req.accessToken.userId != null) {
|
|
principals.push({
|
|
principalType: Principal.USER,
|
|
principalId: req.accessToken.userId
|
|
});
|
|
principals.push({
|
|
principalType: Principal.ROLE,
|
|
principalId: '$authenticated'
|
|
});
|
|
}
|
|
var appId = req.accessToken.appId || req.accessToken.clientId;
|
|
if (appId != null) {
|
|
principals.push({
|
|
principalType: Principal.APP,
|
|
principalId: appId
|
|
});
|
|
}
|
|
} else {
|
|
principals.push({
|
|
principalType: Principal.ROLE,
|
|
principalId: '$unauthenticated'
|
|
});
|
|
}
|
|
debug('Principals: %j', principals);
|
|
return principals;
|
|
}
|
|
|
|
/**
|
|
* Find matched scopes for the given routes
|
|
* @param {Request} req HTTP request object
|
|
* @param {Object[]} routes An array of routes (methods, path)
|
|
* @returns {Array} Scopes matching the request
|
|
* @private
|
|
*/
|
|
function findMatchedScopes(req, routes) {
|
|
var matchedScopes = [];
|
|
var method = req.method.toLowerCase();
|
|
var url = req.originalUrl || req.url;
|
|
|
|
for (var i = 0, n = routes.length; i < n; i++) {
|
|
var route = routes[i];
|
|
debug('Matching %s %s against %j', method, url, route);
|
|
if (route.methods.indexOf('all') !== -1 ||
|
|
route.methods.indexOf(method) !== -1) {
|
|
debug('url: %s, regexp: %s', url, route.regexp);
|
|
var index = url.indexOf('?');
|
|
if (index !== -1) {
|
|
url = url.substring(0, index);
|
|
}
|
|
if (route.regexp.test(url)) {
|
|
matchedScopes.push(route.scope);
|
|
}
|
|
}
|
|
}
|
|
return matchedScopes;
|
|
}
|
|
|
|
/**
|
|
* Calculate the ACL score based on its specifics of principalType and
|
|
* permission
|
|
* @param {Object} acl ACL rule
|
|
* @returns {number}
|
|
* @private
|
|
*/
|
|
function getACLScore(acl) {
|
|
var score = 0;
|
|
switch (acl.principalType) {
|
|
case ACL.USER:
|
|
score += 4;
|
|
break;
|
|
case ACL.APP:
|
|
score += 3;
|
|
break;
|
|
case ACL.ROLE:
|
|
score += 2;
|
|
break;
|
|
default:
|
|
score += 1;
|
|
}
|
|
score = score * 8;
|
|
if (acl.principalType === ACL.ROLE) {
|
|
switch (acl.principalId) {
|
|
case Role.AUTHENTICATED:
|
|
case Role.UNAUTHENTICATED:
|
|
score += 2;
|
|
break;
|
|
case Role.EVERYONE:
|
|
score += 1;
|
|
break;
|
|
default:
|
|
score += 3;
|
|
}
|
|
}
|
|
score = score * 8;
|
|
switch (acl.permission) {
|
|
case ACL.ALLOW:
|
|
score += 1;
|
|
break;
|
|
case ACL.ALARM:
|
|
score += 2;
|
|
break;
|
|
case ACL.AUDIT:
|
|
score += 3;
|
|
break;
|
|
case ACL.DENY:
|
|
score += 4;
|
|
break;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
function sortACLs(acl1, acl2) {
|
|
// Descending order of scores
|
|
var b = getACLScore(acl1);
|
|
var a = getACLScore(acl2);
|
|
return a === b ? 0 : (a > b ? 1 : -1);
|
|
}
|
|
|
|
/**
|
|
* Compare two routes
|
|
* @param {Object} a The first route {verb: 'get', path: '/:id'}
|
|
* @param [Object} b The second route {verb: 'get', path: '/findOne'}
|
|
* @returns {number} 1: r1 comes after 2, -1: r1 comes before r2, 0: equal
|
|
* @private
|
|
*/
|
|
function sortRoutes(a, b) {
|
|
|
|
var methods1 = normalizeList(a.methods).map(toLowerCase).sort().join(',');
|
|
var methods2 = normalizeList(b.methods).map(toLowerCase).sort().join(',');
|
|
|
|
// Normalize the verbs
|
|
var verb1 = methods1;
|
|
var verb2 = methods2;
|
|
|
|
// Sort by path part by part using the / delimiter
|
|
// For example '/:id' will become ['', ':id'], '/findOne' will become
|
|
// ['', 'findOne']
|
|
var p1 = a.path.split('/');
|
|
var p2 = b.path.split('/');
|
|
var len = Math.min(p1.length, p2.length);
|
|
|
|
// Loop through the parts and decide which path should come first
|
|
for (var i = 0; i < len; i++) {
|
|
// Empty part has lower weight
|
|
if (p1[i] === '' && p2[i] !== '') {
|
|
return 1;
|
|
} else if (p1[i] !== '' && p2[i] === '') {
|
|
return -1;
|
|
}
|
|
// Wildcard has lower weight
|
|
if (p1[i][0] === ':' && p2[i][0] !== ':') {
|
|
return 1;
|
|
} else if (p1[i][0] !== ':' && p2[i][0] === ':') {
|
|
return -1;
|
|
}
|
|
// Now the regular string comparision
|
|
if (p1[i] > p2[i]) {
|
|
return 1;
|
|
} else if (p1[i] < p2[i]) {
|
|
return -1;
|
|
}
|
|
}
|
|
// Both paths have the common parts. The longer one should come before the
|
|
// shorter one
|
|
if (p1.length === p2.length) {
|
|
// First sort by verb
|
|
if (verb1 > verb2) {
|
|
return -1;
|
|
} else if (verb1 < verb2) {
|
|
return 1;
|
|
}
|
|
} else {
|
|
return p2.length > p1.length ? 1 : -1;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check the http request against route based ACLs
|
|
*
|
|
* ```js
|
|
* app.use(loopback.acl(
|
|
* {
|
|
* scopes: {
|
|
* "catalog": [{
|
|
* "methods": "GET",
|
|
* "path": "/api/catalog"
|
|
* }],
|
|
* "order": [{
|
|
* "methods": "ALL",
|
|
* "path": "/api/orders"
|
|
* }],
|
|
* "cancelOrder": [{
|
|
* "methods": "delete",
|
|
* "path": "/api/orders/:id"
|
|
* }],
|
|
* "deleteEntities": [
|
|
* {
|
|
* "methods": ["delete"],
|
|
* "path": "/api/:model"
|
|
* }
|
|
* ]
|
|
* },
|
|
* acls: [
|
|
* {
|
|
* role: '$everyone',
|
|
* scopes: ['catalog'],
|
|
* permission: 'ALLOW'
|
|
* },
|
|
* {
|
|
* role: '$unauthenticated',
|
|
* scopes: ['order'],
|
|
* permission: 'DENY'
|
|
* },
|
|
* {
|
|
* role: '$everyone',
|
|
* scopes: ['deleteEntities'],
|
|
* permission: 'DENY'
|
|
* },
|
|
* {
|
|
* principalType: 'ROLE',
|
|
* principalId: 'admin',
|
|
* scopes: ['deleteEntities'],
|
|
* permission: 'ALLOW'
|
|
* },
|
|
* {
|
|
* principalType: 'ROLE',
|
|
* principalId: 'cs',
|
|
* scopes: ['cancelOrder'],
|
|
* permission: 'ALLOW'
|
|
* }
|
|
* ],
|
|
* roles: {
|
|
* admin: [
|
|
* {principalType: 'USER', principalId: 'mary'}
|
|
* ],
|
|
* demo: [
|
|
* {principalType: 'USER', principalId: 'john'},
|
|
* {principalType: 'APP', principalId: 'demo-app'}]
|
|
* }
|
|
* ));
|
|
* ```
|
|
*
|
|
* @options {Object} [options]
|
|
* @property {Object} [scopes] Object of scope mappings.
|
|
* @property {Array} [acls] Array of ACLs.
|
|
* @property {Array} [roles] Object of Role mappings.
|
|
* @property {Boolean} [ignoreACLsFromDB] Ignore ACLs from databases
|
|
* @property {Function} [identifyScopes] A custom function to identify scopes
|
|
* from the http request
|
|
* @property {Function} [hasPrincipal] A custom function to check if a http
|
|
* request matches the given principal
|
|
* @header loopback.acl([options])
|
|
*/
|
|
function acl(options) {
|
|
options = options || {};
|
|
|
|
var scopes = options.scopes;
|
|
var acls = options.acls;
|
|
var roles = options.roles || {};
|
|
|
|
var registry;
|
|
var aclModel;
|
|
|
|
Role = loopback.Role;
|
|
ACL = loopback.ACL;
|
|
|
|
return function(req, res, next) {
|
|
if (!(options.scopes || options.acls ||
|
|
options.checkACLs || options.identifyScopes)) {
|
|
return next();
|
|
}
|
|
|
|
if (!registry) {
|
|
// Get the registry
|
|
if (req.app) {
|
|
registry = req.app.registry;
|
|
} else {
|
|
registry = loopback.registry;
|
|
}
|
|
aclModel = registry.getModelByType(loopback.ACL);
|
|
}
|
|
|
|
var identifyScopesFunc = identifyScopes;
|
|
if (typeof options.identifyScopes === 'function') {
|
|
identifyScopesFunc = options.identifyScopes;
|
|
}
|
|
|
|
var hasPrincipalFunc;
|
|
if (typeof options.hasPrincipal === 'function') {
|
|
hasPrincipalFunc = options.hasPrincipal;
|
|
} else {
|
|
// List principals from the req
|
|
var principals = identifyPrincipals(req);
|
|
hasPrincipalFunc = function(req, principalType, principalId, cb) {
|
|
principalType = principalType || Principal.ROLE;
|
|
for (var i = 0, n = principals.length; i < n; i++) {
|
|
if (principals[i].principalType === principalType &&
|
|
principals[i].principalId == principalId) {
|
|
debug('Principal %s:%s found for role %s',
|
|
principals[i].principalType, principals[i].principalId, role);
|
|
return cb(null, true);
|
|
}
|
|
}
|
|
|
|
if (principalType === Principal.ROLE) {
|
|
// Check the roles definitions
|
|
var appId = req.accessToken && req.accessToken.appId;
|
|
var userId = req.accessToken && req.accessToken.userId;
|
|
if (appId == null && userId == null) return cb(null, false);
|
|
var role = principalId;
|
|
var principalsInRole = roles[role];
|
|
if (Array.isArray(principalsInRole)) {
|
|
for (i = 0, n = principalsInRole.length; i < n; i++) {
|
|
if ((principalsInRole[i].principalType === Principal.APP &&
|
|
principalsInRole[i].principalId == appId) ||
|
|
(principalsInRole[i].principalType === Principal.USER &&
|
|
principalsInRole[i].principalId == userId)) {
|
|
debug('Principal %s:%s found for role %s',
|
|
principalsInRole[i].principalType,
|
|
principalsInRole[i].principalId, role);
|
|
return cb(null, true);
|
|
}
|
|
}
|
|
}
|
|
if (!options.ignoreACLsFromDB) {
|
|
// Checks the roleMapping model
|
|
aclModel.isMappedToRole(Principal.USER, userId, role, function(err, yes) {
|
|
if (err) return cb(err);
|
|
if (yes) return cb(null, true);
|
|
aclModel.isMappedToRole(Principal.APP, appId, role, function(err, yes) {
|
|
cb(err, yes);
|
|
});
|
|
});
|
|
}
|
|
} else {
|
|
return cb(null, false);
|
|
}
|
|
};
|
|
}
|
|
|
|
function checkACLs(req, scopes, cb) {
|
|
acls = normalizeACLs(acls);
|
|
var matchedACLs = matchACLs(acls, scopes);
|
|
|
|
var error;
|
|
async.detectSeries(matchedACLs, function(acl, done) {
|
|
hasPrincipalFunc(req, acl.principalType, acl.principalId,
|
|
function(err, yes) {
|
|
if (err) {
|
|
error = err;
|
|
return done(true);
|
|
}
|
|
if (!yes) return done(false); // Continue
|
|
if (acl.permission === 'ALLOW') {
|
|
debug('Request is allowed by ACL: %j', acl);
|
|
return done(true);
|
|
} else {
|
|
error = new Error('Authorization failed');
|
|
error.statusCode = 403;
|
|
debug('Request is denied by ACL: %j', acl);
|
|
return done(true);
|
|
}
|
|
});
|
|
}, function(result) {
|
|
cb(error, result);
|
|
});
|
|
}
|
|
|
|
var checkACLsFunc = checkACLs;
|
|
if (typeof options.checkACLs === 'function') {
|
|
checkACLsFunc = options.checkACLs;
|
|
}
|
|
|
|
identifyScopesFunc(req, scopes, function(err, matchedScopes) {
|
|
if (err) return next(err);
|
|
checkACLsFunc(req, matchedScopes, next);
|
|
});
|
|
};
|
|
}
|