sails-must
v0.2.1
Published
a more flexible, more DRY, policy/middleware pattern for `sails.js` apps. Also, works with any `express.js` app.
Downloads
168
Readme
sails-must
a more flexible, more DRY, policy/middleware pattern for sails.js
apps. Also, works with any express.js
app.
Purpose
A module that provides a way build complex and configurable middleware functions out of simple factories and modifiers. These middleware functions are perfect for sails
apps, but they work just as good for traditional express
apps.
Install
npm install --save sails-must
If using with a sails
app, install the hook as well:
npm install --save sails-hook-must
Then, disable the default policy
hook:
// in .sailsrc
{
//..
"hooks": {
"policies": false
}
//..
}
Background
First, let's remember what basic policies look like in a traditional sails
app:
// in config/policies.js
//..
{
ProfileController: {
// Apply the 'isLoggedIn' policy to the 'edit' action of 'ProfileController'
edit: 'isLoggedIn'
// Apply the 'isAdmin' AND 'isLoggedIn' policies, in that order, to the 'create' action
create: ['isAdmin', 'isLoggedIn']
}
}
//..
Not too bad. Let's look at another example from the sails docs:
// in config/policies.js
// ...
RabbitController: {
// Apply the `false` policy as the default for all of RabbitController's actions
// (`false` prevents all access, which ensures that nothing bad happens to our rabbits)
'*': false,
// For the action `nurture`, apply the 'isRabbitMother' policy
// (this overrides `false` above)
nurture : 'isRabbitMother',
// Apply the `isNiceToAnimals` AND `hasRabbitFood` policies
// before letting any users feed our rabbits
feed : ['isNiceToAnimals', 'hasRabbitFood']
}
// ...
This doesn't look too bad at first, but what happens when we add a DogController? And a CatController? And then our pet lovers app grows to include fish, hamsters, and komodo dragons.. now what? We might end up with a lot of policies that do very similar things (hasRabbitFood, hasDogFood, hasCatFood, hasFishFood, etc). This is not very flexible or DRY.
The same implications could apply for a site that uses role based access control and has numerous roles, or a site for an organization with numerous departments (Accounting, Finance, HR, IT, Sales, etc). We shouldn't have to write a thousand different policies to cover every possible scenario in our access control system.
Getting Started
What we need is a way to configure policies by passing parameters to the policies that determine their behavior. That's what this module provides! Take a look:
A sample policy configuration:
// in config/policies.js
var must = require('sails-must')();
module.exports = {
//..
RabbitController: {
nurture: must().be.a('rabbit').mother,
feed: [must().be.nice.to('rabbits'), must().have('rabbit').food]
},
DogController: {
nurture: must().be.a('dog').mother,
feed: [must().be.nice.to('dogs'), must().have('dog').food]
}
//..
//..
SomeController: {
someAction: must().be.able.to('read', 'someModel'),
someOtherAction: must().be.able.to('write', 'someOtherModel').or.be.a.member.of('admins'),
someComplexAction: must().be.able.to(['write', 'publish'], 'someDifferentModel')
}
//..
//..
ProjectController: {
sales: must().be.a.member.of('sales').or.a.member.of('underwriting'),
secret: must().not.be.a.member.of('hr')
}
//..
//..
MovieController: {
adults: must().be.at.least(18, 'years').old,
kids: must().be.at.most(17, 'years').old,
teens: [must().be.at.least(13, 'years').old, must().be.at.most(19, 'years').old]
}
//..
};
In the above example, we've assumed you have installed the corresponding sails-hook-must
module and disabled the default policies
hook in your config/hooks.js
file. This hook takes care of auto-building your policies for you. If not, you will need to manually build your policies via the build
method like so:
//..
MovieController: {
adults: must().be.at.least(18, 'years').old.build(),
kids: must().be.at.most(17, 'years').old.build(),
teens: [must().be.at.least(13, 'years').old.build(), must().be.at.most(19, 'years').old.build()]
}
//..
API
Each must()
call creates a new policy provider, with access to all of your custom policy factories, helpers, and modifiers.
In the above example, the policy factories are:
mother
nice
food
able
member
old
The helpers are:
be
(this is the only helper provided by default)a
to
have
of
The policy modifiers are:
or
(reserved modifier provided by default, see below for more info)not
(also provided by default, see below for more info)least
most
Factories
A policy factory is simply a function that returns a valid sails
policy (which is just an express
middleware function). From the express
docs:
Middleware is a function with access to the request object (req), the response object (res), and the next middleware in the application’s request-response cycle, commonly denoted by a variable named next.
Middleware can: Execute any code. Make changes to the request and the response objects. End the request-response cycle. Call the next middleware in the stack.
It receives an options
argument as the first argument always, followed by whatever parameters you choose. The options
argument contains a list of all of the modifiers used as well as any custom data provided by the modifiers.
Let's write our own policy factory, the food
factory from the above example. This policy checks to see if the user has food for the provided type (must().have('rabbit').food
). It assumes that the user has been authenticated and attached to the request at some other point, maybe with an authenticated policy using passport
.
// in api/policyFactories/food.js
module.exports = function(options, for) {
return function(req, res, next) {
// get all of the different types of food the user has
var foods = req.user.foods || [];
// if the user doesn't have any food of the correct type, we prevent the user
// from accessing the endpoint
if (foods.indexOf(for) === -1) {
return next('Uh oh! You do not have any food for ' + for + '!');
}
// if the user does have the correct food type, continue on
next();
}
};
Let's write another factory, this time the old
factory from the example above. This factory will provide policies that check if the user is an appropriate age. We'll be able to use our factory like this:
must().be(32, 'years').old
must().be(1000, 'months').old
must().be.at.least(18, 'years').old // using the 'least' modifier
must().be.at.most(65, 'years').old // using the 'most' modifier
Ok, the factory definition:
// in api/policyFactories/old.js
var moment = require('moment'),
_ = require('lodash');
module.exports = function(options, age, units) {
return function(req, res, next) {
var birthday = req.user.dateOfBirth, // lookup the user on the request and locate their date of birth
userAge = moment().diff(moment(birthday), units); // convert their date of birth into an 'age' quantity in the appropriate units
// define a map of modifier/test pairs
var tests = {
'atLeast': 'gte', // if the 'least' modifier was used, use the 'gte' comparison
'atMost': 'lt', // if the 'most' modifier was used, use the 'lt' comparison
'default': 'isEqual' // otherwise, we'll expect the user to the exact age
};
// look through options.modifiers to see if a supported modifier was used
var test = options.modifiers.find(function(modifier) {
return ['atLeast', 'atMost'].indexOf(modifier) !== -1;
});
// determine which test fn to use
var testFn = test && tests[test] ? tests[test] : tests['default'];
// if the test fails, we prevent the user from accessing the endpoint
if (!_[testFn](userAge, age)) {
return next('Uh oh! You do not meet the age requirements!');
}
// otherwise, continue..
next();
}
};
Helpers
Helpers are simply chainable properties that return the same must
policy provider. They exist to make your policy definitions easier to read. When called as a function, they are able to accept arguments that will be passed to your factories during the build phase. In the following example, be
and to
are helpers, while able
is the factory:
must().be.able.to('approve', 'users')
In this example, love
could be a modifier or a helper, to
is a helper, and eat
is a factory:
must().love.to.eat('pizza')
be
is the only helper you get by default. But you can specify more like so:
// in config/policies.js
var must = require('sails-must')({
helpers: ['to', 'of', 'at', 'a', 'have', 'the']
});
Modifiers
Modifiers allow you to tweak the behavior of a factory. In the example with the MovieController
, least
and most
acted as modifiers, and each modified the behavior of the old
policy. Modifiers can be one of three types, a method
, property
, or methodProperty
, depending on how you intend to use the modifier. If the modifier will never need to accept arguments, use the property
type. If the policy will always accept arguments, use the method
type. If the policy will sometimes except arguments, use the methodProperty
type.
A good example of a property
modifier is the built-in not
modifier. This modifier modifies the behavior of the corresponding factory by negating the outcome.
must().love.to.eat('pizza').build() // if this one passes
must().not.love.to.eat('pizza').build() // this one will fail
The not
modifier is shown below. It works by adding the 'not' modifier to the list of modifiers for the policy it was called with. sails-must
looks for this modifier when executing policies, and if found, negates the outcome of that particular policy.
module.exports = {
type: 'property',
fn: function() {
this.options.modifiers.push('not');
}
};
Let's write our first modifier, the least
modifier. We will use the method
type, meaning we will always call it as a function.
must().be.at.least(17, 'years').old
must().be.at.least(6, 'feet').tall
The modifier will be responsible for performing two tasks:
- adding the 'atLeast' modifier to the list of modifiers
- passing any arguments it was called with to the factory function via the
this.args
array
the modifier definition:
// in api/policyModifiers/least.js
module.exports = {
type: 'method',
fn: function() {
// add the 'atLeast' modifier to the list of modifiers
this.options.modifiers.push('atLeast');
// pass any arguments to the 'args' array, which will be passed to the
// factory function when the policy is built
this.args = this.args.concat(Array.prototype.slice.call(arguments));
}
};
Now, let's try writing a methodProperty
modifier. These modifiers contain a fn
property that defines a function to be called when the modifier is used as a function, as well as an additional behavior
property that contains a function that will be called when the modifier is used as a function OR a property.
must().like.to.eat('pizza')
must().like('pizza').for.breakfast
Anytime this modifier is used, we will add the 'like' modifier to the list. If it is used as a function, we will pass any arguments to the factory function.
// in api/policyModifiers/like.js
module.exports = {
type: 'methodProperty',
behavior: function() {
// add the 'like' modifier to the list of modifiers
this.options.modifiers.push('like');
},
fn: function() {
// pass any arguments to the 'args' array, which will be passed to the
// factory function when the policy is built
this.args = this.args.concat(Array.prototype.slice.call(arguments));
}
};
Reponse
By default, when a policy fails by calling next
with a truthy value, sails-must
will default to calling next
with 'Unauthorized'. In order to customize the behavior of failed policy responses, override the default response handler by passing a response
key in your config options.
The example below shows the default policy response handler.
// in config/policies.js
var must = require('sails-must')({
/**
* The policy response handler. By default, if an error occurs in one of the policies in the policy chain, next
* will be called with the error. If at least of one of the policies in the policy chain called next with a null value,
* the policy will call next(). If the policy failed, the policy will call next('Unauthorized').
*
* @param {Object|String} err - unexpected error thrown by one of the policies in the policy chain
* @param {Array} errors - the results of each executed policy
* @param {Object} req - the request object
* @param {Object} res - the response object
* @param {Function} next - next middleware function
*/
response: function(err, errors, req, res, next) {
if (err) return next(err);
var atLeastOneSuccessful = false;
_.every(errors, function(error) {
if (error === null) {
atLeastOneSuccessful = true;
return false;
}
return true;
});
if (atLeastOneSuccessful) {
return next();
}
return next('Unauthorized');
}
});
Additional Info
The or modifier
The or
modifier, which is available by default, allows you to combine multiple policies into a single policy. During the build phase, the policies will be converted into a single parent policy that executes all child policies in parallel. If any of the policies return next()
, the parent policy will return next()
. When or
is called, the current policy chain (factory, helpers, modifiers) is converted into a single policy object and added to a queue. When the build()
method is called, the remaining policy chain is converted into a single policy object and added to the queue.
// the first policy chain: .be.at.least(13, 'years').old
// the second policy chain: .at.least(5, 'feet').tall
must().be.at.least(13, 'years').old.or.at.least(5, 'feet').tall
The build phase
The build method takes all policy objects in the queue and converts them into a single middleware function / sails policy. If you are using the sails-hook-must
sibling module, you do not need to call .build()
inside your policy config file, as the hook will take care of this for you.
In the following example:
must().be.at.least(13, 'years').old.build()
a single policy would be created by calling the old
factory function with the following arguments:
var policy = old({modifiers: ['atLeast']}, 13, 'years');
Configuration
sails-must
takes an optional options
hash when it is first required
var must = require('sails-must')({
helpers: [] // an array of additional helpers to create
paths: {
factories: '/path/to/factories' // defaults to /api/policyFactories
modifiers: '/path/to/modifiers' // defaults to /api/policyModifiers
},
response: function(err, errors, req, res, next) {
// define custom response handler here
}
});
Testing
npm test
To Do
- [x] update docs
- [x] implement as
sails-hook
replacing the defaultpolicy
hook and remove the need to call.build()
on every policy
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request
License
Copyright (c) 2015 Chris Ludden. Licensed under the MIT license.