require 'has_uuid'
+require 'record_filters'
class ArvadosModel < ActiveRecord::Base
self.abstract_class = true
include CurrentApiClient # current_user, current_api_client, etc.
+ include DbCurrentTime
+ extend RecordFilters
attr_protected :created_at
attr_protected :modified_by_user_uuid
after_destroy :log_destroy
after_find :convert_serialized_symbols_to_strings
before_validation :normalize_collection_uuids
+ before_validation :set_default_owner
validate :ensure_serialized_attribute_type
validate :ensure_valid_uuids
class AlreadyLockedError < StandardError
def http_status
- 403
+ 422
+ end
+ end
+
+ class InvalidStateTransitionError < StandardError
+ def http_status
+ 422
end
end
end
end
+ class UnresolvableContainerError < StandardError
+ def http_status
+ 422
+ end
+ end
+
def self.kind_class(kind)
kind.match(/^arvados\#(.+)$/)[1].classify.safe_constantize rescue nil
end
"#{current_api_base}/#{self.class.to_s.pluralize.underscore}/#{self.uuid}"
end
+ def self.selectable_attributes(template=:user)
+ # Return an array of attribute name strings that can be selected
+ # in the given template.
+ api_accessible_attributes(template).map { |attr_spec| attr_spec.first.to_s }
+ end
+
def self.searchable_columns operator
textonly_operator = !operator.match(/[<=>]/)
self.columns.select do |col|
api_column_map
end
+ def self.ignored_select_attributes
+ ["href", "kind", "etag"]
+ end
+
+ def self.columns_for_attributes(select_attributes)
+ if select_attributes.empty?
+ raise ArgumentError.new("Attribute selection list cannot be empty")
+ end
+ api_column_map = attributes_required_columns
+ invalid_attrs = []
+ select_attributes.each do |s|
+ next if ignored_select_attributes.include? s
+ if not s.is_a? String or not api_column_map.include? s
+ invalid_attrs << s
+ end
+ end
+ if not invalid_attrs.empty?
+ raise ArgumentError.new("Invalid attribute(s): #{invalid_attrs.inspect}")
+ end
+ # Given an array of attribute names to select, return an array of column
+ # names that must be fetched from the database to satisfy the request.
+ select_attributes.flat_map { |attr| api_column_map[attr] }.uniq
+ end
+
+ def self.default_orders
+ ["#{table_name}.modified_at desc", "#{table_name}.uuid"]
+ end
+
+ def self.unique_columns
+ ["id", "uuid"]
+ end
+
# If current user can manage the object, return an array of uuids of
# users and groups that have permission to write the object. The
# first two elements are always [self.owner_uuid, current user's
# If current user cannot write this object, just return
# [self.owner_uuid].
def writable_by
+ return [owner_uuid] if not current_user
unless (owner_uuid == current_user.uuid or
current_user.is_admin or
(current_user.groups_i_can(:manage) & [uuid, owner_uuid]).any?)
return self
end
- # Collect the uuids for each user and any groups readable by each user.
+ # Collect the UUIDs of the authorized users.
user_uuids = users_list.map { |u| u.uuid }
- uuid_list = user_uuids + users_list.flat_map { |u| u.groups_i_can(:read) }
+
+ # Collect the UUIDs of all groups readable by any of the
+ # authorized users. If one of these (or the UUID of one of the
+ # authorized users themselves) is an object's owner_uuid, that
+ # object is readable.
+ owner_uuids = user_uuids + users_list.flat_map { |u| u.groups_i_can(:read) }
+ owner_uuids.uniq!
+
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
- # readable by a member of users_list
- # or
- # This row uuid is the uuid of a member of users_list
- # or
- # A permission link exists ('write' and 'manage' implicitly include
- # 'read') from a member of users_list, or a group readable by users_list,
- # to this row, or to the owner of this row (see join() below).
- sql_conds += ["#{sql_table}.uuid in (?)"]
- sql_params += [user_uuids]
+ # Match any object (evidently a group or user) whose UUID is
+ # listed explicitly in owner_uuids.
+ sql_conds += ["#{sql_table}.uuid in (:owner_uuids)"]
- if uuid_list.any?
- sql_conds += ["#{sql_table}.owner_uuid in (?)"]
- sql_params += [uuid_list]
+ # Match any object whose owner is listed explicitly in
+ # owner_uuids.
+ sql_conds += ["#{sql_table}.owner_uuid IN (:owner_uuids)"]
- sanitized_uuid_list = uuid_list.
- collect { |uuid| sanitize(uuid) }.join(', ')
- permitted_uuids = "(SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (#{sanitized_uuid_list}))"
- sql_conds += ["#{sql_table}.uuid IN #{permitted_uuids}"]
- end
+ # Match the head of any permission link whose tail is listed
+ # explicitly in owner_uuids.
+ sql_conds += ["#{sql_table}.uuid IN (SELECT head_uuid FROM links WHERE link_class='permission' AND tail_uuid IN (:owner_uuids))"]
- 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 += ["(#{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]
+ if sql_table == "links"
+ # Match any permission link that gives one of the authorized
+ # users some permission _or_ gives anyone else permission to
+ # view one of the authorized users.
+ sql_conds += ["(#{sql_table}.link_class in (:permission_link_classes) AND "+
+ "(#{sql_table}.head_uuid IN (:user_uuids) OR #{sql_table}.tail_uuid IN (:user_uuids)))"]
end
- if sql_table == "logs" and users_list.any?
- # Link head points to the object described by this row
- sql_conds += ["#{sql_table}.object_uuid IN #{permitted_uuids}"]
+ where(sql_conds.join(' OR '),
+ owner_uuids: owner_uuids,
+ user_uuids: user_uuids,
+ permission_link_classes: ['permission', 'resources'])
+ end
- # This object described by this row is owned by this user, or owned by a group readable by this user
- sql_conds += ["#{sql_table}.object_owner_uuid in (?)"]
- sql_params += [uuid_list]
- end
+ def logged_attributes
+ attributes.except *Rails.configuration.unlogged_attributes
+ end
- # Link head points to this row, or to the owner of this row (the
- # thing to be read)
- #
- # Link tail originates from this user, or a group that is readable
- # by this user (the identity with authorization to read)
- #
- # Link class is 'permission' ('write' and 'manage' implicitly
- # include 'read')
- where(sql_conds.join(' OR '), *sql_params)
+ def self.full_text_searchable_columns
+ self.columns.select do |col|
+ col.type == :string or col.type == :text
+ end.map(&:name)
end
- def logged_attributes
- attributes
+ def self.full_text_tsvector
+ parts = full_text_searchable_columns.collect do |column|
+ "coalesce(#{column},'')"
+ end
+ # We prepend a space to the tsvector() argument here. Otherwise,
+ # it might start with a column that has its own (non-full-text)
+ # index, which causes Postgres to use the column index instead of
+ # the tsvector index, which causes full text queries to be just as
+ # slow as if we had no index at all.
+ "to_tsvector('english', ' ' || #{parts.join(" || ' ' || ")})"
+ end
+
+ def self.apply_filters query, filters
+ ft = record_filters filters, self
+ if not ft[:cond_out].any?
+ return query
+ end
+ query.where('(' + ft[:cond_out].join(') AND (') + ')',
+ *ft[:param_out])
end
protected
+ def self.deep_sort_hash(x)
+ if x.is_a? Hash
+ x.sort.collect do |k, v|
+ [k, deep_sort_hash(v)]
+ end.to_h
+ elsif x.is_a? Array
+ x.collect { |v| deep_sort_hash(v) }
+ else
+ x
+ end
+ end
+
def ensure_ownership_path_leads_to_user
if new_record? or owner_uuid_changed?
uuid_in_path = {owner_uuid => true, uuid => true}
true
end
- def ensure_owner_uuid_is_permitted
- raise PermissionDeniedError if !current_user
-
- if new_record? and respond_to? :owner_uuid=
+ def set_default_owner
+ if new_record? and current_user and respond_to? :owner_uuid=
self.owner_uuid ||= current_user.uuid
end
+ end
+
+ def ensure_owner_uuid_is_permitted
+ raise PermissionDeniedError if !current_user
if self.owner_uuid.nil?
errors.add :owner_uuid, "cannot be nil"
# 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
+ # current user can_write new owner, or this object if owner unchanged
+ if new_record? or owner_uuid_changed? or is_a?(ApiClientAuthorization)
+ write_target = owner_uuid
+ else
+ write_target = uuid
+ end
+ unless current_user == self or current_user.can? write: write_target
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
def update_modified_by_fields
- self.updated_at = Time.now
+ current_time = db_current_time
+ self.updated_at = current_time
self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
- self.modified_at = Time.now
+ self.modified_at = current_time
self.modified_by_user_uuid = current_user ? current_user.uuid : nil
self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
true
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)
+ elsif x.is_a? Symbol
+ return true
+ elsif x.is_a? String
+ return true if x.start_with?(':') && !x.start_with?('::')
end
+ false
end
def self.recursive_stringify x
end
elsif x.is_a? Symbol
x.to_s
+ elsif x.is_a? String and x.start_with?(':') and !x.start_with?('::')
+ x[1..-1]
else
x
end
end
def self.uuid_like_pattern
- "_____-#{uuid_prefix}-_______________"
+ "#{Rails.configuration.uuid_prefix}-#{uuid_prefix}-_______________"
end
def self.uuid_regex
end
def log_start_state
- @old_etag = etag
- @old_attributes = logged_attributes
+ @old_attributes = Marshal.load(Marshal.dump(attributes))
+ @old_logged_attributes = Marshal.load(Marshal.dump(logged_attributes))
end
def log_change(event_type)
def log_update
log_change('update') do |log|
- log.fill_properties('old', @old_etag, @old_attributes)
+ log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
log.update_to self
end
end
def log_destroy
- log_change('destroy') do |log|
- log.fill_properties('old', @old_etag, @old_attributes)
+ log_change('delete') do |log|
+ log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
log.update_to nil
end
end