2762: Rename assign_uuid to has_uuid to reflect expanded scope.
[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   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'", dependent: :destroy
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           elsif !owner_class.respond_to? :find_by_uuid
166             raise ActiveRecord::RecordNotFound.new
167           else
168             x = owner_class.find_by_uuid(x).owner_uuid
169           end
170         rescue ActiveRecord::RecordNotFound => e
171           errors.add :owner_uuid, "is not owned by any user: #{e}"
172           return false
173         end
174         if uuid_in_path[x]
175           if x == owner_uuid
176             errors.add :owner_uuid, "would create an ownership cycle"
177           else
178             errors.add :owner_uuid, "has an ownership cycle"
179           end
180           return false
181         end
182         uuid_in_path[x] = true
183       end
184     end
185     true
186   end
187
188   def ensure_owner_uuid_is_permitted
189     raise PermissionDeniedError if !current_user
190     self.owner_uuid ||= current_user.uuid
191     if self.owner_uuid_changed?
192       if current_user.uuid == self.owner_uuid or
193           current_user.can? write: self.owner_uuid
194         # current_user is, or has :write permission on, the new owner
195       else
196         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}"
197         raise PermissionDeniedError
198       end
199     end
200     if new_record?
201       return true
202     elsif current_user.uuid == self.owner_uuid_was or
203         current_user.uuid == self.uuid or
204         current_user.can? write: self.owner_uuid_was
205       # current user is, or has :write permission on, the previous owner
206       return true
207     else
208       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}"
209       raise PermissionDeniedError
210     end
211   end
212
213   def ensure_permission_to_save
214     unless (new_record? ? permission_to_create : permission_to_update)
215       raise PermissionDeniedError
216     end
217   end
218
219   def permission_to_create
220     current_user.andand.is_active
221   end
222
223   def permission_to_update
224     if !current_user
225       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
226       return false
227     end
228     if !current_user.is_active
229       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
230       return false
231     end
232     return true if current_user.is_admin
233     if self.uuid_changed?
234       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
235       return false
236     end
237     return true
238   end
239
240   def destroy_permission_links
241     Link.destroy_all(['link_class=? and (head_uuid=? or tail_uuid=?)',
242                       'permission', uuid, uuid])
243   end
244
245   def ensure_permission_to_destroy
246     raise PermissionDeniedError unless permission_to_destroy
247   end
248
249   def permission_to_destroy
250     permission_to_update
251   end
252
253   def maybe_update_modified_by_fields
254     update_modified_by_fields if self.changed? or self.new_record?
255     true
256   end
257
258   def update_modified_by_fields
259     self.updated_at = Time.now
260     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
261     self.modified_at = Time.now
262     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
263     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
264     true
265   end
266
267   def ensure_serialized_attribute_type
268     # Specifying a type in the "serialize" declaration causes rails to
269     # raise an exception if a different data type is retrieved from
270     # the database during load().  The validation preventing such
271     # crash-inducing records from being inserted in the database in
272     # the first place seems to have been left as an exercise to the
273     # developer.
274     self.class.serialized_attributes.each do |colname, attr|
275       if attr.object_class
276         unless self.attributes[colname].is_a? attr.object_class
277           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}"
278         end
279       end
280     end
281   end
282
283   def foreign_key_attributes
284     attributes.keys.select { |a| a.match /_uuid$/ }
285   end
286
287   def skip_uuid_read_permission_check
288     %w(modified_by_client_uuid)
289   end
290
291   def skip_uuid_existence_check
292     []
293   end
294
295   def normalize_collection_uuids
296     foreign_key_attributes.each do |attr|
297       attr_value = send attr
298       if attr_value.is_a? String and
299           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
300         begin
301           send "#{attr}=", Collection.normalize_uuid(attr_value)
302         rescue
303           # TODO: abort instead of silently accepting unnormalizable value?
304         end
305       end
306     end
307   end
308
309   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
310
311   @@prefixes_hash = nil
312   def self.uuid_prefixes
313     unless @@prefixes_hash
314       @@prefixes_hash = {}
315       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
316         if k.respond_to?(:uuid_prefix)
317           @@prefixes_hash[k.uuid_prefix] = k
318         end
319       end
320     end
321     @@prefixes_hash
322   end
323
324   def self.uuid_like_pattern
325     "_____-#{uuid_prefix}-_______________"
326   end
327
328   def ensure_valid_uuids
329     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
330
331     foreign_key_attributes.each do |attr|
332       if new_record? or send (attr + "_changed?")
333         next if skip_uuid_existence_check.include? attr
334         attr_value = send attr
335         next if specials.include? attr_value
336         if attr_value
337           if (r = ArvadosModel::resource_class_for_uuid attr_value)
338             unless skip_uuid_read_permission_check.include? attr
339               r = r.readable_by(current_user)
340             end
341             if r.where(uuid: attr_value).count == 0
342               errors.add(attr, "'#{attr_value}' not found")
343             end
344           end
345         end
346       end
347     end
348   end
349
350   class Email
351     def self.kind
352       "email"
353     end
354
355     def kind
356       self.class.kind
357     end
358
359     def self.readable_by (*u)
360       self
361     end
362
363     def self.where (u)
364       [{:uuid => u[:uuid]}]
365     end
366   end
367
368   def self.resource_class_for_uuid(uuid)
369     if uuid.is_a? ArvadosModel
370       return uuid.class
371     end
372     unless uuid.is_a? String
373       return nil
374     end
375     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
376       return Collection
377     end
378     resource_class = nil
379
380     Rails.application.eager_load!
381     uuid.match @@UUID_REGEX do |re|
382       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
383     end
384
385     if uuid.match /.+@.+/
386       return Email
387     end
388
389     nil
390   end
391
392   def log_start_state
393     @old_etag = etag
394     @old_attributes = logged_attributes
395   end
396
397   def log_change(event_type)
398     log = Log.new(event_type: event_type).fill_object(self)
399     yield log
400     log.save!
401     connection.execute "NOTIFY logs, '#{log.id}'"
402     log_start_state
403   end
404
405   def log_create
406     log_change('create') do |log|
407       log.fill_properties('old', nil, nil)
408       log.update_to self
409     end
410   end
411
412   def log_update
413     log_change('update') do |log|
414       log.fill_properties('old', @old_etag, @old_attributes)
415       log.update_to self
416     end
417   end
418
419   def log_destroy
420     log_change('destroy') do |log|
421       log.fill_properties('old', @old_etag, @old_attributes)
422       log.update_to nil
423     end
424   end
425 end