Commit 67c694c7 authored by Alex Kalderimis's avatar Alex Kalderimis Committed by John T Skarbek

Require 'api' scope to execute mutations

Verify that read_api tokens cannot run mutations.

Also: adds tests use of OAuth tokens for GraphQL

We make some changes to the sessionless_authentication module
in order to capture the request_authenticator, so that we can access
the token scopes, without making any extra queries.

We ensure we always authorize the mutation, which, like all resolvers,
needs to opt in to the check.

Unlike resolvers, mutations should always raise. So
`BaseMutation.authorized?` raises on failure.

Logic for handling scopes is pushed down to the `ObjectAuthorization`
class, and encapsulated in the `ScopeValidator`, which limits the
methods that can be called by resolvers.
parent daeb7d83
...@@ -7,11 +7,15 @@ ...@@ -7,11 +7,15 @@
module SessionlessAuthentication module SessionlessAuthentication
# This filter handles personal access tokens, atom requests with rss tokens, and static object tokens # This filter handles personal access tokens, atom requests with rss tokens, and static object tokens
def authenticate_sessionless_user!(request_format) def authenticate_sessionless_user!(request_format)
user = Gitlab::Auth::RequestAuthenticator.new(request).find_sessionless_user(request_format) user = request_authenticator.find_sessionless_user(request_format)
sessionless_sign_in(user) if user sessionless_sign_in(user) if user
end end
def request_authenticator
@request_authenticator ||= Gitlab::Auth::RequestAuthenticator.new(request)
end
def sessionless_user? def sessionless_user?
current_user && !session.key?('warden.user.user.key') current_user && !session.key?('warden.user.user.key')
end end
......
...@@ -112,7 +112,13 @@ class GraphqlController < ApplicationController ...@@ -112,7 +112,13 @@ class GraphqlController < ApplicationController
# When modifying the context, also update GraphqlChannel#context if needed # When modifying the context, also update GraphqlChannel#context if needed
# so that we have similar context when executing queries, mutations, and subscriptions # so that we have similar context when executing queries, mutations, and subscriptions
def context def context
@context ||= { current_user: current_user, is_sessionless_user: !!sessionless_user?, request: request } 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 end
def build_variables(variable_info) def build_variables(variable_info)
......
...@@ -44,9 +44,18 @@ module Mutations ...@@ -44,9 +44,18 @@ module Mutations
end end
end end
def self.authorizes_object?
true
end
def self.authorized?(object, context) def self.authorized?(object, context)
# we never provide an object to mutations, but we do need to have a user. auth = ::Gitlab::Graphql::Authorize::ObjectAuthorization.new(:execute_graphql_mutation, :api)
context[:current_user].present? && !context[:current_user].blocked?
return true if auth.ok?(:global, context[:current_user],
scope_validator: context[:scope_validator])
# in our mutations we raise, rather than returning a null value.
raise_resource_not_available_error!
end end
# See: AuthorizeResource#authorized_resource? # See: AuthorizeResource#authorized_resource?
......
...@@ -23,6 +23,7 @@ class GlobalPolicy < BasePolicy ...@@ -23,6 +23,7 @@ class GlobalPolicy < BasePolicy
prevent :receive_notifications prevent :receive_notifications
prevent :use_quick_actions prevent :use_quick_actions
prevent :create_group prevent :create_group
prevent :execute_graphql_mutation
end end
rule { default }.policy do rule { default }.policy do
...@@ -32,6 +33,7 @@ class GlobalPolicy < BasePolicy ...@@ -32,6 +33,7 @@ class GlobalPolicy < BasePolicy
enable :receive_notifications enable :receive_notifications
enable :use_quick_actions enable :use_quick_actions
enable :use_slash_commands enable :use_slash_commands
enable :execute_graphql_mutation
end end
rule { inactive }.policy do rule { inactive }.policy do
...@@ -48,6 +50,8 @@ class GlobalPolicy < BasePolicy ...@@ -48,6 +50,8 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands prevent :use_slash_commands
end end
rule { ~can?(:access_api) }.prevent :execute_graphql_mutation
rule { blocked | (internal & ~migration_bot & ~security_bot) }.policy do rule { blocked | (internal & ~migration_bot & ~security_bot) }.policy do
prevent :access_git prevent :access_git
end end
......
---
title: Prevent tokens with only read_api scope from executing mutations
merge_request:
author:
type: security
# frozen_string_literal: true
# Wrapper around a RequestAuthenticator to
# perform authorization of scopes. Access is limited to
# only those methods needed to validate that an API user
# has at least one permitted scope.
module Gitlab
module Auth
class ScopeValidator
def initialize(api_user, request_authenticator)
@api_user = api_user
@request_authenticator = request_authenticator
end
def valid_for?(permitted)
return true unless @api_user
return true if permitted.none?
scopes = permitted.map { |s| API::Scope.new(s) }
@request_authenticator.valid_access_token?(scopes: scopes)
end
end
end
end
...@@ -4,10 +4,11 @@ module Gitlab ...@@ -4,10 +4,11 @@ module Gitlab
module Graphql module Graphql
module Authorize module Authorize
class ObjectAuthorization class ObjectAuthorization
attr_reader :abilities attr_reader :abilities, :permitted_scopes
def initialize(abilities) def initialize(abilities, scopes = %i[api read_api])
@abilities = Array.wrap(abilities).flatten @abilities = Array.wrap(abilities).flatten
@permitted_scopes = Array.wrap(scopes)
end end
def none? def none?
...@@ -18,7 +19,13 @@ module Gitlab ...@@ -18,7 +19,13 @@ module Gitlab
abilities.present? abilities.present?
end end
def ok?(object, current_user) def ok?(object, current_user, scope_validator: nil)
scopes_ok?(scope_validator) && abilities_ok?(object, current_user)
end
private
def abilities_ok?(object, current_user)
return true if none? return true if none?
subject = object.try(:declarative_policy_subject) || object subject = object.try(:declarative_policy_subject) || object
...@@ -26,6 +33,12 @@ module Gitlab ...@@ -26,6 +33,12 @@ module Gitlab
Ability.allowed?(current_user, ability, subject) Ability.allowed?(current_user, ability, subject)
end end
end end
def scopes_ok?(validator)
return true unless validator.present?
validator.valid_for?(permitted_scopes)
end
end end
end end
end end
......
...@@ -31,6 +31,8 @@ RSpec.describe 'Adding a Note' do ...@@ -31,6 +31,8 @@ RSpec.describe 'Adding a Note' do
project.add_developer(current_user) project.add_developer(current_user)
end end
it_behaves_like 'a working GraphQL mutation'
it_behaves_like 'a Note mutation that creates a Note' it_behaves_like 'a Note mutation that creates a Note'
it_behaves_like 'a Note mutation when there are active record validation errors' it_behaves_like 'a Note mutation when there are active record validation errors'
......
...@@ -396,17 +396,21 @@ module GraphqlHelpers ...@@ -396,17 +396,21 @@ module GraphqlHelpers
post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers post api('/', current_user, version: 'graphql'), params: { _json: queries }, headers: headers
end end
def post_graphql(query, current_user: nil, variables: nil, headers: {}) def post_graphql(query, current_user: nil, variables: nil, headers: {}, token: {})
params = { query: query, variables: serialize_variables(variables) } params = { query: query, variables: serialize_variables(variables) }
post api('/', current_user, version: 'graphql'), params: params, headers: headers post api('/', current_user, version: 'graphql', **token), params: params, headers: headers
if graphql_errors # Errors are acceptable, but not this one: return unless graphql_errors
# Errors are acceptable, but not this one:
expect(graphql_errors).not_to include(a_hash_including('message' => 'Internal server error')) expect(graphql_errors).not_to include(a_hash_including('message' => 'Internal server error'))
end end
end
def post_graphql_mutation(mutation, current_user: nil) def post_graphql_mutation(mutation, current_user: nil, token: {})
post_graphql(mutation.query, current_user: current_user, variables: mutation.variables) post_graphql(mutation.query,
current_user: current_user,
variables: mutation.variables,
token: token)
end end
def post_graphql_mutation_with_uploads(mutation, current_user: nil) def post_graphql_mutation_with_uploads(mutation, current_user: nil)
......
...@@ -10,6 +10,52 @@ RSpec.shared_examples 'a working graphql query' do ...@@ -10,6 +10,52 @@ RSpec.shared_examples 'a working graphql query' do
end end
end end
RSpec.shared_examples 'a working GraphQL mutation' do
include GraphqlHelpers
before do
post_graphql_mutation(mutation, current_user: current_user, token: token)
end
shared_examples 'allows access to the mutation' do
let(:scopes) { ['api'] }
it_behaves_like 'a working graphql query' do
it 'returns data' do
expect(graphql_data.compact).not_to be_empty
end
end
end
shared_examples 'prevents access to the mutation' do
let(:scopes) { ['read_api'] }
it 'does not resolve the mutation' do
expect(graphql_data.compact).to be_empty
expect(graphql_errors).to be_present
end
end
context 'with a personal access token' do
let(:token) do
pat = create(:personal_access_token, user: current_user, scopes: scopes)
{ personal_access_token: pat }
end
it_behaves_like 'prevents access to the mutation'
it_behaves_like 'allows access to the mutation'
end
context 'with an OAuth token' do
let(:token) do
{ oauth_access_token: create(:oauth_access_token, resource_owner: current_user, scopes: scopes.join(' ')) }
end
it_behaves_like 'prevents access to the mutation'
it_behaves_like 'allows access to the mutation'
end
end
RSpec.shared_examples 'a mutation on an unauthorized resource' do RSpec.shared_examples 'a mutation on an unauthorized resource' do
it_behaves_like 'a mutation that returns top-level errors', it_behaves_like 'a mutation that returns top-level errors',
errors: [::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] errors: [::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR]
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment