Merge branch 'master' into 3036-collection-uuids
[arvados.git] / services / api / app / models / arvados_model.rb
index bfd228e5e0441725676dd3c720cd62149e555fca..f95a35e739d470dfa57e0d753ef1ea864ac9a193 100644 (file)
@@ -20,14 +20,15 @@ class ArvadosModel < ActiveRecord::Base
   after_create :log_create
   after_update :log_update
   after_destroy :log_destroy
+  after_find :convert_serialized_symbols_to_strings
+  before_validation :normalize_collection_uuids
   validate :ensure_serialized_attribute_type
-  validate :normalize_collection_uuids
   validate :ensure_valid_uuids
 
   # Note: This only returns permission links. It does not account for
   # permissions obtained via user.is_admin or
   # user.uuid==object.owner_uuid.
-  has_many :permissions, :foreign_key => :head_uuid, :class_name => 'Link', :primary_key => :uuid, :conditions => "link_class = 'permission'", dependent: :destroy
+  has_many :permissions, :foreign_key => :head_uuid, :class_name => 'Link', :primary_key => :uuid, :conditions => "link_class = 'permission'"
 
   class PermissionDeniedError < StandardError
     def http_status
@@ -51,19 +52,44 @@ class ArvadosModel < ActiveRecord::Base
 
   def self.searchable_columns operator
     textonly_operator = !operator.match(/[<=>]/)
-    self.columns.collect do |col|
-      if [:string, :text].index(col.type)
-        col.name
-      elsif !textonly_operator and [:datetime, :integer].index(col.type)
-        col.name
+    self.columns.select do |col|
+      case col.type
+      when :string, :text
+        true
+      when :datetime, :integer, :boolean
+        !textonly_operator
+      else
+        false
       end
-    end.compact
+    end.map(&:name)
   end
 
   def self.attribute_column attr
     self.columns.select { |col| col.name == attr.to_s }.first
   end
 
+  def self.attributes_required_columns
+    # This method returns a hash.  Each key is the name of an API attribute,
+    # and it's mapped to a list of database columns that must be fetched
+    # to generate that attribute.
+    # This implementation generates a simple map of attributes to
+    # matching column names.  Subclasses can override this method
+    # to specify that method-backed API attributes need to fetch
+    # specific columns from the database.
+    all_columns = columns.map(&:name)
+    api_column_map = Hash.new { |hash, key| hash[key] = [] }
+    methods.grep(/^api_accessible_\w+$/).each do |method_name|
+      next if method_name == :api_accessible_attributes
+      send(method_name).each_pair do |api_attr_name, col_name|
+        col_name = col_name.to_s
+        if all_columns.include?(col_name)
+          api_column_map[api_attr_name.to_s] |= [col_name]
+        end
+      end
+    end
+    api_column_map
+  end
+
   # Return nil if current user is not allowed to see the list of
   # writers. Otherwise, return a list of user_ and group_uuids with
   # write permission. (If not returning nil, current_user is always in
@@ -90,6 +116,13 @@ class ArvadosModel < ActiveRecord::Base
     # Get rid of troublesome nils
     users_list.compact!
 
+    # Load optional keyword arguments, if they exist.
+    if users_list.last.is_a? Hash
+      kwargs = users_list.pop
+    else
+      kwargs = {}
+    end
+
     # Check if any of the users are admin.  If so, we're done.
     if users_list.select { |u| u.is_admin }.empty?
 
@@ -100,6 +133,7 @@ class ArvadosModel < ActiveRecord::Base
         collect { |uuid| sanitize(uuid) }.join(', ')
       sql_conds = []
       sql_params = []
+      sql_table = kwargs.fetch(:table_name, table_name)
       or_object_uuid = ''
 
       # This row is owned by a member of users_list, or owned by a group
@@ -112,25 +146,25 @@ class ArvadosModel < ActiveRecord::Base
       # to this row, or to the owner of this row (see join() below).
       permitted_uuids = "(SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"
 
-      sql_conds += ["#{table_name}.owner_uuid in (?)",
-                    "#{table_name}.uuid in (?)",
-                    "#{table_name}.uuid IN #{permitted_uuids}"]
+      sql_conds += ["#{sql_table}.owner_uuid in (?)",
+                    "#{sql_table}.uuid in (?)",
+                    "#{sql_table}.uuid IN #{permitted_uuids}"]
       sql_params += [uuid_list, user_uuids]
 
-      if self == Link and users_list.any?
+      if sql_table == "links" and users_list.any?
         # This row is a 'permission' or 'resources' link class
         # The uuid for a member of users_list is referenced in either the head
         # or tail of the link
-        sql_conds += ["(#{table_name}.link_class in (#{sanitize 'permission'}, #{sanitize 'resources'}) AND (#{table_name}.head_uuid IN (?) OR #{table_name}.tail_uuid IN (?)))"]
+        sql_conds += ["(#{sql_table}.link_class in (#{sanitize 'permission'}, #{sanitize 'resources'}) AND (#{sql_table}.head_uuid IN (?) OR #{sql_table}.tail_uuid IN (?)))"]
         sql_params += [user_uuids, user_uuids]
       end
 
-      if self == Log and users_list.any?
+      if sql_table == "logs" and users_list.any?
         # Link head points to the object described by this row
-        sql_conds += ["#{table_name}.object_uuid IN #{permitted_uuids}"]
+        sql_conds += ["#{sql_table}.object_uuid IN #{permitted_uuids}"]
 
         # This object described by this row is owned by this user, or owned by a group readable by this user
-        sql_conds += ["#{table_name}.object_owner_uuid in (?)"]
+        sql_conds += ["#{sql_table}.object_owner_uuid in (?)"]
         sql_params += [uuid_list]
       end
 
@@ -151,13 +185,20 @@ class ArvadosModel < ActiveRecord::Base
     attributes
   end
 
+  def has_permission? perm_type, target_uuid
+    Link.where(link_class: "permission",
+               name: perm_type,
+               tail_uuid: uuid,
+               head_uuid: target_uuid).any?
+  end
+
   protected
 
   def ensure_ownership_path_leads_to_user
     if new_record? or owner_uuid_changed?
       uuid_in_path = {owner_uuid => true, uuid => true}
       x = owner_uuid
-      while (owner_class = self.class.resource_class_for_uuid(x)) != User
+      while (owner_class = ArvadosModel::resource_class_for_uuid(x)) != User
         begin
           if x == uuid
             # Test for cycles with the new version, not the DB contents
@@ -187,29 +228,50 @@ class ArvadosModel < ActiveRecord::Base
 
   def ensure_owner_uuid_is_permitted
     raise PermissionDeniedError if !current_user
-    if respond_to? :owner_uuid=
+
+    if new_record? and respond_to? :owner_uuid=
       self.owner_uuid ||= current_user.uuid
     end
-    if self.owner_uuid_changed?
-      if current_user.uuid == self.owner_uuid or
-          current_user.can? write: self.owner_uuid
-        # current_user is, or has :write permission on, the new owner
-      else
-        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}"
-        raise PermissionDeniedError
-      end
+
+    if self.owner_uuid.nil?
+      errors.add :owner_uuid, "cannot be nil"
+      raise PermissionDeniedError
+    end
+
+    rsc_class = ArvadosModel::resource_class_for_uuid owner_uuid
+    unless rsc_class == User or rsc_class == Group
+      errors.add :owner_uuid, "can only be set to User or Group"
+      raise PermissionDeniedError
     end
-    if new_record?
-      return true
-    elsif current_user.uuid == self.owner_uuid_was or
+
+    # Verify "write" permission on old owner
+    # default fail unless one of:
+    # owner_uuid did not change
+    # previous owner_uuid is nil
+    # current user is the old owner
+    # current user is this object
+    # current user can_write old owner
+    unless !owner_uuid_changed? or
+        owner_uuid_was.nil? or
+        current_user.uuid == self.owner_uuid_was or
         current_user.uuid == self.uuid or
         current_user.can? write: self.owner_uuid_was
-      # current user is, or has :write permission on, the previous owner
-      return true
-    else
-      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}"
+      logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{uuid} but does not have permission to write old owner_uuid #{owner_uuid_was}"
+      errors.add :owner_uuid, "cannot be changed without write permission on old owner"
+      raise PermissionDeniedError
+    end
+
+    # Verify "write" permission on new owner
+    # default fail unless one of:
+    # current_user is this object
+    # current user can_write new owner
+    unless current_user == self or current_user.can? write: owner_uuid
+      logger.warn "User #{current_user.uuid} tried to modify #{self.class.to_s} #{uuid} but does not have permission to write new owner_uuid #{owner_uuid}"
+      errors.add :owner_uuid, "cannot be changed without write permission on new owner"
       raise PermissionDeniedError
     end
+
+    true
   end
 
   def ensure_permission_to_save
@@ -261,6 +323,38 @@ class ArvadosModel < ActiveRecord::Base
     true
   end
 
+  def self.has_symbols? x
+    if x.is_a? Hash
+      x.each do |k,v|
+        return true if has_symbols?(k) or has_symbols?(v)
+      end
+      false
+    elsif x.is_a? Array
+      x.each do |k|
+        return true if has_symbols?(k)
+      end
+      false
+    else
+      (x.class == Symbol)
+    end
+  end
+
+  def self.recursive_stringify x
+    if x.is_a? Hash
+      Hash[x.collect do |k,v|
+             [recursive_stringify(k), recursive_stringify(v)]
+           end]
+    elsif x.is_a? Array
+      x.collect do |k|
+        recursive_stringify k
+      end
+    elsif x.is_a? Symbol
+      x.to_s
+    else
+      x
+    end
+  end
+
   def ensure_serialized_attribute_type
     # Specifying a type in the "serialize" declaration causes rails to
     # raise an exception if a different data type is retrieved from
@@ -270,13 +364,31 @@ class ArvadosModel < ActiveRecord::Base
     # developer.
     self.class.serialized_attributes.each do |colname, attr|
       if attr.object_class
-        unless self.attributes[colname].is_a? attr.object_class
-          self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}"
+        if self.attributes[colname].class != attr.object_class
+          self.errors.add colname.to_sym, "must be a #{attr.object_class.to_s}, not a #{self.attributes[colname].class.to_s}"
+        elsif self.class.has_symbols? attributes[colname]
+          self.errors.add colname.to_sym, "must not contain symbols: #{attributes[colname].inspect}"
         end
       end
     end
   end
 
+  def convert_serialized_symbols_to_strings
+    # ensure_serialized_attribute_type should prevent symbols from
+    # getting into the database in the first place. If someone managed
+    # to get them into the database (perhaps using an older version)
+    # we'll convert symbols to strings when loading from the
+    # database. (Otherwise, loading and saving an object with existing
+    # symbols in a serialized field will crash.)
+    self.class.serialized_attributes.each do |colname, attr|
+      if self.class.has_symbols? attributes[colname]
+        attributes[colname] = self.class.recursive_stringify attributes[colname]
+        self.send(colname + '=',
+                  self.class.recursive_stringify(attributes[colname]))
+      end
+    end
+  end
+
   def foreign_key_attributes
     attributes.keys.select { |a| a.match /_uuid$/ }
   end
@@ -303,8 +415,6 @@ class ArvadosModel < ActiveRecord::Base
     end
   end
 
-  @@UUID_REGEX = /^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/
-
   @@prefixes_hash = nil
   def self.uuid_prefixes
     unless @@prefixes_hash
@@ -323,7 +433,7 @@ class ArvadosModel < ActiveRecord::Base
   end
 
   def ensure_valid_uuids
-    specials = [system_user_uuid, 'd41d8cd98f00b204e9800998ecf8427e+0']
+    specials = [system_user_uuid]
 
     foreign_key_attributes.each do |attr|
       if new_record? or send (attr + "_changed?")
@@ -369,13 +479,10 @@ class ArvadosModel < ActiveRecord::Base
     unless uuid.is_a? String
       return nil
     end
-    if uuid.match /^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/
-      return Collection
-    end
     resource_class = nil
 
     Rails.application.eager_load!
-    uuid.match @@UUID_REGEX do |re|
+    uuid.match HasUuid::UUID_REGEX do |re|
       return uuid_prefixes[re[1]] if uuid_prefixes[re[1]]
     end
 
@@ -386,6 +493,18 @@ class ArvadosModel < ActiveRecord::Base
     nil
   end
 
+  # ArvadosModel.find_by_uuid needs extra magic to allow it to return
+  # an object in any class.
+  def self.find_by_uuid uuid
+    if self == ArvadosModel
+      # If called directly as ArvadosModel.find_by_uuid rather than via subclass,
+      # delegate to the appropriate subclass based on the given uuid.
+      self.resource_class_for_uuid(uuid).find_by_uuid(uuid)
+    else
+      super
+    end
+  end
+
   def log_start_state
     @old_etag = etag
     @old_attributes = logged_attributes