+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require 'arvados_model_updates'
require 'has_uuid'
require 'record_filters'
require 'serializers'
+require 'request_error'
-class ArvadosModel < ActiveRecord::Base
+class ArvadosModel < ApplicationRecord
self.abstract_class = true
+ include ArvadosModelUpdates
include CurrentApiClient # current_user, current_api_client, etc.
include DbCurrentTime
extend RecordFilters
class_name: 'Link',
primary_key: :uuid)
- class PermissionDeniedError < StandardError
+ # If async is true at create or update, permission graph
+ # update is deferred allowing making multiple calls without the performance
+ # penalty.
+ attr_accessor :async_permissions_update
+
+ # Ignore listed attributes on mass assignments
+ def self.protected_attributes
+ []
+ end
+
+ class PermissionDeniedError < RequestError
def http_status
403
end
end
- class AlreadyLockedError < StandardError
+ class AlreadyLockedError < RequestError
def http_status
422
end
end
- class InvalidStateTransitionError < StandardError
+ class LockFailedError < RequestError
def http_status
422
end
end
- class UnauthorizedError < StandardError
+ class InvalidStateTransitionError < RequestError
+ def http_status
+ 422
+ end
+ end
+
+ class UnauthorizedError < RequestError
def http_status
401
end
end
- class UnresolvableContainerError < StandardError
+ class UnresolvableContainerError < RequestError
def http_status
422
end
# The following permit! is necessary even with
# "ActionController::Parameters.permit_all_parameters = true",
# because permit_all does not permit nested attributes.
+ raw_params ||= {}
+
if raw_params
+ raw_params = raw_params.to_hash
+ raw_params.delete_if { |k, _| self.protected_attributes.include? k }
serialized_attributes.each do |colname, coder|
param = raw_params[colname.to_sym]
if param.nil?
super(self.class.permit_attribute_params(raw_params), *args)
end
+ # Reload "old attributes" for logging, too.
+ def reload(*args)
+ super
+ log_start_state
+ end
+
def self.create raw_params={}, *args
super(permit_attribute_params(raw_params), *args)
end
["id", "uuid"]
end
+ def self.limit_index_columns_read
+ # This method returns a list of column names.
+ # If an index request reads that column from the database,
+ # APIs that return lists will only fetch objects until reaching
+ # max_index_database_read bytes of data from those columns.
+ []
+ 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
end.compact.uniq
end
- # Return a query with read permissions restricted to the union of of the
+ # Return a query with read permissions restricted to the union of the
# permissions of the members of users_list, i.e. if something is readable by
# any user in users_list, it will be readable in the query returned by this
# function.
kwargs = {}
end
- # Check if any of the users are admin. If so, we're done.
- if users_list.select { |u| u.is_admin }.any?
- return self
- end
-
# Collect the UUIDs of the authorized users.
+ sql_table = kwargs.fetch(:table_name, table_name)
+ include_trash = kwargs.fetch(:include_trash, false)
+ include_old_versions = kwargs.fetch(:include_old_versions, false)
+
+ sql_conds = nil
user_uuids = users_list.map { |u| u.uuid }
- # 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!
+ exclude_trashed_records = ""
+ if !include_trash and (sql_table == "groups" or sql_table == "collections") then
+ # Only include records that are not explicitly trashed
+ exclude_trashed_records = "AND #{sql_table}.is_trashed = false"
+ end
- sql_conds = []
- sql_table = kwargs.fetch(:table_name, table_name)
+ if users_list.select { |u| u.is_admin }.any?
+ # Admin skips most permission checks, but still want to filter on trashed items.
+ if !include_trash
+ if sql_table != "api_client_authorizations"
+ # Only include records where the owner is not trashed
+ sql_conds = "#{sql_table}.owner_uuid NOT IN (SELECT target_uuid FROM #{PERMISSION_VIEW} "+
+ "WHERE trashed = 1) #{exclude_trashed_records}"
+ end
+ end
+ else
+ trashed_check = ""
+ if !include_trash then
+ trashed_check = "AND trashed = 0"
+ end
+
+ # Note: it is possible to combine the direct_check and
+ # owner_check into a single EXISTS() clause, however it turns
+ # out query optimizer doesn't like it and forces a sequential
+ # table scan. Constructing the query with separate EXISTS()
+ # clauses enables it to use the index.
+ #
+ # see issue 13208 for details.
+
+ # Match a direct read permission link from the user to the record uuid
+ direct_check = "#{sql_table}.uuid IN (SELECT target_uuid FROM #{PERMISSION_VIEW} "+
+ "WHERE user_uuid IN (:user_uuids) AND perm_level >= 1 #{trashed_check})"
+
+ # Match a read permission link from the user to the record's owner_uuid
+ owner_check = ""
+ if sql_table != "api_client_authorizations" and sql_table != "groups" then
+ owner_check = "OR #{sql_table}.owner_uuid IN (SELECT target_uuid FROM #{PERMISSION_VIEW} "+
+ "WHERE user_uuid IN (:user_uuids) AND perm_level >= 1 #{trashed_check} AND target_owner_uuid IS NOT NULL) "
+ end
- # Match any object (evidently a group or user) whose UUID is
- # listed explicitly in owner_uuids.
- sql_conds += ["#{sql_table}.uuid in (:owner_uuids)"]
+ links_cond = ""
+ 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.
+ links_cond = "OR (#{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
- # Match any object whose owner is listed explicitly in
- # owner_uuids.
- sql_conds += ["#{sql_table}.owner_uuid IN (:owner_uuids)"]
+ sql_conds = "(#{direct_check} #{owner_check} #{links_cond}) #{exclude_trashed_records}"
- # 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))"]
+ end
- 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)))"]
+ if !include_old_versions && sql_table == "collections"
+ exclude_old_versions = "#{sql_table}.uuid = #{sql_table}.current_version_uuid"
+ if sql_conds.nil?
+ sql_conds = exclude_old_versions
+ else
+ sql_conds += " AND #{exclude_old_versions}"
+ end
end
- where(sql_conds.join(' OR '),
- owner_uuids: owner_uuids,
- user_uuids: user_uuids,
- permission_link_classes: ['permission', 'resources'])
+ self.where(sql_conds,
+ user_uuids: user_uuids,
+ permission_link_classes: ['permission', 'resources'])
end
def save_with_unique_name!
# discover a unique name. It is necessary to handle name choosing at
# this level (as opposed to the client) to ensure that record creation
# never fails due to a race condition.
- err = rn.original_exception
+ err = rn.cause
raise unless err.is_a?(PG::UniqueViolation)
# Unfortunately ActiveRecord doesn't abstract out any of the
end
self[:name] = new_name
- self[:uuid] = nil if uuid_was.nil? && !uuid.nil?
+ if uuid_was.nil? && !uuid.nil?
+ self[:uuid] = nil
+ if self.is_a? Collection
+ # Reset so that is assigned to the new UUID
+ self[:current_version_uuid] = nil
+ end
+ end
conn.exec_query 'SAVEPOINT save_with_unique_name'
retry
ensure
def self.full_text_searchable_columns
self.columns.select do |col|
- col.type == :string or col.type == :text
+ [:string, :text, :jsonb].include?(col.type)
end.map(&:name)
end
def self.full_text_tsvector
parts = full_text_searchable_columns.collect do |column|
- "coalesce(#{column},'')"
+ cast = serialized_attributes[column] ? '::text' : ''
+ "coalesce(#{column}#{cast},'')"
end
- "to_tsvector('english', #{parts.join(" || ' ' || ")})"
+ "to_tsvector('english', substr(#{parts.join(" || ' ' || ")}, 0, 8000))"
end
def self.apply_filters query, filters
end
rescue ActiveRecord::RecordNotFound => e
errors.add :owner_uuid, "is not owned by any user: #{e}"
- return false
+ throw(:abort)
end
if uuid_in_path[x]
if x == owner_uuid
else
errors.add :owner_uuid, "has an ownership cycle"
end
- return false
+ throw(:abort)
end
uuid_in_path[x] = true
end
def update_modified_by_fields
current_time = db_current_time
- self.created_at = created_at_was || current_time
+ self.created_at ||= created_at_was || current_time
self.updated_at = current_time
self.owner_uuid ||= current_default_owner if self.respond_to? :owner_uuid=
- self.modified_at = current_time
- self.modified_by_user_uuid = current_user ? current_user.uuid : nil
+ if !anonymous_updater
+ self.modified_by_user_uuid = current_user ? current_user.uuid : nil
+ end
+ if !timeless_updater
+ self.modified_at = current_time
+ end
self.modified_by_client_uuid = current_api_client ? current_api_client.uuid : nil
true
end
end
end
- def self.where_serialized(colname, value)
+ def self.where_serialized(colname, value, md5: false)
+ colsql = colname.to_s
+ if md5
+ colsql = "md5(#{colsql})"
+ end
if value.empty?
# rails4 stores as null, rails3 stored as serialized [] or {}
- sql = "#{colname.to_s} is null or #{colname.to_s} IN (?)"
+ sql = "#{colsql} is null or #{colsql} IN (?)"
sorted = value
else
- sql = "#{colname.to_s} IN (?)"
+ sql = "#{colsql} IN (?)"
sorted = deep_sort_hash(value)
end
- where(sql, [sorted.to_yaml, SafeJSON.dump(sorted)])
+ params = [sorted.to_yaml, SafeJSON.dump(sorted)]
+ if md5
+ params = params.map { |x| Digest::MD5.hexdigest(x) }
+ end
+ where(sql, params)
end
Serializer = {
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).unscoped.find_by_uuid(uuid)
+ self.resource_class_for_uuid(uuid).find_by_uuid(uuid)
else
super
end
end
+ def is_audit_logging_enabled?
+ return !(Rails.configuration.max_audit_log_age.to_i == 0 &&
+ Rails.configuration.max_audit_log_delete_batch.to_i > 0)
+ end
+
def log_start_state
- @old_attributes = Marshal.load(Marshal.dump(attributes))
- @old_logged_attributes = Marshal.load(Marshal.dump(logged_attributes))
+ if is_audit_logging_enabled?
+ @old_attributes = Marshal.load(Marshal.dump(attributes))
+ @old_logged_attributes = Marshal.load(Marshal.dump(logged_attributes))
+ end
end
def log_change(event_type)
- log = Log.new(event_type: event_type).fill_object(self)
- yield log
- log.save!
- log_start_state
+ if is_audit_logging_enabled?
+ log = Log.new(event_type: event_type).fill_object(self)
+ yield log
+ log.save!
+ log_start_state
+ end
end
def log_create
- log_change('create') do |log|
- log.fill_properties('old', nil, nil)
- log.update_to self
+ if is_audit_logging_enabled?
+ log_change('create') do |log|
+ log.fill_properties('old', nil, nil)
+ log.update_to self
+ end
end
end
def log_update
- log_change('update') do |log|
- log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
- log.update_to self
+ if is_audit_logging_enabled?
+ log_change('update') do |log|
+ log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
+ log.update_to self
+ end
end
end
def log_destroy
- log_change('delete') do |log|
- log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
- log.update_to nil
+ if is_audit_logging_enabled?
+ log_change('delete') do |log|
+ log.fill_properties('old', etag(@old_attributes), @old_logged_attributes)
+ log.update_to nil
+ end
end
end
end