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
169 status = e.respond_to?(:http_status) ? e.http_status : 422
170 send_error(*errors, status: status)
173 def render_not_found(e=ActionController::RoutingError.new("Path not found"))
174 logger.error e.inspect
175 send_error("Path not found", status: 404)
179 send_json ({accepted: true}), status: 202
184 def bool_param(pname)
185 if params.include?(pname)
186 if params[pname].is_a?(Boolean)
189 logger.warn "Warning: received non-boolean value #{params[pname].inspect} for boolean parameter #{pname} on #{self.class.inspect}, treating as false."
195 def send_error(*args)
196 if args.last.is_a? Hash
201 err[:errors] ||= args
202 err[:errors].map! do |err|
203 err += " (#{request.request_id})"
205 err[:error_token] = [Time.now.utc.to_i, "%08x" % rand(16 ** 8)].join("+")
206 status = err.delete(:status) || 422
207 logger.error "Error #{err[:error_token]}: #{status}"
208 send_json err, status: status
211 def send_json response, opts={}
212 # The obvious render(json: ...) forces a slow JSON encoder. See
213 # #3021 and commit logs. Might be fixed in Rails 4.1.
215 plain: SafeJSON.dump(response).html_safe,
216 content_type: 'application/json'
220 def find_objects_for_index
221 @objects ||= model_class.readable_by(*@read_users, {
222 :include_trash => ((self.class._index_requires_parameters[:include_trash] && bool_param(:include_trash)) || 'untrash' == action_name),
223 :include_old_versions => self.class._index_requires_parameters[:include_old_versions] && bool_param(:include_old_versions)
225 apply_where_limit_order_params
228 def apply_filters model_class=nil
229 model_class ||= self.model_class
230 @objects = model_class.apply_filters(@objects, @filters)
233 def select_for_klass sel, model_class, raise_unknown=true
234 return nil if sel.nil?
235 # Filter the select fields to only the ones that apply to the
238 sp = column.split(".")
239 if sp.length == 2 && sp[0] == model_class.table_name && model_class.selectable_attributes.include?(sp[1])
241 elsif model_class.selectable_attributes.include? column
244 raise ArgumentError.new("Invalid attribute '#{column}' of #{model_class.name} in select parameter")
251 def apply_where_limit_order_params model_class=nil
252 model_class ||= self.model_class
253 apply_filters model_class
255 ar_table_name = @objects.table_name
256 if @where.is_a? Hash and @where.any?
258 @where.each do |attr,value|
259 if attr.to_s == 'any'
260 if value.is_a?(Array) and
261 value.length == 2 and
262 value[0] == 'contains' then
264 model_class.searchable_columns('ilike').each do |column|
265 # Including owner_uuid in an "any column" search will
266 # probably just return a lot of false positives.
267 next if column == 'owner_uuid'
268 ilikes << "#{ar_table_name}.#{column} ilike ?"
269 conditions << "%#{value[1]}%"
272 conditions[0] << ' and (' + ilikes.join(' or ') + ')'
275 elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
276 model_class.columns.collect(&:name).index(attr.to_s)
278 conditions[0] << " and #{ar_table_name}.#{attr} is ?"
280 elsif value.is_a? Array
281 if value[0] == 'contains' and value.length == 2
282 conditions[0] << " and #{ar_table_name}.#{attr} like ?"
283 conditions << "%#{value[1]}%"
285 conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
288 elsif value.is_a? String or value.is_a? Integer or value == true or value == false
289 conditions[0] << " and #{ar_table_name}.#{attr}=?"
291 elsif value.is_a? Hash
292 # Not quite the same thing as "equal?" but better than nothing?
295 conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
296 conditions << "%#{k}%#{v}%"
302 if conditions.length > 1
303 conditions[0].sub!(/^1=1 and /, '')
310 unless action_name.in? %w(create update destroy)
311 # Map attribute names in @select to real column names, resolve
312 # those to fully-qualified SQL column names, and pass the
313 # resulting string to the select method.
314 columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
315 map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
316 @objects = @objects.select(columns_list.join(", "))
319 # This information helps clients understand what they're seeing
320 # (Workbench always expects it), but they can't select it explicitly
321 # because it's not an SQL column. Always add it.
322 # (This is harmless, given that clients can deduce what they're
323 # looking at by the returned UUID anyway.)
326 @objects = @objects.order(@orders.join ", ") if @orders.any?
327 @objects = @objects.limit(@limit)
328 @objects = @objects.offset(@offset)
329 @objects = @objects.distinct() if @distinct
332 # limit_database_read ensures @objects (which must be an
333 # ActiveRelation) does not return too many results to fit in memory,
334 # by previewing the results and calling @objects.limit() if
336 def limit_database_read(model_class:)
337 return if @limit == 0 || @limit == 1
338 model_class ||= self.model_class
339 limit_columns = model_class.limit_index_columns_read
340 limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
341 return if limit_columns.empty?
342 model_class.transaction do
343 # This query does not use `pg_column_size()` because the returned value
344 # can be smaller than the apparent length thanks to compression.
345 # `octet_length(::text)` better reflects how expensive it will be for
346 # Rails to process the data.
347 limit_query = @objects.
348 except(:select, :distinct).
349 select("(%s) as read_length" %
350 limit_columns.map { |s| "octet_length(#{model_class.table_name}.#{s}::text)" }.join(" + "))
353 limit_query.each do |record|
355 read_total += record.read_length.to_i
356 if read_total >= Rails.configuration.API.MaxIndexDatabaseRead
357 new_limit -= 1 if new_limit > 1
360 elsif new_limit >= @limit
364 @objects = @objects.limit(@limit)
365 # Force @objects to run its query inside this transaction.
366 @objects.each { |_| break }
371 return @attrs if @attrs
372 @attrs = params[resource_name]
375 elsif @attrs.is_a? String
376 @attrs = Oj.strict_load @attrs, symbol_keys: true
378 unless [Hash, ActionController::Parameters].include? @attrs.class
379 message = "No #{resource_name}"
380 if resource_name.index('_')
381 message << " (or #{resource_name.camelcase(:lower)})"
383 message << " hash provided with request"
384 raise ArgumentError.new(message)
386 %w(created_at modified_by_client_uuid modified_by_user_uuid modified_at).each do |x|
387 @attrs.delete x.to_sym
389 @attrs = @attrs.symbolize_keys if @attrs.is_a? ActiveSupport::HashWithIndifferentAccess
396 if current_api_client_authorization
397 @read_auths << current_api_client_authorization
399 # Load reader tokens if this is a read request.
400 # If there are too many reader tokens, assume the request is malicious
402 if request.get? and params[:reader_tokens] and
403 params[:reader_tokens].size < 100
404 secrets = params[:reader_tokens].map { |t|
405 if t.is_a? String and t.starts_with? "v2/"
411 @read_auths += ApiClientAuthorization
413 .where('api_token IN (?) AND
414 (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)',
418 @read_auths.select! { |auth| auth.scopes_allow_request? request }
419 @read_users = @read_auths.map(&:user).uniq
424 respond_to do |format|
425 format.json { send_error("Not logged in", status: 401) }
426 format.html { redirect_to '/login' }
433 unless current_user and current_user.is_admin
434 send_error("Forbidden", status: 403)
438 def require_auth_scope
439 unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
440 if require_login != false
441 send_error("Forbidden", status: 403)
447 def set_current_request_id
448 Rails.logger.tagged(request.request_id) do
453 def append_info_to_payload(payload)
455 payload[:request_id] = request.request_id
456 payload[:client_ipaddr] = @remote_ip
457 payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
460 def disable_api_methods
461 if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
462 send_error("Disabled", status: 404)
467 response.headers['Access-Control-Allow-Origin'] = '*'
468 response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
469 response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
470 response.headers['Access-Control-Max-Age'] = '86486400'
473 def respond_with_json_by_default
474 html_index = request.accepts.index(Mime[:html])
475 if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
476 request.format = :json
481 controller_name.classify.constantize
484 def resource_name # params[] key used by client
485 controller_name.singularize
492 def find_object_for_update
493 find_object_by_uuid(with_lock: true)
496 def find_object_by_uuid(with_lock: false)
497 if params[:id] and params[:id].match(/\D/)
498 params[:uuid] = params.delete :id
500 @where = { uuid: params[:uuid] }
507 # This is a little hacky but sometimes the fields the user wants
508 # to selecting on are unrelated to the object being loaded here,
509 # for example groups#contents, so filter the fields that will be
510 # used in find_objects_for_index and then reset afterwards. In
511 # some cases, code that modifies the @select list needs to set
513 @preserve_select = @select
514 @select = select_for_klass(@select, self.model_class, false)
516 find_objects_for_index
517 if with_lock && Rails.configuration.API.LockBeforeUpdate
518 @object = @objects.lock.first
520 @object = @objects.first
522 @select = @preserve_select
525 def nullable_attributes
529 # Go code may send empty values (ie: empty string instead of NULL) that
530 # should be translated to NULL on the database.
531 def set_nullable_attrs_to_null
532 nullify_attrs(resource_attrs.to_hash).each do |k, v|
533 resource_attrs[k] = v
537 def nullify_attrs(a = {})
538 new_attrs = a.to_hash.symbolize_keys
539 (new_attrs.keys & nullable_attributes).each do |attr|
540 val = new_attrs[attr]
541 if (val.class == Integer && val == 0) || (val.class == String && val == "")
542 new_attrs[attr] = nil
548 def reload_object_before_update
549 # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
550 # error when updating an object which was retrieved using a join.
551 if @object.andand.readonly?
552 @object = model_class.find_by_uuid(@objects.first.uuid)
556 def load_json_value(hash, key, must_be_class=nil)
557 return if hash[key].nil?
560 if val.is_a? ActionController::Parameters
561 val = val.to_unsafe_hash
562 elsif val.is_a? String
563 val = SafeJSON.load(val)
566 # When assigning a Hash to an ActionController::Parameters and then
567 # retrieve it, we get another ActionController::Parameters instead of
568 # a Hash. This doesn't happen with other types. This is why 'val' is
569 # being used to do type checking below.
570 if must_be_class and !val.is_a? must_be_class
571 raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
575 def self.accept_attribute_as_json(attr, must_be_class=nil)
576 before_action lambda { accept_attribute_as_json attr, must_be_class }
578 accept_attribute_as_json :properties, Hash
579 accept_attribute_as_json :info, Hash
580 def accept_attribute_as_json(attr, must_be_class)
581 if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
582 if resource_attrs[attr].is_a? Hash
583 # Convert symbol keys to strings (in hashes provided by
585 resource_attrs[attr] = resource_attrs[attr].
586 with_indifferent_access.to_hash
588 load_json_value(resource_attrs, attr, must_be_class)
593 def self.accept_param_as_json(key, must_be_class=nil)
594 prepend_before_action lambda { load_json_value(params, key, must_be_class) }
596 accept_param_as_json :reader_tokens, Array
598 def object_list(model_class:)
599 if @objects.respond_to?(:except)
600 limit_database_read(model_class: model_class)
603 :kind => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
608 :items => @objects.as_api_response(nil, {select: @select})
611 list[:included] = @extra_included.as_api_response(nil, {select: @select})
614 when nil, '', 'exact'
615 if @objects.respond_to? :except
616 list[:items_available] = @objects.
617 except(:limit).except(:offset).
618 count(@distinct ? :id : '*')
622 raise ArgumentError.new("count parameter must be 'exact' or 'none'")
628 send_json object_list(model_class: self.model_class)
632 # Caveat: this is highly dependent on the proxy setup. YMMV.
633 if request.headers.key?('HTTP_X_REAL_IP') then
634 # We're behind a reverse proxy
635 @remote_ip = request.headers['HTTP_X_REAL_IP']
637 # Hopefully, we are not!
638 @remote_ip = request.env['REMOTE_ADDR']
642 def load_required_parameters
643 (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
645 if info[:required] and not params.include?(key)
646 raise ArgumentError.new("#{key} parameter is required")
647 elsif info[:type] == 'boolean'
648 # Make sure params[key] is either true or false -- not a
649 # string, not nil, etc.
650 if not params.include?(key)
651 params[key] = info[:default] || false
652 elsif [false, 'false', '0', 0].include? params[key]
654 elsif [true, 'true', '1', 1].include? params[key]
657 raise TypeError.new("#{key} parameter must be a boolean, true or false")
664 def self._create_requires_parameters
668 description: "An array of names of attributes to return in the response.",
671 ensure_unique_name: {
673 description: "If the given name is already used by this owner, adjust the name to ensure uniqueness instead of returning an error.",
680 description: "Cluster ID of a federated cluster where this object should be created.",
687 def self._update_requires_parameters
691 description: "An array of names of attributes to return in the response.",
697 def self._show_requires_parameters
701 description: "An array of names of attributes to return in the response.",
707 def self._index_requires_parameters
712 description: "Filters to limit which objects are returned by their attributes.
713 Refer to the [filters reference][] for more information about how to write filters.
715 [filters reference]: https://doc.arvados.org/api/methods.html#filters
721 description: "An object to limit which objects are returned by their attributes.
722 The keys of this object are attribute names.
723 Each value is either a single matching value or an array of matching values for that attribute.
724 The `filters` parameter is more flexible and preferred.
730 description: "An array of strings to set the order in which matching objects are returned.
731 Each string has the format `<ATTRIBUTE> <DIRECTION>`.
732 `DIRECTION` can be `asc` or omitted for ascending, or `desc` for descending.
737 description: "An array of names of attributes to return from each matching object.",
744 description: "If this is true, and multiple objects have the same values
745 for the attributes that you specify in the `select` parameter, then each unique
746 set of values will only be returned once in the result set.
752 default: DEFAULT_LIMIT,
753 description: "The maximum number of objects to return in the result.
754 Note that the API may return fewer results than this if your request hits other
755 limits set by the administrator.
762 description: "Return matching objects starting from this index.
763 Note that result indexes may change if objects are modified in between a series
771 description: "A string to determine result counting behavior. Supported values are:
773 * `\"exact\"`: The response will include an `items_available` field that
774 counts the number of objects that matched this search criteria,
775 including ones not included in `items`.
777 * `\"none\"`: The response will not include an `items_avaliable`
778 field. This improves performance by returning a result as soon as enough
779 `items` have been loaded for this result.
785 description: "Cluster ID of a federated cluster to return objects from",
793 description: "If true, do not return results from other clusters in the
794 federation, only the cluster that received the request.
795 You must be an administrator to use this flag.
803 response = opts.first[:json]
804 if response.is_a?(Hash) &&
806 Thread.current[:request_starttime]
807 response[:_profile] = {
808 request_time: Time.now - Thread.current[:request_starttime]