469b0a3f78be50bbf5d052e2522b4f0135143dcf
[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     if owner_uuid_changed? and owner_uuid_was
213       # Verify permission to write to existing owner
214       unless current_user.uuid == self.owner_uuid_was or
215           current_user.uuid == self.uuid or
216           current_user.can? write: self.owner_uuid_was
217         logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{uuid} but does not have permission to write existing owner_uuid #{owner_uuid_was}"
218         errors.add :owner_uuid, "cannot be changed without write permission on existing owner"
219         raise PermissionDeniedError
220       end
221     end
222     # Verify permission to write to new owner
223     unless current_user == self or current_user.can? write: owner_uuid
224       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}"
225       errors.add :owner_uuid, "cannot be changed without write permission on new owner"
226       raise PermissionDeniedError
227     end
228   end
229
230   def ensure_permission_to_save
231     unless (new_record? ? permission_to_create : permission_to_update)
232       raise PermissionDeniedError
233     end
234   end
235
236   def permission_to_create
237     current_user.andand.is_active
238   end
239
240   def permission_to_update
241     if !current_user
242       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
243       return false
244     end
245     if !current_user.is_active
246       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
247       return false
248     end
249     return true if current_user.is_admin
250     if self.uuid_changed?
251       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
252       return false
253     end
254     return true
255   end
256
257   def ensure_permission_to_destroy
258     raise PermissionDeniedError unless permission_to_destroy
259   end
260
261   def permission_to_destroy
262     permission_to_update
263   end
264
265   def maybe_update_modified_by_fields
266     update_modified_by_fields if self.changed? or self.new_record?
267     true
268   end
269
270   def update_modified_by_fields
271     self.updated_at = Time.now
272     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
273     self.modified_at = Time.now
274     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
275     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
276     true
277   end
278
279   def self.has_symbols? x
280     if x.is_a? Hash
281       x.each do |k,v|
282         return true if has_symbols?(k) or has_symbols?(v)
283       end
284       false
285     elsif x.is_a? Array
286       x.each do |k|
287         return true if has_symbols?(k)
288       end
289       false
290     else
291       (x.class == Symbol)
292     end
293   end
294
295   def self.recursive_stringify x
296     if x.is_a? Hash
297       Hash[x.collect do |k,v|
298              [recursive_stringify(k), recursive_stringify(v)]
299            end]
300     elsif x.is_a? Array
301       x.collect do |k|
302         recursive_stringify k
303       end
304     elsif x.is_a? Symbol
305       x.to_s
306     else
307       x
308     end
309   end
310
311   def ensure_serialized_attribute_type
312     # Specifying a type in the "serialize" declaration causes rails to
313     # raise an exception if a different data type is retrieved from
314     # the database during load().  The validation preventing such
315     # crash-inducing records from being inserted in the database in
316     # the first place seems to have been left as an exercise to the
317     # developer.
318     self.class.serialized_attributes.each do |colname, attr|
319       if attr.object_class
320         if self.attributes[colname].class != attr.object_class
321           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}, not a #{self.attributes[colname].class.to_s}"
322         elsif self.class.has_symbols? attributes[colname]
323           self.errors.add colname.to_sym, "must not contain symbols: #{attributes[colname].inspect}"
324         end
325       end
326     end
327   end
328
329   def convert_serialized_symbols_to_strings
330     # ensure_serialized_attribute_type should prevent symbols from
331     # getting into the database in the first place. If someone managed
332     # to get them into the database (perhaps using an older version)
333     # we'll convert symbols to strings when loading from the
334     # database. (Otherwise, loading and saving an object with existing
335     # symbols in a serialized field will crash.)
336     self.class.serialized_attributes.each do |colname, attr|
337       if self.class.has_symbols? attributes[colname]
338         attributes[colname] = self.class.recursive_stringify attributes[colname]
339         self.send(colname + '=',
340                   self.class.recursive_stringify(attributes[colname]))
341       end
342     end
343   end
344
345   def foreign_key_attributes
346     attributes.keys.select { |a| a.match /_uuid$/ }
347   end
348
349   def skip_uuid_read_permission_check
350     %w(modified_by_client_uuid)
351   end
352
353   def skip_uuid_existence_check
354     []
355   end
356
357   def normalize_collection_uuids
358     foreign_key_attributes.each do |attr|
359       attr_value = send attr
360       if attr_value.is_a? String and
361           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
362         begin
363           send "#{attr}=", Collection.normalize_uuid(attr_value)
364         rescue
365           # TODO: abort instead of silently accepting unnormalizable value?
366         end
367       end
368     end
369   end
370
371   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
372
373   @@prefixes_hash = nil
374   def self.uuid_prefixes
375     unless @@prefixes_hash
376       @@prefixes_hash = {}
377       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
378         if k.respond_to?(:uuid_prefix)
379           @@prefixes_hash[k.uuid_prefix] = k
380         end
381       end
382     end
383     @@prefixes_hash
384   end
385
386   def self.uuid_like_pattern
387     "_____-#{uuid_prefix}-_______________"
388   end
389
390   def ensure_valid_uuids
391     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
392
393     foreign_key_attributes.each do |attr|
394       if new_record? or send (attr + "_changed?")
395         next if skip_uuid_existence_check.include? attr
396         attr_value = send attr
397         next if specials.include? attr_value
398         if attr_value
399           if (r = ArvadosModel::resource_class_for_uuid attr_value)
400             unless skip_uuid_read_permission_check.include? attr
401               r = r.readable_by(current_user)
402             end
403             if r.where(uuid: attr_value).count == 0
404               errors.add(attr, "'#{attr_value}' not found")
405             end
406           end
407         end
408       end
409     end
410   end
411
412   class Email
413     def self.kind
414       "email"
415     end
416
417     def kind
418       self.class.kind
419     end
420
421     def self.readable_by (*u)
422       self
423     end
424
425     def self.where (u)
426       [{:uuid => u[:uuid]}]
427     end
428   end
429
430   def self.resource_class_for_uuid(uuid)
431     if uuid.is_a? ArvadosModel
432       return uuid.class
433     end
434     unless uuid.is_a? String
435       return nil
436     end
437     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
438       return Collection
439     end
440     resource_class = nil
441
442     Rails.application.eager_load!
443     uuid.match @@UUID_REGEX do |re|
444       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
445     end
446
447     if uuid.match /.+@.+/
448       return Email
449     end
450
451     nil
452   end
453
454   # ArvadosModel.find_by_uuid needs extra magic to allow it to return
455   # an object in any class.
456   def self.find_by_uuid uuid
457     if self == ArvadosModel
458       # If called directly as ArvadosModel.find_by_uuid rather than via subclass,
459       # delegate to the appropriate subclass based on the given uuid.
460       self.resource_class_for_uuid(uuid).find_by_uuid(uuid)
461     else
462       super
463     end
464   end
465
466   def log_start_state
467     @old_etag = etag
468     @old_attributes = logged_attributes
469   end
470
471   def log_change(event_type)
472     log = Log.new(event_type: event_type).fill_object(self)
473     yield log
474     log.save!
475     connection.execute "NOTIFY logs, '#{log.id}'"
476     log_start_state
477   end
478
479   def log_create
480     log_change('create') do |log|
481       log.fill_properties('old', nil, nil)
482       log.update_to self
483     end
484   end
485
486   def log_update
487     log_change('update') do |log|
488       log.fill_properties('old', @old_etag, @old_attributes)
489       log.update_to self
490     end
491   end
492
493   def log_destroy
494     log_change('destroy') do |log|
495       log.fill_properties('old', @old_etag, @old_attributes)
496       log.update_to nil
497     end
498   end
499 end