mongoid_ability 1.0.0 → 2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/Gemfile +7 -0
  4. data/README.md +55 -23
  5. data/lib/cancancan/model_adapters/mongoid_adapter.rb +156 -0
  6. data/lib/cancancan/model_additions.rb +30 -0
  7. data/lib/mongoid_ability.rb +12 -12
  8. data/lib/mongoid_ability/ability.rb +67 -26
  9. data/lib/mongoid_ability/find_lock.rb +71 -0
  10. data/lib/mongoid_ability/lock.rb +25 -16
  11. data/lib/mongoid_ability/locks_decorator.rb +45 -0
  12. data/lib/mongoid_ability/owner.rb +23 -3
  13. data/lib/mongoid_ability/subject.rb +10 -22
  14. data/lib/mongoid_ability/version.rb +1 -1
  15. data/test/cancancan/model_adapters/mongoid_adapter_options_test.rb +102 -0
  16. data/test/cancancan/model_adapters/mongoid_adapter_test.rb +207 -0
  17. data/test/mongoid_ability/ability_basic_benchmark.rb +30 -0
  18. data/test/mongoid_ability/ability_basic_test.rb +44 -13
  19. data/test/mongoid_ability/ability_marshal_test.rb +17 -0
  20. data/test/mongoid_ability/ability_options_test.rb +93 -0
  21. data/test/mongoid_ability/ability_test.rb +87 -106
  22. data/test/mongoid_ability/find_lock_test.rb +67 -0
  23. data/test/mongoid_ability/lock_test.rb +32 -40
  24. data/test/mongoid_ability/owner_locks_test.rb +14 -21
  25. data/test/mongoid_ability/owner_test.rb +4 -14
  26. data/test/mongoid_ability/subject_test.rb +32 -58
  27. data/test/support/test_classes/my_lock.rb +8 -13
  28. data/test/support/test_classes/my_owner.rb +13 -15
  29. data/test/support/test_classes/my_role.rb +9 -11
  30. data/test/support/test_classes/my_subject.rb +16 -9
  31. data/test/test_helper.rb +12 -2
  32. metadata +18 -25
  33. data/lib/mongoid_ability/accessible_query_builder.rb +0 -64
  34. data/lib/mongoid_ability/resolve_default_locks.rb +0 -17
  35. data/lib/mongoid_ability/resolve_inherited_locks.rb +0 -35
  36. data/lib/mongoid_ability/resolve_locks.rb +0 -12
  37. data/lib/mongoid_ability/resolve_owner_locks.rb +0 -35
  38. data/lib/mongoid_ability/resolver.rb +0 -24
  39. data/lib/mongoid_ability/values_for_accessible_query.rb +0 -74
  40. data/test/mongoid_ability/ability_syntactic_sugar_test.rb +0 -32
  41. data/test/mongoid_ability/accessible_query_builder_test.rb +0 -119
  42. data/test/mongoid_ability/can_options_test.rb +0 -17
  43. data/test/mongoid_ability/resolve_default_locks_test.rb +0 -41
  44. data/test/mongoid_ability/resolve_inherited_locks_test.rb +0 -50
  45. data/test/mongoid_ability/resolve_owner_locks_test.rb +0 -56
  46. data/test/mongoid_ability/resolver_test.rb +0 -23
  47. data/test/mongoid_ability/subject_accessible_by_test.rb +0 -147
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 7e6c83227c58a27548a247cbf0de7931eccaed58
4
- data.tar.gz: 4cfe776c15b918916f2f6d20acd786a6674755ff
3
+ metadata.gz: dae67db7ec93cdbf840238c3dd2b482646550ea5
4
+ data.tar.gz: 9585efb9e63dbbee879f2865d378e9dcf81081a8
5
5
  SHA512:
6
- metadata.gz: c79d13c3df3c743fe5c6c8cc5272336590dc4f24b666ec798b8c355898f4bcf6a63b4c3e418506342af415165048bbb0aa8f8e72f6771f42eb8e18d97733a3a2
7
- data.tar.gz: 4d8a1b214ab11100d361d31d46ee4a607642951f9727e967fb70e395b0282dcc9967153b1482c9cffd750e6bc18fba8d3f9a63c952055e352c58c935e41f5929
6
+ metadata.gz: e92310170b4733d744668f02b59595f5ffc0a1b10b8aab414ff1db137a0fb417d0392f370b0cc341895503fea19bebd89799d8d7744c989ddc9d23aef946e06c
7
+ data.tar.gz: f02421d9e891f91af19df44dc0a289beef01719e5fff3a1ef116a341f6ae10515bd4fc34fbddbc139ba03056e945fdf8a187e96c859203adc975c77539edbd62
@@ -1,3 +1,7 @@
1
+ # 2.0.0
2
+
3
+ * Full rewrite, which more closely follows the `CanCanCan` conventions: instead of custom algorithm for resolving permissions, the `Lock` documents are now converted to standard `CanCanCan` rules. Similarly the `.acessible_by` criteria are now handled by standard model adapter (`MongoidAdapter`), to be extracted to separate gem. Therefore this gem can benefit for potential future performance improvements of `CanCanCan`. Lastly, the `ability` objects are now cacheable, therefore the conversion of `Lock` documents to `CanCanCan` rule objects does not need to be performed on every request.
4
+
1
5
  # 1.0.0
2
6
 
3
7
  * conforms to '[MONGOID-4418](https://jira.mongodb.org/browse/MONGOID-4418) Don't allow PersistenceContext method as field names' by renaming the `Lock` field `:options` to `:opts` (but aliasing it `as: :options`). As a result the `Mongoid::Ability` API stays unchanged, however in some cases it might be necessary to migrate the values from the `:options` fields to the new `:opts`.
data/Gemfile CHANGED
@@ -1,3 +1,10 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
+ git_source(:github) do |repo_name|
4
+ repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
5
+ "git@github.com:#{repo_name}.git"
6
+ end
7
+
3
8
  gemspec
9
+
10
+ # gem 'cancancan-mongoid', github: 'tomasc/cancancan-mongoid', branch: 'master'
data/README.md CHANGED
@@ -50,24 +50,11 @@ This class defines a permission itself using the following fields:
50
50
 
51
51
  These fields define what subject (respectively subject type, when referring to a class) the lock applies to, which action it is defined for (for example `:read`), and whether the outcome is positive or negative.
52
52
 
53
- For more specific behavior, it is possible to override the `#calculated_outcome` method (should, for example, the permission depend on some additional factors). The `#calculated_outcome` method receives options that are passed when checking the permissions using for example `can? :read, MyClass, { option_1: 1 }`
54
-
55
- ```ruby
56
- def calculated_outcome options={}
57
- # custom behaviour
58
- # return true/false
59
- end
60
- ```
61
-
62
- If you wish to check the state of a lock directly, please use the convenience methods `#open?` and `#closed?`. These take into account the `#calculated_outcome`. Using the `:outcome` field directly is discouraged as it just returns the boolean attribute.
63
-
64
- The lock class can be further subclassed in order to customise its behavior, for example per action.
65
-
66
53
  ### Subject
67
54
 
68
55
  All subjects (classes which permissions you want to control) will include the `MongoidAbility::Subject` module.
69
56
 
70
- Each action and its default outcome, needs to be defined using the `.default_lock` macro.
57
+ Each action and its default outcome needs to be defined using the `.default_lock` macro.
71
58
 
72
59
  ```ruby
73
60
  class MySubject
@@ -130,6 +117,60 @@ current_user.can?(:read, resource, options)
130
117
  other_user.can?(:read, ResourceClass, options)
131
118
  ```
132
119
 
120
+ Ability can be easily obtained as:
121
+
122
+ ```ruby
123
+ current_user.ability
124
+ ```
125
+
126
+ ### Caching
127
+
128
+ The ability object is fully cache-able, which means it is possible to save some precious time on every request (instead of always converting the Lock documents to CanCan rules):
129
+
130
+ ```ruby
131
+ class ActionController::Base
132
+ def current_ability
133
+ @current_ability ||= Rails.cache.fetch([current_user.cache_key, 'ability'].join('/')) do
134
+ MongoidAbility::Ability.new(current_user)
135
+ end.tap do |ability|
136
+ ability.owner ||= current_user
137
+ end
138
+ end
139
+ end
140
+ ```
141
+
142
+ And on the owner:
143
+
144
+ ```ruby
145
+ def ability
146
+ @ability ||= Rails.cache.fetch([cache_key, 'ability'].join('/')) do
147
+ MongoidAbility::Ability.new(self)
148
+ end.tap do |ability|
149
+ ability.owner ||= self
150
+ end
151
+ end
152
+ ```
153
+
154
+ Of course this assumes the user's `cache_key` updates when any of its locks (or locks stored on its roles) change.
155
+
156
+ Note the owner has to be assigned after fetching the ability from cache.
157
+
158
+ ### Decoration
159
+
160
+ To be able to check permissions on decorated objects (for example via the Draper gem) subclass the Ability class as follows:
161
+
162
+ ```ruby
163
+ class MyAbility < MongoidAbility::Ability
164
+ def can?(action, subject, *extra_args)
165
+ while subject.is_a?(Draper::Decorator)
166
+ subject = subject.model
167
+ end
168
+
169
+ super(action, subject, *extra_args)
170
+ end
171
+ end
172
+ ```
173
+
133
174
  ### CanCanCan
134
175
 
135
176
  The default `:current_ability` defined by [CanCanCan](https://github.com/CanCanCommunity/cancancan) will be automatically overriden by the `Ability` class provided by this gem.
@@ -140,15 +181,6 @@ The default `:current_ability` defined by [CanCanCan](https://github.com/CanCanC
140
181
  2. Define permissions using lock objects embedded (or associated to) either in user or role.
141
182
  3. Use standard [CanCanCan](https://github.com/CanCanCommunity/cancancan) helpers (`.authorize!`, `#can?`, `#cannot?`) to authorize the current user.
142
183
 
143
- ## How it works?
144
-
145
- The ability class in this gem looks up and calculates the outcome in the following order:
146
-
147
- 1. User locks, defined for `:subject_id`, then `:subject_type` (then its superclasses), then defined in the subject class itself (via the `.default_lock` macro) and its superclasses.
148
- 2. Role locks have the same look up chain as the user locks. The role permissions are optimistic, meaning that in case a user has multiple roles, and the roles have locks with conflicting outcomes, the ability favors the positive one.
149
-
150
- See the test suite for more details.
151
-
152
184
  ## Contributing
153
185
 
154
186
  1. Fork it ( https://github.com/tomasc/mongoid_ability/fork )
@@ -0,0 +1,156 @@
1
+ module CanCan
2
+ module ModelAdapters
3
+ class MongoidAdapter < AbstractAdapter
4
+ def self.for_class?(model_class)
5
+ model_class <= Mongoid::Document
6
+ end
7
+
8
+ # Used to determine if this model adapter will override the matching behavior for a hash of conditions.
9
+ # If this returns true then matches_conditions_hash? will be called. See Rule#matches_conditions_hash
10
+ def self.override_conditions_hash_matching?(_subject, _conditions)
11
+ false
12
+ end
13
+
14
+ # Override if override_conditions_hash_matching? returns true
15
+ def self.matches_conditions_hash?(_subject, _conditions)
16
+ raise NotImplemented, 'This model adapter does not support matching on a conditions hash.'
17
+ end
18
+
19
+ # Used to determine if this model adapter will override the matching behavior for a specific condition.
20
+ # If this returns true then matches_condition? will be called. See Rule#matches_conditions_hash
21
+ def self.override_condition_matching?(_subject, _name, _value)
22
+ true
23
+ end
24
+
25
+ # Override if override_condition_matching? returns true
26
+ def self.matches_condition?(subject, name, value)
27
+ attribute = subject.send(name)
28
+
29
+ case value
30
+ when Hash then hash_condition_match?(attribute, value)
31
+ when Range then value.cover?(attribute)
32
+ when Regexp then value.match(attribute)
33
+ when Array then value.include?(attribute)
34
+ when Enumerable then value.include?(attribute)
35
+ else attribute == value
36
+ end
37
+ end
38
+
39
+ def initialize(model_class, rules, options = {})
40
+ @model_class = model_class
41
+ @rules = rules
42
+ @options = options
43
+ end
44
+
45
+ def subject_types
46
+ @subject_types ||= begin
47
+ root_cls = @model_class.root_class
48
+ [root_cls, *root_cls.descendants].compact
49
+ end
50
+ end
51
+
52
+ def open_subject_types
53
+ @open_subject_types ||= begin
54
+ subject_types.inject([]) do |res, cls|
55
+ subject_type_rules_for(cls).each do |rule|
56
+ cls_list = [cls, *cls.descendants].compact
57
+ rule.base_behavior ? res += cls_list : res -= cls_list
58
+ end
59
+ res.uniq
60
+ end
61
+ end
62
+ end
63
+
64
+ def closed_subject_types
65
+ @closed_subject_types ||= begin
66
+ subject_types - open_subject_types
67
+ end
68
+ end
69
+
70
+ def open_conditions
71
+ @open_conditions ||= begin
72
+ condition_rules.select(&:base_behavior).each_with_object([]) do |rule, res|
73
+ rule.conditions.each do |key, value|
74
+ key = id_key if %i[id _id].include?(key.to_sym)
75
+ res << case value
76
+ when Array then { key => { '$in' => value } }
77
+ else { key => value }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ def closed_conditions
85
+ @closed_conditions ||= begin
86
+ condition_rules.reject(&:base_behavior).each_with_object([]) do |rule, res|
87
+ rule.conditions.each do |key, value|
88
+ key = id_key if %i[id _id].include?(key.to_sym)
89
+ res << case value
90
+ when Regexp then { key => { '$not' => value } }
91
+ when Array then { key => { '$nin' => value } }
92
+ else { key => { '$ne' => value } }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ def subject_type_conditions
100
+ return unless open_subject_types.present?
101
+ { :"#{type_key}".nin => closed_subject_types.map(&:to_s) }
102
+ end
103
+
104
+ def has_any_conditions?
105
+ subject_type_conditions.present? ||
106
+ open_conditions.present? ||
107
+ closed_conditions.present?
108
+ end
109
+
110
+ def database_records
111
+ return @model_class.none unless has_any_conditions?
112
+
113
+ or_conditions = { '$or' => ([subject_type_conditions, *open_conditions]).compact }
114
+ or_conditions = nil if or_conditions['$or'].empty?
115
+
116
+ and_conditions = { '$and' => [or_conditions, *closed_conditions].compact }
117
+ and_conditions = nil if and_conditions['$and'].empty?
118
+
119
+ @model_class.where(and_conditions)
120
+ end
121
+
122
+ private
123
+
124
+ def subject_type_rules_for(subject_type)
125
+ subject_type_rules.select do |rule|
126
+ rule.subjects.include?(subject_type)
127
+ end
128
+ end
129
+
130
+ def subject_type_rules
131
+ @rules.reject { |rule| rule.conditions.present? }
132
+ end
133
+
134
+ def condition_rules
135
+ @rules.select { |rule| rule.conditions.present? }
136
+ end
137
+
138
+ def prefix
139
+ @options.fetch(:prefix, nil)
140
+ end
141
+
142
+ def id_key
143
+ @id_key ||= [prefix, '_id'].reject(&:blank?).join.to_sym
144
+ end
145
+
146
+ def type_key
147
+ @type_key ||= [prefix, '_type'].reject(&:blank?).join.to_sym
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ # simplest way to add `accessible_by` to all Mongoid Documents
154
+ module Mongoid::Document::ClassMethods
155
+ include CanCan::ModelAdditions::ClassMethods
156
+ end
@@ -0,0 +1,30 @@
1
+ module CanCan
2
+ # This module adds the accessible_by class method to a model. It is included in the model adapters.
3
+ module ModelAdditions
4
+ module ClassMethods
5
+ # Returns a scope which fetches only the records that the passed ability
6
+ # can perform a given action on. The action defaults to :index. This
7
+ # is usually called from a controller and passed the +current_ability+.
8
+ #
9
+ # @articles = Article.accessible_by(current_ability)
10
+ #
11
+ # Here only the articles which the user is able to read will be returned.
12
+ # If the user does not have permission to read any articles then an empty
13
+ # result is returned. Since this is a scope it can be combined with any
14
+ # other scopes or pagination.
15
+ #
16
+ # An alternative action can optionally be passed as a second argument.
17
+ #
18
+ # @articles = Article.accessible_by(current_ability, :update)
19
+ #
20
+ # Here only the articles which the user can update are returned.
21
+ def accessible_by(ability, action = :index, options = {})
22
+ ability.model_adapter(self, action, options).database_records
23
+ end
24
+ end
25
+
26
+ def self.included(base)
27
+ base.extend ClassMethods
28
+ end
29
+ end
30
+ end
@@ -1,19 +1,19 @@
1
- require "mongoid_ability/version"
1
+ require 'cancancan'
2
+ require 'mongoid'
2
3
 
3
- require "mongoid_ability/ability"
4
+ require 'cancancan/model_adapters/mongoid_adapter'
5
+ require 'cancancan/model_additions'
4
6
 
5
- require "mongoid_ability/lock"
6
- require "mongoid_ability/owner"
7
- require "mongoid_ability/subject"
7
+ require 'mongoid_ability/version'
8
8
 
9
- require "mongoid_ability/resolver"
10
- require "mongoid_ability/resolve_locks"
11
- require "mongoid_ability/resolve_default_locks"
12
- require "mongoid_ability/resolve_inherited_locks"
13
- require "mongoid_ability/resolve_owner_locks"
9
+ require 'mongoid_ability/ability'
14
10
 
15
- require "mongoid_ability/values_for_accessible_query"
16
- require "mongoid_ability/accessible_query_builder"
11
+ require 'mongoid_ability/lock'
12
+ require 'mongoid_ability/owner'
13
+ require 'mongoid_ability/subject'
14
+
15
+ require 'mongoid_ability/locks_decorator'
16
+ require 'mongoid_ability/find_lock'
17
17
 
18
18
  # ---------------------------------------------------------------------
19
19
 
@@ -4,47 +4,88 @@ module MongoidAbility
4
4
  class Ability
5
5
  include CanCan::Ability
6
6
 
7
- attr_reader :owner
7
+ attr_accessor :owner
8
+
9
+ def marshal_dump
10
+ @rules
11
+ end
12
+
13
+ def marshal_load(array)
14
+ Array(array).each do |rule|
15
+ add_rule(rule)
16
+ end
17
+ end
8
18
 
9
19
  def self.subject_classes
10
- Object.descendants.select { |cls| cls.included_modules.include?(MongoidAbility::Subject) }
20
+ Object.descendants.select do |cls|
21
+ cls.included_modules.include?(MongoidAbility::Subject)
22
+ end
11
23
  end
12
24
 
13
25
  def self.subject_root_classes
14
- subject_classes.reject { |cls| cls.superclass.included_modules.include?(MongoidAbility::Subject) }
26
+ subject_classes.reject do |cls|
27
+ cls.superclass.included_modules.include?(MongoidAbility::Subject)
28
+ end
15
29
  end
16
30
 
17
31
  def initialize(owner)
18
32
  @owner = owner
19
33
 
20
- can do |action, subject_type, subject, options = {}|
21
- subject_id = subject ? subject.id : nil
22
- if lock = ResolveLocks.call(owner, action, subject_type, subject_id, options)
23
- lock.calculated_outcome(options)
34
+ inherited_locks = owner.respond_to?(owner.class.inherit_from_relation_name) ? owner.inherit_from_relation.flat_map(&:locks_relation) : []
35
+ inherited_locks = LocksDecorator.new(inherited_locks)
36
+
37
+ owner_locks = owner.respond_to?(owner.class.locks_relation_name) ? owner.locks_relation : []
38
+
39
+ self.class.subject_root_classes.each do |cls|
40
+ cls_list = [cls] + cls.descendants
41
+ cls_list.each do |subcls|
42
+ # if 2 of the same, prefer open
43
+ locks = subcls.default_locks.for_subject_type(subcls).group_by(&:group_key_for_calc).flat_map do |_, locks|
44
+ locks.detect(&:open?) || locks.first
45
+ end
46
+
47
+ # if 2 of the same, prefer open
48
+ locks += inherited_locks.for_subject_type(subcls).group_by(&:group_key_for_calc).flat_map do |_, locks|
49
+ locks.detect(&:open?) || locks.first
50
+ end
51
+
52
+ # if 2 of the same, prefer open
53
+ locks += owner_locks.for_subject_type(subcls).group_by(&:group_key_for_calc).flat_map do |_, locks|
54
+ locks.detect(&:open?) || locks.first
55
+ end
56
+
57
+ selected_locks = locks.group_by(&:group_key_for_calc).flat_map do |_, locks|
58
+ # prefer last one, i.e. the one closest to owner
59
+ locks.last
60
+ end
61
+
62
+ selected_locks.sort(&Lock.sort).each do |lock|
63
+ apply_lock_rule(lock)
64
+ end
24
65
  end
25
66
  end
26
67
  end
27
68
 
28
- # lambda for easy permission checking:
29
- # .select(&current_ability.can_read)
30
- # .select(&current_ability.can_update)
31
- # .select(&current_ability.can_destroy)
32
- # etc.
33
- def method_missing(name, *args)
34
- return super unless name.to_s =~ /\A(can|cannot)_/
35
- return unless action = name.to_s.scan(/\A(can|cannot)_(\w+)/).flatten.last.to_sym
36
-
37
- if args.empty? || args.first.is_a?(Hash)
38
- case name
39
- when /can_/ then -> (doc) { can?(action, doc, *args) }
40
- else -> (doc) { cannot?(action, doc, *args) }
41
- end
42
- else
43
- case name
44
- when /can_/ then can?(action, *args)
45
- else cannot?(action, *args)
46
- end
69
+ def model_adapter(model_class, action, options = {})
70
+ adapter_class = CanCan::ModelAdapters::AbstractAdapter.adapter_class(model_class)
71
+ # include all rules that apply for descendants as well
72
+ # so the adapter can exclude include subclasses from critieria
73
+ rules = ([model_class] + model_class.descendants).inject([]) do |res, cls|
74
+ res += relevant_rules_for_query(action, cls)
75
+ res.uniq
47
76
  end
77
+ adapter_class.new(model_class, rules, options)
78
+ end
79
+
80
+ private
81
+
82
+ def apply_lock_rule(lock)
83
+ ability_type = lock.outcome ? :can : :cannot
84
+ cls = lock.subject_class
85
+ options = lock.options
86
+ options = options.merge(id: lock.subject_id) if lock.id_lock?
87
+ action = lock.action
88
+ send ability_type, action, cls, options
48
89
  end
49
90
  end
50
91
  end