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 (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)',
428 @read_auths.select! { |auth| auth.scopes_allow_request? request }
429 @read_users = @read_auths.map(&:user).uniq
434 respond_to do |format|
435 format.json { send_error("Not logged in", status: 401) }
436 format.html { redirect_to '/login' }
443 unless current_user and current_user.is_admin
444 send_error("Forbidden", status: 403)
448 def require_auth_scope
449 unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
450 if require_login != false
451 send_error("Forbidden", status: 403)
457 def set_current_request_id
458 Rails.logger.tagged(request.request_id) do
463 def append_info_to_payload(payload)
465 payload[:request_id] = request.request_id
466 payload[:client_ipaddr] = @remote_ip
467 payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
470 def disable_api_methods
471 if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
472 send_error("Disabled", status: 404)
477 response.headers['Access-Control-Allow-Origin'] = '*'
478 response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
479 response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
480 response.headers['Access-Control-Max-Age'] = '86486400'
483 def respond_with_json_by_default
484 html_index = request.accepts.index(Mime[:html])
485 if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
486 request.format = :json
491 controller_name.classify.constantize
494 def resource_name # params[] key used by client
495 controller_name.singularize
502 def find_object_for_update
503 find_object_by_uuid(with_lock: true)
506 def find_object_by_uuid(with_lock: false)
507 if params[:id] and params[:id].match(/\D/)
508 params[:uuid] = params.delete :id
510 @where = { uuid: params[:uuid] }
517 # This is a little hacky but sometimes the fields the user wants
518 # to selecting on are unrelated to the object being loaded here,
519 # for example groups#contents, so filter the fields that will be
520 # used in find_objects_for_index and then reset afterwards. In
521 # some cases, code that modifies the @select list needs to set
523 @preserve_select = @select
524 @select = select_for_klass(@select, self.model_class, false)
526 find_objects_for_index
527 if with_lock && Rails.configuration.API.LockBeforeUpdate
528 @object = @objects.lock.first
530 @object = @objects.first
532 @select = @preserve_select
535 def nullable_attributes
539 # Go code may send empty values (ie: empty string instead of NULL) that
540 # should be translated to NULL on the database.
541 def set_nullable_attrs_to_null
542 nullify_attrs(resource_attrs.to_hash).each do |k, v|
543 resource_attrs[k] = v
547 def nullify_attrs(a = {})
548 new_attrs = a.to_hash.symbolize_keys
549 (new_attrs.keys & nullable_attributes).each do |attr|
550 val = new_attrs[attr]
551 if (val.class == Integer && val == 0) || (val.class == String && val == "")
552 new_attrs[attr] = nil
558 def reload_object_before_update
559 # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
560 # error when updating an object which was retrieved using a join.
561 if @object.andand.readonly?
562 @object = model_class.find_by_uuid(@objects.first.uuid)
566 def load_json_value(hash, key, must_be_class=nil)
567 return if hash[key].nil?
570 if val.is_a? ActionController::Parameters
571 val = val.to_unsafe_hash
572 elsif val.is_a? String
573 val = SafeJSON.load(val)
576 # When assigning a Hash to an ActionController::Parameters and then
577 # retrieve it, we get another ActionController::Parameters instead of
578 # a Hash. This doesn't happen with other types. This is why 'val' is
579 # being used to do type checking below.
580 if must_be_class and !val.is_a? must_be_class
581 raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
585 def self.accept_attribute_as_json(attr, must_be_class=nil)
586 before_action lambda { accept_attribute_as_json attr, must_be_class }
588 accept_attribute_as_json :properties, Hash
589 accept_attribute_as_json :info, Hash
590 def accept_attribute_as_json(attr, must_be_class)
591 if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
592 if resource_attrs[attr].is_a? Hash
593 # Convert symbol keys to strings (in hashes provided by
595 resource_attrs[attr] = resource_attrs[attr].
596 with_indifferent_access.to_hash
598 load_json_value(resource_attrs, attr, must_be_class)
603 def self.accept_param_as_json(key, must_be_class=nil)
604 prepend_before_action lambda { load_json_value(params, key, must_be_class) }
606 accept_param_as_json :reader_tokens, Array
608 def object_list(model_class:)
609 if @objects.respond_to?(:except)
610 limit_database_read(model_class: model_class)
613 :kind => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
618 :items => @objects.as_api_response(nil, {select: @select})
621 list[:included] = @extra_included.as_api_response(nil, {select: @select})
624 when nil, '', 'exact'
625 if @objects.respond_to? :except
626 list[:items_available] = @objects.
627 except(:limit).except(:offset).
628 count(@distinct ? :id : '*')
632 raise ArgumentError.new("count parameter must be 'exact' or 'none'")
638 send_json object_list(model_class: self.model_class)
642 # Caveat: this is highly dependent on the proxy setup. YMMV.
643 if request.headers.key?('HTTP_X_REAL_IP') then
644 # We're behind a reverse proxy
645 @remote_ip = request.headers['HTTP_X_REAL_IP']
647 # Hopefully, we are not!
648 @remote_ip = request.env['REMOTE_ADDR']
652 def load_required_parameters
653 (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
655 if info[:required] and not params.include?(key)
656 raise ArgumentError.new("#{key} parameter is required")
657 elsif info[:type] == 'boolean'
658 # Make sure params[key] is either true or false -- not a
659 # string, not nil, etc.
660 if not params.include?(key)
661 params[key] = info[:default] || false
662 elsif [false, 'false', '0', 0].include? params[key]
664 elsif [true, 'true', '1', 1].include? params[key]
667 raise TypeError.new("#{key} parameter must be a boolean, true or false")
674 def self._create_requires_parameters
678 description: "An array of names of attributes to return in the response.",
681 ensure_unique_name: {
683 description: "If the given name is already used by this owner, adjust the name to ensure uniqueness instead of returning an error.",
690 description: "Cluster ID of a federated cluster where this object should be created.",
697 def self._update_requires_parameters
701 description: "An array of names of attributes to return in the response.",
707 def self._show_requires_parameters
711 description: "An array of names of attributes to return in the response.",
717 def self._index_requires_parameters
722 description: "Filters to limit which objects are returned by their attributes.
723 Refer to the [filters reference][] for more information about how to write filters.
725 [filters reference]: https://doc.arvados.org/api/methods.html#filters
731 description: "An object to limit which objects are returned by their attributes.
732 The keys of this object are attribute names.
733 Each value is either a single matching value or an array of matching values for that attribute.
734 The `filters` parameter is more flexible and preferred.
740 description: "An array of strings to set the order in which matching objects are returned.
741 Each string has the format `<ATTRIBUTE> <DIRECTION>`.
742 `DIRECTION` can be `asc` or omitted for ascending, or `desc` for descending.
747 description: "An array of names of attributes to return from each matching object.",
754 description: "If this is true, and multiple objects have the same values
755 for the attributes that you specify in the `select` parameter, then each unique
756 set of values will only be returned once in the result set.
762 default: DEFAULT_LIMIT,
763 description: "The maximum number of objects to return in the result.
764 Note that the API may return fewer results than this if your request hits other
765 limits set by the administrator.
772 description: "Return matching objects starting from this index.
773 Note that result indexes may change if objects are modified in between a series
781 description: "A string to determine result counting behavior. Supported values are:
783 * `\"exact\"`: The response will include an `items_available` field that
784 counts the number of objects that matched this search criteria,
785 including ones not included in `items`.
787 * `\"none\"`: The response will not include an `items_avaliable`
788 field. This improves performance by returning a result as soon as enough
789 `items` have been loaded for this result.
795 description: "Cluster ID of a federated cluster to return objects from",
803 description: "If true, do not return results from other clusters in the
804 federation, only the cluster that received the request.
805 You must be an administrator to use this flag.
813 response = opts.first[:json]
814 if response.is_a?(Hash) &&
816 Thread.current[:request_starttime]
817 response[:_profile] = {
818 request_time: Time.now - Thread.current[:request_starttime]