• Stars
    star
    313
  • Rank 128,806 (Top 3 %)
  • Language
    Ruby
  • License
    MIT License
  • Created about 13 years ago
  • Updated about 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Scope-based authorization for Ruby on Rails.

Consul β€” A next gen authorization solution

Tests Code Climate

Consul is an authorization solution for Ruby on Rails where you describe sets of accessible things to control what a user can see or edit.

We have used Consul in combination with assignable_values to solve a variety of authorization requirements ranging from boring to bizarre. Also see our crash course video: Solving bizare authorization requirements with Rails.

Consul is tested with Rails 5.2, 6.1 and 7.0 on Ruby 2.5, 2.7 and 3.2 (only if supported, for each Ruby/Rails combination). If you need support for Rails 3.2, please use v0.13.2.

Describing access to your application

You describe access to your application by putting a Power model into app/models/power.rb. Inside your Power you can talk about what is accessible for the current user, e.g.

A Power might look like this:

class Power
  include Consul::Power

  def initialize(user)
    @user = user
  end

  power :users do
    User if @user.admin?
  end

  power :notes do
    Note.by_author(@user)
  end

  power :dashboard do
    true # not a scope, but a boolean power. This is useful to control access to stuff that doesn't live in the database.
  end

end

There are no restrictions on the name or constructor arguments of this class.

You can deposit all kinds of objects in your power. See the sections below for details.

Scope powers (relations)

A typical use case in a Rails application is to restrict access to your ActiveRecord models. For example:

  • Anonymous visitors may only see public posts
  • Users may only see their own notes
  • Only admins may edit users

You do this by making your powers return an ActiveRecord scope (or "relation"):

class Power
  ...

  power :notes do
    Note.by_author(@user)
  end

  power :users do
    User if @user.admin?
  end

end

You can now query these powers in order to retrieve the scope:

power = Power.new(user)
power.notes  # => returns an ActiveRecord::Scope

Or you can ask if the power is given (meaning it's not nil):

power.notes? # => returns true if Power#notes returns a scope and not nil

Or you can raise an error unless a power is given, e.g. to guard access into a controller action:

power.notes! # => raises Consul::Powerless unless Power#notes returns a scope (even if it's empty)

Or you ask whether a given record is included in its scope (can be optimized):

power.note?(Note.last) # => returns whether the given Note is in the Power#notes scope. Caches the result for subsequent queries.

Or you can raise an error unless a given record is included in its scope:

power.note!(Note.last) # => raises Consul::Powerless unless the given Note is in the Power#notes scope

See our crash course video Solving bizare authorization requirements with Rails for many different use cases you can cover with this pattern.

Defining different powers for different actions

If you have different access rights for e.g. viewing or updating posts, simply use different powers:

class Power
  ...

  power :notes do
    Note.published
  end

  power :updatable_notes do
    Note.by_author(@user)
  end

  power :destroyable_notes do
    Note if @user.admin?
  end

end

There is also a shortcut to map different powers to RESTful controller actions.

Boolean powers

Boolean powers are useful to control access to stuff that doesn't live in the database:

class Power
  ...

  power :dashboard do
    true
  end

end

You can query it like the other powers:

power = Power.new(@user)
power.dashboard? # => true
power.dashboard! # => raises Consul::Powerless unless Power#dashboard? returns true

Powers that give no access at all

Note that there is a difference between having access to an empty list of records, and having no access at all. If you want to express that a user has no access at all, make the respective power return nil.

Note how the power in the example below returns nil unless the user is an admin:

class Power
  ...

  power :users do
    User if @user.admin?
  end

end

When a non-admin queries the :users power, she will get the following behavior:

power = Power.new(@user)
power.users # => returns nil
power.users? # => returns false
power.users! # => raises Consul::Powerless
power.user?(User.last) # => returns false
power.user!(User.last) # => raises Consul::Powerless

Powers that only check a given object

Sometimes it is not convenient to define powers as a collection or scope (relation). Sometimes you only want to store a method that checks whether a given object is accessible.

To do so, simply define a power that ends in a question mark:

class Power
  ...

  power :updatable_post? do |post|
    post.author == @user
  end

end

You can query such an power as always:

power = Power.new(@user)
power.updatable_post?(Post.last) # return true if the author of the post is @user
power.updatable_post!(Post.last) # raises Consul::Powerless unless the author of the post is @user

Other types of powers

A power can return any type of object. For instance, you often want to return an array:

class Power
  ...

  power :assignable_note_states do
    if admin?
      %w[draft pending published retracted]
    else
      %w[draft pending]
    end
  end

end

You can query it like any other power. E.g. if a non-admin queries this power she will get the following behavior:

power.assignable_note_states # => ['draft', 'pending']
power.assignable_note_states? # => returns true
power.assignable_note_states! # => does nothing (because the power isn't nil)
power.assignable_note_state?('draft') # => returns true
power.assignable_note_state?('published') # => returns false
power.assignable_note_state!('published') # => raises Consul::Powerless

Defining multiple powers at once

You can define multiple powers at once by giving multiple power names:

class Power
  ...

  power :destroyable_users, :updatable_users do
    User if admin?
  end

end

Powers that require context (arguments)

Sometimes it can be useful to define powers that require context. To do so, just take an argument in your power block:

class Power
  ...

  power :client_notes do |client|
    client.notes.where(:state => 'published')
  end

end

When querying such a power, you always need to provide the context, e.g.:

client = ...
note = ...
Power.current.client_note?(client, note)

Optimizing record checks for scope powers

You can query a scope power for a given record, e.g.

class Power
  ...

  power :posts do |post|
    Post.where(:author_id => @user.id)
  end
end

power = Power.new(@user)
power.post?(Post.last)

What Consul does internally is fetch all the IDs of the power.posts scope and test if the given record's ID is among them. This list of IDs is cached for subsequent calls, so you will only touch the database once.

As scary as it might sound, fetching all IDs of a scope scales quiet nicely for many thousand records. There will however be the point where you want to optimize this.

What you can do in Consul is to define a second power that checks a given record in plain Ruby:

class Power
  ...

  power :posts do |post|
    Post.where(:author_id => @user.id)
  end

  power :post? do |post|
    post.author_id == @user.id
  end

end

This way you do not need to touch the database at all.

Role-based permissions

Consul has no built-in support for role-based permissions, but you can easily implement it yourself. Let's say your User model has a string column role which can be "author" or "admin":

class Power
  include Consul::Power

  def initialize(user)
    @user = user
  end

  power :notes do
    case role
      when :admin then Note
      when :author then Note.by_author
    end
  end

  private

  def role
    @user.role.to_sym
  end

end

Controller integration

It is convenient to expose the power for the current request to the rest of the application. Consul will help you with that if you tell it how to instantiate a power for the current request:

class ApplicationController < ActionController::Base
  include Consul::Controller

  current_power do
    Power.new(current_user)
  end

end

You now have a helper method current_power for your controller and views. Everywhere else, you can access it from Power.current. The power will be instantiated when the request is handed over from routing to ApplicationController, and will be nilified once the request was processed.

You can now use power scopes to control access:

class NotesController < ApplicationController

  def show
    @note = current_power.notes.find(params[:id])
  end

end

Protect entry into controller actions

To make sure a power is given before every action in a controller:

class NotesController < ApplicationController
  power :notes
end

You can use :except and :only options like in before_actions.

You can also map different powers to different actions:

class NotesController < ApplicationController
  power :notes, :map => { [:edit, :update, :destroy] => :changeable_notes }
end

Actions that are not listed in :map will get the default action :notes.

Note that in moderately complex authorization scenarios you will often find yourself writing a map like this:

class NotesController < ApplicationController
  power :notes, :map => {
    [:edit, :update] => :updatable_notes,
    [:new, :create] => :creatable_notes,
    [:destroy] => :destroyable_notes
  }
end

Because this pattern is so common, there is a shortcut :crud to do the same:

class NotesController < ApplicationController
  power :crud => :notes
end

And if your power requires context (is parametrized), you can give it using the :context method:

class ClientNotesController < ApplicationController

  power :client_notes, :context => :load_client

  private

  def load_client
    @client ||= Client.find(params[:client_id])
  end

end

Auto-mapping a power scope to a controller method

It is often convenient to map a power scope to a private controller method:

class NotesController < ApplicationController

  power :notes, :as => :note_scope

  def show
    @note = note_scope.find(params[:id])
  end

end

The mapped method is aware of the :map option.

The mapped method can be overridden and access the original implementation using super:

class NotesController < ApplicationController

  power :notes, :as => :note_scope

  # ...
    
  def note_scope
    super.where(trashed: false)
  end

end

Multiple power-mappings for nested resources

When using nested resources you probably want two power checks and method mappings: One for the parent resource, another for the child resource.

Say you have the following routes:

resources :clients do
  resources :notes
end

And the following power definitions:

class Power
  ...

  power :clients do
    Client.active if signed_in?
  end

  power :client_notes do |client|
    client.notes.where(:state => 'published')
  end

end

You can now check and map both powers in the nested NotesController:

class NotesController < ApplicationController

  power :clients, :as => :client_scope
  power :client_notes, :context => :load_client, :as => :note_scope

  def show
    load_note
  end

  private

  def load_client
    @client ||= client_scope.find(params[:client_id])
  end

  def load_note
    @note ||= note_scope.find(params[:id])
  end

end

Note how we provide the Client parameter for the :client_notes power by using the :context => :load_client option in the power directive.

How to never forget a power check

You can force yourself to use a power check in every controller. This will raise Consul::UncheckedPower if you ever forget it:

class ApplicationController < ActionController::Base
  include Consul::Controller
  require_power_check
end

Note that this check is satisfied by any .power directive in the controller class or its ancestors, even if that .power directive has :only or :except options that do not apply to the current action.

Should you want to forego the power check (e.g. to remove authorization checks from an entirely public controller):

class ApiController < ApplicationController
  skip_power_check
end

Validating assignable values

Sometimes a scope is not enough to express what a user can edit. You will often want to give a user write access to a record, but restrict the values she can assign to a given field.

Consul leverages the assignable_values gem to add an optional authorization layer to your models. This layer adds additional validations in the context of a request, but skips those validations in other contexts (console, background jobs, etc.).

You can enable the authorization layer by using the macro authorize_values_for:

class Story < ActiveRecord::Base
  authorize_values_for :state
end

The macro defines an accessor power on instances of Story. If that field is set to a power, the values of state will be validated against a whitelist of values provided by that power. If that field is nil, the validation is skipped.

Here is a power implementation that can provide a list of assignable values for the example above:

class Power
  ...

  def assignable_story_states(story)
    if admin?
      ['delivered', 'accepted', 'rejected']
    else
      ['delivered']
    end
  end

end

Here you can see how to activate the authorization layer and use the new validations:

story = Story.new
Power.current = Power.new(:role => :guest) # activate the authorization layer

story.assignable_states # ['delivered'] # apparently we're not admins

story.state = 'accepted' # a disallowed value
story.valid? # => false

story.state = 'delivered' # an allowed value
story.valid? # => true

You can not only authorize scalar attributes like strings or integers that way, you can also authorize belongs_to associations:

class Story < ActiveRecord::Base
  belongs_to :project
  authorize_values_for :project
end

class Power
  ...

  power :assignable_story_projects do |story|
    user.account.projects
  end
end

The authorize_values_for macro comes with many useful options and details best explained in the assignable_values README, so head over there for more. The macro is basically a shortcut for this:

assignable_values_for :field, :through => lambda { Power.current }

Memoization

All power methods are memoized for performance reasons. Multiple calls to the same method will only call your block the first time, and return a cached result afterwards:

power = Power.new
power.projects! # calls the `power :projects { ... }` block
power.projects! # returns the cached result from earlier
power.projects! # returns the cached result from earlier

If you want to discard all cached results, call #unmemoize_all:

power.unmemoize_all

Dynamic power access

Consul gives you a way to dynamically access and query powers for a given name, model class or record. A common use case for this are generic helper methods, e.g. a method to display an "edit" link for any given record if the user is authorized to change that record:

module CrudHelper

  def edit_record_action(record)
    if current_power.include_record?(:updatable, record)
      link_to 'Edit', [:edit, record]
    end
  end

end

You can find a full list of available dynamic calls below:

Dynamic call Equivalent
Power.current.send(:notes) Power.current.notes
Power.current.include_power?(:notes) Power.current.notes?
Power.current.include_power!(:notes) Power.current.notes!
Power.current.include_object?(:notes, Note.last) Power.current.note?(Note.last)
Power.current.include_object!(:notes, Note.last) Power.current.note!(Note.last)
Power.current.for_model(Note) Power.current.notes
Power.current.for_model(:updatable, Note) Power.current.updatable_notes
Power.current.include_model?(Note) Power.current.notes?
Power.current.include_model?(:updatable, Note) Power.current.updatable_notes?
Power.current.include_model!(Note) Power.current.notes!
Power.current.include_model!(:updatable, Note) Power.current.updatable_notes!
Power.current.include_record?(Note.last) Power.current.note?(Note.last)
Power.current.include_record?(:updatable, Note.last) Power.current.updatable_note?(Note.last)
Power.current.include_record!(Note.last) Power.current.note!(Note.last)
Power.current.include_record!(:updatable, Note.last) Power.current.updatable_note!(Note.last)
Power.current.name_for_model(Note) :notes
Power.current.name_for_model(:updatable, Note) :updatable_notes

Querying a power that might be nil

You will often want to access Power.current from another model, to e.g. iterate through the list of accessible users:

class UserReport

  def data
    Power.current.users.collect do |user|
      [user.name, user.email, user.income]
    end
  end

end

Good practice is for your model to not crash when Power.current is nil. This is the case when your model isn't called as part of processing a browser request, e.g. on the console, during tests and during batch processes. In such cases your model should simply skip authorization and assume that all users are accessible:

class UserReport

  def data
    accessible_users = Power.current.present? ? Power.current.users : User
    accessible_users.collect do |user|
      [user.name, user.email, user.income]
    end
  end

end

Because this pattern is so common, the Power class comes with a number of class methods you can use to either query Power.current or, if it is not set, just assume that everything is accessible:

class UserReport

  def data
    Power.for_model(User).collect do |user|
      [user.name, user.email, user.income]
    end
  end

end

There is a long selection of class methods that behave neutrally in case Power.current is nil:

Call Equivalent
Power.for_model(Note) Power.current.present? ? Power.current.notes : Note
Power.for_model(:updatable, Note) Power.current.present? ? Power.current.updatable_notes : Note
Power.include_model?(Note) Power.current.present? ? Power.notes? : true
Power.include_model?(:updatable, Note) Power.current.present? ? Power.updatable_notes? : true
Power.include_model!(Note) Power.notes! if Power.current.present?
Power.include_model!(:updatable, Note) Power.updatable_notes! if Power.current.present?
Power.include_record?(Note.last) Power.current.present? ? Power.note?(Note.last) : true
Power.include_record?(:updatable, Note.last) Power.current.present? ? Power.updatable_note?(Note.last?) : true
Power.include_record!(Note.last) Power.note!(Note.last) if Power.current.present?
Power.include_record!(:updatable, Note.last) Power.updatable_note!(Note.last) if Power.current.present?

Testing

This section Some hints for testing authorization with Consul.

Test that a controller checks against a power

Include the Consul Matcher spec/support/consul_matchers.rb:

require 'consul/spec/matchers'

RSpec.configure do |c|
  c.include Consul::Spec::Matchers
end

You can say this in any controller spec:

describe CakesController do

  it { should check_power(:cakes) }

end

You can test against all options of the power macro:

describe CakesController do

  it { should check_power(:cakes, :map => { [:edit, :update] => :updatable_cakes }) }

end

Temporarily change the current power

When you set Power.current to a power in an RSpec example, you must remember to nilify it afterwards. Otherwise other examples will see your global changes.

A better way is to use the .with_power method to change the current power for the duration of a block:

admin = User.new(:role => 'admin')
admin_power = Power.new(admin)

Power.with_power(admin_power) do
  # run code that uses Power.current
end

Power.current will be nil (or its former value) after the block has ended.

A nice shortcut is that when you call with_power with an argument that is not already a Power, Consul will instantiate a Power for you:

admin = User.new(:role => 'admin')

Power.with_power(admin) do
  # run code that uses Power.current
end

There is also a method .without_power that runs a block without a current Power:

Power.without_power do
  # run code that should not see a Power
end

Installation

Add the following to your Gemfile:

gem 'consul'

Now run bundle install to lock the gem into your project.

Development

We currently develop using Ruby 2.5.3 (see .ruby-version) since that version works for current versions of ActiveRecord that we support. GitHub Actions will test additional Ruby versions (2.3.8, 2.4.5, and 3.0.1).

There are tests in spec. We only accept PRs with tests. To run tests:

  • Install Ruby 2.5.3
  • run bundle install
  • Put your database credentials into spec/support/database.yml. There's a database.sample.yml you can use as a template.
  • There are gem bundles in the project root for each rails version that we support.
  • You can bundle all test applications by saying bundle exec rake matrix:install
  • You can run specs from the project root by saying bundle exec rake matrix:spec. This will run all gemfiles compatible with your current Ruby.

If you would like to contribute:

  • Fork the repository.
  • Push your changes with specs.
  • Send me a pull request.

Note that we have configured GitHub Actions to automatically run tests in all supported Ruby versions and dependency sets after each push. We will only merge pull requests after a green GitHub Actions run.

I'm very eager to keep this gem leightweight and on topic. If you're unsure whether a change would make it into the gem, talk to me beforehand.

Credits

Henning Koch from makandra

More Repositories

1

active_type

Make any Ruby object quack like ActiveRecord
Ruby
1,036
star
2

query_diet

A Rails database query counter that stays out of your way
Ruby
244
star
3

aegis

Complete authorization solution for Rails that supports roles and a RESTish, resource-style declaration of permission rules.
Ruby
191
star
4

modularity

Traits and partial classes for Ruby
Ruby
174
star
5

spreewald

Our collection of useful cucumber steps.
Ruby
137
star
6

assignable_values

Restrict the values assignable to ActiveRecord attributes or associations.
Ruby
120
star
7

cucumber_factory

Create records from Cucumber features without writing step definitions.
Ruby
116
star
8

geordi

Collection of command line tools used in our daily work with Ruby, Rails and Linux.
Ruby
105
star
9

edge_rider

Power tools for Active Record relations (scopes)
Ruby
85
star
10

rspec_candy

RSpec helpers and matchers
Ruby
68
star
11

capybara-lockstep

Synchronize Capybara commands with application JavaScript and AJAX requests
Ruby
65
star
12

dusen

Comprehensive search solution for ActiveRecord and MySQL.
Ruby
61
star
13

gemika

Test a Ruby gem against multiple versions of everything
Ruby
61
star
14

minidusen

Low-tech search for ActiveRecord with MySQL or PostgreSQL
Ruby
32
star
15

superclamp

Cross-browser ellipsis on multi-line texts. Optimized for performance, and supports tags inside clamped element. Even looks better than -webkit-clamp. Supports all real browsers and IE11+.
HTML
29
star
16

apify

Apify lets you bolt a JSON-API onto your Rails application. UNMAINTAINED.
Ruby
27
star
17

katapult

Kickstart Rails development!
Ruby
22
star
18

rails_state_machine

Rails State Machine is an ActiveRecord-bound state machine.
Ruby
19
star
19

cucumber_spinner

Progress bar formatter for cucumber. Shows failing scenarios immediately and can automatically show the error page in the browser.
Ruby
16
star
20

mail_magnet

Mail Magnet allows you to override e-mail recipients in ActionMailer so all mails go to a given address. This library is UNMAINTAINED. Use the official ActionMailer::Base.register_interceptor API instead: https://makandracards.com/makandra/46094-rails-how-to-write-custom-email-interceptors
Ruby
15
star
21

safe_cookies

Have cookies as secure as possible
Ruby
11
star
22

validate-hls

Smoke test for HLS stream integrity
Ruby
9
star
23

upjs

Progressive enhancement, reloaded
8
star
24

machinist_callbacks

Callback hooks for machinist blueprints. UNMAINTAINED.
Ruby
7
star
25

pollyanna

Very simple search for your ActiveRecord models. UNMAINTAINED.
Ruby
7
star
26

makandra-rubocop

makandra's default Rubocop configuration
Ruby
6
star
27

rack-steady_etag

Rack Middleware that produces the same ETag for responses that only differ in CSRF tokens or CSP nonces.
Ruby
6
star
28

capistrano-opscomplete

Capistrano tasks for easy deployment to a makandra opscomplete environment.
Ruby
6
star
29

precompiled_assets

Serve assets without Rails doing any processing. Just requires a manifest file to resolve filenames.
Ruby
6
star
30

navy

Comprehensive solution for multi-level horizontal navigation bars. UNMAINTAINED.
Ruby
5
star
31

angular_xss

Patches rails_xss and Haml so AngularJS interpolations are auto-escaped in unsafe strings.
Ruby
5
star
32

memoized

Memoize your methods.
Ruby
5
star
33

ie-css-test

Tests Internet Explorer CSS breakage
HTML
4
star
34

cucumber_priority

Overridable step definitions for Cucumber
Ruby
4
star
35

makandra_sidekiq

Support code for sidekiq.
Ruby
3
star
36

rascal

Spin up CI environments locally
Ruby
3
star
37

esbuild-manifest-plugin

esbuild plugin to generate a manifest file for all digested files
JavaScript
2
star
38

angular-coffee-style-guide

Styleguide for Angular 1 using CoffeeScript
2
star
39

cloud_test

Ruby
2
star
40

akamai_tools

Akamai Tools provide some helpful tools, to deal with CCP and Net Storage.
Ruby
1
star
41

github-actions

1
star
42

terraform-aws-modules

Collection of some standard modules for deployment in AWS accounts.
HCL
1
star
43

webdev101

Examples for lecture "Grundlagen der Webentwicklung" at University of Augsburg
JavaScript
1
star
44

serum-rails

Scans a Rails application for metrics relevant to security audits. UNMAINTAINED.
Ruby
1
star
45

secret_service

Better secrets for Rails. UNMAINTAINED.
Ruby
1
star
46

micro_exiftool

Minimal ruby wrapper around exiftool
Ruby
1
star
47

cards_mvc

Sample project to evaluate JavaScript frameworks in the spirit of TodoMVC
JavaScript
1
star
48

check-config-rule-s3-lifecycle

This lambda function evaluates if an AWS Config rule is compliant. It's written to check if AWS::S3::Bucket resources with versioning enabled have a lifecycle policy. It does not check what is configured in the lifecycle policy.
Python
1
star