iknow_view_models 2.8.4

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 (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. metadata +490 -0
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'view_model/access_control_error'
4
+
5
+ ## Defines an access control discipline for a given action against a viewmodel.
6
+ ##
7
+ ## Access control is based around three edit check hooks: visible, editable and
8
+ ## valid_edit. The visible determines whether a view can be seen. The editable
9
+ ## check determines whether a view in its current state is eligible to be
10
+ ## changed. The valid_edit change determines whether an attempted change is
11
+ ## permitted. Each edit check returns a pair of boolean success and optional
12
+ ## exception to raise.
13
+ class ViewModel::AccessControl
14
+ Result = Struct.new(:permit, :error) do
15
+ def initialize(permit, error: nil)
16
+ raise ArgumentError.new("Successful AccessControl::Result may not have an error") if permit && error
17
+ super(permit, error)
18
+ end
19
+
20
+ alias :permit? :permit
21
+
22
+ # Merge this result with another access control result. Takes a block
23
+ # returning a result, and returns a combined result for both tests. Access
24
+ # is permitted if both results permit. Otherwise, access is denied with the
25
+ # error value of the first denying Result.
26
+ def merge(&_block)
27
+ if permit?
28
+ yield
29
+ else
30
+ self
31
+ end
32
+ end
33
+ end
34
+
35
+ Result::PERMIT = Result.new(true).freeze
36
+ Result::DENY = Result.new(false).freeze
37
+
38
+ def initialize
39
+ @initial_editability_store = {}
40
+ end
41
+
42
+ # Check that the user is permitted to view the record in its current state, in
43
+ # the given context.
44
+ def visible_check(_traversal_env)
45
+ Result::DENY
46
+ end
47
+
48
+ # Editable checks during deserialization are always a combination of
49
+ # `editable_check` and `valid_edit_check`, which express the following
50
+ # separate properties. `The after_deserialize check passes if both checks are
51
+ # successful.
52
+
53
+ # Check that the record is eligible to be changed in its current state, in the
54
+ # given context. This must be called before any edits have taken place (thus
55
+ # checking against the initial state of the viewmodel), and if editing is
56
+ # denied, an error must be raised only if an edit is later attempted. To be
57
+ # overridden by viewmodel implementations.
58
+ def editable_check(_traversal_env)
59
+ Result::DENY
60
+ end
61
+
62
+ # Once the changes to be made to the viewmodel are known, check that the
63
+ # attempted changes are permitted in the given context. For viewmodels with
64
+ # transactional backing models, the changes may be made in advance to give the
65
+ # edit checks the opportunity to compare values. To be overridden by viewmodel
66
+ # implementations.
67
+ def valid_edit_check(_traversal_env)
68
+ Result::DENY
69
+ end
70
+
71
+ # Wrappers to check access control for a single view directly. Because the
72
+ # checking is run directly on one node without any tree context, it's only
73
+ # valid to run:
74
+ # * on root views
75
+ # * when no children could contribute to the result
76
+ def visible!(view, context:)
77
+ run_callback(ViewModel::Callbacks::Hook::BeforeVisit, view, context)
78
+ run_callback(ViewModel::Callbacks::Hook::AfterVisit, view, context)
79
+ end
80
+
81
+ def editable!(view, deserialize_context:, changes:)
82
+ run_callback(ViewModel::Callbacks::Hook::BeforeVisit, view, deserialize_context)
83
+ run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize, view, deserialize_context)
84
+ run_callback(ViewModel::Callbacks::Hook::OnChange, view, deserialize_context, changes: changes) if changes
85
+ run_callback(ViewModel::Callbacks::Hook::AfterDeserialize, view, deserialize_context, changes: changes)
86
+ run_callback(ViewModel::Callbacks::Hook::AfterVisit, view, deserialize_context)
87
+ end
88
+
89
+ # Edit checks are invoked via traversal callbacks:
90
+ include ViewModel::Callbacks
91
+
92
+ before_visit do
93
+ result = visible_check(self)
94
+
95
+ raise_if_error!(result) do
96
+ ViewModel::AccessControlError.new(
97
+ "Illegal access to viewmodel '#{view.class.view_name}'",
98
+ view.blame_reference)
99
+ end
100
+ end
101
+
102
+ before_deserialize do
103
+ initial_result = editable_check(self)
104
+
105
+ save_editability(view, initial_result)
106
+ end
107
+
108
+ on_change do
109
+ initial_result = fetch_editability(view)
110
+ result = initial_result.merge do
111
+ valid_edit_check(self)
112
+ end
113
+
114
+ raise_if_error!(result) do
115
+ ViewModel::AccessControlError.new(
116
+ "Illegal edit to viewmodel '#{view.class.view_name}'",
117
+ view.blame_reference)
118
+ end
119
+ end
120
+
121
+ after_deserialize do
122
+ # If there was no change to consume the initial editability we still want to clean it up
123
+ cleanup_editability(view)
124
+ end
125
+
126
+ private
127
+
128
+ def save_editability(view, initial_editability)
129
+ if @initial_editability_store.has_key?(view.object_id)
130
+ raise RuntimeError.new("Access control data already recorded for view #{view.to_reference}")
131
+ end
132
+ @initial_editability_store[view.object_id] = initial_editability
133
+ end
134
+
135
+ def fetch_editability(view)
136
+ unless @initial_editability_store.has_key?(view.object_id)
137
+ raise RuntimeError.new("No access control data recorded for view #{view.to_reference}")
138
+ end
139
+ @initial_editability_store.delete(view.object_id)
140
+ end
141
+
142
+ def cleanup_editability(view)
143
+ @initial_editability_store.delete(view.object_id)
144
+ end
145
+
146
+ def raise_if_error!(result)
147
+ raise (result.error || yield) unless result.permit?
148
+ end
149
+ end
150
+
151
+ require 'view_model/access_control/open'
152
+ require 'view_model/access_control/read_only'
153
+ require 'view_model/access_control/composed'
154
+ require 'view_model/access_control/tree'
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ ## Provides access control as a combination of `x_if!` and `x_unless!` checks
4
+ ## for each access check (visible, editable, edit_valid). An action is permitted
5
+ ## if at least one `if` check and no `unless` checks succeed. For example:
6
+ ## edit_valid_if!("logged in as specified user") { ... }
7
+ ## edit_valid_unless!("user is on fire") { ... }
8
+ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
9
+ ComposedResult = Struct.new(:allow, :veto, :allow_error, :veto_error) do
10
+ def initialize(allow, veto, allow_error, veto_error)
11
+ raise ArgumentError.new("Non-vetoing result may not have a veto error") if veto_error && !veto
12
+ raise ArgumentError.new("Allowing result may not have a allow error") if allow_error && allow
13
+ super
14
+ end
15
+
16
+ def permit?
17
+ !veto && allow
18
+ end
19
+
20
+ def error
21
+ case
22
+ when veto then veto_error
23
+ when !allow then allow_error
24
+ else nil
25
+ end
26
+ end
27
+
28
+ # Merge this composed result with another. `allow`s widen and `veto`es narrow.
29
+ def merge(&_block)
30
+ if self.veto
31
+ self
32
+ else
33
+ other = yield
34
+
35
+ new_allow = self.allow || other.allow
36
+
37
+ new_allow_error =
38
+ case
39
+ when new_allow
40
+ nil
41
+ when self.allow_error && other.allow_error
42
+ self.allow_error.merge(other.allow_error)
43
+ else
44
+ self.allow_error || other.allow_error
45
+ end
46
+
47
+ ComposedResult.new(new_allow, other.veto, new_allow_error, other.veto_error)
48
+ end
49
+ end
50
+ end
51
+
52
+ PermissionsCheck = Struct.new(:location, :reason, :error_type, :checker) do
53
+ def name
54
+ "#{reason} (#{location})"
55
+ end
56
+
57
+ def check(env)
58
+ env.instance_exec(&self.checker)
59
+ end
60
+ end
61
+
62
+ # Error type when no `if` conditions succeed.
63
+ class NoRequiredConditionsError < ViewModel::AccessControlError
64
+ attr_reader :reasons
65
+
66
+ def initialize(nodes, reasons)
67
+ super("Action not permitted because none of the possible conditions were met.", nodes)
68
+ @reasons = reasons
69
+ end
70
+
71
+ def meta
72
+ super.merge(conditions: @reasons.to_a)
73
+ end
74
+
75
+ def merge(other)
76
+ NoRequiredConditionsError.new(nodes | other.nodes,
77
+ Lazily.concat(reasons, other.reasons).uniq)
78
+ end
79
+ end
80
+
81
+ class << self
82
+ attr_reader :edit_valid_ifs,
83
+ :edit_valid_unlesses,
84
+ :editable_ifs,
85
+ :editable_unlesses,
86
+ :visible_ifs,
87
+ :visible_unlesses
88
+
89
+ def inherited(subclass)
90
+ super
91
+ subclass.initialize_as_composed_access_control
92
+ end
93
+
94
+ def initialize_as_composed_access_control
95
+ @included_checkers = []
96
+
97
+ @edit_valid_ifs = []
98
+ @edit_valid_unlesses = []
99
+
100
+ @editable_ifs = []
101
+ @editable_unlesses = []
102
+
103
+ @visible_ifs = []
104
+ @visible_unlesses = []
105
+ end
106
+
107
+ ## Configuration API
108
+ def include_from(ancestor)
109
+ unless ancestor < ViewModel::AccessControl::Composed
110
+ raise ArgumentError.new("Invalid ancestor: #{ancestor}")
111
+ end
112
+
113
+ @included_checkers << ancestor
114
+ end
115
+
116
+ def visible_if!(reason, &block)
117
+ @visible_ifs << new_permission_check(reason, &block)
118
+ end
119
+
120
+ def visible_unless!(reason, &block)
121
+ @visible_unlesses << new_permission_check(reason, &block)
122
+ end
123
+
124
+ def editable_if!(reason, &block)
125
+ @editable_ifs << new_permission_check(reason, &block)
126
+ end
127
+
128
+ def editable_unless!(reason, &block)
129
+ @editable_unlesses << new_permission_check(reason, &block)
130
+ end
131
+
132
+ def edit_valid_if!(reason, &block)
133
+ @edit_valid_ifs << new_permission_check(reason, &block)
134
+ end
135
+
136
+ def edit_valid_unless!(reason, &block)
137
+ @edit_valid_unlesses << new_permission_check(reason, &block)
138
+ end
139
+
140
+ ## Implementation
141
+
142
+ def new_permission_check(reason, error_type: ViewModel::AccessControlError, &block)
143
+ PermissionsCheck.new(self.name&.demodulize, reason, error_type, block)
144
+ end
145
+
146
+ def each_check(check_name, include_ancestor = nil)
147
+ return enum_for(:each_check, check_name, include_ancestor) unless block_given?
148
+
149
+ self.public_send(check_name).each { |x| yield x }
150
+
151
+ visited = Set.new
152
+ @included_checkers.each do |ancestor|
153
+ next unless visited.add?(ancestor)
154
+ next if include_ancestor && !include_ancestor.call(ancestor)
155
+ ancestor.each_check(check_name) { |x| yield x }
156
+ end
157
+ end
158
+
159
+ def inspect
160
+ s = super + "("
161
+ s += inspect_checks.join(", ")
162
+ s += " includes checkers: #{@included_checkers.inspect}" if @included_checkers.present?
163
+ s += ")"
164
+ s
165
+ end
166
+
167
+ def inspect_checks
168
+ checks = []
169
+ checks << "visible_if: #{@visible_ifs.map(&:reason)}" if @visible_ifs.present?
170
+ checks << "visible_unless: #{@visible_unlesses.map(&:reason)}" if @visible_unlesses.present?
171
+ checks << "editable_if: #{@editable_ifs.map(&:reason)}" if @editable_ifs.present?
172
+ checks << "editable_unless: #{@editable_unlesses.map(&:reason)}" if @editable_unlesses.present?
173
+ checks << "edit_valid_if: #{@edit_valid_ifs.map(&:reason)}" if @edit_valid_ifs.present?
174
+ checks << "edit_valid_unless: #{@edit_valid_unlesses.map(&:reason)}" if @edit_valid_unlesses.present?
175
+ checks
176
+ end
177
+
178
+ end
179
+
180
+ # final
181
+ def visible_check(traversal_env)
182
+ check_delegates(traversal_env, self.class.each_check(:visible_ifs), self.class.each_check(:visible_unlesses))
183
+ end
184
+
185
+ # final
186
+ def editable_check(traversal_env)
187
+ check_delegates(traversal_env, self.class.each_check(:editable_ifs), self.class.each_check(:editable_unlesses))
188
+ end
189
+
190
+ # final
191
+ def valid_edit_check(traversal_env)
192
+ check_delegates(traversal_env, self.class.each_check(:edit_valid_ifs), self.class.each_check(:edit_valid_unlesses))
193
+ end
194
+
195
+ protected
196
+
197
+ def check_delegates(env, ifs, unlesses)
198
+ vetoed_checker = unlesses.detect { |checker| checker.check(env) }
199
+
200
+ veto = vetoed_checker.present?
201
+ if veto
202
+ veto_error = vetoed_checker.error_type.new("Action not permitted because: " +
203
+ vetoed_checker.reason,
204
+ env.view.blame_reference)
205
+ end
206
+
207
+ allow = ifs.any? { |checker| checker.check(env) }
208
+
209
+ unless allow
210
+ allow_error = NoRequiredConditionsError.new(env.view.blame_reference,
211
+ ifs.map(&:name))
212
+ end
213
+
214
+ ComposedResult.new(allow, veto, allow_error, veto_error)
215
+ end
216
+ end
@@ -0,0 +1,13 @@
1
+ class ViewModel::AccessControl::Open < ViewModel::AccessControl
2
+ def visible_check(_traversal_env)
3
+ ViewModel::AccessControl::Result::PERMIT
4
+ end
5
+
6
+ def editable_check(_traversal_env)
7
+ ViewModel::AccessControl::Result::PERMIT
8
+ end
9
+
10
+ def valid_edit_check(_traversal_env)
11
+ ViewModel::AccessControl::Result::PERMIT
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ class ViewModel::AccessControl::ReadOnly < ViewModel::AccessControl
2
+ def visible_check(_traversal_env)
3
+ ViewModel::AccessControl::Result::PERMIT
4
+ end
5
+
6
+ def editable_check(_traversal_env)
7
+ ViewModel::AccessControl::Result::DENY
8
+ end
9
+
10
+ def valid_edit_check(_traversal_env)
11
+ ViewModel::AccessControl::Result::DENY
12
+ end
13
+ end
@@ -0,0 +1,264 @@
1
+ ## Defines an access control discipline for a given action against a tree of
2
+ ## viewmodels.
3
+ ##
4
+ ## Extends the basic AccessControl to offer different checking based on the view
5
+ ## type and position in a viewmodel tree.
6
+ ##
7
+ ## Access checks for each given node type are specified at class level as
8
+ ## `ComposedAccessControl`s, using `view` blocks. Checks that apply to all node
9
+ ## types are specified in an `always` block.
10
+ ##
11
+ ## In addition, node types can be marked as a 'root'. Root types may permit and
12
+ ## veto access to their non-root tree descendents with the additional access
13
+ ## checks `root_children_{editable,visible}_if!` and `root_children_
14
+ ## {editable,visible}_unless!`. The results of evaluating these checks on entry
15
+ ## to the root node.object_id will be cached and used when evaluating `visible` and
16
+ ## `editable` on children.
17
+ class ViewModel::AccessControl::Tree < ViewModel::AccessControl
18
+ class << self
19
+ attr_reader :view_policies
20
+
21
+ def inherited(subclass)
22
+ super
23
+ subclass.initialize_as_tree_access_control
24
+ end
25
+
26
+ def initialize_as_tree_access_control
27
+ @included_checkers = []
28
+ @view_policies = {}
29
+ const_set(:AlwaysPolicy, Class.new(Node))
30
+ end
31
+
32
+ def include_from(ancestor)
33
+ unless ancestor < ViewModel::AccessControl::Tree
34
+ raise ArgumentError.new("Invalid ancestor: #{ancestor}")
35
+ end
36
+
37
+ @included_checkers << ancestor
38
+
39
+ self::AlwaysPolicy.include_from(ancestor::AlwaysPolicy)
40
+ ancestor.view_policies.each do |view_name, ancestor_policy|
41
+ policy = find_or_create_policy(view_name)
42
+ policy.include_from(ancestor_policy)
43
+ end
44
+ end
45
+
46
+ # Definition language
47
+ def view(view_name, &block)
48
+ policy = find_or_create_policy(view_name)
49
+ policy.instance_exec(&block)
50
+ end
51
+
52
+ def always(&block)
53
+ self::AlwaysPolicy.instance_exec(&block)
54
+ end
55
+
56
+ ## implementation
57
+
58
+ def create_policy(view_name)
59
+ policy = Class.new(Node)
60
+ # View names are not necessarily rails constants, but we want
61
+ # `const_set` them so they show up in stack traces.
62
+ mangled_name = view_name.tr('.', '_')
63
+ const_set(:"#{mangled_name}Policy", policy)
64
+ view_policies[view_name] = policy
65
+ policy.include_from(self::AlwaysPolicy)
66
+ policy
67
+ end
68
+
69
+ def find_or_create_policy(view_name)
70
+ view_policies.fetch(view_name) { create_policy(view_name) }
71
+ end
72
+
73
+ def inspect
74
+ "#{super}(checks:\n#{@view_policies.values.map(&:inspect).join("\n")}\n#{self::AlwaysPolicy.inspect}\nincluded checkers: #{@included_checkers})"
75
+ end
76
+ end
77
+
78
+ def initialize
79
+ super()
80
+ @always_policy_instance = self.class::AlwaysPolicy.new(self)
81
+ @view_policy_instances = self.class.view_policies.each_with_object({}) { |(name, policy), h| h[name] = policy.new(self) }
82
+ @root_visibility_store = {}
83
+ @root_editability_store = {}
84
+ end
85
+
86
+ # Evaluation entry points
87
+ def visible_check(traversal_env)
88
+ policy_instance_for(traversal_env.view).visible_check(traversal_env)
89
+ end
90
+
91
+ def editable_check(traversal_env)
92
+ policy_instance_for(traversal_env.view).editable_check(traversal_env)
93
+ end
94
+
95
+ def valid_edit_check(traversal_env)
96
+ policy_instance_for(traversal_env.view).valid_edit_check(traversal_env)
97
+ end
98
+
99
+ def store_descendent_editability(view, descendent_editability)
100
+ if @root_editability_store.has_key?(view.object_id)
101
+ raise RuntimeError.new("Root access control data already saved for root")
102
+ end
103
+ @root_editability_store[view.object_id] = descendent_editability
104
+ end
105
+
106
+ def fetch_descendent_editability(view)
107
+ @root_editability_store.fetch(view.object_id) do
108
+ raise RuntimeError.new("No root access control data recorded for root")
109
+ end
110
+ end
111
+
112
+ def store_descendent_visibility(view, descendent_visibility)
113
+ if @root_visibility_store.has_key?(view.object_id)
114
+ raise RuntimeError.new("Root access control data already saved for root")
115
+ end
116
+ @root_visibility_store[view.object_id] = descendent_visibility
117
+ end
118
+
119
+ def fetch_descendent_visibility(view)
120
+ @root_visibility_store.fetch(view.object_id) do
121
+ raise RuntimeError.new("No root access control data recorded for root")
122
+ end
123
+ end
124
+
125
+ def cleanup_descendent_results(view)
126
+ @root_visibility_store.delete(view.object_id)
127
+ @root_editability_store.delete(view.object_id)
128
+ end
129
+
130
+ after_visit do
131
+ cleanup_descendent_results(view) if context.root?
132
+ end
133
+
134
+ private
135
+
136
+ def policy_instance_for(view)
137
+ view_name = view.class.view_name
138
+ @view_policy_instances.fetch(view_name) { @always_policy_instance }
139
+ end
140
+
141
+ class Node < ViewModel::AccessControl::Composed
142
+ class << self
143
+ attr_reader :root_children_editable_ifs,
144
+ :root_children_editable_unlesses,
145
+ :root_children_visible_ifs,
146
+ :root_children_visible_unlesses
147
+
148
+ def inherited(subclass)
149
+ super
150
+ subclass.initialize_as_node
151
+ end
152
+
153
+ def initialize_as_node
154
+ @root = false
155
+ @root_children_editable_ifs = []
156
+ @root_children_editable_unlesses = []
157
+ @root_children_visible_ifs = []
158
+ @root_children_visible_unlesses = []
159
+ end
160
+
161
+ def root_children_visible_if!(reason, &block)
162
+ @root = true
163
+ root_children_visible_ifs << new_permission_check(reason, &block)
164
+ end
165
+
166
+ def root_children_visible_unless!(reason, &block)
167
+ @root = true
168
+ root_children_visible_unlesses << new_permission_check(reason, &block)
169
+ end
170
+
171
+ def root_children_editable_if!(reason, &block)
172
+ @root = true
173
+ root_children_editable_ifs << new_permission_check(reason, &block)
174
+ end
175
+
176
+ def root_children_editable_unless!(reason, &block)
177
+ @root = true
178
+ root_children_editable_unlesses << new_permission_check(reason, &block)
179
+ end
180
+
181
+ def root?
182
+ @root
183
+ end
184
+
185
+ alias requires_root? root?
186
+
187
+ def inspect_checks
188
+ checks = super
189
+ if root?
190
+ checks << "no root checks"
191
+ else
192
+ checks << "root_children_visible_if: #{root_children_visible_ifs.map(&:reason)}" if root_children_visible_ifs.present?
193
+ checks << "root_children_visible_unless: #{root_children_visible_unlesses.map(&:reason)}" if root_children_visible_unlesses.present?
194
+ checks << "root_children_editable_if: #{root_children_editable_ifs.map(&:reason)}" if root_children_editable_ifs.present?
195
+ checks << "root_children_editable_unless: #{root_children_editable_unlesses.map(&:reason)}" if root_children_editable_unlesses.present?
196
+ end
197
+ checks
198
+ end
199
+ end
200
+
201
+ def initialize(tree_access_control)
202
+ super()
203
+ @tree_access_control = tree_access_control
204
+ end
205
+
206
+ delegate :store_descendent_visibility, :fetch_descendent_visibility,
207
+ :store_descendent_editability, :fetch_descendent_editability,
208
+ to: :@tree_access_control
209
+
210
+ def visible_check(traversal_env)
211
+ view = traversal_env.view
212
+ context = traversal_env.context
213
+
214
+ validate_root!(view, context)
215
+
216
+ if context.root?
217
+ save_root_visibility!(traversal_env)
218
+ super
219
+ else
220
+ root_visibility = fetch_descendent_visibility(context.nearest_root_viewmodel)
221
+ root_visibility.merge { super }
222
+ end
223
+ end
224
+
225
+ def editable_check(traversal_env)
226
+ view = traversal_env.view
227
+ deserialize_context = traversal_env.deserialize_context
228
+
229
+ validate_root!(view, deserialize_context)
230
+
231
+ if deserialize_context.root?
232
+ save_root_editability!(traversal_env)
233
+ super
234
+ else
235
+ root_editability = fetch_descendent_editability(deserialize_context.nearest_root_viewmodel)
236
+ root_editability.merge { super }
237
+ end
238
+ end
239
+
240
+ private
241
+
242
+ def validate_root!(view, context)
243
+ if self.class.requires_root? && !context.root?
244
+ raise RuntimeError.new("AccessControl instance for #{view.class.view_name} node requires root context but was visited in owned context.")
245
+ end
246
+ end
247
+
248
+ def save_root_visibility!(traversal_env)
249
+ result = check_delegates(traversal_env,
250
+ self.class.each_check(:root_children_visible_ifs, ->(a) { a.is_a?(Node) }),
251
+ self.class.each_check(:root_children_visible_unlesses, ->(a) { a.is_a?(Node) }))
252
+
253
+ store_descendent_visibility(traversal_env.view, result)
254
+ end
255
+
256
+ def save_root_editability!(traversal_env)
257
+ result = check_delegates(traversal_env,
258
+ self.class.each_check(:root_children_editable_ifs, ->(a) { a.is_a?(Node) }),
259
+ self.class.each_check(:root_children_editable_unlesses, ->(a) { a.is_a?(Node) }))
260
+
261
+ store_descendent_editability(traversal_env.view, result)
262
+ end
263
+ end
264
+ end