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