Merge branch '2640-folder-api' into 1970-folder-view
[arvados.git] / services / api / app / models / arvados_model.rb
1 require 'assign_uuid'
2 class ArvadosModel < ActiveRecord::Base
3   self.abstract_class = true
4
5   include CurrentApiClient      # current_user, current_api_client, etc.
6
7   attr_protected :created_at
8   attr_protected :modified_by_user_uuid
9   attr_protected :modified_by_client_uuid
10   attr_protected :modified_at
11   after_initialize :log_start_state
12   before_save :ensure_permission_to_save
13   before_save :ensure_owner_uuid_is_permitted
14   before_save :ensure_ownership_path_leads_to_user
15   before_destroy :ensure_owner_uuid_is_permitted
16   before_destroy :ensure_permission_to_destroy
17
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   validate :ensure_serialized_attribute_type
24   validate :normalize_collection_uuids
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.collect do |col|
55       if [:string, :text].index(col.type)
56         col.name
57       elsif !textonly_operator and [:datetime, :integer].index(col.type)
58         col.name
59       end
60     end.compact
61   end
62
63   def self.attribute_column attr
64     self.columns.select { |col| col.name == attr.to_s }.first
65   end
66
67   # Return nil if current user is not allowed to see the list of
68   # writers. Otherwise, return a list of user_ and group_uuids with
69   # write permission. (If not returning nil, current_user is always in
70   # the list because can_manage permission is needed to see the list
71   # of writers.)
72   def writable_by
73     unless (owner_uuid == current_user.uuid or
74             current_user.is_admin or
75             current_user.groups_i_can(:manage).index(owner_uuid))
76       return nil
77     end
78     [owner_uuid, current_user.uuid] + permissions.collect do |p|
79       if ['can_write', 'can_manage'].index p.name
80         p.tail_uuid
81       end
82     end.compact.uniq
83   end
84
85   # Return a query with read permissions restricted to the union of of the
86   # permissions of the members of users_list, i.e. if something is readable by
87   # any user in users_list, it will be readable in the query returned by this
88   # function.
89   def self.readable_by(*users_list)
90     # Get rid of troublesome nils
91     users_list.compact!
92
93     # Check if any of the users are admin.  If so, we're done.
94     if users_list.select { |u| u.is_admin }.empty?
95
96       # Collect the uuids for each user and any groups readable by each user.
97       user_uuids = users_list.map { |u| u.uuid }
98       uuid_list = user_uuids + users_list.flat_map { |u| u.groups_i_can(:read) }
99       sanitized_uuid_list = uuid_list.
100         collect { |uuid| sanitize(uuid) }.join(', ')
101       sql_conds = []
102       sql_params = []
103       or_object_uuid = ''
104
105       # This row is owned by a member of users_list, or owned by a group
106       # readable by a member of users_list
107       # or
108       # This row uuid is the uuid of a member of users_list
109       # or
110       # A permission link exists ('write' and 'manage' implicitly include
111       # 'read') from a member of users_list, or a group readable by users_list,
112       # to this row, or to the owner of this row (see join() below).
113       permitted_uuids = "(SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"
114
115       sql_conds += ["#{table_name}.owner_uuid in (?)",
116                     "#{table_name}.uuid in (?)",
117                     "#{table_name}.uuid IN #{permitted_uuids}"]
118       sql_params += [uuid_list, user_uuids]
119
120       if self == Link and users_list.any?
121         # This row is a 'permission' or 'resources' link class
122         # The uuid for a member of users_list is referenced in either the head
123         # or tail of the link
124         sql_conds += ["(#{table_name}.link_class in (#{sanitize 'permission'}, #{sanitize 'resources'}) AND (#{table_name}.head_uuid IN (?) OR #{table_name}.tail_uuid IN (?)))"]
125         sql_params += [user_uuids, user_uuids]
126       end
127
128       if self == Log and users_list.any?
129         # Link head points to the object described by this row
130         sql_conds += ["#{table_name}.object_uuid IN #{permitted_uuids}"]
131
132         # This object described by this row is owned by this user, or owned by a group readable by this user
133         sql_conds += ["#{table_name}.object_owner_uuid in (?)"]
134         sql_params += [uuid_list]
135       end
136
137       # Link head points to this row, or to the owner of this row (the thing to be read)
138       #
139       # Link tail originates from this user, or a group that is readable by this
140       # user (the identity with authorization to read)
141       #
142       # Link class is 'permission' ('write' and 'manage' implicitly include 'read')
143       where(sql_conds.join(' OR '), *sql_params)
144     else
145       # At least one user is admin, so don't bother to apply any restrictions.
146       self
147     end
148   end
149
150   def logged_attributes
151     attributes
152   end
153
154   protected
155
156   def ensure_ownership_path_leads_to_user
157     if new_record? or owner_uuid_changed?
158       uuid_in_path = {owner_uuid => true, uuid => true}
159       x = owner_uuid
160       while (owner_class = self.class.resource_class_for_uuid(x)) != User
161         begin
162           if x == uuid
163             # Test for cycles with the new version, not the DB contents
164             x = owner_uuid
165           else
166             x = owner_class.find_by_uuid(x).owner_uuid
167           end
168         rescue ActiveRecord::RecordNotFound => e
169           errors.add :owner_uuid, "is not owned by any user: #{e}"
170           return false
171         end
172         if uuid_in_path[x]
173           if x == owner_uuid
174             errors.add :owner_uuid, "would create an ownership cycle"
175           else
176             errors.add :owner_uuid, "has an ownership cycle"
177           end
178           return false
179         end
180         uuid_in_path[x] = true
181       end
182     end
183     true
184   end
185
186   def ensure_owner_uuid_is_permitted
187     return false if !current_user
188     self.owner_uuid ||= current_user.uuid
189     if self.owner_uuid_changed?
190       if current_user.uuid == self.owner_uuid or
191           current_user.can? write: self.owner_uuid
192         # current_user is, or has :write permission on, the new owner
193       else
194         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}"
195         return false
196       end
197     end
198     if new_record?
199       return true
200     elsif current_user.uuid == self.owner_uuid_was or
201         current_user.uuid == self.uuid or
202         current_user.can? write: self.owner_uuid_was
203       # current user is, or has :write permission on, the previous owner
204       return true
205     else
206       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}"
207       raise PermissionDeniedError
208     end
209   end
210
211   def ensure_permission_to_save
212     unless (new_record? ? permission_to_create : permission_to_update)
213       raise PermissionDeniedError
214     end
215   end
216
217   def permission_to_create
218     current_user.andand.is_active
219   end
220
221   def permission_to_update
222     if !current_user
223       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
224       return false
225     end
226     if !current_user.is_active
227       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
228       return false
229     end
230     return true if current_user.is_admin
231     if self.uuid_changed?
232       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
233       return false
234     end
235     return true
236   end
237
238   def ensure_permission_to_destroy
239     raise PermissionDeniedError unless permission_to_destroy
240   end
241
242   def permission_to_destroy
243     permission_to_update
244   end
245
246   def maybe_update_modified_by_fields
247     update_modified_by_fields if self.changed? or self.new_record?
248   end
249
250   def update_modified_by_fields
251     self.updated_at = Time.now
252     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
253     self.modified_at = Time.now
254     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
255     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
256   end
257
258   def ensure_serialized_attribute_type
259     # Specifying a type in the "serialize" declaration causes rails to
260     # raise an exception if a different data type is retrieved from
261     # the database during load().  The validation preventing such
262     # crash-inducing records from being inserted in the database in
263     # the first place seems to have been left as an exercise to the
264     # developer.
265     self.class.serialized_attributes.each do |colname, attr|
266       if attr.object_class
267         unless self.attributes[colname].is_a? attr.object_class
268           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}"
269         end
270       end
271     end
272   end
273
274   def foreign_key_attributes
275     attributes.keys.select { |a| a.match /_uuid$/ }
276   end
277
278   def skip_uuid_read_permission_check
279     %w(modified_by_client_uuid)
280   end
281
282   def skip_uuid_existence_check
283     []
284   end
285
286   def normalize_collection_uuids
287     foreign_key_attributes.each do |attr|
288       attr_value = send attr
289       if attr_value.is_a? String and
290           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
291         begin
292           send "#{attr}=", Collection.normalize_uuid(attr_value)
293         rescue
294           # TODO: abort instead of silently accepting unnormalizable value?
295         end
296       end
297     end
298   end
299
300   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
301
302   @@prefixes_hash = nil
303   def self.uuid_prefixes
304     unless @@prefixes_hash
305       @@prefixes_hash = {}
306       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
307         if k.respond_to?(:uuid_prefix)
308           @@prefixes_hash[k.uuid_prefix] = k
309         end
310       end
311     end
312     @@prefixes_hash
313   end
314
315   def self.uuid_like_pattern
316     "_____-#{uuid_prefix}-_______________"
317   end
318
319   def ensure_valid_uuids
320     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
321
322     foreign_key_attributes.each do |attr|
323       if new_record? or send (attr + "_changed?")
324         next if skip_uuid_existence_check.include? attr
325         attr_value = send attr
326         next if specials.include? attr_value
327         if attr_value
328           if (r = ArvadosModel::resource_class_for_uuid attr_value)
329             unless skip_uuid_read_permission_check.include? attr
330               r = r.readable_by(current_user)
331             end
332             if r.where(uuid: attr_value).count == 0
333               errors.add(attr, "'#{attr_value}' not found")
334             end
335           end
336         end
337       end
338     end
339   end
340
341   class Email
342     def self.kind
343       "email"
344     end
345
346     def kind
347       self.class.kind
348     end
349
350     def self.readable_by (*u)
351       self
352     end
353
354     def self.where (u)
355       [{:uuid => u[:uuid]}]
356     end
357   end
358
359   def self.resource_class_for_uuid(uuid)
360     if uuid.is_a? ArvadosModel
361       return uuid.class
362     end
363     unless uuid.is_a? String
364       return nil
365     end
366     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
367       return Collection
368     end
369     resource_class = nil
370
371     Rails.application.eager_load!
372     uuid.match @@UUID_REGEX do |re|
373       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
374     end
375
376     if uuid.match /.+@.+/
377       return Email
378     end
379
380     nil
381   end
382
383   def log_start_state
384     @old_etag = etag
385     @old_attributes = logged_attributes
386   end
387
388   def log_change(event_type)
389     log = Log.new(event_type: event_type).fill_object(self)
390     yield log
391     log.save!
392     connection.execute "NOTIFY logs, '#{log.id}'"
393     log_start_state
394   end
395
396   def log_create
397     log_change('create') do |log|
398       log.fill_properties('old', nil, nil)
399       log.update_to self
400     end
401   end
402
403   def log_update
404     log_change('update') do |log|
405       log.fill_properties('old', @old_etag, @old_attributes)
406       log.update_to self
407     end
408   end
409
410   def log_destroy
411     log_change('destroy') do |log|
412       log.fill_properties('old', @old_etag, @old_attributes)
413       log.update_to nil
414     end
415   end
416 end