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