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