├── Gemfile ├── .gitignore ├── lib ├── ability_list │ └── version.rb └── ability_list.rb ├── Rakefile ├── test ├── helper.rb ├── helpers_test.rb ├── nil_test.rb └── basic_test.rb ├── ability_list.gemspec ├── RECIPES.md └── README.md /Gemfile: -------------------------------------------------------------------------------- 1 | gemspec 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | -------------------------------------------------------------------------------- /lib/ability_list/version.rb: -------------------------------------------------------------------------------- 1 | class AbilityList 2 | VERSION = "0.0.1" 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | desc "Run tests." 2 | task :test do 3 | Dir['./test/*_test.rb'].each { |f| load f } 4 | end 5 | 6 | task :default => :test 7 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.expand_path('../../lib', __FILE__) 2 | 3 | require 'minitest/autorun' 4 | require 'ability_list' 5 | require 'ostruct' 6 | 7 | class TestCase < MiniTest::Unit::TestCase 8 | 9 | end 10 | -------------------------------------------------------------------------------- /ability_list.gemspec: -------------------------------------------------------------------------------- 1 | require './lib/ability_list/version' 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "ability_list" 5 | s.version = AbilityList::VERSION 6 | s.summary = %[Simple user permissions management.] 7 | s.description = %[A very simple way to manage permissions. Works with any ORM.] 8 | s.authors = ["Rico Sta. Cruz"] 9 | s.email = ["hi@ricostacruz.com"] 10 | s.homepage = "http://github.com/rstacruz/ability_list" 11 | s.files = `git ls-files`.strip.split("\n") 12 | 13 | s.add_development_dependency "minitest" 14 | s.add_development_dependency "rake" 15 | end 16 | -------------------------------------------------------------------------------- /test/helpers_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper.rb', __FILE__) 2 | 3 | module HelpersTest 4 | Video = Class.new(OpenStruct) 5 | 6 | # --- 7 | 8 | class User < OpenStruct 9 | include AbilityList::Helpers 10 | 11 | def abilities 12 | end 13 | end 14 | 15 | # --- 16 | 17 | describe "Helper tests" do 18 | let(:user) { User.new } 19 | 20 | it '#can? fail' do 21 | (!! user.can?(:cook, :spam)).must_equal false 22 | end 23 | 24 | it '#cannot? fail' do 25 | (!! user.cannot?(:cook, :spam)).must_equal true 26 | end 27 | 28 | it '#authorize! fail' do 29 | assert_raises AbilityList::Error do 30 | user.authorize! :cook, :spam 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/nil_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper.rb', __FILE__) 2 | 3 | module NilTest 4 | class User < OpenStruct 5 | include AbilityList::Helpers 6 | 7 | def abilities 8 | @abilities ||= Abilities.new(self) 9 | end 10 | end 11 | 12 | # --- 13 | 14 | class Abilities < AbilityList 15 | def initialize(user) 16 | can :make, :fire 17 | can :make 18 | end 19 | end 20 | 21 | # --- 22 | 23 | describe "Nil tests" do 24 | let(:user) { User.new } 25 | 26 | it "#can? 1" do 27 | user.can?(:make, :fire).must_equal true 28 | end 29 | it "#can? 2" do 30 | user.can?(:make).must_equal true 31 | end 32 | it "#can? 3" do 33 | user.can?(:make, :lasagna).must_equal false 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/basic_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../helper.rb', __FILE__) 2 | 3 | module BasicTest 4 | Video = Class.new(OpenStruct) 5 | 6 | # --- 7 | 8 | class User < OpenStruct 9 | include AbilityList::Helpers 10 | 11 | def abilities 12 | @abilities ||= Abilities.new(self) 13 | end 14 | end 15 | 16 | # --- 17 | 18 | class Abilities < AbilityList 19 | def initialize(user) 20 | # Every can view videos. 21 | can :view, Video 22 | 23 | # ...except restricted ones. 24 | cannot :view, Video, &:restricted 25 | 26 | # ...but for those that are restricted, it's okay if the user is old enough. 27 | can :view, Video do |vid| 28 | vid.restricted && user.age > 18 29 | end 30 | end 31 | end 32 | 33 | # --- 34 | 35 | describe "Basic tests" do 36 | it "#can? 1" do 37 | user = User.new :age => 22 38 | video = Video.new :restricted => false 39 | 40 | user.can?(:view, video).must_equal true 41 | end 42 | 43 | it "#can? 2" do 44 | user = User.new :age => 10 45 | video = Video.new :restricted => true 46 | 47 | user.can?(:view, video).must_equal false 48 | end 49 | 50 | it "#can? 3" do 51 | user = User.new :age => 42 52 | video = Video.new :restricted => true 53 | 54 | user.can?(:view, video).must_equal true 55 | end 56 | 57 | it "#cannot?" do 58 | user = User.new :age => 10 59 | video = Video.new :restricted => true 60 | 61 | user.cannot?(:view, video).must_equal true 62 | end 63 | 64 | it "#authorize!" do 65 | user = User.new :age => 10 66 | video = Video.new :restricted => true 67 | 68 | assert_raises AbilityList::Error do 69 | user.authorize! :view, video 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /RECIPES.md: -------------------------------------------------------------------------------- 1 | How to implement roles 2 | ---------------------- 3 | 4 | AbilityList has no explicit support for roles because they're pretty easy to do 5 | in the plain ol' Ruby way. 6 | 7 | ``` ruby 8 | class Abilities < AbilityList 9 | def initialize(user) 10 | # Assume this returns an array of strings like ['admin', 'editor']. 11 | roles = user.roles 12 | 13 | # Now simply call the respective methods of each role. 14 | roles.each { |role| send role } 15 | end 16 | 17 | def admin 18 | can :manage, User 19 | can :manage, Group 20 | end 21 | 22 | def editor 23 | can :edit, Article 24 | end 25 | 26 | def publisher 27 | can :publish, Article 28 | end 29 | 30 | def writer 31 | can :create, Article 32 | end 33 | end 34 | ``` 35 | 36 | How to integrate with Rails controllers 37 | --------------------------------------- 38 | 39 | This Rails example raises an error when the logged in user is not allowed of a 40 | certain permission. 41 | 42 | ``` ruby 43 | class ArticleController 44 | before_filter :authorize_viewing 45 | 46 | private 47 | 48 | def authorize_viewing 49 | current_user.authorize! :view, Article 50 | end 51 | end 52 | ``` 53 | 54 | How to implement user levels 55 | ---------------------------- 56 | 57 | If your `User` model has a numeric field `level`, where more permissions are 58 | available to users with higher levels, you can implement it like so: 59 | 60 | ``` ruby 61 | class Abilities < AbilityList 62 | def initialize(user) 63 | if user.level > 50 64 | can :launch, Rocket 65 | can :command, Army 66 | end 67 | 68 | if user.level > 30 69 | can :view_status, Rocket 70 | end 71 | 72 | if user.level > 1 73 | can :login, :site 74 | end 75 | end 76 | end 77 | ``` 78 | 79 | Defining Rails helpers 80 | ---------------------- 81 | 82 | The `Helpers` module used in Users can be used as Rails helpers too. 83 | 84 | ``` ruby 85 | module PermissionsHelper 86 | # Provides `can?` and `cannot?`... as long as you have `#abilities` defined. 87 | include AbilityList::Helpers 88 | 89 | def abilities 90 | current_user.try :abilities 91 | end 92 | end 93 | ``` 94 | 95 | So that you may: 96 | 97 | ``` erb 98 | <% if can?(:edit, @post) %> 99 | Edit 100 | <% end %> 101 | ``` 102 | -------------------------------------------------------------------------------- /lib/ability_list.rb: -------------------------------------------------------------------------------- 1 | class AbilityList 2 | Error = Class.new(StandardError) 3 | 4 | # Returns a list of rules. These are populated by `can` and `cannot`. 5 | # (Rules are tuples) 6 | def rules 7 | @rules ||= [] 8 | end 9 | 10 | # --- 11 | 12 | # Declares that the owner can perform `verb` on `class`. 13 | def can(verb, klass=nil, &block) 14 | rules << [true, verb, get_class(klass), block] 15 | end 16 | 17 | # Inverse of `can`. 18 | def cannot(verb, klass=nil, &block) 19 | rules << [false, verb, get_class(klass), block] 20 | end 21 | 22 | # --- 23 | 24 | # Checks if the owner can perform `verb` on the given `object` (or class). 25 | def can?(verb, object=nil) 26 | rules = rules_for(verb, get_class(object)) 27 | rules.inject(false) do |bool, (sign, _, _, proc)| 28 | sign ? 29 | (bool || !proc || proc.call(object)) : # can 30 | (bool && proc && !proc.call(object)) # cannot 31 | end 32 | end 33 | 34 | # Inverse of `can?`. 35 | def cannot?(verb, object=nil) 36 | ! can?(verb, object) 37 | end 38 | 39 | # --- 40 | 41 | # Ensures that the owner can perform `verb` on `object/class` -- raises an 42 | # error otherwise. 43 | def authorize!(verb, object=nil) 44 | can?(verb, object) or raise Error.new("Access denied (#{verb})") 45 | end 46 | 47 | # Inverse of `authorize!`. 48 | def unauthorize!(verb, object=nil) 49 | cannot?(verb, object) or raise Error.new("Access denied (#{verb})") 50 | end 51 | 52 | # --- 53 | 54 | # Returns a subset of `rules` that match the given `verb` and `class`. 55 | def rules_for(verb, klass) 56 | rules.select do |(sign, _verb, _klass, block)| 57 | (_verb == :manage || _verb == verb) && 58 | (_klass == :all || _klass == klass) 59 | end 60 | end 61 | 62 | private 63 | 64 | def get_class(object) 65 | [NilClass, Symbol, Class].include?(object.class) ? object : object.class 66 | end 67 | end 68 | 69 | # Provides `#can?` and `#cannot?` and other helpers. 70 | # Assumes that you have an `#ability` method defined. 71 | module AbilityList::Helpers 72 | def can?(*a) 73 | abilities && abilities.can?(*a) 74 | end 75 | 76 | def cannot?(*a) 77 | !abilities || abilities.cannot?(*a) 78 | end 79 | 80 | def authorize!(*a) 81 | raise AbilityList::Error.new("No 'ability' defined") unless abilities 82 | abilities.authorize!(*a) 83 | end 84 | end 85 | 86 | require 'ability_list/version' 87 | 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AbilityList 2 | =========== 3 | 4 | Simple permissions system as plain old Ruby objects. No fancy integration with 5 | ORMs or frameworks. 6 | 7 | All of this is just a single Ruby file with less than 50 lines of significant 8 | code. [Read it now][ability_list.rb]. 9 | 10 | ## Defining abilities 11 | 12 | Define the list of abilities a user has by subclassing `AbilityList`. 13 | 14 | Each ability is comprised of a **verb** (required) and an **object** (optional). 15 | A *verb* is any symbol, while the *object* can be a symbol or a class. 16 | 17 | ``` ruby 18 | class Abilities < AbilityList 19 | def initialize(user) 20 | can :view, Video 21 | 22 | if user.admin? 23 | can :delete, Video 24 | can :upload, Video 25 | end 26 | 27 | can :login 28 | 29 | can :view, :admin 30 | end 31 | end 32 | ``` 33 | 34 | Then hook it to user by defining an `abilities` method. 35 | 36 | ``` ruby 37 | class User < OpenStruct 38 | include AbilityList::Helpers 39 | 40 | def abilities 41 | @abilities ||= Abilities.new(self) 42 | end 43 | end 44 | ``` 45 | 46 | ## Checking for abilities 47 | 48 | Now you may use `can?`: 49 | 50 | ``` ruby 51 | user = User.new 52 | user.can?(:view, Video) 53 | user.can?(:view, Video.find(20)) 54 | 55 | user.can?(:login) 56 | user.can?(:view, :admin) 57 | ``` 58 | 59 | The inverse `cannot?` is also available. 60 | 61 | ## Raising errors 62 | 63 | Or you can use `authorize!`, which is exactly like `can?` except it raises 64 | an `AbilityList::Error` exception. Perfect for controllers. 65 | 66 | ``` ruby 67 | user.authorize! :view, Video.find(20) 68 | ``` 69 | 70 | ## Custom criteria 71 | 72 | You can pass a block to `can` for custom criteria: 73 | 74 | ``` ruby 75 | can :view, Video do |video| 76 | !video.restricted? or user.age > 18 77 | end 78 | ``` 79 | 80 | You can even use Ruby's `&:sym` syntax: 81 | 82 | ``` ruby 83 | cannot :edit, Article, &:published? 84 | 85 | # Equivalent to cannot(:edit, Article) { |article| article.published? } 86 | ``` 87 | 88 | ## Object types 89 | 90 | The method `can` always accepts at least 2 arguments: a *verb* and an *object*. 91 | 92 | You can define your permissions by passing a class as the object: 93 | 94 | ``` ruby 95 | can :view, Video 96 | ``` 97 | 98 | which makes it possible to check for instances or classes: 99 | 100 | ``` ruby 101 | user.can?(:view, Video) #-> passing a class 102 | user.can?(:view, Video.find(1008)) #-> passing an instance 103 | ``` 104 | 105 | But this doesn't have to be classes. Just pass anything else, like a symbol: 106 | 107 | ``` ruby 108 | can :login, :mobile_site 109 | 110 | # user.can?(:login, :mobile_site) 111 | ``` 112 | 113 | ## Overriding criteria 114 | 115 | Criteria are evaluated on a top-down basis, and the ones at the bottom will 116 | override the ones on top. 117 | 118 | The method `cannot` is provided to make exceptions to rules. 119 | 120 | For example: 121 | 122 | ``` ruby 123 | # Everyone can edit comments. 124 | can :edit, Comment 125 | 126 | # ...but unconfirmed users can't edit their comments. 127 | if user.unconfirmed? 128 | cannot :edit, Comment 129 | end 130 | 131 | # ...but if the comments are really new, they can be edited, even if the user 132 | # hasn't confirmed. 133 | can :edit, Comment { |c| c.created_at < 3.minutes.ago } 134 | ``` 135 | 136 | ## The `:manage` keyword 137 | 138 | You can use `:manage` as the verb to allow any verb. 139 | 140 | ``` ruby 141 | can :manage, Group 142 | ``` 143 | 144 | This allows the user to do anything to `Group` its instances. 145 | 146 | ``` ruby 147 | user.can?(:delete, Group) #=> true 148 | user.can?(:create, Group) #=> true 149 | user.can?(:eviscerate, Group) #=> true 150 | ``` 151 | 152 | ## The `:all` keyword 153 | 154 | You can use `:all` as the object for any permission. This allows a verb to work 155 | on anything. 156 | 157 | Don't know why you'll want this, but cancan has it, so: 158 | 159 | ``` ruby 160 | can :delete, :all 161 | ``` 162 | 163 | So you can: 164 | 165 | ``` ruby 166 | user.can?(:delete, Video) #=> true 167 | user.can?(:delete, Article) #=> true 168 | user.can?(:delete, Recipe) #=> true 169 | ``` 170 | 171 | More examples 172 | ------------- 173 | 174 | See [RECIPES.md] for some practical examples. 175 | 176 | Limitations 177 | ----------- 178 | 179 | AbilityList aims to be extremely lean, and to be as framework- and ORM-agnostic 180 | as possible. As such, it doesn't: 181 | 182 | * No explicit integration with Rails controllers. 183 | 184 | * No explicit integration with ActiveRecord (or any other ORM). 185 | 186 | * No explicit provisions for roles. 187 | 188 | See [RECIPES.md] on how to do these things. 189 | 190 | Acknowledgements 191 | ---------------- 192 | 193 | Heavily inspired by [cancan]. AbilityList is generally a stripped-down version 194 | of cancan with a lot less features (see Limitations) above. 195 | 196 | (c) 2013 MIT License. 197 | 198 | [cancan]: https://github.com/ryanb/cancan 199 | [RECIPES.md]: https://github.com/rstacruz/ability_list/blob/master/RECIPES.md 200 | [ability_list.rb]:https://github.com/rstacruz/ability_list/blob/master/lib/ability_list.rb 201 | --------------------------------------------------------------------------------