diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index e39f1148cf2ab15abc01810455812b0e08dab21c..b9c6fa8735c137c394d50e66b7d302d7fccf54bc 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -30,7 +30,13 @@ class GraphqlController < ApplicationController 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(only: [:execute]) do + if Feature.enabled? :graphql_minimal_auth_methods + authenticate_graphql + else + authenticate_sessionless_user!(:api) + end + end before_action :authorize_access_api! before_action :set_user_last_activity @@ -121,6 +127,18 @@ def feature_category private + # unwound from SessionlessAuthentication concern + # use a minimal subset of Gitlab::Auth::RequestAuthenticator.find_sessionless_user + # so only token types allowed for GraphQL can authenticate users + # CI_JOB_TOKENs are not allowed for now, since their access is too broad + def authenticate_graphql + user = request_authenticator.find_user_from_web_access_token(:api, scopes: [:api, :read_api]) + user ||= request_authenticator.find_user_from_personal_access_token_for_api_or_git + sessionless_sign_in(user) if user + rescue Gitlab::Auth::AuthenticationError + nil + end + def permitted_multiplex_params params.permit(_json: [:query, :operationName, { variables: {} }]) end diff --git a/config/feature_flags/gitlab_com_derisk/graphql_minimal_auth_methods.yml b/config/feature_flags/gitlab_com_derisk/graphql_minimal_auth_methods.yml new file mode 100644 index 0000000000000000000000000000000000000000..afdfa1b65c4ad74cc8f891d2606737cc77a1bfc3 --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/graphql_minimal_auth_methods.yml @@ -0,0 +1,9 @@ +--- +name: graphql_minimal_auth_methods +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/438462 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150407 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/444929 +milestone: '17.0' +group: group::authentication +type: gitlab_com_derisk +default_enabled: false diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index bdc3d0acdf7e544b56f32984f0316bafaf854ac6..0ae74a36f6ac7078ba0533a8f67bd80651adb852 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -262,6 +262,215 @@ expect(graphql_data['echo']).to eq("\"#{token.user.username}\" says: Hello world") end + shared_examples 'valid token' do + it 'accepts from header' do + post_graphql(query, headers: { 'Authorization' => "Bearer #{token}" }) + + expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world") + end + + it 'accepts from access_token parameter' do + post "/api/graphql?access_token=#{token}", params: { query: query } + + expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world") + end + + it 'accepts from private_token parameter' do + post "/api/graphql?private_token=#{token}", params: { query: query } + + expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world") + end + end + + context 'with oAuth user access token' do + let(:oauth_application) do + create( + :oauth_application, + scopes: 'api read_user', + redirect_uri: 'http://example.com', + confidential: true + ) + end + + let(:oauth_access_token) do + create( + :oauth_access_token, + application: oauth_application, + resource_owner: user, + scopes: 'api' + ) + end + + let(:token) { oauth_access_token.plaintext_token } + + # Doorkeeper does not support the private_token=? param + # https://github.com/doorkeeper-gem/doorkeeper/blob/960f1501131683b16c2704d1b6f9597b9583b49d/lib/doorkeeper/oauth/token.rb#L26 + # so we cannot use shared examples here + it 'accepts from header' do + post_graphql(query, headers: { 'Authorization' => "Bearer #{token}" }) + + expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world") + end + + it 'accepts from access_token parameter' do + post "/api/graphql?access_token=#{token}", params: { query: query } + + expect(graphql_data['echo']).to eq("\"#{user.username}\" says: Hello world") + end + end + + context 'with personal access token' do + let(:personal_access_token) { create(:personal_access_token, user: user) } + let(:token) { personal_access_token.token } + + it_behaves_like 'valid token' + end + + context 'with group or project access token' do + let_it_be(:user) { create(:user, :project_bot) } + let_it_be(:project_access_token) { create(:personal_access_token, user: user) } + + let(:token) { project_access_token.token } + + it_behaves_like 'valid token' + end + + describe 'invalid authentication types' do + let(:query) { 'query { currentUser { id, username } }' } + + describe 'with git-lfs token' do + let(:lfs_token) { Gitlab::LfsToken.new(user).token } + let(:header_token) { Base64.encode64("#{user.username}:#{lfs_token}") } + let(:headers) do + { 'Authorization' => "Basic #{header_token}" } + end + + it 'does not authenticate users with an LFS token' do + post '/api/graphql.git', params: { query: query }, headers: headers + + expect(graphql_data['currentUser']).to be_nil + end + + context 'when graphql_minimal_auth_methods FF is disabled' do + before do + stub_feature_flags(graphql_minimal_auth_methods: false) + end + + it 'authenticates users with an LFS token' do + post '/api/graphql.git', params: { query: query }, headers: headers + + expect(graphql_data['currentUser']['username']).to eq(user.username) + end + end + end + + describe 'with job token' do + let(:project) do + create(:project).tap do |proj| + proj.add_owner(user) + end + end + + let(:job) { create(:ci_build, :running, project: project, user: user) } + let(:job_token) { job.token } + + it 'raises "Invalid token" error' do + post '/api/graphql', params: { query: query, job_token: job_token } + + expect_graphql_errors_to_include(/Invalid token/) + end + + context 'when graphql_minimal_auth_methods FF is disabled' do + before do + stub_feature_flags(graphql_minimal_auth_methods: false) + end + + it 'authenticates as the user' do + post '/api/graphql', params: { query: query, job_token: job_token } + + expect(graphql_data['currentUser']['username']).to eq(user.username) + end + end + end + + describe 'with static object token' do + let(:headers) do + { 'X-Gitlab-Static-Object-Token' => user.static_object_token } + end + + it 'does not authenticate user from header' do + post '/api/graphql', params: { query: query }, headers: headers + + expect(graphql_data['currentUser']).to be_nil + end + + it 'does not authenticate user from parameter' do + post "/api/graphql?token=#{user.static_object_token}", params: { query: query } + + expect_graphql_errors_to_include(/Invalid token/) + end + + # context is included to demonstrate that the FF code is not changing this behavior + context 'when graphql_minimal_auth_methods FF is disabled' do + before do + stub_feature_flags(graphql_minimal_auth_methods: false) + end + + # expect(graphql_data['currentUser']).to be_nil + it 'does not authenticate user from header' do + post '/api/graphql', params: { query: query }, headers: headers + + expect(graphql_data['currentUser']).to be_nil + end + + it 'does not authenticate user from parameter' do + post "/api/graphql?token=#{user.static_object_token}", params: { query: query } + + expect_graphql_errors_to_include(/Invalid token/) + end + end + end + + describe 'with dependency proxy token' do + include DependencyProxyHelpers + let(:token) { build_jwt(user).encoded } + let(:headers) do + { 'Authorization' => "Bearer #{token}" } + end + + it 'does not authenticate user from dependency proxy token in headers' do + post '/api/graphql', params: { query: query }, headers: headers + + expect_graphql_errors_to_include(/Invalid token/) + end + + it 'does not authenticate user from dependency proxy token in parameter' do + post "/api/graphql?access_token=#{token}", params: { query: query } + + expect_graphql_errors_to_include(/Invalid token/) + end + + # context is included to demonstrate that the FF code is not changing this behavior + context 'when graphql_minimal_auth_methods FF is disabled' do + before do + stub_feature_flags(graphql_minimal_auth_methods: false) + end + + it 'does not authenticate user from dependency proxy token in headers' do + post '/api/graphql', params: { query: query }, headers: headers + + expect_graphql_errors_to_include(/Invalid token/) + end + + it 'does not authenticate user from dependency proxy token in parameter' do + post "/api/graphql?access_token=#{token}", params: { query: query } + + expect_graphql_errors_to_include(/Invalid token/) + end + end + end + end + it 'prevents access by deactived users' do token.user.deactivate!