3214: Readability: rearrange conditionals, and say "old" instead of "existing"
[arvados.git] / services / api / app / models / arvados_model.rb
1 require 'has_uuid'
2
3 class ArvadosModel < ActiveRecord::Base
4   self.abstract_class = true
5
6   include CurrentApiClient      # current_user, current_api_client, etc.
7
8   attr_protected :created_at
9   attr_protected :modified_by_user_uuid
10   attr_protected :modified_by_client_uuid
11   attr_protected :modified_at
12   after_initialize :log_start_state
13   before_save :ensure_permission_to_save
14   before_save :ensure_owner_uuid_is_permitted
15   before_save :ensure_ownership_path_leads_to_user
16   before_destroy :ensure_owner_uuid_is_permitted
17   before_destroy :ensure_permission_to_destroy
18   before_create :update_modified_by_fields
19   before_update :maybe_update_modified_by_fields
20   after_create :log_create
21   after_update :log_update
22   after_destroy :log_destroy
23   after_find :convert_serialized_symbols_to_strings
24   validate :ensure_serialized_attribute_type
25   validate :normalize_collection_uuids
26   validate :ensure_valid_uuids
27
28   # Note: This only returns permission links. It does not account for
29   # permissions obtained via user.is_admin or
30   # user.uuid==object.owner_uuid.
31   has_many :permissions, :foreign_key => :head_uuid, :class_name => 'Link', :primary_key => :uuid, :conditions => "link_class = 'permission'"
32
33   class PermissionDeniedError < StandardError
34     def http_status
35       403
36     end
37   end
38
39   class UnauthorizedError < StandardError
40     def http_status
41       401
42     end
43   end
44
45   def self.kind_class(kind)
46     kind.match(/^arvados\#(.+)$/)[1].classify.safe_constantize rescue nil
47   end
48
49   def href
50     "#{current_api_base}/#{self.class.to_s.pluralize.underscore}/#{self.uuid}"
51   end
52
53   def self.searchable_columns operator
54     textonly_operator = !operator.match(/[<=>]/)
55     self.columns.select do |col|
56       case col.type
57       when :string, :text
58         true
59       when :datetime, :integer, :boolean
60         !textonly_operator
61       else
62         false
63       end
64     end.map(&:name)
65   end
66
67   def self.attribute_column attr
68     self.columns.select { |col| col.name == attr.to_s }.first
69   end
70
71   # Return nil if current user is not allowed to see the list of
72   # writers. Otherwise, return a list of user_ and group_uuids with
73   # write permission. (If not returning nil, current_user is always in
74   # the list because can_manage permission is needed to see the list
75   # of writers.)
76   def writable_by
77     unless (owner_uuid == current_user.uuid or
78             current_user.is_admin or
79             current_user.groups_i_can(:manage).index(owner_uuid))
80       return nil
81     end
82     [owner_uuid, current_user.uuid] + permissions.collect do |p|
83       if ['can_write', 'can_manage'].index p.name
84         p.tail_uuid
85       end
86     end.compact.uniq
87   end
88
89   # Return a query with read permissions restricted to the union of of the
90   # permissions of the members of users_list, i.e. if something is readable by
91   # any user in users_list, it will be readable in the query returned by this
92   # function.
93   def self.readable_by(*users_list)
94     # Get rid of troublesome nils
95     users_list.compact!
96
97     # Load optional keyword arguments, if they exist.
98     if users_list.last.is_a? Hash
99       kwargs = users_list.pop
100     else
101       kwargs = {}
102     end
103
104     # Check if any of the users are admin.  If so, we're done.
105     if users_list.select { |u| u.is_admin }.empty?
106
107       # Collect the uuids for each user and any groups readable by each user.
108       user_uuids = users_list.map { |u| u.uuid }
109       uuid_list = user_uuids + users_list.flat_map { |u| u.groups_i_can(:read) }
110       sanitized_uuid_list = uuid_list.
111         collect { |uuid| sanitize(uuid) }.join(', ')
112       sql_conds = []
113       sql_params = []
114       sql_table = kwargs.fetch(:table_name, table_name)
115       or_object_uuid = ''
116
117       # This row is owned by a member of users_list, or owned by a group
118       # readable by a member of users_list
119       # or
120       # This row uuid is the uuid of a member of users_list
121       # or
122       # A permission link exists ('write' and 'manage' implicitly include
123       # 'read') from a member of users_list, or a group readable by users_list,
124       # to this row, or to the owner of this row (see join() below).
125       permitted_uuids = "(SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"
126
127       sql_conds += ["#{sql_table}.owner_uuid in (?)",
128                     "#{sql_table}.uuid in (?)",
129                     "#{sql_table}.uuid IN #{permitted_uuids}"]
130       sql_params += [uuid_list, user_uuids]
131
132       if sql_table == "links" and users_list.any?
133         # This row is a 'permission' or 'resources' link class
134         # The uuid for a member of users_list is referenced in either the head
135         # or tail of the link
136         sql_conds += ["(#{sql_table}.link_class in (#{sanitize 'permission'}, #{sanitize 'resources'}) AND (#{sql_table}.head_uuid IN (?) OR #{sql_table}.tail_uuid IN (?)))"]
137         sql_params += [user_uuids, user_uuids]
138       end
139
140       if sql_table == "logs" and users_list.any?
141         # Link head points to the object described by this row
142         sql_conds += ["#{sql_table}.object_uuid IN #{permitted_uuids}"]
143
144         # This object described by this row is owned by this user, or owned by a group readable by this user
145         sql_conds += ["#{sql_table}.object_owner_uuid in (?)"]
146         sql_params += [uuid_list]
147       end
148
149       # Link head points to this row, or to the owner of this row (the thing to be read)
150       #
151       # Link tail originates from this user, or a group that is readable by this
152       # user (the identity with authorization to read)
153       #
154       # Link class is 'permission' ('write' and 'manage' implicitly include 'read')
155       where(sql_conds.join(' OR '), *sql_params)
156     else
157       # At least one user is admin, so don't bother to apply any restrictions.
158       self
159     end
160   end
161
162   def logged_attributes
163     attributes
164   end
165
166   def has_permission? perm_type, target_uuid
167     Link.where(link_class: "permission",
168                name: perm_type,
169                tail_uuid: uuid,
170                head_uuid: target_uuid).any?
171   end
172
173   protected
174
175   def ensure_ownership_path_leads_to_user
176     if new_record? or owner_uuid_changed?
177       uuid_in_path = {owner_uuid => true, uuid => true}
178       x = owner_uuid
179       while (owner_class = self.class.resource_class_for_uuid(x)) != User
180         begin
181           if x == uuid
182             # Test for cycles with the new version, not the DB contents
183             x = owner_uuid
184           elsif !owner_class.respond_to? :find_by_uuid
185             raise ActiveRecord::RecordNotFound.new
186           else
187             x = owner_class.find_by_uuid(x).owner_uuid
188           end
189         rescue ActiveRecord::RecordNotFound => e
190           errors.add :owner_uuid, "is not owned by any user: #{e}"
191           return false
192         end
193         if uuid_in_path[x]
194           if x == owner_uuid
195             errors.add :owner_uuid, "would create an ownership cycle"
196           else
197             errors.add :owner_uuid, "has an ownership cycle"
198           end
199           return false
200         end
201         uuid_in_path[x] = true
202       end
203     end
204     true
205   end
206
207   def ensure_owner_uuid_is_permitted
208     raise PermissionDeniedError if !current_user
209     if new_record? and respond_to? :owner_uuid=
210       self.owner_uuid ||= current_user.uuid
211     end
212     # Verify permission to write to old owner (unless owner_uuid was
213     # nil -- or hasn't changed, in which case the following
214     # "permission to write to new owner" block will take care of us)
215     unless !owner_uuid_changed? or
216         owner_uuid_was.nil? or
217         current_user.uuid == self.owner_uuid_was or
218         current_user.uuid == self.uuid or
219         current_user.can? write: self.owner_uuid_was
220       logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{uuid} but does not have permission to write old owner_uuid #{owner_uuid_was}"
221       errors.add :owner_uuid, "cannot be changed without write permission on old owner"
222       raise PermissionDeniedError
223     end
224     # Verify permission to write to new owner
225     unless current_user == self or current_user.can? write: owner_uuid
226       logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{uuid} but does not have permission to write new owner_uuid #{owner_uuid}"
227       errors.add :owner_uuid, "cannot be changed without write permission on new owner"
228       raise PermissionDeniedError
229     end
230   end
231
232   def ensure_permission_to_save
233     unless (new_record? ? permission_to_create : permission_to_update)
234       raise PermissionDeniedError
235     end
236   end
237
238   def permission_to_create
239     current_user.andand.is_active
240   end
241
242   def permission_to_update
243     if !current_user
244       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
245       return false
246     end
247     if !current_user.is_active
248       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
249       return false
250     end
251     return true if current_user.is_admin
252     if self.uuid_changed?
253       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
254       return false
255     end
256     return true
257   end
258
259   def ensure_permission_to_destroy
260     raise PermissionDeniedError unless permission_to_destroy
261   end
262
263   def permission_to_destroy
264     permission_to_update
265   end
266
267   def maybe_update_modified_by_fields
268     update_modified_by_fields if self.changed? or self.new_record?
269     true
270   end
271
272   def update_modified_by_fields
273     self.updated_at = Time.now
274     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
275     self.modified_at = Time.now
276     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
277     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
278     true
279   end
280
281   def self.has_symbols? x
282     if x.is_a? Hash
283       x.each do |k,v|
284         return true if has_symbols?(k) or has_symbols?(v)
285       end
286       false
287     elsif x.is_a? Array
288       x.each do |k|
289         return true if has_symbols?(k)
290       end
291       false
292     else
293       (x.class == Symbol)
294     end
295   end
296
297   def self.recursive_stringify x
298     if x.is_a? Hash
299       Hash[x.collect do |k,v|
300              [recursive_stringify(k), recursive_stringify(v)]
301            end]
302     elsif x.is_a? Array
303       x.collect do |k|
304         recursive_stringify k
305       end
306     elsif x.is_a? Symbol
307       x.to_s
308     else
309       x
310     end
311   end
312
313   def ensure_serialized_attribute_type
314     # Specifying a type in the "serialize" declaration causes rails to
315     # raise an exception if a different data type is retrieved from
316     # the database during load().  The validation preventing such
317     # crash-inducing records from being inserted in the database in
318     # the first place seems to have been left as an exercise to the
319     # developer.
320     self.class.serialized_attributes.each do |colname, attr|
321       if attr.object_class
322         if self.attributes[colname].class != attr.object_class
323           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}, not a #{self.attributes[colname].class.to_s}"
324         elsif self.class.has_symbols? attributes[colname]
325           self.errors.add colname.to_sym, "must not contain symbols: #{attributes[colname].inspect}"
326         end
327       end
328     end
329   end
330
331   def convert_serialized_symbols_to_strings
332     # ensure_serialized_attribute_type should prevent symbols from
333     # getting into the database in the first place. If someone managed
334     # to get them into the database (perhaps using an older version)
335     # we'll convert symbols to strings when loading from the
336     # database. (Otherwise, loading and saving an object with existing
337     # symbols in a serialized field will crash.)
338     self.class.serialized_attributes.each do |colname, attr|
339       if self.class.has_symbols? attributes[colname]
340         attributes[colname] = self.class.recursive_stringify attributes[colname]
341         self.send(colname + '=',
342                   self.class.recursive_stringify(attributes[colname]))
343       end
344     end
345   end
346
347   def foreign_key_attributes
348     attributes.keys.select { |a| a.match /_uuid$/ }
349   end
350
351   def skip_uuid_read_permission_check
352     %w(modified_by_client_uuid)
353   end
354
355   def skip_uuid_existence_check
356     []
357   end
358
359   def normalize_collection_uuids
360     foreign_key_attributes.each do |attr|
361       attr_value = send attr
362       if attr_value.is_a? String and
363           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
364         begin
365           send "#{attr}=", Collection.normalize_uuid(attr_value)
366         rescue
367           # TODO: abort instead of silently accepting unnormalizable value?
368         end
369       end
370     end
371   end
372
373   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
374
375   @@prefixes_hash = nil
376   def self.uuid_prefixes
377     unless @@prefixes_hash
378       @@prefixes_hash = {}
379       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
380         if k.respond_to?(:uuid_prefix)
381           @@prefixes_hash[k.uuid_prefix] = k
382         end
383       end
384     end
385     @@prefixes_hash
386   end
387
388   def self.uuid_like_pattern
389     "_____-#{uuid_prefix}-_______________"
390   end
391
392   def ensure_valid_uuids
393     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
394
395     foreign_key_attributes.each do |attr|
396       if new_record? or send (attr + "_changed?")
397         next if skip_uuid_existence_check.include? attr
398         attr_value = send attr
399         next if specials.include? attr_value
400         if attr_value
401           if (r = ArvadosModel::resource_class_for_uuid attr_value)
402             unless skip_uuid_read_permission_check.include? attr
403               r = r.readable_by(current_user)
404             end
405             if r.where(uuid: attr_value).count == 0
406               errors.add(attr, "'#{attr_value}' not found")
407             end
408           end
409         end
410       end
411     end
412   end
413
414   class Email
415     def self.kind
416       "email"
417     end
418
419     def kind
420       self.class.kind
421     end
422
423     def self.readable_by (*u)
424       self
425     end
426
427     def self.where (u)
428       [{:uuid => u[:uuid]}]
429     end
430   end
431
432   def self.resource_class_for_uuid(uuid)
433     if uuid.is_a? ArvadosModel
434       return uuid.class
435     end
436     unless uuid.is_a? String
437       return nil
438     end
439     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
440       return Collection
441     end
442     resource_class = nil
443
444     Rails.application.eager_load!
445     uuid.match @@UUID_REGEX do |re|
446       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
447     end
448
449     if uuid.match /.+@.+/
450       return Email
451     end
452
453     nil
454   end
455
456   # ArvadosModel.find_by_uuid needs extra magic to allow it to return
457   # an object in any class.
458   def self.find_by_uuid uuid
459     if self == ArvadosModel
460       # If called directly as ArvadosModel.find_by_uuid rather than via subclass,
461       # delegate to the appropriate subclass based on the given uuid.
462       self.resource_class_for_uuid(uuid).find_by_uuid(uuid)
463     else
464       super
465     end
466   end
467
468   def log_start_state
469     @old_etag = etag
470     @old_attributes = logged_attributes
471   end
472
473   def log_change(event_type)
474     log = Log.new(event_type: event_type).fill_object(self)
475     yield log
476     log.save!
477     connection.execute "NOTIFY logs, '#{log.id}'"
478     log_start_state
479   end
480
481   def log_create
482     log_change('create') do |log|
483       log.fill_properties('old', nil, nil)
484       log.update_to self
485     end
486   end
487
488   def log_update
489     log_change('update') do |log|
490       log.fill_properties('old', @old_etag, @old_attributes)
491       log.update_to self
492     end
493   end
494
495   def log_destroy
496     log_change('destroy') do |log|
497       log.fill_properties('old', @old_etag, @old_attributes)
498       log.update_to nil
499     end
500   end
501 end