├── .gitignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── README.md ├── lib ├── authorized.js ├── errors.js ├── manager.js ├── role.js └── view.js ├── package.json └── test ├── integration └── express │ ├── app.spec.js │ └── middleware │ └── fakeauth.js └── lib ├── errors.spec.js ├── manager.spec.js ├── role.spec.js └── view.spec.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * @param {Object} grunt Grunt. 5 | */ 6 | module.exports = function(grunt) { 7 | 8 | var _ = grunt.util._; 9 | 10 | var libSrc = 'lib/**/*.js'; 11 | var testSrc = 'test/**/*.js'; 12 | 13 | var lintOptions = { 14 | curly: true, 15 | eqeqeq: true, 16 | indent: 2, 17 | latedef: true, 18 | newcap: true, 19 | nonew: true, 20 | quotmark: 'single', 21 | undef: true, 22 | trailing: true, 23 | maxlen: 80, 24 | globals: { 25 | exports: true, 26 | module: false, 27 | process: false, 28 | require: false 29 | } 30 | }; 31 | 32 | var testLintOptions = _.clone(lintOptions, true); 33 | _.merge(testLintOptions.globals, { 34 | it: false, 35 | describe: false, 36 | beforeEach: false 37 | }); 38 | 39 | grunt.initConfig({ 40 | jshint: { 41 | gruntfile: { 42 | options: lintOptions, 43 | src: 'Gruntfile.js' 44 | }, 45 | lib: { 46 | options: lintOptions, 47 | src: [libSrc] 48 | }, 49 | test: { 50 | options: testLintOptions, 51 | src: [testSrc] 52 | } 53 | }, 54 | cafemocha: { 55 | options: { 56 | reporter: 'spec' 57 | }, 58 | all: { 59 | src: testSrc 60 | } 61 | }, 62 | watch: { 63 | gruntfile: { 64 | files: 'Gruntfile.js', 65 | tasks: ['jshint:gruntfile'] 66 | }, 67 | lib: { 68 | files: libSrc, 69 | tasks: ['jshint:lib', 'cafemocha'] 70 | }, 71 | test: { 72 | files: testSrc, 73 | tasks: ['jshint:test', 'cafemocha'] 74 | } 75 | } 76 | }); 77 | 78 | grunt.loadNpmTasks('grunt-contrib-jshint'); 79 | grunt.loadNpmTasks('grunt-contrib-watch'); 80 | grunt.loadNpmTasks('grunt-cafe-mocha'); 81 | 82 | grunt.registerTask('test', ['jshint', 'cafemocha']); 83 | 84 | grunt.registerTask('default', ['test']); 85 | 86 | }; 87 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2013 Tim Schaub, http://tschaub.net/ 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Authorized! 2 | **Action based authorization middleware** 3 | 4 | The `authorized` package is available on [npm](https://npmjs.org/package/authorized). 5 | 6 | $ npm install authorized 7 | 8 | ## Quick start 9 | 10 | Import an authorization manager. 11 | 12 | ```js 13 | var auth = require('authorized'); 14 | ``` 15 | 16 | ### Roles 17 | 18 | Provide getters for your application roles. 19 | 20 | ```js 21 | auth.role('admin', function(req, done) { 22 | done(null, req.user && req.user.admin); 23 | }); 24 | ``` 25 | 26 | Roles can use `.` syntax. 27 | 28 | ```js 29 | // getters for entity.relation type roles are called with the entity 30 | auth.role('organization.owner', function(org, req, done) { 31 | if (!req.user) { 32 | done(); 33 | } else { 34 | done(null, !!~org.owners.indexOf(req.user.id)); 35 | } 36 | }); 37 | ``` 38 | 39 | ### Entities 40 | 41 | Provide getters for your application entities. 42 | 43 | ```js 44 | auth.entity('organization', function(req, done) { 45 | // assume url like /organizations/:orgId 46 | var match = req.url.match(/^\/organizations\/(\w+)/); 47 | if (!match) { 48 | done(new Error('Expected url like /organizations/:orgId')); 49 | return; 50 | } 51 | // pretend we're going to the db for the organization 52 | process.nextTick(function() { 53 | // mock org 54 | var org = {id: match[1], owners: ['user.1']}; 55 | done(null, org); 56 | }); 57 | }); 58 | ``` 59 | 60 | ### Actions 61 | 62 | Now define what roles are required for your actions. 63 | 64 | ```js 65 | auth.action('add members to organization', ['admin', 'organization.owner']); 66 | auth.action('delete organization', ['admin']); 67 | ``` 68 | 69 | To perform the provided action, a user must have at least one of the given 70 | roles. In the first case, a user must be `admin` or `organization.owner` to add 71 | members to an organization. In the second case, a user must be `admin` to be 72 | able to delete an organization. 73 | 74 | Note that entity and role getters can be added in any order, but you cannot 75 | configure actions until all entity and role getters have been added. 76 | 77 | ### Middleware 78 | 79 | Now you're ready to generate authorization middleware. 80 | 81 | ```js 82 | var middleware = auth.can('add members to organization'); 83 | ``` 84 | 85 | This middleware can be used in Connect/Express apps in your route definitions. 86 | 87 | ```js 88 | var assert = require('assert'); 89 | var express = require('express'); 90 | var app = express(); 91 | app.post( 92 | '/organizations/:orgId/members', 93 | auth.can('add members to organization'), 94 | function(req, res, next) { 95 | // you can safely let the user add members to the org here 96 | // you can also access entities, roles, and actions for your view 97 | var view = auth.view(req); 98 | assert.ok(view.get('organization')); 99 | assert.strictEqual(view.has('admin'), false); 100 | assert.strictEqual(view.has('organization.owner'), true); 101 | // this is implicit since this middleware is only called if true 102 | assert.strictEqual(view.can('add members to organization'), true); 103 | // pretend we added a member to the org 104 | res.send(202, 'member added'); 105 | }); 106 | ``` 107 | 108 | If you have a view that might allow a user to perform multiple actions, you 109 | can create middleware that allows the view to be rendered if any of a list of 110 | actions are allowed. In this case, the view will also have access to which 111 | specific actions are allowed so you can conditionally render page elements. 112 | 113 | ```js 114 | app.get( 115 | '/organizations/:orgId/manage', 116 | auth.can('add members to organization', 'delete organization'), 117 | function(req, res, next) { 118 | /** 119 | * We've reached this point because the user can either add members or 120 | * delete the organization. 121 | */ 122 | var view = auth.view(req); 123 | /** 124 | * To determine which actions are allowed, call the `can` method (or 125 | * inspect all of `view.actions`). 126 | */ 127 | res.render('manage.html', { 128 | actions: view.actions 129 | }); 130 | }); 131 | ``` 132 | 133 | ### Handling unauthorized actions 134 | 135 | If the auth manager decides a user is not authorized to perform a specific 136 | action, an `UnauthorizedError` will be passed down the middleware chain. To 137 | provide specific handling for this error, configure your application with 138 | error handling middleware. 139 | 140 | ```js 141 | app.use(function(err, req, res, next) { 142 | if (err instanceof auth.UnauthorizedError) { 143 | res.send(401, 'Unauthorized'); 144 | } else { 145 | next(err); 146 | } 147 | }); 148 | ``` 149 | 150 | ## API 151 | 152 | ```js 153 | var auth = require('authorized'); 154 | ``` 155 | 156 | The `authorized` module exports a [`Manager`](#manager) instance with the 157 | methods below. 158 | 159 | ### `Manager` 160 | 161 | #### `role(role, getter)` 162 | 163 | * **role** `string` - Role name (e.g. 'organization.owner'). 164 | * **getter** `function(req, done)` - Function that determines if the current 165 | user has the given role. This function will be called with the request 166 | object and a callback. The callback has the form 167 | `function(Error, boolean)` where the first argument is any error value 168 | generated while checking for the given role and the second is a boolean 169 | indicating whether the user has the role. 170 | 171 | Register a getter for a role. If the role is a string of the form 172 | `entity.relation`, a getter for the entity must be registered with the 173 | [`entity`](#manager.entity) method. Roles without `.` are "simple" roles (e.g. 174 | `"admin"`) and no entity is looked up. Throws [`ConfigError`](#configerror) if 175 | called with an invalid role name. 176 | 177 | #### `entity(type, getter)` 178 | 179 | * **type** {string} - Entity type (e.g. 'organization'). 180 | * **getter** `{function(req, done)` - Function called to get an entity from 181 | the provided request. The `done` function has the form 182 | `function(Error, Object)` where the first argument is any error value 183 | generated while getting the entity and the second is the target entity. 184 | 185 | Register a getter for an entity. Throws [`ConfigError`](#configerror) if called 186 | with invalid arguments. 187 | 188 | #### `action(name, roles)` 189 | 190 | * **name** `string` - Action name (e.g. 'add member to organization'). 191 | * **roles** `Array.`Roles allowed to perform this action. If 192 | the current user has any one of the supplied roles, they can perform the 193 | action (e.g. ['admin', 'organization.owner']). 194 | 195 | Specify the roles that a user must have to perform the named action. Throws 196 | [`ConfigError`](#configerror) if the provided roles have not yet been registered 197 | with the [`role`](#manager.role) method. 198 | 199 | #### `can(action)` 200 | 201 | * **action** `string` Action name (e.g. 'add members to organization'). 202 | May also be called with multiple action arguments. Supplying '*' is an 203 | alternative to specifying all actions. 204 | 205 | Create action based authorization middleware. Returns a middleware function 206 | with the signature `function(IncomingMessage, ServerResponse, function)`. An 207 | [`UnauthorizedError`](#unauthorizederror) will be passed to following middleware 208 | when the user is not authorized to perform the given action. Throws 209 | [`ConfigError`](#configerror) if the provide action has not been registered 210 | with the [`action`](#manager.action) method. 211 | 212 | #### `view(req)` 213 | 214 | * **req** `Object` - The request object. 215 | 216 | Get cached authorization info for a request. Returns a [`View`](#view) 217 | instance for accessing authorization info for the given request. 218 | 219 | ### `View` 220 | 221 | #### `can(action)` 222 | 223 | * **action** `string` - Action name. 224 | 225 | Returns a `boolean` indicating whether the given action may be performed. 226 | 227 | #### `get(type)` 228 | 229 | * **type** `string` - The entity type. 230 | 231 | Returns the cached entity `Object` (or `null` if none found). 232 | 233 | #### `has(role)` 234 | 235 | * **role** `string` - The role name. 236 | 237 | Returns a `boolean` indicating whether the current user has the given role. 238 | 239 | #### `freeze()` 240 | 241 | Freeze the view. This prevents entities, actions, and roles from being 242 | modified. 243 | 244 | ### `ConfigError` 245 | 246 | Thrown on configuration error. 247 | 248 | ### `UnauthorizedError` 249 | 250 | Passed down the middleware chain when a user is not authorized to perform an 251 | action. 252 | 253 | ## What else? 254 | 255 | This package is strictly about authorization. For a full-featured 256 | authentication package, see [PassportJS](http://passportjs.org/). 257 | 258 | Inspiration is drawn here from [connect-roles](https://github.com/ForbesLindesay/connect-roles). 259 | One major difference is that this is all async (you don't have to determine 260 | if a user can perform an action synchronously). 261 | 262 | ## Check out the tests for more 263 | 264 | See the [unit](test/lib) and [integration](test/integration) tests for more detail on how `authorized` is used. 265 | 266 | To run the linter and tests: 267 | 268 | npm test 269 | 270 | During development, the linter and tests can be run continously: 271 | 272 | npm run watch 273 | 274 | [![Current Status](https://secure.travis-ci.org/tschaub/authorized.png?branch=master)](https://travis-ci.org/tschaub/authorized) 275 | -------------------------------------------------------------------------------- /lib/authorized.js: -------------------------------------------------------------------------------- 1 | var Manager = require('./manager').Manager; 2 | var Role = require('./role').Role; 3 | var View = require('./view').View; 4 | var errors = require('./errors'); 5 | 6 | 7 | /** 8 | * Singleton with default options. 9 | */ 10 | exports = module.exports = new Manager(); 11 | 12 | 13 | /** 14 | * @type {ConfigError} 15 | */ 16 | exports.ConfigError = errors.ConfigError; 17 | 18 | 19 | /** 20 | * @type {Manager} 21 | */ 22 | exports.Manager = Manager; 23 | 24 | 25 | /** 26 | * @type {Role} 27 | */ 28 | exports.Role = Role; 29 | 30 | 31 | /** 32 | * @type {UnauthorizedError} 33 | */ 34 | exports.UnauthorizedError = errors.UnauthorizedError; 35 | 36 | 37 | /** 38 | * @type {View} 39 | */ 40 | exports.View = View; 41 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | function makeError(self, args) { 4 | Error.apply(self, args); 5 | Error.captureStackTrace(self, args.callee); 6 | self.message = args[0]; 7 | self.name = args.callee.name; 8 | } 9 | 10 | 11 | 12 | /** 13 | * @constructor 14 | * @param {string} message Error message. 15 | */ 16 | exports.ConfigError = function ConfigError(message) { 17 | makeError(this, arguments); 18 | }; 19 | util.inherits(exports.ConfigError, Error); 20 | 21 | 22 | 23 | /** 24 | * @constructor 25 | * @param {string} message Error message. 26 | */ 27 | exports.UnauthorizedError = function UnauthorizedError(message) { 28 | makeError(this, arguments); 29 | }; 30 | util.inherits(exports.UnauthorizedError, Error); 31 | -------------------------------------------------------------------------------- /lib/manager.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | 3 | var pause = require('pause'); 4 | 5 | var errors = require('./errors'); 6 | var Role = require('./role').Role; 7 | var View = require('./view').View; 8 | 9 | 10 | /** 11 | * Used to store authorized info on a request object. 12 | * @const 13 | */ 14 | var LOOKUP_ID = '__authorized'; 15 | 16 | 17 | 18 | /** 19 | * Create a new authorization manager. 20 | * @param {Object} options Manager options. 21 | * * pauseStream {boolean} Pause the request body stream while checking 22 | * authorization (default is `true`). 23 | * @constructor 24 | */ 25 | function Manager(options) { 26 | this.roleGetters_ = {}; 27 | this.entityGetters_ = {}; 28 | this.actionDefs_ = {}; 29 | this.options = { 30 | pauseStream: true 31 | }; 32 | if (options) { 33 | for (var option in this.options) { 34 | if (options.hasOwnProperty(option)) { 35 | this.options[option] = options[option]; 36 | } 37 | } 38 | } 39 | } 40 | 41 | 42 | /** 43 | * Register the roles for a specific action. 44 | * @param {string} name Action name (e.g. 'add member to organization'). 45 | * @param {Array.} roles Roles allowed to perform this action. If 46 | * the current user has any one of the supplied roles, they can perform the 47 | * action (e.g. ['admin', 'organization.owner']). 48 | */ 49 | Manager.prototype.action = function(name, roles) { 50 | if (!Array.isArray(roles)) { 51 | roles = [roles]; 52 | } 53 | if (roles.length === 0) { 54 | throw new errors.ConfigError('Actions require one or more roles'); 55 | } 56 | var self = this; 57 | roles = roles.map(function(role) { 58 | if (typeof role === 'string') { 59 | role = new Role(role); 60 | } 61 | if (!(role instanceof Role)) { 62 | throw new errors.ConfigError( 63 | 'Expected a string or a Role instance, got ' + String(role)); 64 | } 65 | // confirm that role getter has already been added 66 | if (!self.roleGetters_.hasOwnProperty(role.name)) { 67 | throw new errors.ConfigError('No getter found for role: ' + role.name); 68 | } 69 | // confirm that any entity getter has been added 70 | if (role.entity && !self.entityGetters_.hasOwnProperty(role.entity)) { 71 | throw new errors.ConfigError( 72 | 'No getter found for entity: ' + role.entity); 73 | } 74 | return role; 75 | }); 76 | this.actionDefs_[name] = roles; 77 | }; 78 | 79 | 80 | /** 81 | * Check if an action is allowed. 82 | * @param {string} action Action name. 83 | * @param {Object} req Request object. 84 | * @param {function(Error, boolean)} done Callback. 85 | * @private 86 | */ 87 | Manager.prototype.actionAllowed_ = function(action, req, done) { 88 | var self = this; 89 | function cacheBeforeDone(role, done) { 90 | self.hasRole_(role, req, done); 91 | } 92 | 93 | var view_ = this.view_(req); 94 | if (action in view_.actions) { 95 | process.nextTick(function() { 96 | done(null, view_.actions[action]); 97 | }); 98 | } else { 99 | var roles = this.actionDefs_[action]; 100 | async.each(roles, cacheBeforeDone, function(err) { 101 | if (err) { 102 | return done(err); 103 | } 104 | // user must have one of the roles to perform the action 105 | var can = roles.some(function(role) { 106 | return !!view_.roles[role.name]; 107 | }); 108 | view_.actions[action] = can; 109 | done(null, can); 110 | }); 111 | } 112 | }; 113 | 114 | 115 | /** 116 | * Create action based authorization middleware. 117 | * @param {string} action Action name (e.g. 'add members to organization'). 118 | * May also be called with multiple action arguments. Supplying '*' is an 119 | * alternative to specifying all actions. 120 | * @return {function(Object, Object, function)} Authorization middleware. 121 | */ 122 | Manager.prototype.can = function(action) { 123 | var actions; 124 | if (action === '*') { 125 | actions = Object.keys(this.actionDefs_); 126 | } else { 127 | actions = Array.prototype.slice.call(arguments); 128 | } 129 | for (var i = 0, ii = actions.length; i < ii; ++i) { 130 | if (!this.actionDefs_.hasOwnProperty(actions[i])) { 131 | throw new errors.ConfigError('Action not found: ' + actions[i]); 132 | } 133 | } 134 | var self = this; 135 | var pauseStream = this.options.pauseStream; 136 | 137 | return function(req, res, next) { 138 | var paused = pauseStream ? pause(req) : null; 139 | var canSome = false; 140 | var disallowed = []; 141 | async.each(actions, function(action, done) { 142 | self.actionAllowed_(action, req, function(err, can) { 143 | if (!can) { 144 | disallowed.push(action); 145 | } else { 146 | canSome = true; 147 | } 148 | done(err); 149 | }); 150 | }, function(err) { 151 | if (!err && !canSome) { 152 | err = new errors.UnauthorizedError( 153 | 'Action not allowed: ' + disallowed.join(',')); 154 | } 155 | if (err) { 156 | next(err); 157 | } else { 158 | next(); 159 | } 160 | if (paused) { 161 | paused.resume(); 162 | } 163 | }); 164 | }; 165 | }; 166 | 167 | 168 | /** 169 | * Register a getter for an entity. 170 | * @param {string} type Entity type (e.g. 'organization'). 171 | * @param {function(req, done)} getter Function called to get an entity from 172 | * the provided request. The `done` function has the form 173 | * {function(Error, Object)} where `err` is any error value 174 | * generated while getting the entity and `entity` is the target entity. 175 | */ 176 | Manager.prototype.entity = function(type, getter) { 177 | if (typeof type !== 'string') { 178 | throw new errors.ConfigError('Entity type must be a string'); 179 | } 180 | if (typeof getter !== 'function' || getter.length !== 2) { 181 | throw new errors.ConfigError( 182 | 'Entity getter must be a function that takes two arguments'); 183 | } 184 | this.entityGetters_[type] = getter; 185 | }; 186 | 187 | 188 | /** 189 | * Get an entity from a request. 190 | * @param {string} type Entity type. 191 | * @param {Object} req Request object. 192 | * @param {function(Error, Object)} done Callback. 193 | * @private 194 | */ 195 | Manager.prototype.getEntity_ = function(type, req, done) { 196 | if (!type) { 197 | process.nextTick(function() { 198 | done(); 199 | }); 200 | } else { 201 | if (!(type in this.entityGetters_)) { 202 | done(new errors.ConfigError('No getter found for entity: ' + type)); 203 | } else { 204 | var view_ = this.view_(req); 205 | if (type in view_.entities) { 206 | process.nextTick(function() { 207 | done(null, view_.entities[type]); 208 | }); 209 | } else { 210 | this.entityGetters_[type](req, function(err, entity) { 211 | if (!err) { 212 | // cache entity for future access 213 | view_.entities[type] = entity; 214 | } 215 | done(err, entity); 216 | }); 217 | } 218 | } 219 | } 220 | }; 221 | 222 | 223 | /** 224 | * Check if a user has the given role. 225 | * @param {Role} role Target role. 226 | * @param {Object} req Current request. 227 | * @param {function(Error, boolean)} done Callback. 228 | * @private 229 | */ 230 | Manager.prototype.hasRole_ = function(role, req, done) { 231 | var getter = this.roleGetters_[role.name]; 232 | if (!getter) { 233 | done(new errors.ConfigError( 234 | 'No getter found for role: ' + role.name)); 235 | } else { 236 | var view_ = this.view_(req); 237 | if (role.name in view_.roles) { 238 | process.nextTick(function() { 239 | done(null, view_.roles[role.name]); 240 | }); 241 | } else { 242 | this.getEntity_(role.entity, req, function(err, entity) { 243 | if (err) { 244 | return done(err); 245 | } 246 | function cacheBeforeDone(err, has) { 247 | if (!err) { 248 | view_.roles[role.name] = !!has; 249 | } 250 | done(err, has); 251 | } 252 | if (role.entity) { 253 | if (!entity) { 254 | // don't even bother checking the relation 255 | cacheBeforeDone(null, false); 256 | } else { 257 | getter.apply(null, [entity, req, cacheBeforeDone]); 258 | } 259 | } else { 260 | getter.apply(null, [req, cacheBeforeDone]); 261 | } 262 | }); 263 | } 264 | } 265 | }; 266 | 267 | 268 | /** 269 | * Register a getter for a role. 270 | * @param {string} role Role name (e.g. 'organization.owner'). 271 | * @param {function(req, done)} getter Function that determines if the current 272 | * user has the given role. This function will be called with the request 273 | * object and a callback. The callback has the form 274 | * {function(Error, boolean)} where `err` is any error value 275 | * generated while checking for the given role and `has` is a boolean 276 | * indicating whether the user has the role. 277 | */ 278 | Manager.prototype.role = function(role, getter) { 279 | if (typeof role === 'string') { 280 | role = new Role(role); 281 | } 282 | if (!(role instanceof Role)) { 283 | throw new errors.ConfigError('Role must be a string or Role instance'); 284 | } 285 | if (typeof getter !== 'function') { 286 | throw new errors.ConfigError('Role getter must be a function'); 287 | } 288 | if (role.entity && getter.length !== 3) { 289 | throw new errors.ConfigError( 290 | 'Getters for roles with entities take three arguments ' + 291 | '(first arg will be the resolved entity)'); 292 | } 293 | if (!role.entity && getter.length !== 2) { 294 | throw new errors.ConfigError( 295 | 'Getters for simple roles (without entities) take two arguments'); 296 | } 297 | this.roleGetters_[role.name] = getter; 298 | }; 299 | 300 | 301 | /** 302 | * Get cached authorization info for a request. 303 | * @param {Object} req Request object. 304 | * @return {View} A cache of authorization info. 305 | */ 306 | Manager.prototype.view = function(req) { 307 | var view = this.view_(req); 308 | view.freeze(); 309 | return view; 310 | }; 311 | 312 | 313 | /** 314 | * Get cached authorization info for a request. This view can still be 315 | * modified (in constrast with the view returned from the public #view() 316 | * method). 317 | * @param {Object} req Request object. 318 | * @return {View} A cache of authorization info. 319 | * @private 320 | */ 321 | Manager.prototype.view_ = function(req) { 322 | var storage = req[LOOKUP_ID]; 323 | if (!storage) { 324 | storage = req[LOOKUP_ID] = { 325 | view: new View() 326 | }; 327 | } 328 | return storage.view; 329 | }; 330 | 331 | 332 | /** 333 | * @type {Manager} 334 | */ 335 | exports.Manager = Manager; 336 | -------------------------------------------------------------------------------- /lib/role.js: -------------------------------------------------------------------------------- 1 | var ConfigError = require('./errors').ConfigError; 2 | 3 | 4 | /** 5 | * @param {string} str Role name (assumes entity.relation syntax). 6 | * @return {Object} Role config object. 7 | */ 8 | function parseConfig(str) { 9 | var parts = str.split('.'), 10 | config = {}; 11 | 12 | config.relation = parts.pop(); 13 | config.entity = parts.pop(); 14 | return config; 15 | } 16 | 17 | 18 | 19 | /** 20 | * @constructor 21 | * @param {Object} config Role config object. 22 | */ 23 | function Role(config) { 24 | if (typeof config === 'string') { 25 | config = parseConfig(config); 26 | } 27 | if (!config.relation) { 28 | throw new ConfigError('Role must have a relation'); 29 | } 30 | this.entity = config.entity; 31 | this.relation = config.relation; 32 | this.name = this.entity ? this.entity + '.' + this.relation : this.relation; 33 | } 34 | 35 | 36 | /** 37 | * @type {Role} 38 | */ 39 | exports.Role = Role; 40 | -------------------------------------------------------------------------------- /lib/view.js: -------------------------------------------------------------------------------- 1 | var Role = require('./role').Role; 2 | 3 | 4 | 5 | /** 6 | * @constructor 7 | */ 8 | function View() { 9 | this.entities = {}; 10 | this.actions = {}; 11 | this.roles = {}; 12 | Object.freeze(this); 13 | } 14 | 15 | 16 | /** 17 | * Check if an action can be performed. 18 | * @param {string} action Action name. 19 | * @return {boolean} The auth manager has determined that the given action can 20 | * be performed. 21 | */ 22 | View.prototype.can = function(action) { 23 | return this.actions[action]; 24 | }; 25 | 26 | 27 | /** 28 | * Freeze the view. This prevents entities, actions, and roles from being 29 | * modified. 30 | */ 31 | View.prototype.freeze = function() { 32 | Object.freeze(this.entities); 33 | Object.freeze(this.actions); 34 | Object.freeze(this.roles); 35 | }; 36 | 37 | 38 | /** 39 | * Get a cached entity. 40 | * @param {string} type Entity type. 41 | * @return {Object} The cached entity (or null if none found). 42 | */ 43 | View.prototype.get = function(type) { 44 | return this.entities[type] || null; 45 | }; 46 | 47 | 48 | /** 49 | * Check if a role is assigned. 50 | * @param {string} role Role name. 51 | * @return {boolean} The current user has the given role. 52 | */ 53 | View.prototype.has = function(role) { 54 | if (role instanceof Role) { 55 | role = role.name; 56 | } 57 | return this.roles[role]; 58 | }; 59 | 60 | 61 | /** 62 | * @type {View} 63 | */ 64 | exports.View = View; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "authorized", 3 | "version": "1.0.0", 4 | "description": "Action based authorization middleware.", 5 | "keywords": [ 6 | "auth", 7 | "authorization", 8 | "security", 9 | "roles", 10 | "express", 11 | "connect" 12 | ], 13 | "licenses": [ 14 | { 15 | "type": "MIT", 16 | "url": "http://tschaub.mit-license.org/" 17 | } 18 | ], 19 | "author": { 20 | "name": "Tim Schaub", 21 | "url": "http://tschaub.net/" 22 | }, 23 | "homepage": "https://github.com/tschaub/authorized", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/tschaub/authorized.git" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/tschaub/authorized/issues" 30 | }, 31 | "devDependencies": { 32 | "chai": "1.5.0", 33 | "mocha": "1.9.0", 34 | "grunt": "0.4.1", 35 | "grunt-contrib-jshint": "0.4.3", 36 | "grunt-cafe-mocha": "0.1.2", 37 | "grunt-contrib-watch": "0.3.1", 38 | "grunt-cli": "0.1.7", 39 | "express": "3.2.0", 40 | "chai-http": "0.3.0" 41 | }, 42 | "main": "./lib/authorized", 43 | "scripts": { 44 | "test": "grunt test", 45 | "watch": "grunt watch" 46 | }, 47 | "dependencies": { 48 | "async": "0.9.0", 49 | "pause": "0.0.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/integration/express/app.spec.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var chai = require('chai'); 3 | var chaiHttp = require('chai-http'); 4 | 5 | var authenticate = require('./middleware/fakeauth'); 6 | var authorized = require('../../../lib/authorized'); 7 | var Manager = authorized.Manager; 8 | var UnauthorizedError = authorized.UnauthorizedError; 9 | 10 | /** @type {boolean} */ 11 | chai.Assertion.includeStack = true; 12 | chai.use(chaiHttp); 13 | var assert = chai.assert; 14 | 15 | describe('Usage in Express app', function() { 16 | 17 | var auth = new Manager(); 18 | var app = express(); 19 | 20 | // configure fake authentication middleware 21 | // adds req.user if x-fake-user-id header is set 22 | app.use(authenticate()); 23 | 24 | auth.role('admin', function(req, done) { 25 | done(null, req.user && req.user.admin); 26 | }); 27 | 28 | auth.role('organization.owner', function(org, req, done) { 29 | var id = req.user && req.user.id; 30 | if (!id) { 31 | done(); 32 | } else { 33 | done(null, !!~org.owners.indexOf(id)); 34 | } 35 | }); 36 | 37 | auth.entity('organization', function(req, done) { 38 | // assume url like /organizations/:orgId 39 | var match = req.url.match(/^\/organizations\/(\w+)/); 40 | if (!match) { 41 | done(new Error('Expected url like /organizations/:orgId')); 42 | } 43 | // pretend we're going to the db for the organization 44 | process.nextTick(function() { 45 | // mock org 46 | var org = {id: match[1], owners: ['user.1']}; 47 | done(null, org); 48 | }); 49 | }); 50 | 51 | auth.action('add members to organization', ['admin', 'organization.owner']); 52 | 53 | app.post( 54 | '/organizations/:orgId/members', 55 | auth.can('add members to organization'), 56 | express.json(), 57 | function(req, res, next) { 58 | var view = auth.view(req); 59 | res.send(202, { 60 | roles: view.roles, 61 | entities: view.entities, 62 | actions: view.actions 63 | }); 64 | }); 65 | 66 | app.use(function(err, req, res, next) { 67 | if (err instanceof UnauthorizedError) { 68 | res.send(401, 'Unauthorized'); 69 | } else { 70 | next(err); 71 | } 72 | }); 73 | 74 | describe('adding an organization member', function() { 75 | 76 | it('should be allowed if user is owner', function() { 77 | 78 | chai.request(app) 79 | .post('/organizations/org1/members') 80 | .req(function(req) { 81 | req.set('x-fake-user-id', 'user.1'); 82 | }) 83 | .res(function(res) { 84 | assert.strictEqual(res.status, 202); 85 | var body = res.body; 86 | assert.isFalse(body.roles.admin, 'not admin'); 87 | assert.isTrue(body.roles['organization.owner'], 'org owner'); 88 | assert.isTrue( 89 | body.actions['add members to organization'], 'can add'); 90 | }); 91 | 92 | }); 93 | 94 | it('should be denied if user is not owner', function() { 95 | 96 | chai.request(app) 97 | .post('/organizations/org1/members') 98 | .req(function(req) { 99 | req.set('x-fake-user-id', 'user.2'); 100 | }) 101 | .res(function(res) { 102 | assert.strictEqual(res.status, 401); 103 | }); 104 | 105 | }); 106 | 107 | }); 108 | 109 | 110 | }); 111 | -------------------------------------------------------------------------------- /test/integration/express/middleware/fakeauth.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = function() { 4 | return function(req, res, next) { 5 | var id = req.get('x-fake-user-id'); 6 | if (id) { 7 | req.user = {id: id}; 8 | } 9 | next(); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /test/lib/errors.spec.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | 3 | var authorized = require('../../lib/authorized'); 4 | 5 | var ConfigError = authorized.ConfigError; 6 | var UnauthorizedError = authorized.UnauthorizedError; 7 | 8 | 9 | /** @type {boolean} */ 10 | chai.Assertion.includeStack = true; 11 | var assert = chai.assert; 12 | 13 | describe('ConfigError', function() { 14 | 15 | describe('constructor', function() { 16 | it('creates a new error', function() { 17 | var error = new ConfigError(); 18 | assert.instanceOf(error, ConfigError); 19 | assert.instanceOf(error, Error); 20 | assert.notInstanceOf(error, UnauthorizedError); 21 | }); 22 | }); 23 | 24 | describe('#name', function() { 25 | it('identifies the error type', function() { 26 | var error = new ConfigError(); 27 | assert.strictEqual(error.name, 'ConfigError'); 28 | }); 29 | }); 30 | 31 | describe('#message', function() { 32 | it('describes the error', function() { 33 | var error = new ConfigError('messed up'); 34 | assert.strictEqual(error.message, 'messed up'); 35 | }); 36 | }); 37 | 38 | }); 39 | 40 | 41 | describe('UnauthorizedError', function() { 42 | 43 | describe('constructor', function() { 44 | it('creates a new error', function() { 45 | var error = new UnauthorizedError(); 46 | assert.instanceOf(error, UnauthorizedError); 47 | assert.instanceOf(error, Error); 48 | assert.notInstanceOf(error, ConfigError); 49 | }); 50 | }); 51 | 52 | describe('#name', function() { 53 | it('identifies the error type', function() { 54 | var error = new UnauthorizedError(); 55 | assert.strictEqual(error.name, 'UnauthorizedError'); 56 | }); 57 | }); 58 | 59 | describe('#message', function() { 60 | it('describes the error', function() { 61 | var error = new UnauthorizedError('not allowed'); 62 | assert.strictEqual(error.message, 'not allowed'); 63 | }); 64 | }); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /test/lib/manager.spec.js: -------------------------------------------------------------------------------- 1 | var EventEmitter = require('events').EventEmitter; 2 | 3 | var chai = require('chai'); 4 | 5 | var authorized = require('../../lib/authorized'); 6 | 7 | var ConfigError = authorized.ConfigError; 8 | var Manager = authorized.Manager; 9 | var Role = authorized.Role; 10 | var UnauthorizedError = authorized.UnauthorizedError; 11 | 12 | 13 | /** @type {boolean} */ 14 | chai.Assertion.includeStack = true; 15 | var assert = chai.assert; 16 | 17 | describe('Manager', function() { 18 | 19 | describe('constructor', function() { 20 | 21 | it('creates a new authorization manager with the defaults', function() { 22 | var auth = new Manager(); 23 | assert.instanceOf(auth, Manager); 24 | assert.isTrue(auth.options.pauseStream); 25 | }); 26 | 27 | it('allows options to be set', function() { 28 | var auth = new Manager({pauseStream: false}); 29 | assert.instanceOf(auth, Manager); 30 | assert.isFalse(auth.options.pauseStream); 31 | }); 32 | 33 | it('instance exported by main module', function() { 34 | var auth = require('../../lib/authorized'); 35 | assert.instanceOf(auth, Manager); 36 | }); 37 | 38 | }); 39 | 40 | describe('#action()', function() { 41 | var auth; 42 | beforeEach(function() { 43 | auth = new Manager(); 44 | }); 45 | 46 | it('defines roles required for specific actions', function() { 47 | auth.role('admin', function(req, done) { 48 | // pretend everybody is admin 49 | done(null, true); 50 | }); 51 | 52 | assert.doesNotThrow(function() { 53 | auth.action('can view passwords', ['admin']); 54 | }); 55 | }); 56 | 57 | it('allows multiple roles to perform an action', function() { 58 | auth.role('admin', function(req, done) { 59 | // pretend ~1/2 users are admin 60 | done(null, Math.random() > 0.5); 61 | }); 62 | auth.role('page.author', function(page, req, done) { 63 | // pretend everybody is author 64 | done(null, true); 65 | }); 66 | auth.entity('page', function(req, done) { 67 | // mock page 68 | done(null, {}); 69 | }); 70 | 71 | assert.doesNotThrow(function() { 72 | auth.action('can edit page', ['admin', 'page.author']); 73 | }); 74 | }); 75 | 76 | 77 | it('accepts a single role string instead of an array', function() { 78 | auth.role('admin', function(req, done) { 79 | // pretend everybody is admin 80 | done(null, true); 81 | }); 82 | 83 | assert.doesNotThrow(function() { 84 | auth.action('can view passwords', 'admin'); 85 | }); 86 | }); 87 | 88 | it('does not accept an action with no roles', function() { 89 | assert.throws(function() { 90 | auth.action('can do nothing', []); 91 | }); 92 | }); 93 | 94 | it('requires that all roles have been defined', function() { 95 | auth.role('admin', function(req, done) { 96 | // pretend everybody is admin 97 | done(null, true); 98 | }); 99 | 100 | assert.throws(function() { 101 | auth.action('can view passwords', ['admin', 'foo']); 102 | }); 103 | }); 104 | 105 | it('requires that all entities have been defined', function() { 106 | auth.role('admin', function(req, done) { 107 | // pretend nobody is admin 108 | done(null, false); 109 | }); 110 | auth.role('page.author', function(page, req, done) { 111 | // pretend everybody is author 112 | done(null, true); 113 | }); 114 | 115 | assert.throws(function() { 116 | auth.action('can edit page', ['admin', 'page.author']); 117 | }); 118 | }); 119 | 120 | }); 121 | 122 | describe('#can()', function() { 123 | var auth = new Manager(); 124 | var organizations = { 125 | 'org.1': { 126 | owners: ['user.1'] 127 | } 128 | }; 129 | 130 | auth.entity('organization', function(req, done) { 131 | // assume url like /:orgId 132 | var orgId = req.url.substring(1); 133 | // pretend we're going to the db for the organization 134 | process.nextTick(function() { 135 | done(null, organizations[orgId]); 136 | }); 137 | }); 138 | 139 | auth.role('organization.owner', function(org, req, done) { 140 | if (!req.user) { 141 | done(); 142 | } else { 143 | done(null, ~org.owners.indexOf(req.user.id)); 144 | } 145 | }); 146 | 147 | auth.role('admin', function(req, done) { 148 | done(null, req.user && req.user.admin); 149 | }); 150 | 151 | auth.action('add members to organization', ['admin', 'organization.owner']); 152 | 153 | auth.action('delete organization', ['admin']); 154 | 155 | it('creates a middleware function', function() { 156 | var middleware = auth.can('add members to organization'); 157 | assert.isFunction(middleware, 'is a function'); 158 | assert.lengthOf(middleware, 3, 'takes three args'); 159 | }); 160 | 161 | it('calls next with no args if action is allowed', function(done) { 162 | var middleware = auth.can('add members to organization'); 163 | 164 | var req = new EventEmitter(); 165 | req.url = '/org.1'; 166 | req.user = { 167 | id: 'user.1' 168 | }; 169 | 170 | middleware(req, {}, function(err) { 171 | assert.lengthOf(arguments, 0, 'next called with no arguments'); 172 | 173 | var view = auth.view(req); 174 | 175 | assert.strictEqual(view.entities.organization, organizations['org.1'], 176 | 'got organization'); 177 | assert.strictEqual(view.roles['organization.owner'], true, 178 | 'is organization.owner'); 179 | assert.strictEqual(view.roles.admin, false, 180 | 'is not admin'); 181 | assert.strictEqual(view.actions['add members to organization'], true, 182 | 'can add members to organization'); 183 | 184 | done(); 185 | }); 186 | }); 187 | 188 | it('calls next if any of the actions can be performed', function(done) { 189 | var middleware = auth.can( 190 | 'add members to organization', 'delete organization'); 191 | 192 | var req = new EventEmitter(); 193 | req.url = '/org.1'; 194 | req.user = { 195 | admin: true 196 | }; 197 | 198 | middleware(req, {}, function(err) { 199 | assert.lengthOf(arguments, 0, 'next called with no arguments'); 200 | 201 | var view = auth.view(req); 202 | 203 | assert.strictEqual(view.entities.organization, organizations['org.1'], 204 | 'got organization'); 205 | assert.strictEqual(view.roles['organization.owner'], false, 206 | 'is organization.owner'); 207 | assert.strictEqual(view.roles.admin, true, 208 | 'is not admin'); 209 | assert.strictEqual(view.actions['add members to organization'], true, 210 | 'can add members to organization'); 211 | assert.strictEqual(view.actions['delete organization'], true, 212 | 'can delete organization'); 213 | 214 | done(); 215 | }); 216 | }); 217 | 218 | it('adds all actions to the view', function(done) { 219 | var middleware = auth.can( 220 | 'add members to organization', 'delete organization'); 221 | 222 | var req = new EventEmitter(); 223 | req.url = '/org.1'; 224 | req.user = { 225 | id: 'user.1' 226 | }; 227 | 228 | middleware(req, {}, function(err) { 229 | assert.lengthOf(arguments, 0, 'next called with no arguments'); 230 | 231 | var view = auth.view(req); 232 | 233 | assert.strictEqual(view.entities.organization, organizations['org.1'], 234 | 'got organization'); 235 | assert.strictEqual(view.roles['organization.owner'], true, 236 | 'is organization.owner'); 237 | assert.strictEqual(view.roles.admin, false, 238 | 'is not admin'); 239 | assert.strictEqual(view.actions['add members to organization'], true, 240 | 'can add members to organization'); 241 | assert.strictEqual(view.actions['delete organization'], false, 242 | 'can delete organization'); 243 | 244 | done(); 245 | }); 246 | }); 247 | 248 | it('calls next error if action is not allowed', function(done) { 249 | var middleware = auth.can('add members to organization'); 250 | 251 | var req = new EventEmitter(); 252 | req.url = '/org.1'; 253 | req.user = { 254 | id: 'user.2' 255 | }; 256 | 257 | middleware(req, {}, function(err) { 258 | assert.lengthOf(arguments, 1, 'next called with one argument'); 259 | assert.instanceOf(err, UnauthorizedError, 'UnauthorizedError'); 260 | 261 | var view = auth.view(req); 262 | 263 | assert.strictEqual(view.entities.organization, organizations['org.1'], 264 | 'got organization'); 265 | assert.strictEqual(view.roles['organization.owner'], false, 266 | 'is organization.owner'); 267 | assert.strictEqual(view.roles.admin, false, 268 | 'is not admin'); 269 | assert.strictEqual(view.actions['add members to organization'], false, 270 | 'can add members to organization'); 271 | done(); 272 | }); 273 | }); 274 | 275 | it('requires that an action has been defined', function() { 276 | assert.throws(function() { 277 | auth.can('do things we know nothing about'); 278 | }); 279 | }); 280 | 281 | }); 282 | 283 | describe('#entity()', function() { 284 | 285 | var auth; 286 | beforeEach(function() { 287 | auth = new Manager(); 288 | }); 289 | 290 | it('registers an entity getter', function() { 291 | function getter(req, done) { 292 | done(null, {}); 293 | } 294 | assert.doesNotThrow(function() { 295 | auth.entity('page', getter); 296 | }); 297 | }); 298 | 299 | it('throws ConfigError if type is not a string', function() { 300 | function getter(req, done) { 301 | done(null, {}); 302 | } 303 | assert.throws(function() { 304 | auth.entity(10, getter); 305 | }, ConfigError); 306 | }); 307 | 308 | it('throws ConfigError if getter is not a function', function() { 309 | assert.throws(function() {auth.entity('page', 12);}, ConfigError); 310 | }); 311 | 312 | it('throws ConfigError if role getter arity is not 2', function() { 313 | function getter(err) { 314 | return; 315 | } 316 | assert.throws(function() { 317 | auth.entity('page', getter); 318 | }, ConfigError); 319 | }); 320 | 321 | }); 322 | 323 | describe('#role()', function() { 324 | 325 | var auth; 326 | beforeEach(function() { 327 | auth = new Manager(); 328 | }); 329 | 330 | it('registers a role getter', function() { 331 | function getter(req, done) { 332 | return done(null, true); 333 | } 334 | assert.doesNotThrow(function() { 335 | auth.role('admin', getter); 336 | }); 337 | }); 338 | 339 | it('accepts a Role instance', function() { 340 | function getter(req, done) { 341 | return done(null, true); 342 | } 343 | assert.doesNotThrow(function() { 344 | auth.role(new Role('admin'), getter); 345 | }); 346 | }); 347 | 348 | it('expects a getter of arity 2 for simple roles', function() { 349 | function getter(req, done) { 350 | return done(null, true); 351 | } 352 | assert.doesNotThrow(function() { 353 | auth.role('admin', getter); 354 | }); 355 | }); 356 | 357 | it('expects a getter of arity 3 for entity roles', function() { 358 | function getter(organization, req, done) { 359 | return done(null, true); 360 | } 361 | assert.doesNotThrow(function() { 362 | auth.role('organization.admin', getter); 363 | }); 364 | }); 365 | 366 | it('throws ConfigError if role is not a string or Role', function() { 367 | function getter(req, done) { 368 | return done(null, true); 369 | } 370 | assert.throws(function() { 371 | auth.role(10, getter); 372 | }, ConfigError); 373 | }); 374 | 375 | it('throws ConfigError if role cannot be created from string', function() { 376 | function getter(req, done) { 377 | return done(null, true); 378 | } 379 | assert.throws(function() { 380 | auth.role('foo.bar.bam', getter); 381 | }, ConfigError); 382 | }); 383 | 384 | it('throws ConfigError if getter is not a function', function() { 385 | assert.throws(function() { 386 | auth.role('admin', 12); 387 | }, ConfigError); 388 | }); 389 | 390 | it('throws ConfigError if simple role getter arity is not 2', function() { 391 | function getter(err) { 392 | return; 393 | } 394 | assert.throws(function() { 395 | auth.role('adin', getter); 396 | }, ConfigError); 397 | }); 398 | 399 | it('throws ConfigError if entity role getter arity is not 2', function() { 400 | function getter(err, done) { 401 | return; 402 | } 403 | assert.throws(function() { 404 | auth.role('page.author', getter); 405 | }, ConfigError); 406 | }); 407 | 408 | }); 409 | 410 | 411 | }); 412 | -------------------------------------------------------------------------------- /test/lib/role.spec.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | 3 | var authorized = require('../../lib/authorized'); 4 | 5 | var ConfigError = authorized.ConfigError; 6 | var Role = authorized.Role; 7 | 8 | 9 | /** @type {boolean} */ 10 | chai.Assertion.includeStack = true; 11 | var assert = chai.assert; 12 | 13 | describe('Role', function() { 14 | 15 | describe('constructor', function() { 16 | 17 | it('creates a new role from a string', function() { 18 | var role = new Role('dungeon.master'); 19 | assert.instanceOf(role, Role); 20 | }); 21 | 22 | it('accepts a config object', function() { 23 | var role = new Role({relation: 'admin'}); 24 | assert.instanceOf(role, Role); 25 | }); 26 | 27 | }); 28 | 29 | describe('#entity', function() { 30 | 31 | it('can be parsed from a string', function() { 32 | var role = new Role('repo.admin'); 33 | assert.strictEqual(role.entity, 'repo'); 34 | }); 35 | 36 | it('can also be provided in the config object', function() { 37 | var role = new Role({entity: 'page', relation: 'owner'}); 38 | assert.strictEqual(role.entity, 'page'); 39 | }); 40 | 41 | it('is optional (in the string)', function() { 42 | var role = new Role('admin'); 43 | assert.strictEqual(role.entity, undefined); 44 | }); 45 | 46 | it('is optional (in the config object)', function() { 47 | var role = new Role({relation: 'admin'}); 48 | assert.strictEqual(role.entity, undefined); 49 | }); 50 | 51 | }); 52 | 53 | 54 | describe('#name', function() { 55 | 56 | it('is the same as the string arg', function() { 57 | var name = 'repo.admin'; 58 | var role = new Role(name); 59 | assert.strictEqual(role.name, name); 60 | }); 61 | 62 | it('is a concatenation of entity.string', function() { 63 | var role = new Role({entity: 'page', relation: 'owner'}); 64 | assert.strictEqual(role.name, 'page.owner'); 65 | }); 66 | 67 | it('is just the relation if no entity provided (string form)', function() { 68 | var name = 'admin'; 69 | var role = new Role(name); 70 | assert.strictEqual(role.name, name); 71 | }); 72 | 73 | it('is just the relation if no entity provided (config form)', function() { 74 | var name = 'admin'; 75 | var role = new Role({relation: name}); 76 | assert.strictEqual(role.name, name); 77 | }); 78 | 79 | }); 80 | 81 | 82 | describe('#relation', function() { 83 | 84 | it('can be parsed from a string', function() { 85 | var role = new Role('repo.admin'); 86 | assert.strictEqual(role.relation, 'admin'); 87 | }); 88 | 89 | it('can also be provided in the config object', function() { 90 | var role = new Role({entity: 'page', relation: 'owner'}); 91 | assert.strictEqual(role.relation, 'owner'); 92 | }); 93 | 94 | it('is not optional (in the string)', function() { 95 | assert.throws(function() {var role = new Role('');}, ConfigError); 96 | }); 97 | 98 | it('is not optional (in the config object)', function() { 99 | assert.throws(function() {var role = new Role({entity: 'orphan'});}, 100 | ConfigError); 101 | }); 102 | 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /test/lib/view.spec.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai'); 2 | 3 | var authorized = require('../../lib/authorized'); 4 | 5 | var View = authorized.View; 6 | var Role = authorized.Role; 7 | 8 | 9 | /** @type {boolean} */ 10 | chai.Assertion.includeStack = true; 11 | var assert = chai.assert; 12 | 13 | describe('View', function() { 14 | 15 | describe('constructor', function() { 16 | it('creates a new view', function() { 17 | var view = new View(); 18 | assert.instanceOf(view, View); 19 | }); 20 | 21 | it('creates a frozen object', function() { 22 | var view = new View(); 23 | view.foo = 'bar'; 24 | assert.isUndefined(view.foo); 25 | var before = view.actions; 26 | view.actions = {}; 27 | assert.strictEqual(view.actions, before); 28 | }); 29 | }); 30 | 31 | describe('#actions', function() { 32 | it('is an object', function() { 33 | var view = new View(); 34 | assert.isObject(view.actions); 35 | }); 36 | }); 37 | 38 | describe('#can()', function() { 39 | it('determines if an action can be performed', function() { 40 | var view = new View(); 41 | view.actions['edit page'] = true; 42 | view.actions['delete page'] = false; 43 | assert.isTrue(view.can('edit page')); 44 | assert.isFalse(view.can('delete page')); 45 | }); 46 | 47 | it('returns undefined if unknown action', function() { 48 | var view = new View(); 49 | assert.isUndefined(view.can('do nothing')); 50 | }); 51 | }); 52 | 53 | describe('#entities', function() { 54 | it('is an object', function() { 55 | var view = new View(); 56 | assert.isObject(view.entities); 57 | }); 58 | }); 59 | 60 | describe('#freeze()', function() { 61 | it('prevents cached actions from being modified', function() { 62 | var view = new View(); 63 | view.actions['do anything'] = true; 64 | assert.isTrue(view.can('do anything')); 65 | 66 | view.freeze(); 67 | view.actions['do anything'] = false; 68 | assert.isTrue(view.can('do anything')); 69 | 70 | view.actions['view passwords'] = true; 71 | assert.isUndefined(view.can('view passwords')); 72 | }); 73 | 74 | it('prevents cached entities from being modified', function() { 75 | var view = new View(); 76 | var page = {}; 77 | view.entities.page = page; 78 | assert.strictEqual(view.get('page'), page); 79 | 80 | view.freeze(); 81 | view.entities.page = {}; 82 | assert.strictEqual(view.get('page'), page); 83 | 84 | view.entities.organization = {}; 85 | assert.isNull(view.get('organization')); 86 | }); 87 | 88 | it('prevents cached roles from being modified', function() { 89 | var view = new View(); 90 | view.roles.admin = false; 91 | view.roles['page.author'] = true; 92 | assert.isFalse(view.has('admin')); 93 | assert.isTrue(view.has('page.author')); 94 | 95 | view.freeze(); 96 | view.roles.admin = true; 97 | assert.isFalse(view.has('admin')); 98 | 99 | view.roles['site.owner'] = true; 100 | assert.isUndefined(view.has('site.owner')); 101 | }); 102 | }); 103 | 104 | describe('#get()', function() { 105 | it('gets a cached entity', function() { 106 | var view = new View(); 107 | var page = {}; 108 | view.entities.page = page; 109 | assert.strictEqual(view.get('page'), page); 110 | }); 111 | 112 | it('returns null if none found', function() { 113 | var view = new View(); 114 | assert.isNull(view.get('foo')); 115 | }); 116 | }); 117 | 118 | describe('#has()', function() { 119 | it('determines whether a role is assigned', function() { 120 | var view = new View(); 121 | view.roles.admin = false; 122 | view.roles['page.author'] = true; 123 | assert.isFalse(view.has('admin')); 124 | assert.isTrue(view.has('page.author')); 125 | }); 126 | 127 | it('returns undefined if unknown role', function() { 128 | var view = new View(); 129 | assert.isUndefined(view.has('unknown')); 130 | }); 131 | 132 | it('accepts a Role instance', function() { 133 | var view = new View(); 134 | var admin = new Role('admin'); 135 | var author = new Role('page.author'); 136 | view.roles[admin.name] = false; 137 | view.roles[author.name] = true; 138 | assert.isFalse(view.has(admin)); 139 | assert.isTrue(view.has(author)); 140 | }); 141 | }); 142 | 143 | describe('#roles', function() { 144 | it('is an object', function() { 145 | var view = new View(); 146 | assert.isObject(view.roles); 147 | }); 148 | }); 149 | 150 | }); 151 | --------------------------------------------------------------------------------