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