Merge branch 'master' of git.clinicalfuture.com:arvados
[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_create :ensure_permission_to_create
13   before_update :ensure_permission_to_update
14   before_destroy :ensure_permission_to_destroy
15
16   before_create :update_modified_by_fields
17   before_update :maybe_update_modified_by_fields
18   after_create :log_create
19   after_update :log_update
20   after_destroy :log_destroy
21   validate :ensure_serialized_attribute_type
22   validate :normalize_collection_uuids
23   validate :ensure_valid_uuids
24
25   has_many :permissions, :foreign_key => :head_uuid, :class_name => 'Link', :primary_key => :uuid, :conditions => "link_class = 'permission'"
26
27   class PermissionDeniedError < StandardError
28     def http_status
29       403
30     end
31   end
32
33   class UnauthorizedError < StandardError
34     def http_status
35       401
36     end
37   end
38
39   def self.kind_class(kind)
40     kind.match(/^arvados\#(.+)$/)[1].classify.safe_constantize rescue nil
41   end
42
43   def href
44     "#{current_api_base}/#{self.class.to_s.pluralize.underscore}/#{self.uuid}"
45   end
46
47   def self.searchable_columns operator
48     textonly_operator = !operator.match(/[<=>]/)
49     self.columns.collect do |col|
50       if [:string, :text].index(col.type)
51         col.name
52       elsif !textonly_operator and [:datetime, :integer].index(col.type)
53         col.name
54       end
55     end.compact
56   end
57
58   def self.attribute_column attr
59     self.columns.select { |col| col.name == attr.to_s }.first
60   end
61
62   # Return a query with read permissions restricted to the union of of the
63   # permissions of the members of users_list, i.e. if something is readable by
64   # any user in users_list, it will be readable in the query returned by this
65   # function.
66   def self.readable_by(*users_list)
67     # Get rid of troublesome nils
68     users_list.compact!
69
70     # Check if any of the users are admin.  If so, we're done.
71     if users_list.select { |u| u.is_admin }.empty?
72
73       # Collect the uuids for each user and any groups readable by each user.
74       user_uuids = users_list.map { |u| u.uuid }
75       uuid_list = user_uuids + users_list.flat_map { |u| u.groups_i_can(:read) }
76       sanitized_uuid_list = uuid_list.
77         collect { |uuid| sanitize(uuid) }.join(', ')
78       sql_conds = []
79       sql_params = []
80       or_object_uuid = ''
81
82       # This row is owned by a member of users_list, or owned by a group
83       # readable by a member of users_list
84       # or
85       # This row uuid is the uuid of a member of users_list
86       # or
87       # A permission link exists ('write' and 'manage' implicitly include
88       # 'read') from a member of users_list, or a group readable by users_list,
89       # to this row, or to the owner of this row (see join() below).
90       sql_conds += ["#{table_name}.owner_uuid in (?)",
91                     "#{table_name}.uuid in (?)",
92                     "permissions.head_uuid IS NOT NULL"]
93       sql_params += [uuid_list, user_uuids]
94
95       if self == Link and users_list.any?
96         # This row is a 'permission' or 'resources' link class
97         # The uuid for a member of users_list is referenced in either the head
98         # or tail of the link
99         sql_conds += ["(#{table_name}.link_class in (#{sanitize 'permission'}, #{sanitize 'resources'}) AND (#{table_name}.head_uuid IN (?) OR #{table_name}.tail_uuid IN (?)))"]
100         sql_params += [user_uuids, user_uuids]
101       end
102
103       if self == Log and users_list.any?
104         # Link head points to the object described by this row
105         or_object_uuid = ", #{table_name}.object_uuid"
106
107         # This object described by this row is owned by this user, or owned by a group readable by this user
108         sql_conds += ["#{table_name}.object_owner_uuid in (?)"]
109         sql_params += [uuid_list]
110       end
111
112       # Link head points to this row, or to the owner of this row (the thing to be read)
113       #
114       # Link tail originates from this user, or a group that is readable by this
115       # user (the identity with authorization to read)
116       #
117       # Link class is 'permission' ('write' and 'manage' implicitly include 'read')
118
119       joins("LEFT JOIN links permissions ON permissions.head_uuid in (#{table_name}.owner_uuid, #{table_name}.uuid #{or_object_uuid}) AND permissions.tail_uuid in (#{sanitized_uuid_list}) AND permissions.link_class='permission'")
120         .where(sql_conds.join(' OR '), *sql_params).uniq
121
122     else
123       # At least one user is admin, so don't bother to apply any restrictions.
124       self
125     end
126
127   end
128
129   def logged_attributes
130     attributes
131   end
132
133   protected
134
135   def ensure_permission_to_create
136     raise PermissionDeniedError unless permission_to_create
137   end
138
139   def permission_to_create
140     current_user.andand.is_active
141   end
142
143   def ensure_permission_to_update
144     raise PermissionDeniedError unless permission_to_update
145   end
146
147   def permission_to_update
148     if !current_user
149       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
150       return false
151     end
152     if !current_user.is_active
153       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
154       return false
155     end
156     return true if current_user.is_admin
157     if self.uuid_changed?
158       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
159       return false
160     end
161     if self.owner_uuid_changed?
162       if current_user.uuid == self.owner_uuid or
163           current_user.can? write: self.owner_uuid
164         # current_user is, or has :write permission on, the new owner
165       else
166         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}"
167         return false
168       end
169     end
170     if current_user.uuid == self.owner_uuid_was or
171         current_user.uuid == self.uuid or
172         current_user.can? write: self.owner_uuid_was
173       # current user is, or has :write permission on, the previous owner
174       return true
175     else
176       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}"
177       return false
178     end
179   end
180
181   def ensure_permission_to_destroy
182     raise PermissionDeniedError unless permission_to_destroy
183   end
184
185   def permission_to_destroy
186     permission_to_update
187   end
188
189   def maybe_update_modified_by_fields
190     update_modified_by_fields if self.changed? or self.new_record?
191   end
192
193   def update_modified_by_fields
194     self.updated_at = Time.now
195     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
196     self.modified_at = Time.now
197     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
198     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
199   end
200
201   def ensure_serialized_attribute_type
202     # Specifying a type in the "serialize" declaration causes rails to
203     # raise an exception if a different data type is retrieved from
204     # the database during load().  The validation preventing such
205     # crash-inducing records from being inserted in the database in
206     # the first place seems to have been left as an exercise to the
207     # developer.
208     self.class.serialized_attributes.each do |colname, attr|
209       if attr.object_class
210         unless self.attributes[colname].is_a? attr.object_class
211           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}"
212         end
213       end
214     end
215   end
216
217   def foreign_key_attributes
218     attributes.keys.select { |a| a.match /_uuid$/ }
219   end
220
221   def skip_uuid_read_permission_check
222     %w(modified_by_client_uuid)
223   end
224
225   def skip_uuid_existence_check
226     []
227   end
228
229   def normalize_collection_uuids
230     foreign_key_attributes.each do |attr|
231       attr_value = send attr
232       if attr_value.is_a? String and
233           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
234         begin
235           send "#{attr}=", Collection.normalize_uuid(attr_value)
236         rescue
237           # TODO: abort instead of silently accepting unnormalizable value?
238         end
239       end
240     end
241   end
242
243   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
244
245   @@prefixes_hash = nil
246   def self.uuid_prefixes
247     unless @@prefixes_hash
248       @@prefixes_hash = {}
249       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
250         if k.respond_to?(:uuid_prefix)
251           @@prefixes_hash[k.uuid_prefix] = k
252         end
253       end
254     end
255     @@prefixes_hash
256   end
257
258   def self.uuid_like_pattern
259     "_____-#{uuid_prefix}-_______________"
260   end
261
262   def ensure_valid_uuids
263     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
264
265     foreign_key_attributes.each do |attr|
266       if new_record? or send (attr + "_changed?")
267         next if skip_uuid_existence_check.include? attr
268         attr_value = send attr
269         next if specials.include? attr_value
270         if attr_value
271           if (r = ArvadosModel::resource_class_for_uuid attr_value)
272             unless skip_uuid_read_permission_check.include? attr
273               r = r.readable_by(current_user)
274             end
275             if r.where(uuid: attr_value).count == 0
276               errors.add(attr, "'#{attr_value}' not found")
277             end
278           end
279         end
280       end
281     end
282   end
283
284   class Email
285     def self.kind
286       "email"
287     end
288
289     def kind
290       self.class.kind
291     end
292
293     def self.readable_by (*u)
294       self
295     end
296
297     def self.where (u)
298       [{:uuid => u[:uuid]}]
299     end
300   end
301
302   def self.resource_class_for_uuid(uuid)
303     if uuid.is_a? ArvadosModel
304       return uuid.class
305     end
306     unless uuid.is_a? String
307       return nil
308     end
309     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
310       return Collection
311     end
312     resource_class = nil
313
314     Rails.application.eager_load!
315     uuid.match @@UUID_REGEX do |re|
316       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
317     end
318
319     if uuid.match /.+@.+/
320       return Email
321     end
322
323     nil
324   end
325
326   def log_start_state
327     @old_etag = etag
328     @old_attributes = logged_attributes
329   end
330
331   def log_change(event_type)
332     log = Log.new(event_type: event_type).fill_object(self)
333     yield log
334     log.save!
335     connection.execute "NOTIFY logs, '#{log.id}'"
336     log_start_state
337   end
338
339   def log_create
340     log_change('create') do |log|
341       log.fill_properties('old', nil, nil)
342       log.update_to self
343     end
344   end
345
346   def log_update
347     log_change('update') do |log|
348       log.fill_properties('old', @old_etag, @old_attributes)
349       log.update_to self
350     end
351   end
352
353   def log_destroy
354     log_change('destroy') do |log|
355       log.fill_properties('old', @old_etag, @old_attributes)
356       log.update_to nil
357     end
358   end
359 end