authorule 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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}