9c7fe2b7192dbc0eea643ee579f671d916c4c6a4
[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                     "uuid IN (SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"]
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       where(sql_conds.join(' OR '), *sql_params)
119     else
120       # At least one user is admin, so don't bother to apply any restrictions.
121       self
122     end
123   end
124
125   def logged_attributes
126     attributes
127   end
128
129   protected
130
131   def ensure_permission_to_create
132     raise PermissionDeniedError unless permission_to_create
133   end
134
135   def permission_to_create
136     current_user.andand.is_active
137   end
138
139   def ensure_permission_to_update
140     raise PermissionDeniedError unless permission_to_update
141   end
142
143   def permission_to_update
144     if !current_user
145       logger.warn "Anonymous user tried to update #{self.class.to_s} #{self.uuid_was}"
146       return false
147     end
148     if !current_user.is_active
149       logger.warn "Inactive user #{current_user.uuid} tried to update #{self.class.to_s} #{self.uuid_was}"
150       return false
151     end
152     return true if current_user.is_admin
153     if self.uuid_changed?
154       logger.warn "User #{current_user.uuid} tried to change uuid of #{self.class.to_s} #{self.uuid_was} to #{self.uuid}"
155       return false
156     end
157     if self.owner_uuid_changed?
158       if current_user.uuid == self.owner_uuid or
159           current_user.can? write: self.owner_uuid
160         # current_user is, or has :write permission on, the new owner
161       else
162         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}"
163         return false
164       end
165     end
166     if current_user.uuid == self.owner_uuid_was or
167         current_user.uuid == self.uuid or
168         current_user.can? write: self.owner_uuid_was
169       # current user is, or has :write permission on, the previous owner
170       return true
171     else
172       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}"
173       return false
174     end
175   end
176
177   def ensure_permission_to_destroy
178     raise PermissionDeniedError unless permission_to_destroy
179   end
180
181   def permission_to_destroy
182     permission_to_update
183   end
184
185   def maybe_update_modified_by_fields
186     update_modified_by_fields if self.changed? or self.new_record?
187   end
188
189   def update_modified_by_fields
190     self.updated_at = Time.now
191     self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
192     self.modified_at = Time.now
193     self.modified_by_user_uuid = current_user ? current_user.uuid : nil
194     self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
195   end
196
197   def ensure_serialized_attribute_type
198     # Specifying a type in the "serialize" declaration causes rails to
199     # raise an exception if a different data type is retrieved from
200     # the database during load().  The validation preventing such
201     # crash-inducing records from being inserted in the database in
202     # the first place seems to have been left as an exercise to the
203     # developer.
204     self.class.serialized_attributes.each do |colname, attr|
205       if attr.object_class
206         unless self.attributes[colname].is_a? attr.object_class
207           self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}"
208         end
209       end
210     end
211   end
212
213   def foreign_key_attributes
214     attributes.keys.select { |a| a.match /_uuid$/ }
215   end
216
217   def skip_uuid_read_permission_check
218     %w(modified_by_client_uuid)
219   end
220
221   def skip_uuid_existence_check
222     []
223   end
224
225   def normalize_collection_uuids
226     foreign_key_attributes.each do |attr|
227       attr_value = send attr
228       if attr_value.is_a? String and
229           attr_value.match /^[0-9a-f]{32,}(\+[@\w]+)*$/
230         begin
231           send "#{attr}=", Collection.normalize_uuid(attr_value)
232         rescue
233           # TODO: abort instead of silently accepting unnormalizable value?
234         end
235       end
236     end
237   end
238
239   @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
240
241   @@prefixes_hash = nil
242   def self.uuid_prefixes
243     unless @@prefixes_hash
244       @@prefixes_hash = {}
245       ActiveRecord::Base.descendants.reject(&:abstract_class?).each do |k|
246         if k.respond_to?(:uuid_prefix)
247           @@prefixes_hash[k.uuid_prefix] = k
248         end
249       end
250     end
251     @@prefixes_hash
252   end
253
254   def self.uuid_like_pattern
255     "_____-#{uuid_prefix}-_______________"
256   end
257
258   def ensure_valid_uuids
259     specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
260
261     foreign_key_attributes.each do |attr|
262       if new_record? or send (attr + "_changed?")
263         next if skip_uuid_existence_check.include? attr
264         attr_value = send attr
265         next if specials.include? attr_value
266         if attr_value
267           if (r = ArvadosModel::resource_class_for_uuid attr_value)
268             unless skip_uuid_read_permission_check.include? attr
269               r = r.readable_by(current_user)
270             end
271             if r.where(uuid: attr_value).count == 0
272               errors.add(attr, "'#{attr_value}' not found")
273             end
274           end
275         end
276       end
277     end
278   end
279
280   class Email
281     def self.kind
282       "email"
283     end
284
285     def kind
286       self.class.kind
287     end
288
289     def self.readable_by (*u)
290       self
291     end
292
293     def self.where (u)
294       [{:uuid => u[:uuid]}]
295     end
296   end
297
298   def self.resource_class_for_uuid(uuid)
299     if uuid.is_a? ArvadosModel
300       return uuid.class
301     end
302     unless uuid.is_a? String
303       return nil
304     end
305     if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
306       return Collection
307     end
308     resource_class = nil
309
310     Rails.application.eager_load!
311     uuid.match @@UUID_REGEX do |re|
312       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
313     end
314
315     if uuid.match /.+@.+/
316       return Email
317     end
318
319     nil
320   end
321
322   def log_start_state
323     @old_etag = etag
324     @old_attributes = logged_attributes
325   end
326
327   def log_change(event_type)
328     log = Log.new(event_type: event_type).fill_object(self)
329     yield log
330     log.save!
331     connection.execute "NOTIFY logs, '#{log.id}'"
332     log_start_state
333   end
334
335   def log_create
336     log_change('create') do |log|
337       log.fill_properties('old', nil, nil)
338       log.update_to self
339     end
340   end
341
342   def log_update
343     log_change('update') do |log|
344       log.fill_properties('old', @old_etag, @old_attributes)
345       log.update_to self
346     end
347   end
348
349   def log_destroy
350     log_change('destroy') do |log|
351       log.fill_properties('old', @old_etag, @old_attributes)
352       log.update_to nil
353     end
354   end
355 end