iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
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