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.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 ') + ')'
285 elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
286 model_class.columns.collect(&:name).index(attr.to_s)
288 conditions[0] << " and #{ar_table_name}.#{attr} is ?"
290 elsif value.is_a? Array
291 if value[0] == 'contains' and value.length == 2
292 conditions[0] << " and #{ar_table_name}.#{attr} like ?"
293 conditions << "%#{value[1]}%"
295 conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
298 elsif value.is_a? String or value.is_a? Integer or value == true or value == false
299 conditions[0] << " and #{ar_table_name}.#{attr}=?"
301 elsif value.is_a? Hash
302 # Not quite the same thing as "equal?" but better than nothing?
305 conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
306 conditions << "%#{k}%#{v}%"
312 if conditions.length > 1
313 conditions[0].sub!(/^1=1 and /, '')
320 unless action_name.in? %w(create update destroy)
321 # Map attribute names in @select to real column names, resolve
322 # those to fully-qualified SQL column names, and pass the
323 # resulting string to the select method.
324 columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
325 map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
326 @objects = @objects.select(columns_list.join(", "))
329 # This information helps clients understand what they're seeing
330 # (Workbench always expects it), but they can't select it explicitly
331 # because it's not an SQL column. Always add it.
332 # (This is harmless, given that clients can deduce what they're
333 # looking at by the returned UUID anyway.)
336 @objects = @objects.order(@orders.join ", ") if @orders.any?
337 @objects = @objects.limit(@limit)
338 @objects = @objects.offset(@offset)
339 @objects = @objects.distinct() if @distinct
342 # limit_database_read ensures @objects (which must be an
343 # ActiveRelation) does not return too many results to fit in memory,
344 # by previewing the results and calling @objects.limit() if
346 def limit_database_read(model_class:)
347 return if @limit == 0 || @limit == 1
348 model_class ||= self.model_class
349 limit_columns = model_class.limit_index_columns_read
350 limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
351 return if limit_columns.empty?
352 model_class.transaction do
353 # This query does not use `pg_column_size()` because the returned value
354 # can be smaller than the apparent length thanks to compression.
355 # `octet_length(::text)` better reflects how expensive it will be for
356 # Rails to process the data.
357 limit_query = @objects.
358 except(:select, :distinct).
359 select("(%s) as read_length" %
360 limit_columns.map { |s| "octet_length(#{model_class.table_name}.#{s}::text)" }.join(" + "))
363 limit_query.each do |record|
365 read_total += record.read_length.to_i
366 if read_total >= Rails.configuration.API.MaxIndexDatabaseRead
367 new_limit -= 1 if new_limit > 1
370 elsif new_limit >= @limit
374 @objects = @objects.limit(@limit)
375 # Force @objects to run its query inside this transaction.
376 @objects.each { |_| break }
381 return @attrs if @attrs
382 @attrs = params[resource_name]
385 elsif @attrs.is_a? String
386 @attrs = Oj.strict_load @attrs, symbol_keys: true
388 unless [Hash, ActionController::Parameters].include? @attrs.class
389 message = "No #{resource_name}"
390 if resource_name.index('_')
391 message << " (or #{resource_name.camelcase(:lower)})"
393 message << " hash provided with request"
394 raise ArgumentError.new(message)
396 %w(created_at modified_by_client_uuid modified_by_user_uuid modified_at).each do |x|
397 @attrs.delete x.to_sym
399 @attrs = @attrs.symbolize_keys if @attrs.is_a? ActiveSupport::HashWithIndifferentAccess
406 if current_api_client_authorization
407 @read_auths << current_api_client_authorization
409 # Load reader tokens if this is a read request.
410 # If there are too many reader tokens, assume the request is malicious
412 if request.get? and params[:reader_tokens] and
413 params[:reader_tokens].size < 100
414 secrets = params[:reader_tokens].map { |t|
415 if t.is_a? String and t.starts_with? "v2/"
421 @read_auths += ApiClientAuthorization
423 .where('api_token IN (?) AND
424 (least(expires_at, refreshes_at) IS NULL
425 OR least(expires_at, refreshes_at) > CURRENT_TIMESTAMP)',
429 @read_auths.select! { |auth| auth.scopes_allow_request? request }
430 @read_users = @read_auths.map(&:user).uniq
435 respond_to do |format|
436 format.json { send_error("Not logged in", status: 401) }
437 format.html { redirect_to '/login' }
444 unless current_user and current_user.is_admin
445 send_error("Forbidden", status: 403)
449 def require_auth_scope
450 unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
451 if require_login != false
452 send_error("Forbidden", status: 403)
458 def set_current_request_id
459 Rails.logger.tagged(request.request_id) do
464 def append_info_to_payload(payload)
466 payload[:request_id] = request.request_id
467 payload[:client_ipaddr] = @remote_ip
468 payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
471 def disable_api_methods
472 if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
473 send_error("Disabled", status: 404)
478 response.headers['Access-Control-Allow-Origin'] = '*'
479 response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
480 response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
481 response.headers['Access-Control-Max-Age'] = '86486400'
484 def respond_with_json_by_default
485 html_index = request.accepts.index(Mime[:html])
486 if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
487 request.format = :json
492 controller_name.classify.constantize
495 def resource_name # params[] key used by client
496 controller_name.singularize
503 def find_object_for_update
504 find_object_by_uuid(with_lock: true)
507 def find_object_by_uuid(with_lock: false)
508 if params[:id] and params[:id].match(/\D/)
509 params[:uuid] = params.delete :id
512 # Some APIs (at least groups/contents) take an optional uuid argument.
513 # They go through this method to handle it when present but we cannot
514 # assume it is always set.
515 @where[:uuid] = params[:uuid] if params[:uuid]
522 # This is a little hacky but sometimes the fields the user wants
523 # to selecting on are unrelated to the object being loaded here,
524 # for example groups#contents, so filter the fields that will be
525 # used in find_objects_for_index and then reset afterwards. In
526 # some cases, code that modifies the @select list needs to set
528 @preserve_select = @select
529 @select = select_for_klass(@select, self.model_class, false)
531 find_objects_for_index
532 if with_lock && Rails.configuration.API.LockBeforeUpdate
533 @object = @objects.lock.first
535 @object = @objects.first
537 @select = @preserve_select
540 def nullable_attributes
544 # Go code may send empty values (ie: empty string instead of NULL) that
545 # should be translated to NULL on the database.
546 def set_nullable_attrs_to_null
547 nullify_attrs(resource_attrs.to_hash).each do |k, v|
548 resource_attrs[k] = v
552 def nullify_attrs(a = {})
553 new_attrs = a.to_hash.symbolize_keys
554 (new_attrs.keys & nullable_attributes).each do |attr|
555 val = new_attrs[attr]
556 if (val.class == Integer && val == 0) || (val.class == String && val == "")
557 new_attrs[attr] = nil
563 def reload_object_before_update
564 # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
565 # error when updating an object which was retrieved using a join.
566 if @object.andand.readonly?
567 @object = model_class.find_by_uuid(@objects.first.uuid)
571 def load_json_value(hash, key, must_be_class=nil)
572 return if hash[key].nil?
575 if val.is_a? ActionController::Parameters
576 val = val.to_unsafe_hash
577 elsif val.is_a? String
578 val = SafeJSON.load(val)
581 # When assigning a Hash to an ActionController::Parameters and then
582 # retrieve it, we get another ActionController::Parameters instead of
583 # a Hash. This doesn't happen with other types. This is why 'val' is
584 # being used to do type checking below.
585 if must_be_class and !val.is_a? must_be_class
586 raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
590 def self.accept_attribute_as_json(attr, must_be_class=nil)
591 before_action lambda { accept_attribute_as_json attr, must_be_class }
593 accept_attribute_as_json :properties, Hash
594 accept_attribute_as_json :info, Hash
595 def accept_attribute_as_json(attr, must_be_class)
596 if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
597 if resource_attrs[attr].is_a? Hash
598 # Convert symbol keys to strings (in hashes provided by
600 resource_attrs[attr] = resource_attrs[attr].
601 with_indifferent_access.to_hash
603 load_json_value(resource_attrs, attr, must_be_class)
608 def self.accept_param_as_json(key, must_be_class=nil)
609 prepend_before_action lambda { load_json_value(params, key, must_be_class) }
611 accept_param_as_json :reader_tokens, Array
613 def object_list(model_class:)
614 if @objects.respond_to?(:except)
615 limit_database_read(model_class: model_class)
618 :kind => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
623 :items => @objects.as_api_response(nil, {select: @select})
626 list[:included] = @extra_included.as_api_response(nil, {select: @select})
629 when nil, '', 'exact'
630 if @objects.respond_to? :except
631 list[:items_available] = @objects.
632 except(:limit).except(:offset).
633 count(@distinct ? :id : '*')
637 raise ArgumentError.new("count parameter must be 'exact' or 'none'")
643 send_json object_list(model_class: self.model_class)
647 # Caveat: this is highly dependent on the proxy setup. YMMV.
648 if request.headers.key?('HTTP_X_REAL_IP') then
649 # We're behind a reverse proxy
650 @remote_ip = request.headers['HTTP_X_REAL_IP']
652 # Hopefully, we are not!
653 @remote_ip = request.env['REMOTE_ADDR']
657 def load_required_parameters
658 (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
660 if info[:required] and not params.include?(key)
661 raise ArgumentError.new("#{key} parameter is required")
662 elsif info[:type] == 'boolean'
663 # Make sure params[key] is either true or false -- not a
664 # string, not nil, etc.
665 if not params.include?(key)
666 params[key] = info[:default] || false
667 elsif [false, 'false', '0', 0].include? params[key]
669 elsif [true, 'true', '1', 1].include? params[key]
672 raise TypeError.new("#{key} parameter must be a boolean, true or false")
679 def self._create_requires_parameters
683 description: "An array of names of attributes to return in the response.",
686 ensure_unique_name: {
688 description: "If the given name is already used by this owner, adjust the name to ensure uniqueness instead of returning an error.",
695 description: "Cluster ID of a federated cluster where this object should be created.",
702 def self._update_requires_parameters
706 description: "An array of names of attributes to return in the response.",
712 def self._show_requires_parameters
716 description: "An array of names of attributes to return in the response.",
722 def self._index_requires_parameters
727 description: "Filters to limit which objects are returned by their attributes.
728 Refer to the [filters reference][] for more information about how to write filters.
730 [filters reference]: https://doc.arvados.org/api/methods.html#filters
736 description: "An object to limit which objects are returned by their attributes.
737 The keys of this object are attribute names.
738 Each value is either a single matching value or an array of matching values for that attribute.
739 The `filters` parameter is more flexible and preferred.
745 description: "An array of strings to set the order in which matching objects are returned.
746 Each string has the format `<ATTRIBUTE> <DIRECTION>`.
747 `DIRECTION` can be `asc` or omitted for ascending, or `desc` for descending.
752 description: "An array of names of attributes to return from each matching object.",
759 description: "If this is true, and multiple objects have the same values
760 for the attributes that you specify in the `select` parameter, then each unique
761 set of values will only be returned once in the result set.
767 default: DEFAULT_LIMIT,
768 description: "The maximum number of objects to return in the result.
769 Note that the API may return fewer results than this if your request hits other
770 limits set by the administrator.
777 description: "Return matching objects starting from this index.
778 Note that result indexes may change if objects are modified in between a series
786 description: "A string to determine result counting behavior. Supported values are:
788 * `\"exact\"`: The response will include an `items_available` field that
789 counts the number of objects that matched this search criteria,
790 including ones not included in `items`.
792 * `\"none\"`: The response will not include an `items_avaliable`
793 field. This improves performance by returning a result as soon as enough
794 `items` have been loaded for this result.
800 description: "Cluster ID of a federated cluster to return objects from",
808 description: "If true, do not return results from other clusters in the
809 federation, only the cluster that received the request.
810 You must be an administrator to use this flag.
818 response = opts.first[:json]
819 if response.is_a?(Hash) &&
821 Thread.current[:request_starttime]
822 response[:_profile] = {
823 request_time: Time.now - Thread.current[:request_starttime]