├── .editorconfig ├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE ├── README.md ├── example └── server.js ├── index.js ├── package.json └── test └── index.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | # Change these settings to your own preference 9 | indent_style = space 10 | indent_size = 2 11 | 12 | # We recommend you to keep these unchanged 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.md] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules 3 | test/coverage.html 4 | coverage.html 5 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | - 0.12 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Kevin Wu 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hapi Route ACL 2 | 3 | Fine-grained route access control based on CRUD for [hapi.js](http://hapijs.com/) 4 | 5 | [![Build Status](https://travis-ci.org/eventhough/hapi-route-acl.svg)](https://travis-ci.org/eventhough/hapi-route-acl) 6 | 7 | ## Description 8 | 9 | This hapi.js plugin allows you to specify ACL permission requirements for each of your routes using CRUD. For example let's say you have a resource called "cars". You could protect each route with the following permissions: 10 | 11 | `'cars:read'`, `'cars:create'`, `'cars:edit'`, `'cars:delete'` 12 | 13 | Routes can be protected by multiple permissions. For example you might have a route for drivers of cars that looks like: `POST /cars/1/drivers/` 14 | 15 | You can protect this route with: `['cars:read', 'drivers:create']` 16 | 17 | ## Usage 18 | 19 | ### Example 20 | 21 | ```javascript 22 | var Hapi = require('hapi'); 23 | 24 | var internals = {}; 25 | 26 | internals.permissionsFunc = function(credentials, callback) { 27 | // use credentials here to retrieve permissions for user 28 | // in this example we just return some permissions 29 | 30 | var userPermissions = { 31 | cars: { 32 | read: true, 33 | create: false, 34 | edit: true, 35 | delete: true 36 | }, 37 | drivers: { 38 | read: true, 39 | create: false, 40 | edit: false, 41 | delete: false 42 | } 43 | }; 44 | 45 | callback(null, userPermissions); 46 | }; 47 | 48 | var server = new Hapi.Server(); 49 | server.connection(); 50 | 51 | server.register({ 52 | register: require('hapi-route-acl'), 53 | options: { 54 | permissionsFunc: internals.permissionsFunc 55 | } 56 | }, function(err) { 57 | if (err) { 58 | console.log(err); 59 | } 60 | }); 61 | 62 | server.route([ 63 | { 64 | method: 'GET', 65 | path: '/unprotected', 66 | config: { 67 | handler: function(request, reply) { 68 | reply('hola mi amigo'); 69 | } 70 | } 71 | }, 72 | { 73 | method: 'GET', 74 | path: '/cars', 75 | config: { 76 | handler: function(request, reply) { 77 | reply(['Toyota Camry', 'Honda Accord', 'Ford Fusion']); 78 | }, 79 | plugins: { 80 | hapiRouteAcl: { 81 | permissions: ['cars:read'] 82 | } 83 | } 84 | } 85 | }, 86 | { 87 | method: 'GET', 88 | path: '/cars/{id}', 89 | config: { 90 | handler: function(request, reply) { 91 | reply('Toyota Camry'); 92 | }, 93 | plugins: { 94 | hapiRouteAcl: { 95 | permissions: 'cars:read' 96 | } 97 | } 98 | } 99 | }, 100 | { 101 | method: 'DELETE', 102 | path: '/cars/{id}', 103 | config: { 104 | handler: function(request, reply) { 105 | reply('car deleted!'); 106 | }, 107 | plugins: { 108 | hapiRouteAcl: { 109 | permissions: ['cars:delete'] 110 | } 111 | } 112 | } 113 | }, 114 | { 115 | method: 'GET', 116 | path: '/cars/{id}/drivers', 117 | config: { 118 | handler: function(request, reply) { 119 | reply(['Greg', 'Tom', 'Sam']); 120 | }, 121 | plugins: { 122 | hapiRouteAcl: { 123 | permissions: ['cars:read', 'drivers:read'] 124 | } 125 | } 126 | } 127 | }, 128 | { 129 | method: 'DELETE', 130 | path: '/cars/{carId}/drivers/{driverId}', 131 | config: { 132 | handler: function(request, reply) { 133 | reply('driver deleted!'); 134 | }, 135 | plugins: { 136 | hapiRouteAcl: { 137 | permissions: ['cars:read', 'drivers:delete'] 138 | } 139 | } 140 | } 141 | }] 142 | ); 143 | 144 | server.start(); 145 | ``` 146 | 147 | This plugin requires a permissionsFunc which takes credentials (from request.auth.credentials) and a callback with format: `callback(err, permissions)`. 148 | 149 | The permission format should look something like this: 150 | 151 | ```javascript 152 | { 153 | cars: { 154 | read: true, 155 | create: false, 156 | edit: true, 157 | delete: true 158 | }, 159 | drivers: { 160 | read: true, 161 | create: false, 162 | edit: false, 163 | delete: false 164 | } 165 | }; 166 | ``` 167 | Keys are route names and values are objects that map each crud type to a boolean for access. 168 | 169 | A full example has been provided in the example/ folder. You can run this example with `node example/server.js` and send requests to it. 170 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | var Hapi = require('hapi'); 6 | 7 | // Declare internals 8 | 9 | var internals = {}; 10 | 11 | internals.permissionsFunc = function(credentials, callback) { 12 | // use credentials here to retrieve permissions for user 13 | 14 | var userPermissions = { 15 | cars: { 16 | read: true, 17 | create: false, 18 | edit: true, 19 | delete: true 20 | }, 21 | drivers: { 22 | read: true, 23 | create: false, 24 | edit: false, 25 | delete: false 26 | } 27 | }; 28 | 29 | callback(null, userPermissions); 30 | }; 31 | 32 | exports = module.exports = (function() { 33 | var server = new Hapi.Server(); 34 | server.connection(); 35 | 36 | server.register({ 37 | register: require('./../'), 38 | options: { 39 | permissionsFunc: internals.permissionsFunc 40 | } 41 | }, function(err) { 42 | if (err) { 43 | console.log(err); 44 | } 45 | }); 46 | 47 | server.route([ 48 | { 49 | method: 'GET', 50 | path: '/unprotected1', 51 | config: { 52 | handler: function(request, reply) { 53 | reply('hola mi amigo'); 54 | } 55 | } 56 | }, 57 | { 58 | method: 'GET', 59 | path: '/unprotected2', 60 | config: { 61 | handler: function(request, reply) { 62 | reply('como estas?'); 63 | }, 64 | plugins: { 65 | hapiRouteAcl: { 66 | permissions: [] 67 | } 68 | } 69 | } 70 | }, 71 | { 72 | method: 'GET', 73 | path: '/cars', 74 | config: { 75 | handler: function(request, reply) { 76 | reply(['Toyota Camry', 'Honda Accord', 'Ford Fusion']); 77 | }, 78 | plugins: { 79 | hapiRouteAcl: { 80 | permissions: ['cars:read'] 81 | } 82 | } 83 | } 84 | }, 85 | { 86 | method: 'GET', 87 | path: '/cars/{id}', 88 | config: { 89 | handler: function(request, reply) { 90 | reply('Toyota Camry'); 91 | }, 92 | plugins: { 93 | hapiRouteAcl: { 94 | permissions: 'cars:read' 95 | } 96 | } 97 | } 98 | }, 99 | { 100 | method: 'DELETE', 101 | path: '/cars/{id}', 102 | config: { 103 | handler: function(request, reply) { 104 | reply('car deleted!'); 105 | }, 106 | plugins: { 107 | hapiRouteAcl: { 108 | permissions: ['random:delete'] 109 | } 110 | } 111 | } 112 | }, 113 | { 114 | method: 'GET', 115 | path: '/cars/{id}/drivers', 116 | config: { 117 | handler: function(request, reply) { 118 | reply(['Greg', 'Tom', 'Sam']); 119 | }, 120 | plugins: { 121 | hapiRouteAcl: { 122 | permissions: ['cars:read', 'drivers:read'] 123 | } 124 | } 125 | } 126 | }, 127 | { 128 | method: 'DELETE', 129 | path: '/cars/{carId}/drivers/{driverId}', 130 | config: { 131 | handler: function(request, reply) { 132 | reply('driver deleted!'); 133 | }, 134 | plugins: { 135 | hapiRouteAcl: { 136 | permissions: ['cars:read', 'drivers:delete'] 137 | } 138 | } 139 | } 140 | }] 141 | ); 142 | 143 | server.start(function() { 144 | console.log('Server running at: ' + server.info.uri); 145 | }); 146 | 147 | return server; 148 | })(); 149 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Load modules 2 | 3 | const Boom = require('boom'); 4 | const Hoek = require('hoek'); 5 | const _ = require('lodash'); 6 | 7 | // Declare internals 8 | 9 | let internals = {}; 10 | internals.pluginName = 'hapiRouteAcl'; 11 | 12 | exports.plugin = { 13 | register: (server, options) => { 14 | if (_.isUndefined(options.permissionsFunc)) { 15 | throw new Error('options.permissionsFunc is required'); 16 | } else if (!_.isFunction(options.permissionsFunc)) { 17 | throw new Error('options.permissionsFunc must be a valid function'); 18 | } else { 19 | internals.permissionsFunc = options.permissionsFunc; 20 | server.ext({type:'onPostAuth', method: internals.implementation}); 21 | } 22 | }, 23 | pkg: require('./package.json') 24 | } 25 | 26 | internals.implementation = function(request, h) { 27 | if (!_.isEmpty(request.route.settings.plugins[internals.pluginName])) { 28 | let requiredPermissions = request.route.settings.plugins[internals.pluginName].permissions; 29 | if (!_.isEmpty(requiredPermissions)) { 30 | internals.permissionsFunc(request.auth.credentials, function(error, userPermissions) { 31 | let hasPermission = internals.checkPermissions(requiredPermissions, userPermissions); 32 | if (hasPermission) { 33 | return h.continue(); 34 | } else { 35 | throw Boom.unauthorized('Access denied'); 36 | } 37 | }); 38 | } else { 39 | return h.continue(); 40 | } 41 | } else { 42 | return h.continue(); 43 | } 44 | }; 45 | 46 | internals.checkPermissions = function(requiredPermissions, userPermissions) { 47 | if (_.isString(requiredPermissions)) { 48 | requiredPermissions = [requiredPermissions]; 49 | } 50 | 51 | let permissionMap = {}; 52 | 53 | _.forEach(requiredPermissions, function(requiredPermission) { 54 | Hoek.assert(_.isString(requiredPermission), 'permission must be a string'); 55 | 56 | let parts = requiredPermission.split(':'); 57 | 58 | Hoek.assert(parts.length === 2, 'permission must be formatted: [routeName]:[read|create|edit|delete]'); 59 | 60 | let routeName = parts[0]; 61 | let crud = parts[1]; 62 | 63 | if (_.isUndefined(userPermissions[routeName]) || _.isUndefined(userPermissions[routeName][crud])) { 64 | permissionMap[requiredPermission] = false; 65 | } else { 66 | permissionMap[requiredPermission] = userPermissions[routeName][crud]; 67 | } 68 | }); 69 | 70 | let hasPermission = _.reduce(permissionMap, function(result, permission) { 71 | result = result && permission; 72 | return result; 73 | }); 74 | 75 | return hasPermission; 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hapi-route-acl", 3 | "version": "1.0.3", 4 | "description": "Access control list (ACL) plugin for hapijs", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/lab/bin/lab -t 100 test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/eventhough/hapi-route-acl.git" 12 | }, 13 | "keywords": [ 14 | "hapi", 15 | "hapijs", 16 | "hapi.js", 17 | "acl", 18 | "access control" 19 | ], 20 | "author": "Kevin Wu", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/eventhough/hapi-route-acl/issues" 24 | }, 25 | "homepage": "https://github.com/eventhough/hapi-route-acl", 26 | "dependencies": { 27 | "boom": "", 28 | "hoek": "", 29 | "lodash": "" 30 | }, 31 | "peerDependencies": { 32 | "hapi": ">=8.x.x" 33 | }, 34 | "devDependencies": { 35 | "code": "^1.3.0", 36 | "hapi": "^8.3.1", 37 | "hapi": "", 38 | "lab": "^5.5.0" 39 | }, 40 | "engines": { 41 | "node": ">=0.10" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Load modules 4 | 5 | var Code = require('code'); 6 | var Lab = require('lab'); 7 | var Hapi = require('hapi'); 8 | 9 | // Declare internals 10 | 11 | var internals = {}; 12 | 13 | internals.permissionsFunc = function(credentials, callback) { 14 | var userPermissions = { 15 | cars: { 16 | read: true, 17 | create: false, 18 | edit: true, 19 | delete: true 20 | }, 21 | drivers: { 22 | read: true, 23 | create: false, 24 | edit: false, 25 | delete: false 26 | }, 27 | abilities: { 28 | read: false, 29 | create: false, 30 | edit: false, 31 | delete: false 32 | } 33 | }; 34 | 35 | callback(null, userPermissions); 36 | }; 37 | 38 | // Test shortcuts 39 | 40 | var lab = exports.lab = Lab.script(); 41 | var before = lab.before; 42 | var beforeEach = lab.beforeEach; 43 | var after = lab.after; 44 | var describe = lab.describe; 45 | var it = lab.it; 46 | var expect = Code.expect; 47 | 48 | describe('hapi-route-acl', function() { 49 | 50 | describe('registration', function() { 51 | var server; 52 | 53 | beforeEach(function(done) { 54 | server = new Hapi.Server(); 55 | server.connection(); 56 | done(); 57 | }); 58 | 59 | it('should return an error if options.permissionsFunc is not defined', function(done) { 60 | server.register({ 61 | register: require('./../') 62 | }, function(err) { 63 | expect(err).to.exist(); 64 | done(); 65 | }); 66 | }); 67 | 68 | it('should return an error if options.permissionsFunc is not a function', function(done) { 69 | server.register({ 70 | register: require('./../'), 71 | options: { 72 | permissionsFunc: 123 73 | } 74 | }, function(err) { 75 | expect(err).to.exist(); 76 | done(); 77 | }); 78 | }); 79 | 80 | }); 81 | 82 | describe('route protection', function() { 83 | var server; 84 | 85 | beforeEach(function(done) { 86 | server = new Hapi.Server(); 87 | server.connection(); 88 | server.register({ 89 | register: require('./../'), 90 | options: { 91 | permissionsFunc: internals.permissionsFunc 92 | } 93 | }, function(err) { 94 | if (err) { 95 | throw err; 96 | } 97 | }); 98 | done(); 99 | }); 100 | 101 | it('should allow access to a route if plugin configuration is not defined in route config', function(done) { 102 | server.route({ 103 | method: 'GET', 104 | path: '/unprotected1', 105 | config: { 106 | handler: function(request, reply) { 107 | reply('hola mi amigo'); 108 | } 109 | } 110 | }); 111 | server.inject({ 112 | method: 'GET', 113 | url: '/unprotected1' 114 | }, function(res) { 115 | expect(res.statusCode).to.equal(200); 116 | done(); 117 | }); 118 | }); 119 | 120 | it('should allow access to a route if required permission array is empty', function(done) { 121 | server.route({ 122 | method: 'GET', 123 | path: '/unprotected2', 124 | config: { 125 | handler: function(request, reply) { 126 | reply('como estas?'); 127 | }, 128 | plugins: { 129 | hapiRouteAcl: { 130 | permissions: [] 131 | } 132 | } 133 | } 134 | }); 135 | server.inject({ 136 | method: 'GET', 137 | url: '/unprotected2' 138 | }, function(res) { 139 | expect(res.statusCode).to.equal(200); 140 | done(); 141 | }); 142 | }); 143 | 144 | it('should allow access to a route if user has permission', function(done) { 145 | server.route({ 146 | method: 'GET', 147 | path: '/cars', 148 | config: { 149 | handler: function(request, reply) { 150 | reply(['Toyota Camry', 'Honda Accord', 'Ford Fusion']); 151 | }, 152 | plugins: { 153 | hapiRouteAcl: { 154 | permissions: ['cars:read'] 155 | } 156 | } 157 | } 158 | }); 159 | server.inject({ 160 | method: 'GET', 161 | url: '/cars' 162 | }, function(res) { 163 | expect(res.statusCode).to.equal(200); 164 | done(); 165 | }); 166 | }); 167 | 168 | it('should allow access for permissions defined as a string', function(done) { 169 | server.route({ 170 | method: 'GET', 171 | path: '/cars/{id}', 172 | config: { 173 | handler: function(request, reply) { 174 | reply('Toyota Camry'); 175 | }, 176 | plugins: { 177 | hapiRouteAcl: { 178 | permissions: 'cars:read' 179 | } 180 | } 181 | } 182 | }); 183 | server.inject({ 184 | method: 'GET', 185 | url: '/cars/1' 186 | }, function(res) { 187 | expect(res.statusCode).to.equal(200); 188 | done(); 189 | }); 190 | }); 191 | 192 | it('should deny access to a route if user does not have permission', function(done) { 193 | server.route({ 194 | method: 'POST', 195 | path: '/cars', 196 | config: { 197 | handler: function(request, reply) { 198 | reply('car created!'); 199 | }, 200 | plugins: { 201 | hapiRouteAcl: { 202 | permissions: ['cars:create'] 203 | } 204 | } 205 | } 206 | }); 207 | server.inject({ 208 | method: 'POST', 209 | url: '/cars' 210 | }, function(res) { 211 | expect(res.statusCode).to.equal(401); 212 | done(); 213 | }); 214 | }); 215 | 216 | it('should throw an exception if route permission is not a string', function(done) { 217 | server.ext('onPostAuth', function (request, reply) { 218 | request.domain.on('error', function (error) { 219 | request.caughtError = error; 220 | }); 221 | 222 | return reply.continue(); 223 | }, { before: ['hapi-route-acl'] }); 224 | 225 | server.route({ 226 | method: 'GET', 227 | path: '/cars', 228 | config: { 229 | handler: function(request, reply) { 230 | reply(['Toyota Camry', 'Honda Accord', 'Ford Fusion']); 231 | }, 232 | plugins: { 233 | hapiRouteAcl: { 234 | permissions: [12345] 235 | } 236 | } 237 | } 238 | }); 239 | 240 | server.inject({ 241 | method: 'GET', 242 | url: '/cars' 243 | }, function(res) { 244 | var error = res.request.caughtError; 245 | 246 | expect(error).to.be.an.instanceof(Error); 247 | expect(error.message).to.equal('Uncaught error: permission must be a string'); 248 | done(); 249 | }); 250 | }); 251 | 252 | it('should throw an exception if route permission is not formatted properly', function(done) { 253 | server.ext('onPostAuth', function (request, reply) { 254 | request.domain.on('error', function (error) { 255 | request.caughtError = error; 256 | }); 257 | 258 | return reply.continue(); 259 | }, { before: ['hapi-route-acl'] }); 260 | 261 | server.route({ 262 | method: 'GET', 263 | path: '/cars', 264 | config: { 265 | handler: function(request, reply) { 266 | reply(['Toyota Camry', 'Honda Accord', 'Ford Fusion']); 267 | }, 268 | plugins: { 269 | hapiRouteAcl: { 270 | permissions: ['carsread'] // missing colon 271 | } 272 | } 273 | } 274 | }); 275 | 276 | server.inject({ 277 | method: 'GET', 278 | url: '/cars' 279 | }, function(res) { 280 | var error = res.request.caughtError; 281 | 282 | expect(error).to.be.an.instanceof(Error); 283 | expect(error.message).to.equal('Uncaught error: permission must be formatted: [routeName]:[read|create|edit|delete]'); 284 | done(); 285 | }); 286 | }); 287 | 288 | it('should deny access to a route if user permission is not defined for the route', function(done) { 289 | server.route({ 290 | method: 'DELETE', 291 | path: '/foobar/{id}', 292 | config: { 293 | handler: function(request, reply) { 294 | reply('car deleted!'); 295 | }, 296 | plugins: { 297 | hapiRouteAcl: { 298 | permissions: ['foobar:delete'] 299 | } 300 | } 301 | } 302 | }); 303 | server.inject({ 304 | method: 'DELETE', 305 | url: '/foobar/1' 306 | }, function(res) { 307 | expect(res.statusCode).to.equal(401); 308 | done(); 309 | }); 310 | }); 311 | 312 | it('should allow access to a route with multiple permission requirements if user has permissions', function(done) { 313 | server.route({ 314 | method: 'GET', 315 | path: '/cars/{id}/drivers', 316 | config: { 317 | handler: function(request, reply) { 318 | reply(['Greg', 'Tom', 'Sam']); 319 | }, 320 | plugins: { 321 | hapiRouteAcl: { 322 | permissions: ['cars:read', 'drivers:read'] 323 | } 324 | } 325 | } 326 | }); 327 | server.inject({ 328 | method: 'GET', 329 | url: '/cars/1/drivers' 330 | }, function(res) { 331 | expect(res.statusCode).to.equal(200); 332 | done(); 333 | }); 334 | }); 335 | 336 | it('should deny access to a route with two permission requirements if user does not have permissions', function(done) { 337 | server.route({ 338 | method: 'DELETE', 339 | path: '/cars/{carId}/drivers/{driverId}', 340 | config: { 341 | handler: function(request, reply) { 342 | reply('driver deleted!'); 343 | }, 344 | plugins: { 345 | hapiRouteAcl: { 346 | permissions: ['drivers:delete', 'cars:read'] 347 | } 348 | } 349 | } 350 | }); 351 | server.inject({ 352 | method: 'DELETE', 353 | url: '/cars/1/drivers/1' 354 | }, function(res) { 355 | expect(res.statusCode).to.equal(401); 356 | done(); 357 | }); 358 | }); 359 | 360 | it('should deny access to a route with multiple permission requirements if user does not have permissions', function(done) { 361 | server.route({ 362 | method: 'GET', 363 | path: '/cars/{carId}/drivers/{driverId}/abilities/{abilitiesId}', 364 | config: { 365 | handler: function(request, reply) { 366 | reply('driver deleted!'); 367 | }, 368 | plugins: { 369 | hapiRouteAcl: { 370 | permissions: ['drivers:read', 'cars:read', 'abilities:read'] 371 | } 372 | } 373 | } 374 | }); 375 | server.inject({ 376 | method: 'GET', 377 | url: '/cars/1/drivers/1/abilities/1' 378 | }, function(res) { 379 | expect(res.statusCode).to.equal(401); 380 | done(); 381 | }); 382 | }); 383 | 384 | }); 385 | 386 | }); 387 | --------------------------------------------------------------------------------