20862: Add 'sdk/ruby-google-api-client/' from commit '2f4be67955e48bb65d008ecd9ff6da9...
[arvados.git] / services / api / app / controllers / application_controller.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require 'safe_json'
6 require 'request_error'
7
8 module ApiTemplateOverride
9   def allowed_to_render?(fieldset, field, model, options)
10     return false if !super
11     if options[:select]
12       options[:select].include? field.to_s
13     else
14       true
15     end
16   end
17 end
18
19 class ActsAsApi::ApiTemplate
20   prepend ApiTemplateOverride
21 end
22
23 require 'load_param'
24
25 class ApplicationController < ActionController::Base
26   include ThemesForRails::ActionController
27   include CurrentApiClient
28   include LoadParam
29   include DbCurrentTime
30
31   respond_to :json
32   protect_from_forgery
33
34   ERROR_ACTIONS = [:render_error, :render_not_found]
35
36   around_action :set_current_request_id
37   before_action :disable_api_methods
38   before_action :set_cors_headers
39   before_action :respond_with_json_by_default
40   before_action :remote_ip
41   before_action :load_read_auths
42   before_action :require_auth_scope, except: ERROR_ACTIONS
43
44   before_action :catch_redirect_hint
45   before_action :load_required_parameters
46   before_action :load_limit_offset_order_params, only: [:index, :contents]
47   before_action :load_select_param
48   before_action(:find_object_by_uuid,
49                 except: [:index, :create, :update] + ERROR_ACTIONS)
50   before_action :find_object_for_update, only: [:update]
51   before_action :load_where_param, only: [:index, :contents]
52   before_action :load_filters_param, only: [:index, :contents]
53   before_action :find_objects_for_index, :only => :index
54   before_action(:set_nullable_attrs_to_null, only: [:update, :create])
55   before_action :reload_object_before_update, :only => :update
56   before_action(:render_404_if_no_object,
57                 except: [:index, :create] + ERROR_ACTIONS)
58   before_action :only_admin_can_bypass_federation
59
60   attr_writer :resource_attrs
61
62   begin
63     rescue_from(Exception,
64                 ArvadosModel::PermissionDeniedError,
65                 :with => :render_error)
66     rescue_from(ActiveRecord::RecordNotFound,
67                 ActionController::RoutingError,
68                 AbstractController::ActionNotFound,
69                 :with => :render_not_found)
70   end
71
72   def initialize *args
73     super
74     @object = nil
75     @objects = nil
76     @offset = nil
77     @limit = nil
78     @select = nil
79     @distinct = nil
80     @response_resource_name = nil
81     @attrs = nil
82     @extra_included = nil
83   end
84
85   def default_url_options
86     options = {}
87     if Rails.configuration.Services.Controller.ExternalURL != URI("")
88       exturl = Rails.configuration.Services.Controller.ExternalURL
89       options[:host] = exturl.host
90       options[:port] = exturl.port
91       options[:protocol] = exturl.scheme
92     end
93     options
94   end
95
96   def index
97     if params[:eager] and params[:eager] != '0' and params[:eager] != 0 and params[:eager] != ''
98       @objects.each(&:eager_load_associations)
99     end
100     render_list
101   end
102
103   def show
104     send_json @object.as_api_response(nil, select: select_for_klass(@select, model_class))
105   end
106
107   def create
108     @object = model_class.new resource_attrs
109
110     if @object.respond_to?(:name) && params[:ensure_unique_name]
111       @object.save_with_unique_name!
112     else
113       @object.save!
114     end
115
116     show
117   end
118
119   def update
120     attrs_to_update = resource_attrs.reject { |k,v|
121       [:kind, :etag, :href].index k
122     }
123     @object.update_attributes! attrs_to_update
124     show
125   end
126
127   def destroy
128     @object.destroy
129     show
130   end
131
132   def catch_redirect_hint
133     if !current_user
134       if params.has_key?('redirect_to') then
135         session[:redirect_to] = params[:redirect_to]
136       end
137     end
138   end
139
140   def render_404_if_no_object
141     render_not_found "Object not found" if !@object
142   end
143
144   def only_admin_can_bypass_federation
145     unless !params[:bypass_federation] || current_user.andand.is_admin
146       send_error("The bypass_federation parameter is only permitted when current user is admin", status: 403)
147     end
148   end
149
150   def render_error(e)
151     logger.error e.inspect
152     if e.respond_to? :backtrace and e.backtrace
153       # This will be cleared by lograge after adding it to the log.
154       # Usually lograge would get the exceptions, but in our case we're catching
155       # all of them with exception handlers that cannot re-raise them because they
156       # don't get propagated.
157       Thread.current[:exception] = e.inspect
158       Thread.current[:backtrace] = e.backtrace.collect { |x| x + "\n" }.join('')
159     end
160     if (@object.respond_to? :errors and
161         @object.errors.andand.full_messages.andand.any?)
162       errors = @object.errors.full_messages
163       logger.error errors.inspect
164     else
165       errors = [e.inspect]
166     end
167     status = e.respond_to?(:http_status) ? e.http_status : 422
168     send_error(*errors, status: status)
169   end
170
171   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
172     logger.error e.inspect
173     send_error("Path not found", status: 404)
174   end
175
176   def render_accepted
177     send_json ({accepted: true}), status: 202
178   end
179
180   protected
181
182   def bool_param(pname)
183     if params.include?(pname)
184       if params[pname].is_a?(Boolean)
185         return params[pname]
186       else
187         logger.warn "Warning: received non-boolean value #{params[pname].inspect} for boolean parameter #{pname} on #{self.class.inspect}, treating as false."
188       end
189     end
190     false
191   end
192
193   def send_error(*args)
194     if args.last.is_a? Hash
195       err = args.pop
196     else
197       err = {}
198     end
199     err[:errors] ||= args
200     err[:errors].map! do |err|
201       err += " (#{request.request_id})"
202     end
203     err[:error_token] = [Time.now.utc.to_i, "%08x" % rand(16 ** 8)].join("+")
204     status = err.delete(:status) || 422
205     logger.error "Error #{err[:error_token]}: #{status}"
206     send_json err, status: status
207   end
208
209   def send_json response, opts={}
210     # The obvious render(json: ...) forces a slow JSON encoder. See
211     # #3021 and commit logs. Might be fixed in Rails 4.1.
212     render({
213              plain: SafeJSON.dump(response).html_safe,
214              content_type: 'application/json'
215            }.merge opts)
216   end
217
218   def find_objects_for_index
219     @objects ||= model_class.readable_by(*@read_users, {
220       :include_trash => (bool_param(:include_trash) || 'untrash' == action_name),
221       :include_old_versions => bool_param(:include_old_versions)
222     })
223     apply_where_limit_order_params
224   end
225
226   def apply_filters model_class=nil
227     model_class ||= self.model_class
228     @objects = model_class.apply_filters(@objects, @filters)
229   end
230
231   def select_for_klass sel, model_class, raise_unknown=true
232     return nil if sel.nil?
233     # Filter the select fields to only the ones that apply to the
234     # given class.
235     sel.map do |column|
236       sp = column.split(".")
237       if sp.length == 2 && sp[0] == model_class.table_name && model_class.selectable_attributes.include?(sp[1])
238         sp[1]
239       elsif model_class.selectable_attributes.include? column
240         column
241       elsif raise_unknown
242         raise ArgumentError.new("Invalid attribute '#{column}' of #{model_class.name} in select parameter")
243       else
244         nil
245       end
246     end.compact
247   end
248
249   def apply_where_limit_order_params model_class=nil
250     model_class ||= self.model_class
251     apply_filters model_class
252
253     ar_table_name = @objects.table_name
254     if @where.is_a? Hash and @where.any?
255       conditions = ['1=1']
256       @where.each do |attr,value|
257         if attr.to_s == 'any'
258           if value.is_a?(Array) and
259               value.length == 2 and
260               value[0] == 'contains' then
261             ilikes = []
262             model_class.searchable_columns('ilike').each do |column|
263               # Including owner_uuid in an "any column" search will
264               # probably just return a lot of false positives.
265               next if column == 'owner_uuid'
266               ilikes << "#{ar_table_name}.#{column} ilike ?"
267               conditions << "%#{value[1]}%"
268             end
269             if ilikes.any?
270               conditions[0] << ' and (' + ilikes.join(' or ') + ')'
271             end
272           end
273         elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
274             model_class.columns.collect(&:name).index(attr.to_s)
275           if value.nil?
276             conditions[0] << " and #{ar_table_name}.#{attr} is ?"
277             conditions << nil
278           elsif value.is_a? Array
279             if value[0] == 'contains' and value.length == 2
280               conditions[0] << " and #{ar_table_name}.#{attr} like ?"
281               conditions << "%#{value[1]}%"
282             else
283               conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
284               conditions << value
285             end
286           elsif value.is_a? String or value.is_a? Integer or value == true or value == false
287             conditions[0] << " and #{ar_table_name}.#{attr}=?"
288             conditions << value
289           elsif value.is_a? Hash
290             # Not quite the same thing as "equal?" but better than nothing?
291             value.each do |k,v|
292               if v.is_a? String
293                 conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
294                 conditions << "%#{k}%#{v}%"
295               end
296             end
297           end
298         end
299       end
300       if conditions.length > 1
301         conditions[0].sub!(/^1=1 and /, '')
302         @objects = @objects.
303           where(*conditions)
304       end
305     end
306
307     if @select
308       unless action_name.in? %w(create update destroy)
309         # Map attribute names in @select to real column names, resolve
310         # those to fully-qualified SQL column names, and pass the
311         # resulting string to the select method.
312         columns_list = model_class.columns_for_attributes(select_for_klass @select, model_class).
313           map { |s| "#{ar_table_name}.#{ActiveRecord::Base.connection.quote_column_name s}" }
314         @objects = @objects.select(columns_list.join(", "))
315       end
316
317       # This information helps clients understand what they're seeing
318       # (Workbench always expects it), but they can't select it explicitly
319       # because it's not an SQL column.  Always add it.
320       # (This is harmless, given that clients can deduce what they're
321       # looking at by the returned UUID anyway.)
322       @select |= ["kind"]
323     end
324     @objects = @objects.order(@orders.join ", ") if @orders.any?
325     @objects = @objects.limit(@limit)
326     @objects = @objects.offset(@offset)
327     @objects = @objects.distinct() if @distinct
328   end
329
330   # limit_database_read ensures @objects (which must be an
331   # ActiveRelation) does not return too many results to fit in memory,
332   # by previewing the results and calling @objects.limit() if
333   # necessary.
334   def limit_database_read(model_class:)
335     return if @limit == 0 || @limit == 1
336     model_class ||= self.model_class
337     limit_columns = model_class.limit_index_columns_read
338     limit_columns &= model_class.columns_for_attributes(select_for_klass @select, model_class) if @select
339     return if limit_columns.empty?
340     model_class.transaction do
341       limit_query = @objects.
342         except(:select, :distinct).
343         select("(%s) as read_length" %
344                limit_columns.map { |s| "octet_length(#{model_class.table_name}.#{s})" }.join(" + "))
345       new_limit = 0
346       read_total = 0
347       limit_query.each do |record|
348         new_limit += 1
349         read_total += record.read_length.to_i
350         if read_total >= Rails.configuration.API.MaxIndexDatabaseRead
351           new_limit -= 1 if new_limit > 1
352           @limit = new_limit
353           break
354         elsif new_limit >= @limit
355           break
356         end
357       end
358       @objects = @objects.limit(@limit)
359       # Force @objects to run its query inside this transaction.
360       @objects.each { |_| break }
361     end
362   end
363
364   def resource_attrs
365     return @attrs if @attrs
366     @attrs = params[resource_name]
367     if @attrs.nil?
368       @attrs = {}
369     elsif @attrs.is_a? String
370       @attrs = Oj.strict_load @attrs, symbol_keys: true
371     end
372     unless [Hash, ActionController::Parameters].include? @attrs.class
373       message = "No #{resource_name}"
374       if resource_name.index('_')
375         message << " (or #{resource_name.camelcase(:lower)})"
376       end
377       message << " hash provided with request"
378       raise ArgumentError.new(message)
379     end
380     %w(created_at modified_by_client_uuid modified_by_user_uuid modified_at).each do |x|
381       @attrs.delete x.to_sym
382     end
383     @attrs = @attrs.symbolize_keys if @attrs.is_a? ActiveSupport::HashWithIndifferentAccess
384     @attrs
385   end
386
387   # Authentication
388   def load_read_auths
389     @read_auths = []
390     if current_api_client_authorization
391       @read_auths << current_api_client_authorization
392     end
393     # Load reader tokens if this is a read request.
394     # If there are too many reader tokens, assume the request is malicious
395     # and ignore it.
396     if request.get? and params[:reader_tokens] and
397       params[:reader_tokens].size < 100
398       secrets = params[:reader_tokens].map { |t|
399         if t.is_a? String and t.starts_with? "v2/"
400           t.split("/")[2]
401         else
402           t
403         end
404       }
405       @read_auths += ApiClientAuthorization
406         .includes(:user)
407         .where('api_token IN (?) AND
408                 (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)',
409                secrets)
410         .to_a
411     end
412     @read_auths.select! { |auth| auth.scopes_allow_request? request }
413     @read_users = @read_auths.map(&:user).uniq
414   end
415
416   def require_login
417     if not current_user
418       respond_to do |format|
419         format.json { send_error("Not logged in", status: 401) }
420         format.html { redirect_to '/login' }
421       end
422       false
423     end
424   end
425
426   def admin_required
427     unless current_user and current_user.is_admin
428       send_error("Forbidden", status: 403)
429     end
430   end
431
432   def require_auth_scope
433     unless current_user && @read_auths.any? { |auth| auth.user.andand.uuid == current_user.uuid }
434       if require_login != false
435         send_error("Forbidden", status: 403)
436       end
437       false
438     end
439   end
440
441   def set_current_request_id
442     Rails.logger.tagged(request.request_id) do
443       yield
444     end
445   end
446
447   def append_info_to_payload(payload)
448     super
449     payload[:request_id] = request.request_id
450     payload[:client_ipaddr] = @remote_ip
451     payload[:client_auth] = current_api_client_authorization.andand.uuid || nil
452   end
453
454   def disable_api_methods
455     if Rails.configuration.API.DisabledAPIs[controller_name + "." + action_name]
456       send_error("Disabled", status: 404)
457     end
458   end
459
460   def set_cors_headers
461     response.headers['Access-Control-Allow-Origin'] = '*'
462     response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, PUT, POST, DELETE'
463     response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'
464     response.headers['Access-Control-Max-Age'] = '86486400'
465   end
466
467   def respond_with_json_by_default
468     html_index = request.accepts.index(Mime[:html])
469     if html_index.nil? or request.accepts[0...html_index].include?(Mime[:json])
470       request.format = :json
471     end
472   end
473
474   def model_class
475     controller_name.classify.constantize
476   end
477
478   def resource_name             # params[] key used by client
479     controller_name.singularize
480   end
481
482   def table_name
483     controller_name
484   end
485
486   def find_object_for_update
487     find_object_by_uuid(with_lock: true)
488   end
489
490   def find_object_by_uuid(with_lock: false)
491     if params[:id] and params[:id].match(/\D/)
492       params[:uuid] = params.delete :id
493     end
494     @where = { uuid: params[:uuid] }
495     @offset = 0
496     @limit = 1
497     @orders = []
498     @filters = []
499     @objects = nil
500
501     # This is a little hacky but sometimes the fields the user wants
502     # to selecting on are unrelated to the object being loaded here,
503     # for example groups#contents, so filter the fields that will be
504     # used in find_objects_for_index and then reset afterwards.  In
505     # some cases, code that modifies the @select list needs to set
506     # @preserve_select.
507     @preserve_select = @select
508     @select = select_for_klass(@select, self.model_class, false)
509
510     find_objects_for_index
511     if with_lock && Rails.configuration.API.LockBeforeUpdate
512       @object = @objects.lock.first
513     else
514       @object = @objects.first
515     end
516     @select = @preserve_select
517   end
518
519   def nullable_attributes
520     []
521   end
522
523   # Go code may send empty values (ie: empty string instead of NULL) that
524   # should be translated to NULL on the database.
525   def set_nullable_attrs_to_null
526     nullify_attrs(resource_attrs.to_hash).each do |k, v|
527       resource_attrs[k] = v
528     end
529   end
530
531   def nullify_attrs(a = {})
532     new_attrs = a.to_hash.symbolize_keys
533     (new_attrs.keys & nullable_attributes).each do |attr|
534       val = new_attrs[attr]
535       if (val.class == Integer && val == 0) || (val.class == String && val == "")
536         new_attrs[attr] = nil
537       end
538     end
539     return new_attrs
540   end
541
542   def reload_object_before_update
543     # This is necessary to prevent an ActiveRecord::ReadOnlyRecord
544     # error when updating an object which was retrieved using a join.
545     if @object.andand.readonly?
546       @object = model_class.find_by_uuid(@objects.first.uuid)
547     end
548   end
549
550   def load_json_value(hash, key, must_be_class=nil)
551     return if hash[key].nil?
552
553     val = hash[key]
554     if val.is_a? ActionController::Parameters
555       val = val.to_unsafe_hash
556     elsif val.is_a? String
557       val = SafeJSON.load(val)
558       hash[key] = val
559     end
560     # When assigning a Hash to an ActionController::Parameters and then
561     # retrieve it, we get another ActionController::Parameters instead of
562     # a Hash. This doesn't happen with other types. This is why 'val' is
563     # being used to do type checking below.
564     if must_be_class and !val.is_a? must_be_class
565       raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
566     end
567   end
568
569   def self.accept_attribute_as_json(attr, must_be_class=nil)
570     before_action lambda { accept_attribute_as_json attr, must_be_class }
571   end
572   accept_attribute_as_json :properties, Hash
573   accept_attribute_as_json :info, Hash
574   def accept_attribute_as_json(attr, must_be_class)
575     if params[resource_name] and [Hash, ActionController::Parameters].include?(resource_attrs.class)
576       if resource_attrs[attr].is_a? Hash
577         # Convert symbol keys to strings (in hashes provided by
578         # resource_attrs)
579         resource_attrs[attr] = resource_attrs[attr].
580           with_indifferent_access.to_hash
581       else
582         load_json_value(resource_attrs, attr, must_be_class)
583       end
584     end
585   end
586
587   def self.accept_param_as_json(key, must_be_class=nil)
588     prepend_before_action lambda { load_json_value(params, key, must_be_class) }
589   end
590   accept_param_as_json :reader_tokens, Array
591
592   def object_list(model_class:)
593     if @objects.respond_to?(:except)
594       limit_database_read(model_class: model_class)
595     end
596     list = {
597       :kind  => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
598       :etag => "",
599       :self_link => "",
600       :offset => @offset,
601       :limit => @limit,
602       :items => @objects.as_api_response(nil, {select: @select})
603     }
604     if @extra_included
605       list[:included] = @extra_included.as_api_response(nil, {select: @select})
606     end
607     case params[:count]
608     when nil, '', 'exact'
609       if @objects.respond_to? :except
610         list[:items_available] = @objects.
611           except(:limit).except(:offset).
612           count(@distinct ? :id : '*')
613       end
614     when 'none'
615     else
616       raise ArgumentError.new("count parameter must be 'exact' or 'none'")
617     end
618     list
619   end
620
621   def render_list
622     send_json object_list(model_class: self.model_class)
623   end
624
625   def remote_ip
626     # Caveat: this is highly dependent on the proxy setup. YMMV.
627     if request.headers.key?('HTTP_X_REAL_IP') then
628       # We're behind a reverse proxy
629       @remote_ip = request.headers['HTTP_X_REAL_IP']
630     else
631       # Hopefully, we are not!
632       @remote_ip = request.env['REMOTE_ADDR']
633     end
634   end
635
636   def load_required_parameters
637     (self.class.send "_#{params[:action]}_requires_parameters" rescue {}).
638       each do |key, info|
639       if info[:required] and not params.include?(key)
640         raise ArgumentError.new("#{key} parameter is required")
641       elsif info[:type] == 'boolean'
642         # Make sure params[key] is either true or false -- not a
643         # string, not nil, etc.
644         if not params.include?(key)
645           params[key] = info[:default] || false
646         elsif [false, 'false', '0', 0].include? params[key]
647           params[key] = false
648         elsif [true, 'true', '1', 1].include? params[key]
649           params[key] = true
650         else
651           raise TypeError.new("#{key} parameter must be a boolean, true or false")
652         end
653       end
654     end
655     true
656   end
657
658   def self._create_requires_parameters
659     {
660       select: {
661         type: 'array',
662         description: "Attributes of the new object to return in the response.",
663         required: false,
664       },
665       ensure_unique_name: {
666         type: "boolean",
667         description: "Adjust name to ensure uniqueness instead of returning an error on (owner_uuid, name) collision.",
668         location: "query",
669         required: false,
670         default: false
671       },
672       cluster_id: {
673         type: 'string',
674         description: "Create object on a remote federated cluster instead of the current one.",
675         location: "query",
676         required: false,
677       },
678     }
679   end
680
681   def self._update_requires_parameters
682     {
683       select: {
684         type: 'array',
685         description: "Attributes of the updated object to return in the response.",
686         required: false,
687       },
688     }
689   end
690
691   def self._show_requires_parameters
692     {
693       select: {
694         type: 'array',
695         description: "Attributes of the object to return in the response.",
696         required: false,
697       },
698     }
699   end
700
701   def self._index_requires_parameters
702     {
703       filters: { type: 'array', required: false },
704       where: { type: 'object', required: false },
705       order: { type: 'array', required: false },
706       select: {
707         type: 'array',
708         description: "Attributes of each object to return in the response.",
709         required: false,
710       },
711       distinct: { type: 'boolean', required: false, default: false },
712       limit: { type: 'integer', required: false, default: DEFAULT_LIMIT },
713       offset: { type: 'integer', required: false, default: 0 },
714       count: { type: 'string', required: false, default: 'exact' },
715       cluster_id: {
716         type: 'string',
717         description: "List objects on a remote federated cluster instead of the current one.",
718         location: "query",
719         required: false,
720       },
721       bypass_federation: {
722         type: 'boolean',
723         required: false,
724         description: 'bypass federation behavior, list items from local instance database only'
725       }
726     }
727   end
728
729   def render *opts
730     if opts.first
731       response = opts.first[:json]
732       if response.is_a?(Hash) &&
733           params[:_profile] &&
734           Thread.current[:request_starttime]
735         response[:_profile] = {
736           request_time: Time.now - Thread.current[:request_starttime]
737         }
738       end
739     end
740     super(*opts)
741   end
742 end