Add route based ACL support
This commit is contained in:
parent
9422175510
commit
caa81676b7
|
@ -496,4 +496,76 @@ module.exports = function(ACL) {
|
||||||
if (callback) callback(null, access.permission !== ACL.DENY);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,8 +44,10 @@
|
||||||
"inflection": "^1.6.0",
|
"inflection": "^1.6.0",
|
||||||
"loopback-connector-remote": "^1.0.3",
|
"loopback-connector-remote": "^1.0.3",
|
||||||
"loopback-phase": "^1.2.0",
|
"loopback-phase": "^1.2.0",
|
||||||
|
"methods": "^1.1.1",
|
||||||
"nodemailer": "^1.3.1",
|
"nodemailer": "^1.3.1",
|
||||||
"nodemailer-stub-transport": "^0.1.5",
|
"nodemailer-stub-transport": "^0.1.5",
|
||||||
|
"path-to-regexp": "^1.2.0",
|
||||||
"serve-favicon": "^2.2.0",
|
"serve-favicon": "^2.2.0",
|
||||||
"stable": "^0.1.5",
|
"stable": "^0.1.5",
|
||||||
"strong-remoting": "^2.15.0",
|
"strong-remoting": "^2.15.0",
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -52,6 +52,7 @@ describe('loopback', function() {
|
||||||
'Scope',
|
'Scope',
|
||||||
'User',
|
'User',
|
||||||
'ValidationError',
|
'ValidationError',
|
||||||
|
'acl',
|
||||||
'application',
|
'application',
|
||||||
'arguments',
|
'arguments',
|
||||||
'autoAttach',
|
'autoAttach',
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue