summaryrefslogtreecommitdiff
path: root/app/controllers/graphql_controller.rb
blob: 67eeb43d5a2a42f27b9b050fd6ef804c2da203d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# frozen_string_literal: true

class GraphqlController < ApplicationController
  extend ::Gitlab::Utils::Override

  # Unauthenticated users have access to the API for public data
  skip_before_action :authenticate_user!

  # Header can be passed by tests to disable SQL query limits.
  DISABLE_SQL_QUERY_LIMIT_HEADER = 'HTTP_X_GITLAB_DISABLE_SQL_QUERY_LIMIT'

  # Max size of the query text in characters
  MAX_QUERY_SIZE = 10_000

  # If a user is using their session to access GraphQL, we need to have session
  # storage, since the admin-mode check is session wide.
  # We can't enable this for anonymous users because that would cause users using
  # enforced SSO from using an auth token to access the API.
  skip_around_action :set_session_storage, unless: :current_user

  # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing,
  # the user won't be authenticated but can proceed as an anonymous user.
  #
  # If a CSRF is valid, the user is authenticated. This makes it easier to play
  # around in GraphiQL.
  protect_from_forgery with: :null_session, only: :execute

  # must come first: current_user is set up here
  before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }

  before_action :authorize_access_api!
  before_action :set_user_last_activity
  before_action :track_vs_code_usage
  before_action :track_jetbrains_usage
  before_action :track_gitlab_cli_usage
  before_action :disable_query_limiting
  before_action :limit_query_size

  before_action :disallow_mutations_for_get

  # Since we deactivate authentication from the main ApplicationController and
  # defer it to :authorize_access_api!, we need to override the bypass session
  # callback execution order here
  around_action :sessionless_bypass_admin_mode!, if: :sessionless_user?

  # The default feature category is overridden to read from request
  feature_category :not_owned # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned

  # We don't know what the query is going to be, so we can't set a high urgency
  # See https://gitlab.com/groups/gitlab-org/-/epics/5841 for the work that will
  # allow us to specify an urgency per query.
  # Currently, all queries have a default urgency. And this is measured in the `graphql_queries`
  # SLI. But queries could be multiplexed, so the total duration could be longer.
  urgency :low, [:execute]

  def execute
    result = multiplex? ? execute_multiplex : execute_query
    render json: result
  end

  rescue_from StandardError do |exception|
    @exception_object = exception

    log_exception(exception)

    if Rails.env.test? || Rails.env.development?
      render_error("Internal server error: #{exception.message}", raised_at: exception.backtrace[0..10].join(' <-- '))
    else
      render_error("Internal server error")
    end
  end

  rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
    render_error(exception.message, status: :unprocessable_entity)
  end

  rescue_from Gitlab::Graphql::Errors::ArgumentError do |exception|
    render_error(exception.message, status: :unprocessable_entity)
  end

  rescue_from ::GraphQL::CoercionError do |exception|
    render_error(exception.message, status: :unprocessable_entity)
  end

  rescue_from ActiveRecord::QueryAborted do |exception|
    log_exception(exception)

    error = "Request timed out. Please try a less complex query or a smaller set of records."
    render_error(error, status: :service_unavailable)
  end

  override :feature_category
  def feature_category
    ::Gitlab::FeatureCategories.default.from_request(request) || super
  end

  private

  def disallow_mutations_for_get
    return unless request.get? || request.head?
    return unless any_mutating_query?

    raise ::Gitlab::Graphql::Errors::ArgumentError, "Mutations are forbidden in #{request.request_method} requests"
  end

  def limit_query_size
    total_size = if multiplex?
                   params[:_json].sum { _1[:query].size }
                 else
                   query.size
                 end

    raise ::Gitlab::Graphql::Errors::ArgumentError, "Query too large" if total_size > MAX_QUERY_SIZE
  end

  def any_mutating_query?
    if multiplex?
      multiplex_queries.any? { |q| mutation?(q[:query], q[:operation_name]) }
    else
      mutation?(query)
    end
  end

  def mutation?(query_string, operation_name = params[:operationName])
    ::GraphQL::Query.new(GitlabSchema, query_string, operation_name: operation_name).mutation?
  end

  # Tests may mark some GraphQL queries as exempt from SQL query limits
  def disable_query_limiting
    return unless Gitlab::QueryLimiting.enabled_for_env?

    disable_issue = request.headers[DISABLE_SQL_QUERY_LIMIT_HEADER]
    return unless disable_issue

    Gitlab::QueryLimiting.disable!(disable_issue)
  end

  def set_user_last_activity
    return unless current_user

    Users::ActivityService.new(current_user).execute
  end

  def track_vs_code_usage
    Gitlab::UsageDataCounters::VSCodeExtensionActivityUniqueCounter
      .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
  end

  def track_jetbrains_usage
    Gitlab::UsageDataCounters::JetBrainsPluginActivityUniqueCounter
      .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
  end

  def track_gitlab_cli_usage
    Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter
      .track_api_request_when_trackable(user_agent: request.user_agent, user: current_user)
  end

  def execute_multiplex
    GitlabSchema.multiplex(multiplex_queries, context: context)
  end

  def execute_query
    variables = build_variables(params[:variables])
    operation_name = params[:operationName]

    GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
  end

  def query
    params.fetch(:query, '')
  end

  def multiplex_queries
    params[:_json].map do |single_query_info|
      {
        query: single_query_info[:query],
        variables: build_variables(single_query_info[:variables]),
        operation_name: single_query_info[:operationName],
        context: context
      }
    end
  end

  # When modifying the context, also update GraphqlChannel#context if needed
  # so that we have similar context when executing queries, mutations, and subscriptions
  def context
    api_user = !!sessionless_user?
    @context ||= {
      current_user: current_user,
      is_sessionless_user: api_user,
      request: request,
      scope_validator: ::Gitlab::Auth::ScopeValidator.new(api_user, request_authenticator)
    }
  end

  def build_variables(variable_info)
    Gitlab::Graphql::Variables.new(variable_info).to_h
  end

  def multiplex?
    params[:_json].present?
  end

  def authorize_access_api!
    return if can?(current_user, :access_api)

    render_error('API not accessible for user', status: :forbidden)
  end

  # Overridden from the ApplicationController to make the response look like
  # a GraphQL response. That is nicely picked up in Graphiql.
  def render_404
    render_error("Not found!", status: :not_found)
  end

  def render_error(message, status: 500, raised_at: nil)
    error = { errors: [message: message] }
    error[:errors].first['raisedAt'] = raised_at if raised_at

    render json: error, status: status
  end

  def append_info_to_payload(payload)
    super

    # Merging to :metadata will ensure these are logged as top level keys
    payload[:metadata] ||= {}
    payload[:metadata][:graphql] = logs

    payload[:exception_object] = @exception_object if @exception_object
  end

  def logs
    RequestStore.store[:graphql_logs].to_a
  end
end