Skip to content

Access Control Subsystem

Jason King edited this page Feb 15, 2021 · 7 revisions

By means of access control subsystem you can protect actions of your controller from unauthorized access. Acl9 provides a nice DSL for writing access rules.

Allow and deny

Access control is mostly about allowing and denying. So there are two basic methods: allow and deny. They have the same syntax:

allow ROLE_LIST, OPTIONS
deny  ROLE_LIST, OPTIONS

Specifying roles

ROLE_LIST is a list of roles (at least 1 role should be there). So,

allow :manager, :admin
deny  :banned

will match holders of global role manager and holders of global role admin as allowed. On the contrary, holders of banned role will match as denied.

Basically this snippet is equivalent to

allow :manager
allow :admin
deny  :banned

which means that roles in argument list are OR'ed for a match, and not AND'ed.

Also note that:

  • You may use both strings and :symbols to specify roles (the latter get converted into strings).
  • Role names are singularized before check.

Thus the snippet above can also be written as

allow :managers, :admins
deny  'banned'

or even

allow *%w(managers admins)
deny  'banned'

Object and class roles

Examples in the previous section were all about global roles. Let's see how we can use object and class roles in the ACL block.

allow :responsible, :for => Widget
allow :possessor, :of => :foo
deny  :angry, :at => :me
allow :interested, :in => Future
deny  :short, :on => :time
deny  :hated, :by => :us

To specify an object you use one of the 6 preposition options:

  • :of
  • :at
  • :on
  • :by
  • :for
  • :in

They all have the same meaning, use one that makes better English out of your rule.

Now, each of these prepositions may point to a Class or a :symbol. In the former case we get class role. E.g. allow :responsible, :for => Widget becomes subject.has_role?('responsible', Widget).

Symbol is trickier, it means that the appropriate instance variable of the controller is taken as an object.

allow :possessor, :of => :foo is translated into subject.has_role?('possessor', controller.instance_variable_get('@foo')).

Checking against an instance variable has sense when you have another callback which is executed before the one generated by access_control, like this:

class MoorblesController < ApplicationController
  before_action :load_moorble, :only => [:edit, :update, :destroy]

  access_control do
    allow :creator, :of => :moorble

    # ...
  end

  # ...

  private

  def load_moorble
    @moorble = Moorble.find(params[:id])
  end
end

Note that the object option is applied to all of the roles you specify in the argument list. As such,

allow :devil, :son, :of => God

is equivalent to

allow :devil, :of => God
allow :son,   :of => God

but NOT

allow :devil
allow :son, :of => God

Pseudo-roles

There are three pseudo-roles in the ACL: all, anonymous and logged_in.

allow all will always match (as well as deny all).

allow anonymous and deny anonymous will match when user is anonymous, i.e. subject is nil. You may also use a shorter notation: allow nil (deny nil).

logged_in is direct opposite of anonymous, so allow logged_in will match if the user is logged in (subject is not nil).

No role checks are done in either case.

Limiting action scope

By default rules apply to all actions of the controller. There are two options that narrow the scope of the deny or allow rule: :only and :except.

allow :owner, :of => :site, :only => [:delete, :destroy]
deny anonymous, :except => [:index, :show]

For the first rule to match not only the current user should be an owner of the site, but also current action should be delete or destroy.

In the second rule anonymous user access is denied for all actions, except index and show.

You may not specify both :only and :except.

Note that you can use actions block instead of :only (see Actions block below). You can also use :only and :except options in the access_control call which will serve as options of the before_action and thus limit the scope of the whole ACL.

Note :only was called :to prior to version 2.1, and :to still works. If you happy to provide both a :to and :only option then their values will be merged together and it will work fine.

Rule conditions

You may create conditional rules using :if and :unless options.

allow :owner, :of => :site, :only => [:delete, :destroy], :if => :chance_to_delete

Controller's :chance_to_delete method will be called here. The rule will match if the action is 'delete' or 'destroy' AND if the method returned true.

:unless has the opposite meaning and should return false for a rule to match.

Both options can be specified in the same rule.

allow :visitor, :only => [:index, :show], :if => :right_phase_of_the_moon?, :unless => :suspicious?

right_phase_of_the_moon? should return true AND suspicious? should return false for a poor visitor to see a page.

Currently only controller methods are supported (specify them as :symbols). Lambdas are not supported.

Rule matching order

Rule matching system is similar to that of Apache web server. There are two modes: default allow (corresponding to Order Deny,Allow in Apache) and default deny (Order Allow,Deny in Apache).

Setting modes

Mode is set with a default call.

default :allow will set default allow mode.

default :deny will set default deny mode. Note that this is the default mode, i.e. it will be on if you don't do a default call at all.

Matching algorithm

First of all, regardless of the mode, all allow matches are OR'ed together and all deny matches are OR'ed as well.

We'll express this in the following manner:

ALLOWED = (allow rule 1 matches?) OR ((allow rule 2 matches?) OR ...
NOT_DENIED = NOT ((deny rule 1 matches?) OR (deny rule 2 matches?) OR ...)

So, ALLOWED is true when either of the allow rules matches, and NOT_DENIED is true when none of the deny rules matches.

Let's denote the final result of algorithm as ALLOWANCE. If it's true, access is allowed, if false, denied.

In the case of default allow:

ALLOWANCE = ALLOWED OR NOT_DENIED

In the case of default deny:

ALLOWANCE = ALLOWED AND NOT_DENIED

Same result as a table:

Rule matches Default allow mode Default deny mode
None of the allow and deny rules matched. Allowed Denied
Some of the allow rules matched, none of the deny rules matched. Allowed Allowed
None of the allow rules matched, some of the deny rules matched. Denied Denied
Some of the allow rules matched, some of the deny rules matched. Allowed Denied

Apparently default deny mode is more strict, and that's because it's on by default.

Actions block

You may group rules with the help of the actions block.

An example from the imaginary PostsController:

allow :admin

actions :index, :show do
  allow all
end

actions :new, :create do
  allow :managers, :of => Post
end

actions :edit, :update do
  allow :owner, :of => :post
end

action :destroy do
  allow :owner, :of => :post
end

This is equivalent to:

allow :admin

allow all, :only => [:index, :show]
allow :managers, :of => Post, :only => [:new, :create]
allow :owner, :of => :post, :only => [:edit, :update]
allow :owner, :of => :post, :only => :destroy

Note that only allow and deny calls are available inside actions block, and these may not have :only/:except options.

action is just a synonym for actions.

access_control method

By calling access_control in your controller you can get your ACL block translated into...

  1. a lambda, installed with before_action and raising Acl9::AccessDenied exception on occasion.
  2. a method, installed with before_action and raising Acl9::AccessDenied exception on occasion.
  3. a method, returning true or false, whether access is allowed or denied.

First case is by default. You can catch the exception with rescue_from call and do something you like: make a redirect, or render "Access denied" template, or whatever.

Second case is obtained with specifying method name as an argument to access_control (or using :as_method option, see below) and may be helpful if you want to use skip_before_action somewhere in the derived controller.

Third case will take place if you supply :filter => false along with method name. You'll get an ordinary method which you can call anywhere you want.

:subject_method

Acl9 obtains the subject instance by calling specific method of the controller. By default it's :current_user, but you may change it.

class MyController < ApplicationController
  access_control :subject_method => :current_account do
    allow :nifty
    # ...
  end

  # ...
end

Subject method can also be changed globally. Place the following into config/initializers/acl9.rb:

Acl9.config[:default_subject_method] = :current_account

TODO - add docs for protect_global_roles

:debug

:debug => true will output the filtering expression into the debug log. If Acl9 does something strange, you may look at it as the last resort.

:as_method

In the case

class NiftyController < ApplicationController
  access_control :as_method => :acl do
    allow :nifty
    # ...
  end

  # ...
end

access control checks will be added as acl method onto MyController, with before_action :acl call thereafter.

Instead of using :as_method you may specify the name of the method as a positional argument to access_control:

class MyController < ApplicationController
  access_control :acl do
    # ...
  end

  # ...
end

:filter

If you set :filter to false (it's true by default) and also use :as_method (or method name as 1st argument to access_control, you'll get a method which won't raise Acl9::AccessDenied exception, but rather return true or false (access allowed/denied).

class SecretController < ApplicationController
  access_control :secret_access?, :filter => false do
    allow :james_bond
    # ...
  end

  def index
    if secret_access?
      _secret_index
    else
      _ordinary_index
    end
  end

  # ...

  private

  def _secret_index
    # ...
  end

  def _ordinary_index
    # ...
  end
end

The generated method can receive an objects hash as an argument. In this example,

class LolController < ApplicationController
  access_control :lolcats?, :filter => false do
    allow :cats, :by => :lol
    # ...
  end
end

you may not only call lolcats? with no arguments, which will basically return

current_user.has_role?('cats', @lol)

but also as lolcats?(:lol => Lol.find(params[:lol])). The hash will be looked into first, even if you have an instance variable lol.

TODO - document _action override.

:helper

Sometimes you want to have a boolean method (like :filter => false) accessible in your views. Acl9 can call helper_method for you:

class LolController < ApplicationController
  access_control :helper => :lolcats? do
    allow :cats, :by => :lol
    # ...
  end
end

That's equivalent to

class LolController < ApplicationController
  access_control :lolcats?, :filter => false do
    allow :cats, :by => :lol
    # ...
  end

  helper_method :lolcats?
end

Other options

Other options will be passed to before_action. As such, you may use :only and :except to narrow the action scope of the whole ACL block.

class OmgController < ApplicationController
  access_control :only => [:index, :show] do
    allow all
    deny :banned
  end

  # ...
end

is basically equivalent to

class OmgController < ApplicationController
  access_control do
    actions :index, :show do
      allow all
      deny :banned
    end

    allow all, :except => [:index, :show]
  end

  # ...
end

access_control in your helpers

Apart from using :helper option for access_control call inside controller, there's a way to generate helper methods directly, like this:

module SettingsHelper
  include Acl9Helpers

  access_control :show_settings? do
    allow :admin
    allow :settings_manager
  end
end

Here we mix in Acl9Helpers module which brings in access_control method and call it, obtaining show_settings? method.

An imaginary view:

<% if show_settings? %>
  <%= link_to 'Settings', settings_path %>
<% end %>

show_to in your views

show_to is predefined helper for your views:

<% show_to :admin, :supervisor do %>
  <%= link_to 'destroy', destroy_path %>
<% end %>

or even

<% show_to :prince, :of => :persia do %>
  <%= link_to 'Princess', princess_path %>
<% end %>