-
Notifications
You must be signed in to change notification settings - Fork 40
Expand file tree
/
Copy pathactive_record_base.rb
More file actions
404 lines (340 loc) · 16.8 KB
/
active_record_base.rb
File metadata and controls
404 lines (340 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
# Monkey patches to ActiveRecord for scoping, security, and to synchronize models
module ActiveRecord
# hyperstack adds new features to scopes to allow for computing scopes on client side
# and for hinting at what joins are involved in a scope. _synchromesh_scope_args_check
# processes these arguments, and the will always leave the true server side scoping
# proc in the `:server` opts. This method is common to client and server.
class Base
class << self
def _synchromesh_scope_args_check(args)
opts = if args.count == 2 && args[1].is_a?(Hash)
args[1].merge(server: args[0])
elsif args[0].is_a? Hash
args[0]
else
{ server: args[0] }
end
return opts if opts[:server].respond_to?(:call) || RUBY_ENGINE == 'opal'
raise 'must provide either a proc as the first arg or by the '\
'`:server` option to scope and default_scope methods'
end
alias pre_hyperstack_has_and_belongs_to_many has_and_belongs_to_many unless RUBY_ENGINE == 'opal'
def has_and_belongs_to_many(other, opts = {}, &block)
join_table_name = [other.to_s, table_name].sort.join('_')
join_model_name = "HyperstackInternalHabtm#{join_table_name.singularize.camelize}"
join_model =
if Object.const_defined? join_model_name
Object.const_get(join_model_name)
else
Object.const_set(join_model_name, Class.new(ActiveRecord::Base))
end
join_model.class_eval { belongs_to other.to_s.singularize.to_sym }
has_many join_model_name.underscore.pluralize.to_sym
if RUBY_ENGINE == 'opal'
Object.const_set("HABTM_#{other.to_s.camelize}", join_model)
join_model.inheritance_column = nil
has_many other, through: join_model_name.underscore.pluralize.to_sym
else
join_model.table_name = join_table_name
join_model.belongs_to other
pre_hyperstack_has_and_belongs_to_many(other, opts, &block)
end
end
end
end
if RUBY_ENGINE != 'opal'
# __synchromesh_permission_granted indicates if permission has been given to return a scope
# The acting_user attribute is set to the current acting_user so regulation methods can check it
# The __secure_collection_check method is called at the end of a scope chain and will fail if
# no scope in the chain has positively granted access.
# allows us to easily handle scopes and finder_methods which return arrays of items
# (instead of ActiveRecord::Relations - see below)
class ReactiveRecordPsuedoRelationArray < Array
attr_accessor :__synchromesh_permission_granted
attr_accessor :acting_user
def __secure_collection_check(*)
self
end
end
# add the __synchromesh_permission_granted, acting_user and __secure_collection_check
# methods to Relation
class Relation
attr_accessor :__synchromesh_permission_granted
attr_accessor :acting_user
def __secure_collection_check(cache_item)
return self if __synchromesh_permission_granted
return self if __secure_remote_access_to_all(self, cache_item.acting_user).__synchromesh_permission_granted
return self if __secure_remote_access_to_unscoped(self, cache_item.acting_user).__synchromesh_permission_granted
Hyperstack::InternalPolicy.raise_operation_access_violation(
:scoped_permission_not_granted, "Access denied for #{cache_item}")
end
end
# Monkey patches and extensions to base
class Base
class << self
# every method call that is legal from the client has a wrapper method prefixed with
# __secure_remote_access_to_
# The wrapper method may simply return the normal result or may act to secure the data.
# The simpliest case is for the method to call `denied!` which will raise a Hyperstack
# access protection fault.
def denied!
Hyperstack::InternalPolicy.raise_operation_access_violation(:scoped_denied, "#{self} regulation denies scope access. Called from #{caller_locations(1)}")
end
# Here we set up the base `all` and `unscoped` methods. See below for more on how
# access protection works on relationships.
def __secure_remote_access_to_all(_self, _acting_user)
all
end
def __secure_remote_access_to_unscoped(_self, _acting_user, *args)
unscoped(*args)
end
# finder_method and server_method provide secure RPCs against AR relations and records.
# The block is called in context with the object, and acting_user is set to the
# current acting user. The block may interogate acting_user to insure security as needed.
# For finder_method we have to preapply `all` so that we always have a relationship
def finder_method(name, &block)
singleton_class.send(:define_method, :"__secure_remote_access_to__#{name}") do |this, acting_user, *args|
this = respond_to?(:acting_user) ? this : all
begin
old = this.acting_user
this.acting_user = acting_user
# returns a PsuedoRelationArray which will respond to the
# __secure_collection_check method
ReactiveRecordPsuedoRelationArray.new([*this.instance_exec(*args, &block)])
ensure
this.acting_user = old
end
end
singleton_class.send(:define_method, "_#{name}") do |*args|
all.instance_exec(*args, &block)
end
singleton_class.send(:define_method, name) do |*args|
all.instance_exec(*args, &block)
end
end
def server_method(name, _opts = {}, &block)
# callable from the server internally
define_method(name, &block)
# callable remotely from the client
define_method("__secure_remote_access_to_#{name}") do |_self, acting_user, *args|
begin
old = self.acting_user
self.acting_user = acting_user
send(name, *args)
ensure
self.acting_user = old
end
end
end
# relationships (and scopes) are regulated using a tri-state system. Each
# remote access method will return the relationship as normal but will also set
# the value of __secure_remote_access_granted using the application defined regulation.
# Each regulation can explicitly allow the scope to be chained by returning a truthy
# value from the regulation. Or each regulation can explicitly deny the scope to
# be chained by called `denied!`. Otherwise each regulation can return a falsy
# value meaning the scope can be changed, but unless some other scope (before or
# after) in the chain explicitly allows the scope, the entire chain will fail.
# In otherwords within a chain of relationships and scopes, at least one Regulation
# must be return a truthy value otherwise the whole chain fails. Likewise if any
# regulation called `deined!` the whole chain fails.
# If no regulation is defined, the regulation is inherited from the superclass, and if
# no regulation is defined anywhere in the class heirarchy then the regulation will
# return a falsy value.
# regulations on scopes are inheritable. That is if a superclass defines a regulation
# for a scope, subclasses will inherit the regulation (but can override)
# helper method to sort out the options on the regulate_scope, regulate_relationship macros.
# We allow three forms:
# regulate_xxx name &block : the block is the regulation
# regulate_xxx name: const : const can be denied!, deny, denied, or any other truthy or
# falsy value
# regulate_xxx name: proc : the proc is the regulation
def __synchromesh_parse_regulator_params(name, block)
if name.is_a? Hash
name, block = name.first
if %i[denied! deny denied].include? block
block = ->(*_args) { denied! }
elsif !block.is_a? Proc
value = block
block = ->(*_args) { value }
end
end
[name, block || ->(*_args) { true }]
end
# helper method for providing a regulation in line with a scope or relationship
# this is done using the `regulate` key on the opts.
# if no regulate key is provided and there is no regulation already defined for
# this name, then we create one that returns nil (don't care)
# once we have things figured out, we yield to the provided proc which is either
# regulate_scope or regulate_relationship
def __synchromesh_regulate_from_macro(opts, name, already_defined)
if opts.key?(:regulate)
yield name => opts[:regulate]
elsif !already_defined
yield name => ->(*_args) {}
end
end
# helper method to set the value of __synchromesh_permission_granted on the relationship
# Set acting_user on the object, then or in the result of running the block in context
# of the obj with the current value of __synchromesh_permission_granted
def __set_synchromesh_permission_granted(old_rel, new_rel, obj, acting_user, args = [], &block)
saved_acting_user = obj.acting_user
obj.acting_user = acting_user
new_rel.__synchromesh_permission_granted =
obj.instance_exec(*args, &block) || (old_rel && old_rel.try(:__synchromesh_permission_granted))
new_rel
ensure
obj.acting_user = saved_acting_user
end
# regulate scope has to deal with the special case that the scope returns an
# an array instead of a relationship. In this case we wrap the array and go on
def regulate_scope(name, &block)
name, block = __synchromesh_parse_regulator_params(name, block)
singleton_class.send(:define_method, :"__secure_remote_access_to_#{name}") do |this, acting_user, *args|
r = this.send(name, *args)
r = ReactiveRecordPsuedoRelationArray.new(r) if r.is_a? Array
__set_synchromesh_permission_granted(this, r, r, acting_user, args, &block)
end
end
# regulate_default_scope
def regulate_default_scope(*args, &block)
block = __synchromesh_parse_regulator_params({ all: args[0] }, block).last unless args.empty?
regulate_scope(:all, &block)
end
# monkey patch scope and default_scope macros to process hyperstack special opts,
# and add regulations if present
alias pre_synchromesh_scope scope
def scope(name, *args, &block)
__synchromesh_regulate_from_macro(
(opts = _synchromesh_scope_args_check(args)),
name,
respond_to?(:"__secure_remote_access_to_#{name}"),
&method(:regulate_scope)
)
pre_synchromesh_scope(name, opts[:server], &block)
end
alias pre_synchromesh_default_scope default_scope
def default_scope(*args, &block)
__synchromesh_regulate_from_macro(
(opts = _synchromesh_scope_args_check([*block, *args])),
:all,
respond_to?(:__secure_remote_access_to_all),
&method(:regulate_scope)
)
pre_synchromesh_default_scope(opts[:server], &block)
end
# add regulate_relationship method and monkey patch has_many macro
# to add regulations if present
def regulate_relationship(name, &block)
name, block = __synchromesh_parse_regulator_params(name, block)
define_method(:"__secure_remote_access_to_#{name}") do |this, acting_user, *args|
this.class.__set_synchromesh_permission_granted(
nil, this.send(name, *args), this, acting_user, &block
)
end
end
alias pre_syncromesh_has_many has_many
def has_many(name, *args, &block)
__synchromesh_regulate_from_macro(
opts = args.extract_options!,
name,
method_defined?(:"__secure_remote_access_to_#{name}"),
&method(:regulate_relationship)
)
pre_syncromesh_has_many name, *args, **opts.except(:regulate), &block
end
%i[belongs_to has_one composed_of].each do |macro|
alias_method :"pre_syncromesh_#{macro}", macro
define_method(macro) do |name, *aargs, **kwargs, &block|
define_method(:"__secure_remote_access_to_#{name}") do |this, _acting_user, *args|
this.send(name, *args)
end
send(:"pre_syncromesh_#{macro}", name, *aargs, **kwargs, &block)
end
end
end
def denied!
Hyperstack::InternalPolicy.raise_operation_access_violation(:scoped_denied, "#{self.class} regulation denies scope access. Called from #{caller_locations(1)}")
end
unless method_defined? :saved_changes # for backwards compatibility to Rails < 5.1.7
def saved_changes
previous_changes
end
end
# call do_not_synchronize to block synchronization of a model
def self.do_not_synchronize
@do_not_synchronize = true
end
# used by the broadcast mechanism to determine if this model is to be synchronized
def self.do_not_synchronize?
@do_not_synchronize
end
def do_not_synchronize?
self.class.do_not_synchronize?
end
before_create :synchromesh_mark_update_time
before_update :synchromesh_mark_update_time
before_destroy :synchromesh_mark_update_time
attr_reader :__synchromesh_update_time
def synchromesh_mark_update_time
@__synchromesh_update_time = Time.now.to_f
end
after_commit :synchromesh_after_create, on: [:create]
after_commit :synchromesh_after_change, on: [:update]
after_commit :synchromesh_after_destroy, on: [:destroy]
def synchromesh_after_create
puts "#{self}.synchromesh_after_create: #{do_not_synchronize?} channels: #{Hyperstack::Connection.active}" if Hyperstack::Connection.show_diagnostics
return if do_not_synchronize?
ReactiveRecord::Broadcast.after_commit :create, self
end
def synchromesh_after_change
return if do_not_synchronize? || saved_changes.empty?
ReactiveRecord::Broadcast.after_commit :change, self
end
def synchromesh_after_destroy
return if do_not_synchronize?
ReactiveRecord::Broadcast.after_commit :destroy, self
end
def __hyperstack_secure_attributes(acting_user)
accessible_attributes =
Hyperstack::InternalPolicy.accessible_attributes_for(self, acting_user)
attributes.select { |attr| accessible_attributes.include? attr.to_sym }
end
# regulate built in scopes so they are accesible from the client
%i[limit offset].each do |scope|
regulate_scope(scope) {}
end
finder_method :__hyperstack_internal_scoped_last do
last
end
scope :__hyperstack_internal_scoped_last_n, ->(n) { last(n) }
# implements find_by inside of scopes. For security reasons we return nil
# if we cannot view at least the id of found record. Otherwise a hacker
# could tell if a record exists depending on whether an access violation
# (i.e. it exists) or nil (it doesn't exist is returned.) Note that
# view of id is permitted as long as any attribute of the record is
# accessible.
finder_method :__hyperstack_internal_scoped_find_by do |attrs|
begin
found = find_by(attrs)
found && found.check_permission_with_acting_user(acting_user, :view_permitted?, :id)
rescue Hyperstack::AccessViolation => e
message = []
message << Pastel.new.red("\n\nHYPERSTACK Access violation during find_by operation.")
message << Pastel.new.red("Access to the found record's id is not permitted. nil will be returned")
message << " #{self.name}.find_by("
message << attrs.collect do |attr, value|
" #{attr}: '#{value.inspect.truncate(120, separator: '...')}'"
end.join(",\n")
message << " )"
message << "\n#{e.details}\n"
Hyperstack.on_error('find_by', self, attrs, message.join("\n"))
nil
end
end
scope :__hyperstack_internal_where_hash_scope, ->(*args) { where(*args) }
scope :__hyperstack_internal_where_sql_scope, ->(*args) { where(*args) }
end
end
# Rails 7.1+ changed InternalMetadata to no longer inherit from ActiveRecord::Base
InternalMetadata.do_not_synchronize if defined?(InternalMetadata) && InternalMetadata.respond_to?(:do_not_synchronize)
end