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