Add route based ACL support

This commit is contained in:
Raymond Feng 2015-07-25 09:27:24 -07:00
parent 9422175510
commit caa81676b7
6 changed files with 843 additions and 0 deletions

View File

@ -496,4 +496,76 @@ module.exports = function(ACL) {
if (callback) callback(null, access.permission !== ACL.DENY);
});
};
ACL.getRelatedModels = function() {
if (!this.roleModel) {
var reg = this.registry;
this.roleModel = reg.getModelByType(loopback.Role);
this.roleMappingModel = reg.getModelByType(loopback.RoleMapping);
this.userModel = reg.getModelByType(loopback.User);
this.applicationModel = reg.getModelByType(loopback.Application);
}
};
/**
* Resolve a principal by type/id
* @param {String} type Principal type - ROLE/APP/USER
* @param {String|Number} id Principal id or name
* @param {Function} cb Callback function
*/
ACL.resolvePrincipal = function(type, id, cb) {
type = type || ACL.ROLE;
this.getRelatedModels();
switch (type) {
case ACL.ROLE:
this.roleModel.findOne({where: {or: [{name: id}, {id: id}]}}, cb);
break;
case ACL.USER:
this.userModel.findOne(
{where: {or: [{username: id}, {email: id}, {id: id}]}}, cb);
break;
case ACL.APP:
this.applicationModel.findOne(
{where: {or: [{name: id}, {email: id}, {id: id}]}}, cb);
break;
default:
process.nextTick(function() {
var err = new Error('Invalid principal type: ' + type);
err.statusCode = 400;
cb(err);
});
}
};
/**
* Check if the given principal is mapped to the role
* @param {String} principalType Principal type
* @param {String|*} principalId Principal id/name
* @param {String|*} role Role id/name
* @param {Function} cb Callback function
*/
ACL.isMappedToRole = function(principalType, principalId, role, cb) {
var self = this;
this.resolvePrincipal(principalType, principalId,
function(err, principal) {
if (err) return cb(err);
if (principal != null) {
principalId = principal.id;
}
principalType = principalType || 'ROLE';
self.resolvePrincipal('ROLE', role, function(err, role) {
if (err || !role) return cb(err, role);
self.roleMappingModel.findOne({
where: {
roleId: role.id,
principalType: principalType,
principalId: String(principalId)
}
}, function(err, result) {
if (err) return cb(err);
return cb(null, !!result);
});
});
});
};
};

View File

@ -0,0 +1,23 @@
{
"name": "ScopeMapping",
"description": "Map protected resources to scopes",
"properties": {
"id": {
"type": "string",
"id": true,
"generated": true
},
"resourceType": {
"type": "string",
"description": "The resource type, such as Route or ModelOperation"
},
"resourceId": "string"
},
"relations": {
"role": {
"type": "belongsTo",
"model": "Scope",
"foreignKey": "scopeId"
}
}
}

View File

@ -44,8 +44,10 @@
"inflection": "^1.6.0",
"loopback-connector-remote": "^1.0.3",
"loopback-phase": "^1.2.0",
"methods": "^1.1.1",
"nodemailer": "^1.3.1",
"nodemailer-stub-transport": "^0.1.5",
"path-to-regexp": "^1.2.0",
"serve-favicon": "^2.2.0",
"stable": "^0.1.5",
"strong-remoting": "^2.15.0",

543
server/middleware/acl.js Normal file
View File

@ -0,0 +1,543 @@
/*!
* 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|*}
*/
function normalizeVerb(verb) {
verb = verb.toLowerCase();
if (verb === 'del') {
verb = 'delete';
}
return verb;
}
/**
* Normalize items to string[]
* @param {String|String[]} items
* @returns {String[]}
*/
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}
*/
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}
*/
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
*/
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 {*}
*/
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
*/
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
*/
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 its specifics of principalType and permission
* @param {Object} acl ACL rule
* @returns {number}
*/
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
*/
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);
});
};
}

View File

@ -52,6 +52,7 @@ describe('loopback', function() {
'Scope',
'User',
'ValidationError',
'acl',
'application',
'arguments',
'autoAttach',

202
test/route-acl.test.js Normal file
View File

@ -0,0 +1,202 @@
var routeAcl = require('../server/middleware/acl');
var loopback = require('../index');
var options = {
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'}]
}
};
describe('route based ACLs', function() {
var handler;
before(function(done) {
handler = routeAcl(options);
var ds = loopback.createDataSource({
connector: 'memory',
name: 'db',
defaultForType: 'db'
});
loopback.Application.attachTo(ds);
loopback.User.attachTo(ds);
loopback.Role.attachTo(ds);
loopback.RoleMapping.attachTo(ds);
loopback.Role.create({name: 'cs'}, function(err, role) {
if (err) return done(err);
loopback.RoleMapping.create(
{roleId: role.id, principalType: 'USER', principalId: 'mike'}, done);
});
});
it('should allow $everyone to get /api/catalog', function(done) {
var req = {
method: 'get',
originalUrl: '/api/catalog',
url: '/api/catalog',
headers: {
'Accept': 'application/json'
},
query: {
filter: {
limit: 100
}
}
};
var res = {};
handler(req, res, done);
});
it('should allow $authenticated to get /api/catalog', function(done) {
var req = {
method: 'get',
originalUrl: '/api/catalog',
url: '/api/catalog',
headers: {
'Accept': 'application/json'
},
query: {
filter: {
limit: 100
}
},
accessToken: {
userId: 'john',
appId: 'demo-app'
}
};
var res = {};
handler(req, res, done);
});
it('should deny $unauthenticated get /api/orders', function(done) {
var req = {
method: 'get',
url: '/api/orders',
headers: {
'Accept': 'application/json'
},
query: {
filter: {
limit: 100
}
}
};
var res = {};
handler(req, res, function(err) {
if (err) return done();
else {
return done(new Error('The request should be denied'));
}
});
});
it('should allow deletes for admin users', function(done) {
var req = {
method: 'delete',
originalUrl: '/api/catalog',
url: '/api/catalog',
headers: {
'Accept': 'application/json'
},
accessToken: {
userId: 'mary',
appId: 'demo-app'
}
};
var res = {};
handler(req, res, done);
});
it('should reject deletes for non-admin users', function(done) {
var req = {
method: 'delete',
originalUrl: '/api/catalog',
url: '/api/catalog',
headers: {
'Accept': 'application/json'
},
accessToken: {
userId: 'john',
appId: 'demo-app'
}
};
var res = {};
handler(req, res, function(err) {
if (err) return done();
else {
return done(new Error('The request should be denied'));
}
});
});
it('should allow cancel order for cs users', function(done) {
var req = {
method: 'delete',
url: '/api/orders/100',
headers: {
'Accept': 'application/json'
},
accessToken: {
userId: 'mike',
appId: 'demo-app'
}
};
var res = {};
handler(req, res, done);
});
});