├── .gitignore ├── README.md ├── index.js ├── lib ├── canTheyCombiner.js ├── canTheyExpress.js └── canthey.js ├── package.json └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | npm-debug.log* 3 | *Thumbs.db -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CanThey 2 | A customizeable, flexible authorization checker - usable with or without express. 3 | 4 | ## Install 5 | ``` 6 | npm install canthey 7 | ``` 8 | 9 | ## Authorization vs Authentication 10 | Authentication is determining if a user is who they say they are. Authorization is determining whether or not said user is allowed to access / interact with the resource. This module assumes you've already handled authentication. 11 | 12 | ## Function vs Express plugin 13 | This can be used as a standalone function, seperate of Express. This is useful if you want to bake your own middleware or use it for non Express purposes. 14 | 15 | At its core, the function takes an ACL style string and a JSON object of permissions, and parses them against one another to see if they match. 16 | 17 | ### Example ACL strings 18 | ``` 19 | admins:manage:edit 20 | users :: create 21 | products - music - albums - delete 22 | products::toys::action_figures::edit 23 | ``` 24 | 25 | ### Example permissions JSON: 26 | ```js 27 | { 28 | "admins": 29 | { 30 | "edit": true, 31 | "create": false, 32 | "delete": false 33 | } 34 | "products": 35 | { 36 | "music": "*", 37 | "toys": "videogames" 38 | } 39 | "users": "*" 40 | } 41 | ``` 42 | 43 | In these examples, the first three ACL strings would pass, the fourth would not. 44 | 45 | The function is looking against the JSON for either a name match to the same or higher depth of the ACL string, a boolean true, or a "*" at the same or higher level. For example, with the above given permission JSON, the ACL string of "products:toys" blocking a route that should have "action_figures" in the ACL string will allow the user through. 46 | 47 | # The CanThey Function 48 | 49 | ## Initialize and use example 50 | ```js 51 | var canThey = require('canthey').canThey; 52 | 53 | canThey(requiredACL, givenUserPermissions, opts); //returns true or false 54 | ``` 55 | 56 | ## Parameters 57 | 58 | * requiredACL - Required. The access level required, in a string of similar format to above.. 59 | * givenUserPermissions - Required. The JSON representation of the user's permissions OR an array of JSON permissions. 60 | * if this is an array, an internal function called canTheyCombiner is used to create a singular permissions object from many permissions objects. A great example of this is if you used role based permisisons and a user had many roles. 61 | * opts - Optional. Each attribute in opts is optional, with a default value. 62 | * splitBy - default: ":" - the string delimiter to split the ACL by. 63 | * removeSpaces - default: true - whether or not to remove all spaces from the ACL. 64 | 65 | # Express Middleware 66 | There are two forms of Express Middleware available, as shown in the examples below. 67 | ```js 68 | var Cte = require('canthey').Express 69 | var canThey = new Cte( 70 | { 71 | onRoutecall: function, // to be explaiend later - this determines which middleware you're using 72 | failureStatusCode: 403, //Defaults to 403 - what error code to send if they are not allowed access. 73 | permissionsAttribute: 'userACL', 74 | /* 75 | Defaults to userACL - Required when not using onRouteCall. 76 | Determines what to request the user's permissions from in the request object. 77 | Resulting code is req[this.permissionsAttribute] - we expect you to assign the 78 | value in a prior middleware, probably when authenticating the user. 79 | */, 80 | splitBy: ":", //Same as the canThey option - passed through. 81 | removeSpaces: true //Same as the canThey option - passed through. 82 | } 83 | ); 84 | ``` 85 | 86 | ## Without onRoutecall 87 | 88 | ```js 89 | var Cte = require('canthey').Express; 90 | var canThey = new Cte(); 91 | 92 | app.get('/admins/edit', canThey.do('admins:edit'), function(req, res){ 93 | res.send('Hello World'); 94 | }); 95 | ``` 96 | 97 | ## With onRouteCall 98 | 99 | onRouteCall is a function fired off whenever the middleware is called to determine: 100 | * the ACL string requirements for the route _ (Required) _ 101 | * the user's JSON permissions (Not required if you're using the request object as the other middleware). 102 | 103 | ```js 104 | var Cte = require('canthey').Express; 105 | var canThey = new Cte( 106 | { 107 | onRouteCall: function(req, res, cb){ 108 | //Grab your ACL string 109 | var fakeACL = 'admins:create'; 110 | //And, optionally, grab the user's permissions. 111 | var fakeUserPermissions = 112 | { 113 | admins: "*" 114 | }; 115 | cb(null, fakeACL, fakeUserPermissions); 116 | } 117 | } 118 | ); 119 | 120 | app.get('/admins/create', canThey.do, function(req, res){ 121 | console.log("I've created an admin!"); 122 | }); 123 | ``` 124 | 125 | Function with the following parameters: 126 | * req - Request object, passed for your convenience. 127 | * res - Response object, also passed for your convenience. 128 | * cb - The callback is a function with the following expected responses: 129 | * err - If populated, it will send the the failureStatusCode back to the user, but nothing else (silent fail). 130 | * routeACL - Required - the route's ACL string. 131 | * permissions - Optional - if you are attaching permissions to the request object, you can ignore this. Otherwise, it is the resulting permissions for the user. 132 | 133 | # Tests 134 | Tests are written with mocha and chai. 135 | 136 | To run the tests, install the dev dependencies as well, and then run 137 | ``` 138 | npm test 139 | ``` 140 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | 4 | canThey: require('./lib/canthey'), 5 | Express: require('./lib/canTheyExpress')(), 6 | combiner: require('./lib/canTheyCombiner') 7 | 8 | }; -------------------------------------------------------------------------------- /lib/canTheyCombiner.js: -------------------------------------------------------------------------------- 1 | var merge = require('controlled-merge'), 2 | clone = require('clone'); 3 | 4 | module.exports = 5 | 6 | /* 7 | CanThey Combiner 8 | 9 | permissions: an [] of {}s, each representing a role's permission. 10 | 11 | Given an array of various permission objects, construct and return a 12 | combined permissions object. Useful for role-based permissions 13 | 14 | returns a javascript object 15 | */ 16 | function(permissions){ 17 | if(!Array.isArray(permissions)) throw new Error('CanThey Combiner can only an array of javascript objects'); 18 | 19 | permissions.forEach(function(permission){ 20 | if(typeof permission != 'object') throw new Error('CanThey Combiner can only accept an array of javascript objects'); 21 | }); 22 | 23 | return merge( 24 | function(a, b){ 25 | if(!a) return clone(b); 26 | if(a == '*' || a === true) return clone(a); 27 | if(b == '*' || b === true) return clone(b); 28 | if(typeof a == 'string' && typeof b == 'object'){ 29 | var result; 30 | if(Array.isArray(b)){ 31 | result = clone(b); 32 | result.push(a); 33 | } else { 34 | result = clone(b); 35 | result[a] = true; 36 | } 37 | return result; 38 | } else if(typeof b == 'string' && typeof a == 'object'){ 39 | var result; 40 | if(Array.isArray(a)){ 41 | result = clone(a); 42 | result.push(b); 43 | } else { 44 | result = clone(a); 45 | result[b] = true; 46 | } 47 | return result; 48 | } else if(typeof a == 'string' && typeof b == 'string'){ 49 | var result = {}; 50 | result[a] = true; 51 | result[b] = true; 52 | return result; 53 | } else { 54 | return clone(b); 55 | } 56 | }, 57 | permissions 58 | ); 59 | 60 | }; 61 | -------------------------------------------------------------------------------- /lib/canTheyExpress.js: -------------------------------------------------------------------------------- 1 | var canThey = require('./canthey'); 2 | 3 | module.exports = function(){ 4 | 5 | var CanTheyExpress = function(opts){ 6 | if(!opts) opts = {}; 7 | this.onRouteCall = opts.onRouteCall; 8 | this.failureStatusCode = opts.failureStatusCode || 403; 9 | this.permissionsAttribute = opts.permissionsAttribute || 'userACL'; 10 | 11 | this.canTheyOpts = { 12 | splitBy: opts.splitBy || ":", 13 | removeSpaces: opts.removeSpaces || true 14 | }; 15 | }; 16 | 17 | CanTheyExpress.prototype.do = function(aclRequired, res, next){ 18 | var self = this; 19 | 20 | if(arguments.length != 1 && !self.onRouteCall){ 21 | throw new Error('CanThey.do can only be treated like middleware (req, res, next) passed if onRouteCall is set.'); 22 | } 23 | 24 | if(self.onRouteCall){ 25 | self.onRouteCall(aclRequired, res, function(err, routeACL, permissions){ 26 | if(err) return res.status(self.failureStatusCode).send(); 27 | if(!canThey(routeACL, permissions || aclRequired[self.permissionsAttribute], self.canTheyOpts)) 28 | return res 29 | .status(self.failureStatusCode) 30 | .end(); 31 | 32 | next(); 33 | }); 34 | } else { 35 | return function(req, res, next){ 36 | if(!aclRequired) return res.status(self.failureStatusCode).send(); 37 | if(!canThey(aclRequired, req[self.permissionsAttribute], self.canTheyOpts)) 38 | return res.status(self.failureStatusCode).send(); 39 | next(); 40 | }; 41 | } 42 | }; 43 | 44 | return CanTheyExpress; 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /lib/canthey.js: -------------------------------------------------------------------------------- 1 | var combiner = require('./canTheyCombiner'), 2 | clone = require('clone'); 3 | 4 | module.exports = 5 | 6 | /* 7 | CanThey 8 | 9 | permissionRequired - String of what permission is required "example:permission", 10 | acl: javascript object of permissions 11 | 12 | returns true or false 13 | */ 14 | function(permissionRequired, inputACL, opts){ 15 | if(!permissionRequired || !inputACL) return false; 16 | 17 | if(!opts) opts = {} 18 | 19 | if(!opts.splitBy) opts.splitBy = ":"; 20 | if(!opts.removeSpaces) opts.removeSpaces = true; 21 | 22 | var acl; 23 | if(Array.isArray(inputACL)){ 24 | acl = combiner(inputACL); 25 | } else { 26 | acl = clone(inputACL); 27 | } 28 | 29 | var permissions = permissionRequired.split(opts.splitBy), 30 | currentACLLevel = clone(acl), 31 | can = false; 32 | 33 | if(opts.removeSpaces){ 34 | permissions.forEach(function(toTrim, index){ 35 | permissions[index] = toTrim.replace(' ', ''); 36 | }); 37 | } 38 | 39 | permissions.every(function(permission, index){ 40 | //If the ACL says '*' at this level, they have permission - no need to go further 41 | if(currentACLLevel == '*' || currentACLLevel === true){ 42 | can = true; 43 | return false; //break every 44 | } 45 | //Otherwise, if we have a direct STRING (final) match, then they have permission to all sub levels. 46 | else if(currentACLLevel == permission){ 47 | can = true; 48 | return false; //break every; 49 | } 50 | //If the currentACL does not have a property of the permissions needed at this point, reject 51 | else if(!currentACLLevel[permission] && permission != '*' && !currentACLLevel['*']){ 52 | can = false; 53 | return false; // break every 54 | } 55 | 56 | //The last use case is assumed and examined on next iteration - the currentACLLevel is an object that 57 | //HAS the attribute, continue to next iteration; 58 | if(index + 1 == permissions.length){ 59 | can = true; 60 | return false; 61 | } else if(!currentACLLevel[permission] && currentACLLevel['*']){ 62 | currentACLLevel = clone(currentACLLevel['*']); 63 | return true; 64 | } else { 65 | currentACLLevel = clone(currentACLLevel[permission]); 66 | return true; //continue every 67 | } 68 | }); 69 | 70 | return can; 71 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canthey", 3 | "version": "2.1.4", 4 | "description": "Given a permissions JSON object (ACL style), determine if the user has access to the given route. Otherwise, reject. Not an authentication module, but an authorization module.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node ./node_modules/mocha/bin/mocha" 8 | }, 9 | "keywords": [ 10 | "authorization", 11 | "express", 12 | "permissios", 13 | "roles" 14 | ], 15 | "author": "Keith Chester", 16 | "license": "MIT", 17 | "devDependencies": { 18 | "chai": "^3.0.0", 19 | "mocha": "^2.2.5", 20 | "node-mocks-http": "^1.4.3", 21 | "superagent": "^1.2.0", 22 | "supertest": "^1.0.1" 23 | }, 24 | "dependencies": { 25 | "clone": "^1.0.2", 26 | "controlled-merge": "^1.2.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 2 | var assert = require('chai').assert, 3 | expect = require('chai').expect, 4 | canThey = require('../index').canThey, 5 | cte = require('../index').Express, 6 | httpMocks = require('node-mocks-http'); 7 | 8 | var givenACL = 9 | { 10 | admins: 11 | { 12 | create: true, 13 | read: true, 14 | delete: false 15 | }, 16 | users: "*", 17 | products: 18 | { 19 | books: 20 | { 21 | read: true 22 | }, 23 | music: false 24 | }, 25 | categories: 26 | { 27 | "*": { 28 | "test": true, 29 | "users": { 30 | "*": "GET" 31 | } 32 | } 33 | } 34 | }; 35 | 36 | describe('CanThey - function', function(){ 37 | it('should always succeed if permissions require is "*" w/ givenACL', function(){ 38 | assert.equal(canThey('*', givenACL), true); 39 | }); 40 | 41 | it('should always succeed if the given ACL is simply "*"', function(){ 42 | assert.equal(canThey('admins:create', '*'), true); 43 | }); 44 | 45 | it('should return true if the given permissions required is "admins" w/ givenACL', function(){ 46 | assert.equal(canThey('admins', givenACL), true); 47 | }); 48 | 49 | it('should return false if the given permissions required is "notHere" w/ givenACL', function(){ 50 | assert.equal(canThey('notHere', givenACL), false); 51 | }); 52 | 53 | it('should return true if the given permissions is "products:books:read" w/ givenACL', function(){ 54 | expect(canThey('products:books:read', givenACL)).to.be.true; 55 | }); 56 | 57 | it('should return true if the given permissions is "products:books" w/ givenACL', function(){ 58 | expect(canThey('products:books', givenACL)).to.be.true; 59 | }); 60 | 61 | it('should return false if the given permissions is "products:books:write" w/ givenACL', function(){ 62 | expect(canThey('products:books:write', givenACL)).to.be.false; 63 | }); 64 | 65 | it('should return false if the given permissions is "products:toys:balls" w/ givenACL', function(){ 66 | expect(canThey('products:toys:balls', givenACL)).to.be.false; 67 | }); 68 | 69 | it('should return true if the given permissions is "users:edit:passwords" w/ givenACL', function(){ 70 | expect(canThey('users:edit:passwords', givenACL)).to.be.true; 71 | }); 72 | 73 | it('should allow splitting by other symbols, like -', function(){ 74 | expect(canThey('users-edit-passwords', givenACL, { splitBy: '-' })).to.be.true; 75 | }); 76 | 77 | it('should remove spaces in a required ACL like "users - edit - pass words"', function(){ 78 | expect(canThey('users - edit - pass words', givenACL, { splitBy: '-' })).to.be.true; 79 | }); 80 | 81 | 82 | it('should, if passed an array of permissions, call the combiner function', function(){ 83 | expect(canThey('admins:fake_ability:action', [givenACL, { 'admins': true }])).to.be.true; 84 | }); 85 | 86 | it('should, return true if the given permission is underneath a *, so "categories:subcatgeory:test" w/ given ACL', function(){ 87 | expect(canThey('categories:subcategory:test', givenACL)).to.be.true; 88 | }); 89 | 90 | it('should, return false if the given permission is underneath a *, so "categories:subcategory:people" w/ given ACL', function(){ 91 | expect(canThey('categories:subcategory:people', givenACL)).to.be.false; 92 | }); 93 | 94 | it('should return true if the given permission is underneat a *, so "categories:subcategory:users:anything:GET" w/ given ACL', function(){ 95 | expect(canThey('categories:subcategory:users:anything:GET', givenACL)).to.be.true; 96 | }); 97 | 98 | it('should return false if the given permission is underneat a *, so "categories:subcategory:users:anything:GET" w/ given ACL', function(){ 99 | expect(canThey('categories:subcategory:users:anything:POST', givenACL)).to.be.false; 100 | }); 101 | 102 | }); 103 | 104 | describe('CanThey - express, no onRouteCall setup', function(){ 105 | var canThey, req, res, next; 106 | 107 | before(function(){ 108 | canThey = new cte(); 109 | }); 110 | 111 | beforeEach(function(){ 112 | req = httpMocks.createRequest(); 113 | req.userACL = givenACL; 114 | res = httpMocks.createResponse(); 115 | next = function(){}; 116 | }); 117 | 118 | it('should return an express style middleware function, with three arguments', function(){ 119 | var result = canThey.do('test:acl'); 120 | 121 | expect(typeof result).to.equal('function'); 122 | expect(result.length).to.equal(3); 123 | }); 124 | 125 | it('should return 401 if req.userACL is undefined', function(){ 126 | canThey.do(null)(req, res, next); 127 | 128 | expect(res.statusCode).to.be.equal(403); 129 | }); 130 | 131 | it('should call next if req.userACL is "*" w/ given ACL', function(){ 132 | canThey.do('*')(req, res, function(){ 133 | expect.ok; 134 | }); 135 | }); 136 | 137 | it('should call next if req.userACL is "admins" w/ given ACL', function(){ 138 | canThey.do('admins')(req, res, function(){ 139 | expect.ok; 140 | }); 141 | }); 142 | 143 | it('should return 403 if req.userACL is "admins:delete" w/ given ACL', function(){ 144 | canThey.do('admins:delete')(req, res, next); 145 | 146 | expect(res.statusCode).to.be.equal(403); 147 | }); 148 | 149 | it('should call next if req.userACL is "products:books" w/ given ACL', function(){ 150 | canThey.do('products:books')(req, res, function(){ 151 | expect.ok; 152 | }); 153 | }); 154 | 155 | it('should call next if req.userACL is "products:books:read" w/ given ACL', function(){ 156 | canThey.do('products:books:read')(req, res, function(){ 157 | expect.ok; 158 | }); 159 | }); 160 | 161 | it('should return 403 if req.userACL is "products:books:write" w/ given ACL', function(){ 162 | canThey.do('products:books:write')(req, res, next); 163 | 164 | expect(res.statusCode).to.be.equal(403); 165 | }); 166 | 167 | it('should return 403 if req.userACL is "products:music:albums:sell" w/ given ACL', function(){ 168 | canThey.do('products:music:albums:sell')(req, res, next); 169 | 170 | expect(res.statusCode).to.be.equal(403); 171 | }); 172 | 173 | }); 174 | 175 | describe('CanThey - express, onRouteCall is used', function(){ 176 | var canThey, req, res, next; 177 | 178 | beforeEach(function(){ 179 | req = httpMocks.createRequest(); 180 | res = httpMocks.createResponse(); 181 | canThey = new cte({ 182 | onRouteCall: function(req, res, cb){ 183 | cb(null, req.routeACL, givenACL); 184 | } 185 | }); 186 | next = function(){}; 187 | }); 188 | 189 | it('should throw an error if do is treated directly as middleware without onRouteCall is set', function(){ 190 | var tmp = new cte({ 191 | onRouteCall: null 192 | }); 193 | 194 | expect(function(){ 195 | tmp(req, res, next); 196 | }).to.throw(); 197 | }); 198 | 199 | it('should return 401 if userACL is undefined', function(){ 200 | req.routeACL = null; 201 | canThey.do(req, res, next); 202 | 203 | expect(res.statusCode).to.be.equal(403); 204 | }); 205 | 206 | it('should call next if userACL is "*" w/ given ACL', function(){ 207 | req.routeACL = '*'; 208 | canThey.do(req, res, function(){ 209 | expect.ok; 210 | }); 211 | }); 212 | 213 | it('should call next if userACL is "admins" w/ given ACL', function(){ 214 | req.routeACL = 'admins'; 215 | canThey.do(req, res, function(){ 216 | expect.ok; 217 | }); 218 | }); 219 | 220 | it('should return 403 if userACL is "admins:delete" w/ given ACL', function(){ 221 | req.routeACL = 'admins:delete'; 222 | canThey.do(req, res, next); 223 | 224 | expect(res.statusCode).to.be.equal(403); 225 | }); 226 | 227 | it('should call next if userACL is "products:books" w/ given ACL', function(){ 228 | req.routeACL = 'products:books'; 229 | canThey.do(req, res, function(){ 230 | expect.ok; 231 | }); 232 | }); 233 | 234 | it('should call next if userACL is "products:books:read" w/ given ACL', function(){ 235 | req.routeACL = 'products:books:read'; 236 | canThey.do(req, res, function(){ 237 | expect.ok; 238 | }); 239 | }); 240 | 241 | it('should return 403 if userACL is "products:books:write" w/ given ACL', function(){ 242 | req.routeACL = 'products:books:write'; 243 | canThey.do(req, res, next); 244 | 245 | expect(res.statusCode).to.be.equal(403); 246 | }); 247 | 248 | it('should return 403 if userACL is "products:music:albums:sell" w/ given ACL', function(){ 249 | req.routeACL = 'products:music:albums:sell'; 250 | canThey.do(req, res, next); 251 | expect(res.statusCode).to.be.equal(403); 252 | }); 253 | 254 | }); 255 | 256 | describe('CanThey - combination tests', function(){ 257 | var combiner = require('../index.js').combiner; 258 | 259 | it('should combine multiple JSON permissions into a single reaching one', function(){ 260 | var givenACLs = 261 | [ 262 | { 263 | admins: 264 | { 265 | create: true, 266 | read: true, 267 | delete: false 268 | }, 269 | users: "*", 270 | products: 271 | { 272 | books: 273 | { 274 | read: true 275 | }, 276 | music: false 277 | } 278 | }, 279 | { 280 | admins: 281 | { 282 | create: 283 | { 284 | users: true, 285 | admins: false 286 | }, 287 | read: false, 288 | delete: 289 | { 290 | users: true, 291 | admins: false 292 | } 293 | }, 294 | products: 295 | { 296 | books: '*', 297 | music: 298 | { 299 | read: true 300 | }, 301 | actionFigures: 302 | { 303 | read: true, 304 | play: false 305 | } 306 | }, 307 | settings: 308 | { 309 | modify: true 310 | } 311 | }, 312 | ]; 313 | var expectedACL = 314 | { 315 | admins: 316 | { 317 | create: true, 318 | read: true, 319 | delete: 320 | { 321 | users: true, 322 | admins: false 323 | } 324 | }, 325 | users: '*', 326 | products: 327 | { 328 | books: '*', 329 | music: 330 | { 331 | read: true 332 | }, 333 | actionFigures: 334 | { 335 | read: true, 336 | play: false 337 | } 338 | }, 339 | settings: 340 | { 341 | modify: true 342 | } 343 | }; 344 | 345 | expect(JSON.stringify(expectedACL)).to.equal(JSON.stringify(combiner(givenACLs))); 346 | }); 347 | 348 | it('should throw an error if an array is not passed', function(){ 349 | expect(function(){ 350 | combiner('test'); 351 | }).to.throw(); 352 | }); 353 | 354 | it('should throw an error if one of the objects in the array is not an object', function(){ 355 | expect(function(){ 356 | combiner([ 357 | { 358 | 'test': true 359 | }, 360 | 'test', 361 | { 362 | 'test': false 363 | } 364 | ]) 365 | }).to.throw(); 366 | }); 367 | 368 | it('should, when passed an array of javascript objects, not throw an error', function(){ 369 | expect(function(){ 370 | combiner([ { 'test': 'true' }, { 'test': 'still true' } ]); 371 | }).to.not.throw(); 372 | }); 373 | 374 | }); 375 | --------------------------------------------------------------------------------