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