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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- 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
|