Consent is a project mainly written in Ruby, based on the MIT license.
Access control layer for ActionPack, providing a DSL for writing a firewall to sit in front of Rails controllers
= Consent
Consent is an access control abstraction layer for ActionController. It lets you restrict access to actions across your application using a single config file, rather than scattering access logic across your controllers using verify() and method-specific logic. It acts as a single before_filter to all your controllers and checks whether an action can run according to the rules you've set. Think of it as a firewall that sits between your routes file and your controller logic.
== Installation
ruby script/plugin install git://github.com/jcoglan/consent.git
On installation, Consent adds a file to your app at config/consent.rb. This file is where you write the access control rules for your application. You should see a block beginning:
Consent.rules do
You should write all your rules inside this block. Each rule is made up of two parts: a request expression, and a block that should return +true+ if the request is allowed, and +false+ if it should be denied. For example, here's a simple rule that restricts requests to SiteController#hello to only use Ajax:
site.hello { request.xhr? }
Writing rules is covered in some detail below.
Consent also adds some helpers to your controllers and routes file. To keep your routes working, you need to remove the +map+ parameter in config/routes.rb. So:
ActionController::Routing::Routes.draw do |map|
ActionController::Routing::Routes.draw do
Finally, add the Consent +before_filter+ to the top of your ApplicationController to enable the Consent firewall for incoming requests:
class ApplicationController < ActionController::Base before_filter :check_access_using_consent
# other filters, settings, etc...
end
== Request expressions
The first part of any rule is called the expression list. This is a snippet of Ruby code that is used to match requests to the controllers in your application. The expression language allows for matching of controllers, actions, parameters, response formats and HTTP methods, and provides a declarative method for referring to your app's actions.
=== Expression grammar
The basic grammar for expression lists is:
list ::= expression [+ expression] expression ::= controller[action]?[params]?[format]? controller ::= name[/name] action ::= .name params ::= (:name => value[, :name => value]) format ::= [.|]name name ::= [a-z][a-z_]* value ::= integer|string|regexp|range
That might seem a little abstract, so here are some examples. In the right-hand column, a symbol refers to a parameter, e.g. ":id" should be read as "params[:id]".
site.hello SiteController#hello users All UsersController actions profiles.edit(:id => 12) ProfilesController#edit where :id == 12 pages(:id => "foo") All PagesController actions where :id == "foo" site(:name => /foo/i, :id => 4) SiteController, :name contains "foo", :id == 4 ajax/maps All actions in Ajax::MapsController ajax/maps.find Ajax::MapsController#find admin/users.search(:q => 4..8) Admin::UsersController#search, :q between 4 and 8 tags.list.json TagsController#list where the format is JSON tagsxml Any TagsController action where the format is XML tags(:q => "lolz")txt TagsController, :q == "lolz" and format is TXT
Expressions are combined into a list using the + operator. For example, the following complete rule matches SiteController#hello and all PagesController actions where :id == "foo", and restricts them so that they can only be accessed using GET requests and a :name parameter:
site.hello + pages(:id => "foo") { request.get? and !params[:name].nil? }
=== HTTP method filtering
You can also use HTTP verbs (get, post, etc) in your expressions to match more specific requests. Each HTTP method takes a list of one or more expressions and narrows the scope of the expressions to a specific verb. For example, this matches all UsersController actions using any verb, POST requests to ProfilesController#update, and PUT requests to TagsController and SiteController#hello:
users + post(profiles.update) + put(tags + site.hello)
== Decision blocks
Decision blocks always appear at the end of a rule, and should return +true+ if the request is allowed and +false+ if it should be denied. Within the block, you have access to the +request+, +params+ and +session+ objects so you can use them to make decisions about whether to allow the request.
Within a decision block, you can use the words +deny+ and +allow+ to clean code up a bit. Both these keywords cause the block to return early without processing any other instructions; +deny+ denies the request and +allow+, well, allows it. For example, the following rule blocks requests for :id between 45 and 60, except for if :id is 54:
users.update(:id => 45..60) do allow if params[:id].to_i == 54 false end
If :id is 54, the rule allows the request; +allow+ makes the block return early so it does not return the value +false+ from its last expression.
=== Be careful with +allow+ and +deny+
Note that +allow+ and +deny+ are only intended for returning early from rule blocks, and are provided because you can't actually use the +return+ statement in Ruby blocks without running into some weird differences between procs, blocks and lambdas. You might be tempted to use them to write more expressive single-line rules, but this can lead to ambiguous rules. For example:
tags.create { allow unless request.get? }
tags.create { deny if request.get? }
These look like they might do the same thing, but they don't. In the face of a GET request, rule +A+ won't call +allow+ and will simply return +nil+, while rule +B+ will call +deny+ and block the request. Since +deny+ is a stronger command than +allow+ (requests are allowed by default!), Consent ignores a +nil+ response to a rule as this makes sure +deny+ rules like +B+ work all the time.
In short: if you want a request to be blocked, the rule must call +deny+ or +redirect+, or evaluate to +false+. Anything else will be ignored. The best way to write the above rules is:
tags.create { !request.get? }
=== Redirects
You can also perform redirects from rule blocks using the +redirect+ keyword. Again, this keyword blocks execution of the rest of the block, and it allows you to use the same shorthand for action expressions as is used for matching requests (see above). For example, here's a simple rule to block all requests to ProfilesController, redirecting to SiteController#hello:
profiles { redirect site.hello }
Note that if you only specify a controller with no action to redirect to, the Rails convention is to use the +index+ action. For example, this rule redirects to UsersController#index if there is no user logged in, otherwise the request is allowed:
profiles do redirect users unless session[:user] allow end
=== Throttling
Rule blocks can be used to throttle traffic to actions using identifier keys. For example, you may have a controller that exposes a web service API to your application, and users need to use an API key to make requests to it. If you wanted to limit each client to a maximum of a thousand requests per day to ApiController, you could write:
api { throttle params[:api_key], 1000.per_day }
This monitors incoming requests matching the expression +api+, splits the traffic up by the value of params[:api_key], and makes sure that no one key can make more than 1000 requests in any 24-hour period. You're saying, "Each value of params[:api_key] can make 1000 requests per day to ApiController".
In general, you'll want to throttle on a per-client basis (where "client" could mean an API key, a user in the database, an IP address, a referring URL, etc) but if you want a global throttle just put in a constant value:
graphs.generate { throttle :all, 200.per(15.minutes) }
This means that GraphsController#generate will fulfill no more than 200 requests in any 15-minute period in total for all incoming traffic, not just per user.
The full throtlling API is as follows:
Here are some more examples. Don't take these as gospel as being the best way of doing various things, they're only meant to make the API clearer...
users.login { throttle request.remote_ip, 1.per(5.seconds) }
profiles*html { throttle request.subdomains.first, 500.per_day }
pages.expensive_action { throttle request.referrer, 20.per_second }
photos { throttle :all, 3.per_hour if session[:user] == "joker" }
Note the last one throttles all requests for one chosen user, rather than for all users. You're calling +throttle+ conditionally based on the session data, rather than creating a blanket rule for all users.
Also note that at present Consent stores request history in memory, so memory usage will increase if you use long time periods. This feature has not been load-tested in production yet, but if people report problems I'll consider storing requests in SQLite.
== Helper methods
To make it easier to write clean rules and reduce repetition, Consent allows you to define helper methods in the rule block that you can then use within rules to make decisions. For example, let's say we want a method to grab the current user from the session:
helper(:user) { User.find(session[:user_id]) }
We can then use this helper in our rules:
profiles.update { user && user.is_admin? }
Consent provides a few built-in helpers to access commonly used data for performing access control. They are as follows:
For example, if you want DebugController accessible only during development, this rule will do just that:
debug { development? }
== Extra: request expressions aren't just for your consent.rb
For the sake of being extra specially helpful, Consent gives you the ability to use the request expression language described above in your controllers, views, and in your routes file. For example, you can map a route like so:
map.connect "foo", tags.list.xml
Or, you can use expressions with methods that expect URLs in controllers and views:
redirect_to users.create(:name => "jcoglan")
form_for :post, :url => posts.update
These expressions make heavy use of +method_missing+ and operator overloading, so if you really care about performance or you find they cause any other problems, go into this plugin's init.rb and comment out any line that looks like:
include Consent::Extensions::Xxx
== Copyright
Copyright (c) 2009 James Coglan, released under the MIT license