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