1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: AGPL-3.0
6 require 'request_error'
8 module ApiTemplateOverride
9 def allowed_to_render?(fieldset, field, model, options)
10 return false if !super
12 options[:select].include? field.to_s
19 class ActsAsApi::ApiTemplate
20 prepend ApiTemplateOverride
25 class ApplicationController < ActionController::Base
26 include CurrentApiClient
32 # Although CSRF protection is already enabled by default, this is
33 # still needed to reposition CSRF checks later in callback order.
36 ERROR_ACTIONS = [:render_error, :render_not_found]
38 around_action :set_current_request_id
39 before_action :disable_api_methods
40 before_action :set_cors_headers
41 before_action :respond_with_json_by_default
42 before_action :remote_ip
43 before_action :load_read_auths
44 before_action :require_auth_scope, except: ERROR_ACTIONS
46 before_action :catch_redirect_hint
47 before_action :load_required_parameters
48 before_action :load_limit_offset_order_params, only: [:index, :contents]
49 before_action :load_select_param
50 before_action(:find_object_by_uuid,
51 except: [:index, :create, :update] + ERROR_ACTIONS)
52 before_action :find_object_for_update, only: [:update]
53 before_action :load_where_param, only: [:index, :contents]
54 before_action :load_filters_param, only: [:index, :contents]
55 before_action :find_objects_for_index, :only => :index
56 before_action(:set_nullable_attrs_to_null, only: [:update, :create])
57 before_action :reload_object_before_update, :only => :update
58 before_action(:render_404_if_no_object,
59 except: [:index, :create] + ERROR_ACTIONS)
60 before_action :only_admin_can_bypass_federation
62 attr_writer :resource_attrs
65 rescue_from(Exception,
66 ArvadosModel::PermissionDeniedError,
67 :with => :render_error)
68 rescue_from(ActiveRecord::RecordNotFound,
69 ActionController::RoutingError,
70 AbstractController::ActionNotFound,
71 :with => :render_not_found)
82 @response_resource_name = nil
87 def default_url_options
89 if Rails.configuration.Services.Controller.ExternalURL != URI("")
90 exturl = Rails.configuration.Services.Controller.ExternalURL
91 options[:host] = exturl.host
92 options[:port] = exturl.port
93 options[:protocol] = exturl.scheme
99 if params[:eager] and params[:eager] != '0' and params[:eager] != 0 and params[:eager] != ''
100 @objects.each(&:eager_load_associations)
106 send_json @object.as_api_response(nil, select: select_for_klass(@select, model_class))
110 @object = model_class.new resource_attrs
112 if @object.respond_to?(:name) && params[:ensure_unique_name]
113 @object.save_with_unique_name!
122 attrs_to_update = resource_attrs.reject { |k,v|
123 [:kind, :etag, :href].index k
125 @object.update! attrs_to_update
134 def catch_redirect_hint
136 if params.has_key?('redirect_to') then
137 session[:redirect_to] = params[:redirect_to]
142 def render_404_if_no_object
143 render_not_found "Object not found" if !@object
146 def only_admin_can_bypass_federation
147 unless !params[:bypass_federation] || current_user.andand.is_admin
148 send_error("The bypass_federation parameter is only permitted when current user is admin", status: 403)
153 logger.error e.inspect
154 if e.respond_to? :backtrace and e.backtrace
155 # This will be cleared by lograge after adding it to the log.
156 # Usually lograge would get the exceptions, but in our case we're catching
157 # all of them with exception handlers that cannot re-raise them because they
158 # don't get propagated.
159 Thread.current[:exception] = e.inspect
160 Thread.current[:backtrace] = e.backtrace.collect { |x| x + "\n" }.join('')
162 if (@object.respond_to? :errors and
163 @object.errors.andand.full_messages.andand.any?)
164 errors = @object.errors.full_messages
165 logger.error errors.inspect
171 when ActiveRecord::Deadlocked,
172 ActiveRecord::ConnectionNotEstablished,
173 ActiveRecord::LockWaitTimeout,
174 ActiveRecord::QueryAborted
177 status = e.respond_to?(:http_status) ? e.http_status : 422
180 send_error(*errors, status: status)
183 def render_not_found(e=ActionController::RoutingError.new("Path not found"))
184 logger.error e.inspect
185 send_error("Path not found", status: 404)
189 send_json ({accepted: true}), status: 202
194 def bool_param(pname)
195 if params.include?(pname)
196 if params[pname].is_a?(Boolean)
199 logger.warn "Warning: received non-boolean value #{params[pname].inspect} for boolean parameter #{pname} on #{self.class.inspect}, treating as false."
205 def send_error(*args)
206 if args.last.is_a? Hash
211 err[:errors] ||= args
212 err[:errors].map! do |err|
213 err += " (#{request.request_id})"
215 err[:error_token] = [Time.now.utc.to_i, "%08x" % rand(16 ** 8)].join("+")
216 status = err.delete(:status) || 422
217 logger.error "Error #{err[:error_token]}: #{status}"
218 send_json err, status: status
221 def send_json response, opts={}
222 # The obvious render(json: ...) forces a slow JSON encoder. See
223 # #3021 and commit logs. Might be fixed in Rails 4.1.
225 plain: SafeJSON.dump(response).html_safe,
226 content_type: 'application/json'
230 def find_objects_for_index
231 @objects ||= model_class.readable_by(*@read_users, {
232 :include_trash => ((self.class._index_requires_parameters[:include_trash] && bool_param(:include_trash)) || 'untrash' == action_name),
233 :include_old_versions => self.class._index_requires_parameters[:include_old_versions] && bool_param(:include_old_versions)
235 apply_where_limit_order_params
238 def apply_filters model_class=nil
239 model_class ||= self.model_class
240 @objects = model_class.apply_filters(@objects, @filters)
243 def select_for_klass sel, model_class, raise_unknown=true
244 return nil if sel.nil?
245 # Filter the select fields to only the ones that apply to the
248 sp = column.split(".")
249 if sp.length == 2 && sp[0] == model_class.table_name && model_class.selectable_attributes.include?(sp[1])
251 elsif model_class.selectable_attributes.include? column
254 raise ArgumentError.new("Invalid attribute '#{column}' of #{model_class.name} in select parameter")
261 def apply_where_limit_order_params model_class=nil
262 model_class ||= self.model_class
263 apply_filters model_class
265 ar_table_name = @objects.table_name
266 if @where.is_a? Hash and @where.any?
268 @where.each do |attr,value|
269 if attr.to_s == 'any'
270 if value.is_a?(Array) and
271 value.length == 2 and
272 value[0] == 'contains' then
274 model_class.any_searchable_columns('ilike').each do |column|
275 # Including owner_uuid in an "any column" search will
276 # probably just return a lot of false positives.
277 next if column == 'owner_uuid'
278 ilikes << "#{ar_table_name}.#{column} ilike ?"
279 conditions << "%#{value[1]}%"
282 conditions[0] << ' and (' + ilikes.join(' or ') + ')'
286 model_class.any_searchable_columns('=').each do |column|
287 equals << "#{ar_table_name}.#{column} = ?"
290 conditions[0] << ' and (' + equals.join(' or ') + ')'
292 elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
293 model_class.columns.collect(&:name).index(attr.to_s)
295 conditions[0] << " and #{ar_table_name}.#{attr} is ?"
297 elsif value.is_a? Array
298 if value[0] == 'contains' and value.length == 2
299 conditions[0] << " and #{ar_table_name}.#{attr} like ?"
300 conditions << "%#{value[1]}%"
302 conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
305 elsif value.is_a? String or value.is_a? Integer or value == true or value == false
306 conditions[0] << " and #{ar_table_name}.#{attr}=?"
308 elsif value.is_a? Hash
309 # Not quite the same thing as "equal?" but better than nothing?
312 conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
313 conditions << "%#{k}%#{v}%"
319 if conditions.length > 1
320 conditions[0].sub!(/^1=1 and /, '')
327 unless action_name.in? %w(create update destroy)
328 # Map attribute names in @select to real column names, resolve
329 # those to fully-qualified SQL column names, and pass the
330 # resulting string to the select method.
331 columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
332 map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
333 @objects = @objects.select(columns_list.join(", "))
336 # This information helps clients understand what they're seeing
337 # (Workbench always expects it), but they can't select it explicitly
338 # because it's not an SQL column. Always add it.
339 # (This is harmless, given that clients can deduce what they're
340 # looking at by the returned UUID anyway.)
343 @objects = @objects.order(@orders.join ", ") if @orders.any?
344 @objects = @objects.limit(@limit)
345 @objects = @objects.offset(@offset)
346 @objects = @objects.distinct() if @distinct
349 # limit_database_read ensures @objects (which must be an
350 # ActiveRelation) does not return too many results to fit in memory,
351 # by previewing the results and calling @objects.limit() if
353 def limit_database_read(model_class:)
354 return if @limit == 0 || @limit == 1
355 model_class ||= self.model_class
356 limit_columns = model_class.limit_index_columns_read
357 limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
358 return if limit_columns.empty?
359 model_class.transaction do
360 # This query does not use `pg_column_size()` because the returned value
361 # can be smaller than the apparent length thanks to compression.
362 # `octet_length(::text)` better reflects how expensive it will be for
363 # Rails to process the data.
364 limit_query = @objects.
365 except(:select, :distinct).
366 select("(%s) as read_length" %
367 limit_columns.map { |s| "coalesce(octet_length(#{model_class.table_name}.#{s}::text),0)" }.join(" + "))
370 limit_query.each do |record|
372 read_total += record.read_length.to_i
373 if read_total >= Rails.configuration.API.MaxIndexDatabaseRead
374 new_limit -= 1 if new_limit > 1
377 elsif new_limit >= @limit
381 @objects = @objects.limit(@limit)
382 # Force @objects to run its query inside this transaction.
383 @objects.each { |_| break }
388 return @attrs if @attrs
389 @attrs = params[resource_name]
392 elsif @attrs.is_a? String
393 @attrs = Oj.strict_load @attrs, symbol_keys: true
395 unless [Hash, ActionController::Parameters].include? @attrs.class
396 message = "No #{resource_name}"
397 if resource_name.index('_')
398 message << " (or #{resource_name.camelcase(:lower)})"
400 message << " hash provided with request"
401 raise ArgumentError.new(message)
403 %w(created_at modified_by_client_uuid modified_by_user_uuid modified_at).each do |x|
404 @attrs.delete x.to_sym
406 @attrs = @attrs.symbolize_keys if @attrs.is_a? ActiveSupport::HashWithIndifferentAccess
413 if current_api_client_authorization
414 @read_auths << current_api_client_authorization
416 # Load reader tokens if this is a read request.
417 # If there are too many reader tokens, assume the request is malicious
419 if request.get? and params[:reader_tokens] and
420 params[:reader_tokens].size < 100
421 secrets = params[:reader_tokens].map { |t|
422 if t.is_a? String and t.starts_with? "v2/"
428 @read_auths += ApiClientAuthorization
430 .where('api_token IN (?) AND
431 (least(expires_at, refreshes_at) IS NULL
432 OR least(expires_at, refreshes_at) > CURRENT_TIMESTAMP)',
436 @read_auths.select! { |auth| auth.scopes_allow_request? request }
437 @read_users = @read_auths.map(&:user).uniq
442 respond_to do |format|
443 format.json { send_error("Not logged in", status: 401) }
444 format.html { redirect_to '/login' }
451 unless current_user and current_user.is_admin
452 send_error("Forbidden", status: 403)
456 def require_auth_scope
457 unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
458 if require_login != false
459 send_error("Forbidden", status: 403)
465 def set_current_request_id
466 Rails.logger.tagged(request.request_id) do
471 def append_info_to_payload(payload)
473 payload[:request_id] = request.request_id
474 payload[:client_ipaddr] = @remote_ip
475 payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
478 def disable_api_methods
479 if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
480 send_error("Disabled", status: 404)
485 response.headers['Access-Control-Allow-Origin'] = '*'
486 response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
487 response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
488 response.headers['Access-Control-Max-Age'] = '86486400'
491 def respond_with_json_by_default
492 html_index = request.accepts.index(Mime[:html])
493 if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
494 request.format = :json
499 controller_name.classify.constantize
502 def resource_name # params[] key used by client
503 controller_name.singularize
510 def find_object_for_update
511 find_object_by_uuid(with_lock: true)
514 def find_object_by_uuid(with_lock: false)
515 if params[:id] and params[:id].match(/\D/)
516 params[:uuid] = params.delete :id
519 # Some APIs (at least groups/contents) take an optional uuid argument.
520 # They go through this method to handle it when present but we cannot
521 # assume it is always set.
522 @where[:uuid] = params[:uuid] if params[:uuid]
529 # This is a little hacky but sometimes the fields the user wants
530 # to selecting on are unrelated to the object being loaded here,
531 # for example groups#contents, so filter the fields that will be
532 # used in find_objects_for_index and then reset afterwards. In
533 # some cases, code that modifies the @select list needs to set
535 @preserve_select = @select
536 @select = select_for_klass(@select, self.model_class, false)
538 find_objects_for_index
539 if with_lock && Rails.configuration.API.LockBeforeUpdate
540 @object = @objects.lock.first
542 @object = @objects.first
544 @select = @preserve_select
547 def nullable_attributes
551 # Go code may send empty values (ie: empty string instead of NULL) that
552 # should be translated to NULL on the database.
553 def set_nullable_attrs_to_null
554 nullify_attrs(resource_attrs.to_hash).each do |k, v|
555 resource_attrs[k] = v
559 def nullify_attrs(a = {})
560 new_attrs = a.to_hash.symbolize_keys
561 (new_attrs.keys & nullable_attributes).each do |attr|
562 val = new_attrs[attr]
563 if (val.class == Integer && val == 0) || (val.class == String && val == "")
564 new_attrs[attr] = nil
570 def reload_object_before_update
571 # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
572 # error when updating an object which was retrieved using a join.
573 if @object.andand.readonly?
574 @object = model_class.find_by_uuid(@objects.first.uuid)
578 def load_json_value(hash, key, must_be_class=nil)
579 return if hash[key].nil?
582 if val.is_a? ActionController::Parameters
583 val = val.to_unsafe_hash
584 elsif val.is_a? String
585 val = SafeJSON.load(val)
588 # When assigning a Hash to an ActionController::Parameters and then
589 # retrieve it, we get another ActionController::Parameters instead of
590 # a Hash. This doesn't happen with other types. This is why 'val' is
591 # being used to do type checking below.
592 if must_be_class and !val.is_a? must_be_class
593 raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
597 def self.accept_attribute_as_json(attr, must_be_class=nil)
598 before_action lambda { accept_attribute_as_json attr, must_be_class }
600 accept_attribute_as_json :properties, Hash
601 accept_attribute_as_json :info, Hash
602 def accept_attribute_as_json(attr, must_be_class)
603 if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
604 if resource_attrs[attr].is_a? Hash
605 # Convert symbol keys to strings (in hashes provided by
607 resource_attrs[attr] = resource_attrs[attr].
608 with_indifferent_access.to_hash
610 load_json_value(resource_attrs, attr, must_be_class)
615 def self.accept_param_as_json(key, must_be_class=nil)
616 prepend_before_action lambda { load_json_value(params, key, must_be_class) }
618 accept_param_as_json :reader_tokens, Array
620 def object_list(model_class:)
621 if @objects.respond_to?(:except)
622 limit_database_read(model_class: model_class)
625 :kind => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
630 :items => @objects.as_api_response(nil, {select: @select})
633 list[:included] = @extra_included.as_api_response(nil, {select: @select})
636 when nil, '', 'exact'
637 if @objects.respond_to? :except
638 list[:items_available] = @objects.
639 except(:limit).except(:offset).
640 count(@distinct ? :id : '*')
644 raise ArgumentError.new("count parameter must be 'exact' or 'none'")
650 send_json object_list(model_class: self.model_class)
654 # Caveat: this is highly dependent on the proxy setup. YMMV.
655 if request.headers.key?('HTTP_X_REAL_IP') then
656 # We're behind a reverse proxy
657 @remote_ip = request.headers['HTTP_X_REAL_IP']
659 # Hopefully, we are not!
660 @remote_ip = request.env['REMOTE_ADDR']
664 def load_required_parameters
665 (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
667 if info[:required] and not params.include?(key)
668 raise ArgumentError.new("#{key} parameter is required")
669 elsif info[:type] == 'boolean'
670 # Make sure params[key] is either true or false -- not a
671 # string, not nil, etc.
672 if not params.include?(key)
673 params[key] = info[:default] || false
674 elsif [false, 'false', '0', 0].include? params[key]
676 elsif [true, 'true', '1', 1].include? params[key]
679 raise TypeError.new("#{key} parameter must be a boolean, true or false")
686 def self._create_requires_parameters
690 description: "An array of names of attributes to return in the response.",
693 ensure_unique_name: {
695 description: "If the given name is already used by this owner, adjust the name to ensure uniqueness instead of returning an error.",
702 description: "Cluster ID of a federated cluster where this object should be created.",
709 def self._update_requires_parameters
713 description: "An array of names of attributes to return in the response.",
719 def self._show_requires_parameters
723 description: "An array of names of attributes to return in the response.",
729 def self._index_requires_parameters
734 description: "Filters to limit which objects are returned by their attributes.
735 Refer to the [filters reference][] for more information about how to write filters.
737 [filters reference]: https://doc.arvados.org/api/methods.html#filters
743 description: "An object to limit which objects are returned by their attributes.
744 The keys of this object are attribute names.
745 Each value is either a single matching value or an array of matching values for that attribute.
746 The `filters` parameter is more flexible and preferred.
752 description: "An array of strings to set the order in which matching objects are returned.
753 Each string has the format `<ATTRIBUTE> <DIRECTION>`.
754 `DIRECTION` can be `asc` or omitted for ascending, or `desc` for descending.
759 description: "An array of names of attributes to return from each matching object.",
766 description: "If this is true, and multiple objects have the same values
767 for the attributes that you specify in the `select` parameter, then each unique
768 set of values will only be returned once in the result set.
774 default: DEFAULT_LIMIT,
775 description: "The maximum number of objects to return in the result.
776 Note that the API may return fewer results than this if your request hits other
777 limits set by the administrator.
784 description: "Return matching objects starting from this index.
785 Note that result indexes may change if objects are modified in between a series
793 description: "A string to determine result counting behavior. Supported values are:
795 * `\"exact\"`: The response will include an `items_available` field that
796 counts the number of objects that matched this search criteria,
797 including ones not included in `items`.
799 * `\"none\"`: The response will not include an `items_avaliable`
800 field. This improves performance by returning a result as soon as enough
801 `items` have been loaded for this result.
807 description: "Cluster ID of a federated cluster to return objects from",
815 description: "If true, do not return results from other clusters in the
816 federation, only the cluster that received the request.
817 You must be an administrator to use this flag.
825 response = opts.first[:json]
826 if response.is_a?(Hash) &&
828 Thread.current[:request_starttime]
829 response[:_profile] = {
830 request_time: Time.now - Thread.current[:request_starttime]