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