feathers-rules
A FeathersJS plugin for rule based authorization.
The concept is loosely based on Firebase Firestore Rules.
How does it work?
The plugin forbids every request by default unless there is a rule allowing access.
Rules are plain JavaScript functions that ether return true or false.
If a rule returns true the access is granted and no other rule returning false can revoke the access.
If no rule returns true a Forbidden-Exception is thrown.
So feathers-rules follows an "allow access if any of the rules is followed" approach.
Advantages
- You can't forget to protect a service.
All services will automatically be protected.
- You can define multiple rules per service.
For example: You could split your rules by role and therefore keep your rules clean.
- Plain JavaScript functions as rules are mighty.
You can perform any operation in your rules.
Only want to grant access if its full moon and its the first of the month?
No problem, just implement it ;)
Getting started
Setup
In your app file import the protectServices()
function:
const { protectServices } = require('feathers-rules')
Add the following code after you defined all your hooks:
app.configure(protectServices())
If you created your app with the default feathers generator, a good place is after the service & channel configuration:
app.configure(services)
app.configure(channels)
app.configure(protectServices())
Writing rules
A simple rules
object could look like this:
export const blogPostRules = {
find: (context) => {
return context.params.query && context.params.query.userId === context.params.user._id
},
create: (context) => {
return context.data && context.data.userId === context.params.user._id
},
}
The method names are equal to the service methods (find | get | create | update | patch | remove
) and the values are functions that get feathers' HookContext
as input and return a boolean
or a Promise
of a boolean
.
Using rules
To grant access to your services you add the allow(rules)
hook to your app / service hooks:
blogPostService.hooks({
before: {
all: [
allow(blogPostRules),
],
},
})
Special method names
You can use the following special keywords as method names, too:
all
: Is checked for every methodread
: Is checked for find
& get
write
: Is checked for create
, update
, patch
& remove
For example:
export const blogPostRules = {
read: (context) => {
return true
}
}
Single letter method names
Another way to run one rule for multiple service methods is to use single letter rules:
export const rules = {
cup: (context) => {
return context.params.query && context.params.query.userId === context.params.user
}
}
Single letter rules support the following mapping:
c --> create
f --> find
g --> get
p --> patch
r --> remove
u --> update
You can combine them as you like. cup
& upc
both work.
What happens if your request is forbidden?
As described above a Forbidden-Exception is thrown.
The exceptions message gives insights what went wrong.
But because all rules have returned false there is no single rule that forbid access.
Hence we can't say something like "Your query does not contain your userId".
(To get such behavior see Throwing RulesError in Rules)
Therefore the exception message contains the service name, method, id, data & query of the request.
This helps when you are sending many requests and want to know which one failed (especially helpful when debugging websockets).
To add an additional layer of protection the data properties password
, newPassword
& oldPassword
are replaced with a placeholder.
You can add additional protectedFields
. See Advanced configuration.
Throwing RulesError in Rules
If you want to show errors to the user (like validation errors) you can throw a RulesError
with an array of ErrorInfos
.
const { isValid, errors } = validate(context.data, schema)
if (!isValid) {
throw new RulesError(errors)
}
A RulesError
will be translated to a BadRequest
only if all rules returned false
(to be more specific: not true
).
If some rule returned true
the access is always granted.
Here is an example:
export const rules = {
write: (context) => {
return context.params.user.role === 'admin'
}
}
const schema = require('./user.schema.json')
export const rules = {
write: (context) => {
const { isValid, errors } = validate(context.data, schema)
if (!isValid) throw new RulesError(errors)
}
}
In this example the admin can do whatever she wants but user requests will always be validated.
If the admin is sending a request the RulesError
will be thrown because all rules are always run.
Nevertheless, the request will succeed because the admin rule returns true.
No error is shown to the admin.
If the user is sending the request a BadRequest
is thrown with the errors from RulesError
.
This is due to the fact that the admin rule returns false
and the user rule throws --> no rule returns true.
The request is not allowed and therefore the errors are send to the client.
IMPORTANT: Currently only the errors from the first throwing rule are send to the client.
Good practices
Don't check results in find or get
You should not run the users query in your find
or get
rule and check if the user has access to the returned documents.
This can lead to side channel attacks like measuring the time how long the request took and figuring out if and approx. how many documents the rule has checked.
Always check the query directly: Does the query contain the userId, etc.?
Early escape
If you have many allow(rules)
calls in your hooks, try to escape as early as possible within your rules.
All rules are executed each time a request is sent.
If you have heavyweight rules (e.g. querying the database), try putting them at the end of your function:
export const rules = {
write: async (context) => {
const { isValid, errors } = validate(context.data, lightweightRules)
if (!isValid) throw new RulesError(errors)
const allTheData = await context.app.service('someService').find({})
if (passedComplexValidation) return true
return false
}
}
Advanced configuration
protectServices
The protectServices()
function is preconfigured with the following options.
You can overwrite them.
const options = {
omitServices: ['authentication'],
allowedChecker: allowedChecker(),
}
app.configure(protectServices(options))
allowChecker
The allowChecker can be configured with the following options:
{
protectWord: '[HIDDEN]',
protectedFields: ['password', 'newPassword', 'oldPassword'],
}
To add those options you must configure protectServices()
with your own allowChecker:
app.configure(protectServices({
allowedChecker: allowedChecker({
protectWord: 'NOTHING-TO-SEE-HERE'
})
}))
FAQ
I need to disallow access in a certain rule.
Returning false does not help because another rule keeps granting access.
What can I do?
You can always throw an Forbidden-Exception in your rules.
This breaks the whole hook chain in feathers and denies the access.