authorule 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,141 @@
1
+ module Authorule
2
+
3
+ # A permission rule base. This class performs the heart of the permission checking algorithms.
4
+ #
5
+ # == Algorithm description
6
+ #
7
+ # A rule base is always queried for one permission. The result should be whether it is allowed or denied.
8
+ #
9
+ # When running a permission through the rule base, the permission itself, and all dependent permissions
10
+ # are run through the rule base. See {Permission#dependencies} for more info about permission dependencies.
11
+ #
12
+ # The last defined rule (i.e. the rule with the highest priority) matching *any* of the checks is taken
13
+ # as the deciding rule.
14
+ #
15
+ # == Example
16
+ #
17
+ # Let's illustrate the given algorithm with an example. Let's say we have the following permissions, taken
18
+ # from the UI library:
19
+ #
20
+ # * +SpacePermission+: a permission to access a certain UI space (a collection of UI resources)
21
+ # * +ResourcePermission+: a permission to access a certain resource
22
+ #
23
+ # A resource permission has a dependency on its corresponding space permission. In other words, a user must
24
+ # have access to the resource's space as well as the resource itself for it to be accessible.
25
+ #
26
+ # Then, we consider a rule base with the following rules:
27
+ #
28
+ # 1. Deny all
29
+ # 2. Allow space 'CRM'
30
+ # 3. Deny resource 'Account'
31
+ #
32
+ # Now, we need to check whether the user may view the resource 'Account':
33
+ #
34
+ # permission = ResourcePermission.new(account_resource, :view)
35
+ #
36
+ # When resolving all dependencies, we end up with the following list of permissions:
37
+ #
38
+ # permissions = permission.resolve_dependencies
39
+ # # => [ SpacePermission.new(crm_space), ResourcePermission.new(account_resource, :view) ]
40
+ #
41
+ # Both of these permissions are now run through the rule base. The first permission is matched by rules
42
+ # 1 (all) and 2 (space 'CRM'). The second permission is matched by rules 1 (all) and 3 (resource 'Account').
43
+ # The last defined rule is the third rule. As it is set to deny access, the resulting access to the permission
44
+ # is denied. This makes sense because we deny it last in line.
45
+ #
46
+ # If however, the rule base were to switch around 2 and 3, the rule base would look as follows:
47
+ #
48
+ # 1. Deny all
49
+ # 2. Deny resource 'Account'
50
+ # 3. Allow space 'CRM'
51
+ #
52
+ # Now, the first permission will be matched by rule 3, which is the last rule to match. As it is set to allow
53
+ # access, the resulting access to the permission is allowed. As you can see, the second rule is overruled by the
54
+ # more generic rule 3.
55
+ class RuleBase
56
+
57
+ ######
58
+ # Initialization
59
+
60
+ # Initializes the rule base with the given rules.
61
+ def initialize(rules)
62
+ @rules = rules.to_a
63
+ @index = build_index
64
+ end
65
+
66
+ ######
67
+ # Attributes
68
+
69
+ # @!attribute [r] rules
70
+ # @return [Array] The rules in the rule base.
71
+ attr_reader :rules
72
+
73
+ ######
74
+ # Index
75
+
76
+ # Builds an index that maps a permission key into a rule index.
77
+ def build_index
78
+ index = {}
79
+
80
+ rules.each_with_index do |rule, idx|
81
+ key = rule.key
82
+
83
+ # Use this to make sure any duplicate entry uses the maximum index (i.e. last defined rule).
84
+ index[key] = [ index[key], idx ].compact.max
85
+ end
86
+ index
87
+ end
88
+ private :build_index
89
+
90
+ ######
91
+ # Runner
92
+
93
+ # Runs the given permission through the rule base.
94
+ #
95
+ # @return [true|false] +true+ if the permission is allowed, +false+ if not.
96
+ def run(permission)
97
+ last_rule_index = nil
98
+
99
+ permission.with_dependencies.each do |permission|
100
+ keys = permission_checks(permission)
101
+
102
+ # Compare the current index with the indices of all rules that match and take the maximum.
103
+ last_rule_index = ([ last_rule_index ] + keys.map{ |key| @index[key] }.flatten).compact.max
104
+ end
105
+
106
+ if last_rule_index
107
+ rules[last_rule_index].allow?
108
+ else
109
+ # The default policy is to deny the permission if no rules match.
110
+ false
111
+ end
112
+ end
113
+
114
+ # Determines all permission checks for the given permission.
115
+ def permission_checks(permission)
116
+ keys = []
117
+
118
+ if permission.action
119
+ # Add '<kind>:<name>:<action>'
120
+ keys << [ permission.kind, permission.name, permission.action ].join(':')
121
+
122
+ # Add '<kind>:all(:<action>'
123
+ keys << [ permission.kind, 'all', permission.action ].join(':')
124
+ end
125
+
126
+ # Add '<kind>:<name>'
127
+ keys << [ permission.kind, permission.name ].join(':')
128
+
129
+ # Add '<kind>:all'
130
+ keys << [ permission.kind, 'all' ].join(':')
131
+
132
+ # Add 'all'
133
+ keys << 'all'
134
+
135
+ keys
136
+ end
137
+ private :permission_checks
138
+
139
+ end
140
+
141
+ end
@@ -0,0 +1,3 @@
1
+ module Authorule
2
+ VERSION = "1.0.0"
3
+ end
data/lib/authorule.rb ADDED
@@ -0,0 +1,79 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+
4
+ module Authorule
5
+ extend ActiveSupport::Autoload
6
+
7
+ class PermissionResolutionError < RuntimeError
8
+ end
9
+
10
+ autoload :Permission
11
+ autoload :Rule
12
+ autoload :RuleBase
13
+ autoload :PermissionAccessors
14
+ autoload :PermissionHolder
15
+
16
+ ######
17
+ # Permission registration
18
+
19
+ class DuplicatePermission < RuntimeError
20
+ end
21
+
22
+ @@permission_classes = {}
23
+ mattr_reader :permission_classes
24
+
25
+ def self.register(kind, klass)
26
+ kind = kind.to_sym
27
+ unless klass < Permission
28
+ raise ArgumentError, "class #{klass.name} cannot be registered as a permission kind: it should be derived from Authorule::Permission"
29
+ end
30
+ if Authorule.permission_classes[kind]
31
+ raise DuplicatePermission, "another permission class has already been registered for kind :#{kind}"
32
+ end
33
+
34
+ permission_classes[kind] = klass
35
+ end
36
+
37
+ ######
38
+ # Permission resolution
39
+
40
+ # Resolves a target. Tries all registered permission classes with a resolve block, and runs the target
41
+ # through the block. If anythin is returned, it is passed into the constructor of that permission class.
42
+ def self.resolve(target, action = nil)
43
+ return target if target.is_a?(Authorule::Permission)
44
+
45
+ permission_classes.values.each do |klass|
46
+ next unless klass.resolve_block
47
+ resolved = klass.resolve_block.call(target)
48
+
49
+ return klass.new(resolved, action) if resolved
50
+ end
51
+
52
+ # If we got here, no schema returned a matching permission.
53
+ raise PermissionResolutionError, "target #{target} could not be resolved into a permission"
54
+ end
55
+
56
+ ######
57
+ # Permission listing
58
+
59
+ # Retrieves all available permissions, organized by their kind, into a hash.
60
+ #
61
+ # @return [Hash<Symbol,Array<Permission>>] An organized list of permissions.
62
+ def self.available_permissions
63
+ available_permissions = {}
64
+
65
+ permission_classes.each do |kind, klass|
66
+ next unless klass.list_block
67
+
68
+ available_permissions[kind] = []
69
+ objects = klass.list_block.call
70
+
71
+ objects.each do |object|
72
+ available_permissions[kind] << klass.new(object)
73
+ end
74
+ end
75
+
76
+ available_permissions
77
+ end
78
+
79
+ end
@@ -0,0 +1,17 @@
1
+ namespace :authorule do
2
+
3
+ desc "Lists all available permissions"
4
+ task :list => :environment do
5
+ Authorule.available_permissions.each do |kind, permissions|
6
+ puts "#{kind}:"
7
+ permissions.each do |permission|
8
+ if permission.available_actions.blank?
9
+ puts " #{permission.name}"
10
+ else
11
+ puts " #{permission.name} (#{permission.available_actions.join(', ')})"
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ end
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+ require 'active_record'
3
+
4
+ describe Authorule::PermissionHolder do
5
+
6
+ let(:model) do
7
+ Class.new(ActiveRecord::Base) do
8
+ include Authorule::PermissionHolder
9
+ is_permission_holder!
10
+ end
11
+ end
12
+ let(:record) do
13
+ record = Class.new()
14
+ record.class.send(:include, Authorule::PermissionHolder)
15
+ record.class.stub(:has_many)
16
+ record.class.is_permission_holder!
17
+ record
18
+ end
19
+
20
+ it "should add a 'rules' association" do
21
+ association = model.reflect_on_association(:permission_rules)
22
+
23
+ association.should_not be_nil
24
+ association.macro.should == :has_many
25
+ end
26
+
27
+ describe '#permission_rule_base' do
28
+
29
+ it "should return a rule base based on the permission rules for the holder" do
30
+ rules = []
31
+ record.should_receive(:permission_rules).with(true).and_return(rules)
32
+
33
+ record.permission_rule_base.should be_a(Authorule::RuleBase)
34
+ record.permission_rule_base.rules.should be(rules)
35
+ end
36
+
37
+ it "should cache its value" do
38
+ rules = []
39
+ record.should_receive(:permission_rules).with(true).once.and_return(rules)
40
+
41
+ base = record.permission_rule_base
42
+ record.permission_rule_base.should be(base)
43
+ end
44
+
45
+ it "should not cache its value when reload=false" do
46
+ rules = []
47
+ record.should_receive(:permission_rules).with(true).twice.and_return(rules)
48
+
49
+ base = record.permission_rule_base
50
+ record.permission_rule_base(true).should_not be(base)
51
+ end
52
+
53
+ end
54
+
55
+ describe 'has_permission?' do
56
+ let(:rule_base) { double() }
57
+ before { record.should_receive(:permission_rule_base).and_return(rule_base) }
58
+
59
+ it "should run the given permission through the rule base, and return true if that returns true" do
60
+ permission = double()
61
+ rule_base.should_receive(:run).with(permission).and_return(true)
62
+ record.should have_permission(permission)
63
+ end
64
+
65
+ it "should run the given permission through the rule base, and return false if that returns false" do
66
+ permission = double()
67
+ rule_base.should_receive(:run).with(permission).and_return(false)
68
+ record.should_not have_permission(permission)
69
+ end
70
+ end
71
+
72
+ describe 'may_* methods' do
73
+
74
+ let(:target) { double() }
75
+ let(:permission) { double() }
76
+
77
+ describe 'may_access?' do
78
+
79
+ it "should resolve the given argument to a permission, check it, and return what it returns" do
80
+ result = double()
81
+
82
+ Authorule.should_receive(:resolve).with(target).and_return(permission)
83
+ record.should_receive(:has_permission?).with(permission).and_return(result)
84
+ record.may_access?(target).should be(result)
85
+ end
86
+
87
+ end
88
+
89
+ describe 'may?' do
90
+
91
+ before { Authorule.should_receive(:resolve).with(target, :view).and_return(permission) }
92
+
93
+ context "with a valid action" do
94
+ it "should resolve the given argument to a permission, check it, and return what it returns" do
95
+ permission.should_receive(:available_actions).and_return([:view])
96
+
97
+ result = double()
98
+ record.should_receive(:has_permission?).with(permission).and_return(result)
99
+
100
+ record.may?(:view, target).should be(result)
101
+ end
102
+ end
103
+
104
+ context "with an invalid action" do
105
+ it "should raise an error" do
106
+ permission.should_receive(:available_actions).and_return([:create])
107
+ permission.should_receive(:class).and_return(double(:kind => :test))
108
+ expect { record.may?(:view, target) }.to raise_error(ArgumentError, "action :view not available for permission of kind :test")
109
+ end
110
+ end
111
+
112
+ end
113
+
114
+ describe '#may_not_access?' do
115
+ it "should invert the result from may_access?" do
116
+ record.should_receive(:may_access?).with(target).and_return(false)
117
+ record.may_not_access?(target).should == true
118
+ end
119
+ it "should invert the result from may_access?" do
120
+ record.should_receive(:may_access?).with(target).and_return(true)
121
+ record.may_not_access?(target).should == false
122
+ end
123
+ end
124
+
125
+ describe '#may_not?' do
126
+ it "should invert the result from may?" do
127
+ record.should_receive(:may?).with(:view, target).and_return(false)
128
+ record.may_not?(:view, target).should == true
129
+ end
130
+ it "should invert the result from may?" do
131
+ record.should_receive(:may?).with(:view, target).and_return(true)
132
+ record.may_not?(:view, target).should == false
133
+ end
134
+ end
135
+ end
136
+
137
+ end
@@ -0,0 +1,127 @@
1
+ require 'spec_helper'
2
+
3
+ describe Authorule::RuleBase do
4
+
5
+ let(:permission) { double() }
6
+ let(:rule_base) { Authorule::RuleBase.new(rules) }
7
+
8
+ context "providing no rules" do
9
+ let(:rules) { [] }
10
+
11
+ it "should deny all access" do
12
+ permission.should_receive(:with_dependencies).and_return([
13
+ double(:kind => :custom, :name => 'something', :action => nil)
14
+ ])
15
+ rule_base.run(permission).should == false
16
+ end
17
+ end
18
+
19
+ context "providing an 'allow all' rule" do
20
+ let(:rules) { [ double(:key => 'all', :allow? => true) ] }
21
+
22
+ it "should allow all access" do
23
+ permission.should_receive(:with_dependencies).and_return([
24
+ double(:kind => :custom, :name => 'something', :action => nil)
25
+ ])
26
+ rule_base.run(permission).should == true
27
+ end
28
+ end
29
+
30
+ context "providing a cascading rule set" do
31
+ let(:rules) do
32
+ [
33
+ double(:key => 'all', :allow? => false), # Deny all
34
+ double(:key => 'resource:all', :allow? => true), # Allow all resources
35
+ double(:key => 'resource:account', :allow? => false), # Deny access to resource 'account'
36
+ ]
37
+ end
38
+
39
+ it "should deny access to a non-resource permission" do
40
+ permission.should_receive(:with_dependencies).and_return([
41
+ double(:kind => :custom, :name => 'something', :action => nil)
42
+ ])
43
+ rule_base.run(permission).should == false
44
+ end
45
+
46
+ it "should allow access to a non-account resource permission" do
47
+ permission.should_receive(:with_dependencies).and_return([
48
+ double(:kind => :resource, :name => 'contact', :action => nil)
49
+ ])
50
+ rule_base.run(permission).should == true
51
+ end
52
+
53
+ it "should deny access to an account resource permission" do
54
+ permission.should_receive(:with_dependencies).and_return([
55
+ double(:kind => :resource, :name => 'account', :action => nil)
56
+ ])
57
+ rule_base.run(permission).should == false
58
+ end
59
+ end
60
+
61
+ context "using a permission with dependencies" do
62
+
63
+ let(:permission) do
64
+ # Create a permission that requires both access to space:crm as well as resource:account.
65
+ double(:with_dependencies => [
66
+ double(:kind => :space, :name => 'crm', :action => nil),
67
+ double(:kind => :resource, :name => 'account', :action => nil)
68
+ ])
69
+ end
70
+
71
+ # deny CRM, allow account
72
+ let(:crm_rule) { double(:key => 'space:crm', :allow? => false) }
73
+ let(:account_rule) { double(:key => 'space:crm', :allow? => true) }
74
+
75
+ it "should allow access if the account rule is defined last" do
76
+ rule_base = Authorule::RuleBase.new([ crm_rule, account_rule ])
77
+ rule_base.run(permission).should == true
78
+ end
79
+
80
+ it "should deny access if the CRM rule is defined last" do
81
+ rule_base = Authorule::RuleBase.new([ account_rule, crm_rule ])
82
+ rule_base.run(permission).should == false
83
+ end
84
+
85
+ end
86
+
87
+ context "targeting specific actions" do
88
+
89
+ let(:rules) do
90
+ [
91
+ double(:key => 'resource:all', :allow? => true), # Allow all resources
92
+ double(:key => 'resource:all:create', :allow? => false), # Deny creating all resources
93
+ double(:key => 'resource:account:create', :allow? => true), # Deny creating resource 'account'
94
+ ]
95
+ end
96
+
97
+ it "should allow viewing a 'contact' resource" do
98
+ permission.should_receive(:with_dependencies).and_return([
99
+ double(:kind => :resource, :name => 'contact', :action => :view)
100
+ ])
101
+ rule_base.run(permission).should == true
102
+ end
103
+
104
+ it "should allow viewing an 'account' resource" do
105
+ permission.should_receive(:with_dependencies).and_return([
106
+ double(:kind => :resource, :name => 'account', :action => :view)
107
+ ])
108
+ rule_base.run(permission).should == true
109
+ end
110
+
111
+ it "should deny creating a 'contact' resource" do
112
+ permission.should_receive(:with_dependencies).and_return([
113
+ double(:kind => :resource, :name => 'contact', :action => :create)
114
+ ])
115
+ rule_base.run(permission).should == false
116
+ end
117
+
118
+ it "should allow creating an 'account' resource" do
119
+ permission.should_receive(:with_dependencies).and_return([
120
+ double(:kind => :resource, :name => 'account', :action => :create)
121
+ ])
122
+ rule_base.run(permission).should == true
123
+ end
124
+
125
+ end
126
+
127
+ end
@@ -0,0 +1,113 @@
1
+ require 'spec_helper'
2
+
3
+ describe Authorule do
4
+
5
+ # Stub the permission registry to prevent messing with the actual set up.
6
+ let(:registry) { Hash.new }
7
+ before { Authorule.stub(:permission_classes).and_return(registry) }
8
+
9
+ ######
10
+ # Registration
11
+
12
+ describe '#register' do
13
+
14
+ it "should allow any class derived from Permission to be registered" do
15
+ klass = Class.new(Authorule::Permission)
16
+
17
+ Authorule.register :test, klass
18
+ registry[:test].should == klass
19
+ end
20
+
21
+ it "should not allow any other class to be registered" do
22
+ klass = Class.new
23
+ expect { Authorule.register :test, klass }.to raise_error(ArgumentError)
24
+ end
25
+
26
+ it "should not allow a registration for the same kind twice" do
27
+ klass = Class.new(Authorule::Permission)
28
+
29
+ Authorule.register :test, klass
30
+ expect { Authorule.register :test, klass }.to raise_error(Authorule::DuplicatePermission)
31
+ end
32
+
33
+
34
+ end
35
+
36
+ describe 'Authorule::Permission.kind' do
37
+
38
+ # A bit outside the scope of this file, but it's part of registration.
39
+ it "should allow any Authorule::Permission derived class to register itself" do
40
+ klass = Class.new(Authorule::Permission)
41
+
42
+ Authorule.should_receive(:register).with(:test, klass)
43
+ klass.class_eval { register :test }
44
+ end
45
+
46
+ end
47
+
48
+ ######
49
+ # Resolution & available permissions
50
+
51
+ describe '.resolve' do
52
+ let(:permission_class1) { Class.new(Authorule::Permission) }
53
+ let(:permission_class2) { Class.new(Authorule::Permission) }
54
+ before do
55
+ Authorule.stub(:permission_classes).and_return({})
56
+ Authorule.register :permission1, permission_class1
57
+ Authorule.register :permission2, permission_class2
58
+ end
59
+
60
+ it "should resolve any instance of Authorule::Permission into itself" do
61
+ permission = Authorule::Permission.new(double())
62
+ Authorule.resolve(permission).should be(permission)
63
+ end
64
+
65
+ it "should raise PermissionResolutionError if no permission classes were registered" do
66
+ Authorule.stub(:permission_classes).and_return({})
67
+ expect { Authorule.resolve(double()) }.to raise_error(Authorule::PermissionResolutionError)
68
+ end
69
+
70
+ it "should raise PermissionResolutionError if no permission classes with resolution blocks were registered" do
71
+ expect { Authorule.resolve(double()) }.to raise_error(Authorule::PermissionResolutionError)
72
+ end
73
+
74
+ it "should run through all registered permission classes and try to resolve the target - the first one found should be instantiated" do
75
+ target = double()
76
+ resolved = double()
77
+ permission_class1.stub(:resolve_block).and_return(->(tgt) { tgt != target ? resolved : nil })
78
+ permission_class2.stub(:resolve_block).and_return(->(tgt) { tgt == target ? resolved : nil })
79
+
80
+ permission = double()
81
+ permission_class2.should_receive(:new).with(resolved, :view).and_return(permission)
82
+
83
+ Authorule.resolve(target, :view).should be(permission)
84
+ end
85
+ end
86
+
87
+ describe '.available_permissions' do
88
+ let(:permission_class1) { Class.new(Authorule::Permission) }
89
+ let(:permission_class2) { Class.new(Authorule::Permission) }
90
+ before do
91
+ Authorule.stub(:permission_classes).and_return({})
92
+ Authorule.register :permission1, permission_class1
93
+ Authorule.register :permission2, permission_class2
94
+ end
95
+
96
+ it "should include an item for each permission class having a list block" do
97
+ targets = [ :one, :two ]
98
+ permission_class1.stub(:list_block).and_return(proc { targets })
99
+
100
+ permissions = Authorule.available_permissions
101
+ permissions.should have(1).item
102
+
103
+ permissions[:permission1].should be_a(Array)
104
+ permissions[:permission1].should have(2).items
105
+
106
+ permissions[:permission1][0].should be_a(permission_class1)
107
+ permissions[:permission1][0].object.should == :one
108
+ permissions[:permission1][1].should be_a(permission_class1)
109
+ permissions[:permission1][1].object.should == :two
110
+ end
111
+ end
112
+
113
+ end
@@ -0,0 +1,19 @@
1
+ require 'simplecov'
2
+ SimpleCov.start do
3
+ add_filter "spec/"
4
+ end
5
+
6
+ require 'authorule'
7
+ require 'rspec/autorun'
8
+
9
+ RSpec.configure do |config|
10
+
11
+ config.mock_with :rspec do |config|
12
+ config.syntax = [ :should, :expect ]
13
+ end
14
+
15
+ end
16
+
17
+ # Requires supporting ruby files with custom matchers and macros, etc,
18
+ # in spec/support/ and its subdirectories.
19
+ Dir[File.expand_path("../support/**/*.rb", __FILE__)].each {|f| require f}