Commit 7eae0e9b authored by Marin Jankovski's avatar Marin Jankovski

Merge branch 'security-bvl-enforce-graphql-type-authorization' into 'master'

Fix type authorizations in GraphQL

See merge request gitlab/gitlabhq!3170
parents f66169b3 967cbd08
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module Ci module Ci
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `PipelineType` that has its own authorization
class DetailedStatusType < BaseObject class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus' graphql_name 'DetailedStatus'
...@@ -13,5 +15,6 @@ module Types ...@@ -13,5 +15,6 @@ module Types
field :text, GraphQL::STRING_TYPE, null: false field :text, GraphQL::STRING_TYPE, null: false
field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is a BaseEnum through IssuableEnum, so it does not need authorization
class IssueStateEnum < IssuableStateEnum class IssueStateEnum < IssuableStateEnum
graphql_name 'IssueState' graphql_name 'IssueState'
description 'State of a GitLab issue' description 'State of a GitLab issue'
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
...@@ -4,6 +4,8 @@ module Types ...@@ -4,6 +4,8 @@ module Types
class LabelType < BaseObject class LabelType < BaseObject
graphql_name 'Label' graphql_name 'Label'
authorize :read_label
field :description, GraphQL::STRING_TYPE, null: true field :description, GraphQL::STRING_TYPE, null: true
markdown_field :description_html, null: true markdown_field :description_html, null: true
field :title, GraphQL::STRING_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false
......
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is a BaseEnum through IssuableEnum, so it does not need authorization
class MergeRequestStateEnum < IssuableStateEnum class MergeRequestStateEnum < IssuableStateEnum
graphql_name 'MergeRequestState' graphql_name 'MergeRequestState'
description 'State of a GitLab merge request' description 'State of a GitLab merge request'
value 'merged' value 'merged'
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
...@@ -4,6 +4,8 @@ module Types ...@@ -4,6 +4,8 @@ module Types
class MetadataType < ::Types::BaseObject class MetadataType < ::Types::BaseObject
graphql_name 'Metadata' graphql_name 'Metadata'
authorize :read_instance_metadata
field :version, GraphQL::STRING_TYPE, null: false field :version, GraphQL::STRING_TYPE, null: false
field :revision, GraphQL::STRING_TYPE, null: false field :revision, GraphQL::STRING_TYPE, null: false
end end
......
...@@ -4,6 +4,8 @@ module Types ...@@ -4,6 +4,8 @@ module Types
class NamespaceType < BaseObject class NamespaceType < BaseObject
graphql_name 'Namespace' graphql_name 'Namespace'
authorize :read_namespace
field :id, GraphQL::ID_TYPE, null: false field :id, GraphQL::ID_TYPE, null: false
field :name, GraphQL::STRING_TYPE, null: false field :name, GraphQL::STRING_TYPE, null: false
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module Types module Types
module Notes module Notes
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `NoteType` that has its own authorization
class DiffPositionType < BaseObject class DiffPositionType < BaseObject
graphql_name 'DiffPosition' graphql_name 'DiffPosition'
...@@ -42,5 +44,6 @@ module Types ...@@ -42,5 +44,6 @@ module Types
description: "The total height of the image", description: "The total height of the image",
resolve: -> (position, _args, _ctx) { position.height if position.on_image? } resolve: -> (position, _args, _ctx) { position.height if position.on_image? }
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
...@@ -67,14 +67,14 @@ module Types ...@@ -67,14 +67,14 @@ module Types
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :namespace, Types::NamespaceType, null: false field :namespace, Types::NamespaceType, null: true
field :group, Types::GroupType, null: true field :group, Types::GroupType, null: true
field :statistics, Types::ProjectStatisticsType, field :statistics, Types::ProjectStatisticsType,
null: true, null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find }
field :repository, Types::RepositoryType, null: false field :repository, Types::RepositoryType, null: true
field :merge_requests, field :merge_requests,
Types::MergeRequestType.connection_type, Types::MergeRequestType.connection_type,
......
...@@ -22,10 +22,7 @@ module Types ...@@ -22,10 +22,7 @@ module Types
field :metadata, Types::MetadataType, field :metadata, Types::MetadataType,
null: true, null: true,
resolver: Resolvers::MetadataResolver, resolver: Resolvers::MetadataResolver,
description: 'Metadata about GitLab' do |*args| description: 'Metadata about GitLab'
authorize :read_instance_metadata
end
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
end end
......
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
# rubocop: disable Graphql/AuthorizeTypes
# This is used in `IssueType` and `MergeRequestType` both of which have their
# own authorization
class TaskCompletionStatus < BaseObject class TaskCompletionStatus < BaseObject
graphql_name 'TaskCompletionStatus' graphql_name 'TaskCompletionStatus'
description 'Completion status of tasks' description 'Completion status of tasks'
...@@ -8,4 +11,5 @@ module Types ...@@ -8,4 +11,5 @@ module Types
field :count, GraphQL::INT_TYPE, null: false field :count, GraphQL::INT_TYPE, null: false
field :completed_count, GraphQL::INT_TYPE, null: false field :completed_count, GraphQL::INT_TYPE, null: false
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module Tree module Tree
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class BlobType < BaseObject class BlobType < BaseObject
implements Types::Tree::EntryType implements Types::Tree::EntryType
...@@ -12,6 +14,7 @@ module Types ...@@ -12,6 +14,7 @@ module Types
field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do
Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module Tree module Tree
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class SubmoduleType < BaseObject class SubmoduleType < BaseObject
implements Types::Tree::EntryType implements Types::Tree::EntryType
graphql_name 'Submodule' graphql_name 'Submodule'
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module Tree module Tree
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class TreeEntryType < BaseObject class TreeEntryType < BaseObject
implements Types::Tree::EntryType implements Types::Tree::EntryType
...@@ -11,5 +13,6 @@ module Types ...@@ -11,5 +13,6 @@ module Types
field :web_url, GraphQL::STRING_TYPE, null: true field :web_url, GraphQL::STRING_TYPE, null: true
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
module Types module Types
module Tree module Tree
# rubocop: disable Graphql/AuthorizeTypes
# This is presented through `Repository` that has its own authorization
class TreeType < BaseObject class TreeType < BaseObject
graphql_name 'Tree' graphql_name 'Tree'
...@@ -13,6 +15,7 @@ module Types ...@@ -13,6 +15,7 @@ module Types
field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do
Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository) Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
end end
# rubocop: enable Graphql/AuthorizeTypes
end end
end end
end end
# frozen_string_literal: true
class RepositoryPolicy < BasePolicy
delegate { @subject.project }
end
---
title: Add missing authorizations in GraphQL
merge_request:
author:
type: security
...@@ -39,6 +39,8 @@ module Gitlab ...@@ -39,6 +39,8 @@ module Gitlab
type = node_type_for_basic_connection(type) type = node_type_for_basic_connection(type)
end end
type = type.unwrap if type.kind.non_null?
Array.wrap(type.metadata[:authorize]) Array.wrap(type.metadata[:authorize])
end end
......
# frozen_string_literal: true
require_relative '../../spec_helpers'
module RuboCop
module Cop
module Graphql
class AuthorizeTypes < RuboCop::Cop::Cop
include SpecHelpers
MSG = 'Add an `authorize :ability` call to the type: '\
'https://docs.gitlab.com/ee/development/api_graphql_styleguide.html#type-authorization'
TYPES_DIR = 'app/graphql/types'
# We want to exclude our own basetypes and scalars
WHITELISTED_TYPES = %w[BaseEnum BaseScalar BasePermissionType MutationType
QueryType GraphQL::Schema BaseUnion].freeze
def_node_search :authorize?, <<~PATTERN
(send nil? :authorize ...)
PATTERN
def on_class(node)
return unless in_type?(node)
return if whitelisted?(class_constant(node))
return if whitelisted?(superclass_constant(node))
add_offense(node, location: :expression) unless authorize?(node)
end
private
def in_type?(node)
return if in_spec?(node)
path = node.location.expression.source_buffer.name
path.include?(TYPES_DIR)
end
def whitelisted?(class_node)
return false unless class_node&.const_name
WHITELISTED_TYPES.any? { |whitelisted| class_node.const_name.include?(whitelisted) }
end
def class_constant(node)
node.descendants.first
end
def superclass_constant(class_node)
# First one is the class name itself, second is it's superclass
_class_constant, *others = class_node.descendants
others.find { |node| node.const_type? && node&.const_name != 'Types' }
end
end
end
end
end
...@@ -43,3 +43,4 @@ require_relative 'cop/code_reuse/serializer' ...@@ -43,3 +43,4 @@ require_relative 'cop/code_reuse/serializer'
require_relative 'cop/code_reuse/active_record' require_relative 'cop/code_reuse/active_record'
require_relative 'cop/group_public_or_visible_to_user' require_relative 'cop/group_public_or_visible_to_user'
require_relative 'cop/inject_enterprise_edition_module' require_relative 'cop/inject_enterprise_edition_module'
require_relative 'cop/graphql/authorize_types'
...@@ -7,4 +7,6 @@ describe GitlabSchema.types['Label'] do ...@@ -7,4 +7,6 @@ describe GitlabSchema.types['Label'] do
is_expected.to have_graphql_fields(*expected_fields) is_expected.to have_graphql_fields(*expected_fields)
end end
it { is_expected.to require_graphql_authorizations(:read_label) }
end end
...@@ -2,4 +2,5 @@ require 'spec_helper' ...@@ -2,4 +2,5 @@ require 'spec_helper'
describe GitlabSchema.types['Metadata'] do describe GitlabSchema.types['Metadata'] do
it { expect(described_class.graphql_name).to eq('Metadata') } it { expect(described_class.graphql_name).to eq('Metadata') }
it { is_expected.to require_graphql_authorizations(:read_instance_metadata) }
end end
...@@ -13,4 +13,6 @@ describe GitlabSchema.types['Namespace'] do ...@@ -13,4 +13,6 @@ describe GitlabSchema.types['Namespace'] do
is_expected.to have_graphql_fields(*expected_fields) is_expected.to have_graphql_fields(*expected_fields)
end end
it { is_expected.to require_graphql_authorizations(:read_namespace) }
end end
...@@ -34,9 +34,5 @@ describe GitlabSchema.types['Query'] do ...@@ -34,9 +34,5 @@ describe GitlabSchema.types['Query'] do
is_expected.to have_graphql_type(Types::MetadataType) is_expected.to have_graphql_type(Types::MetadataType)
is_expected.to have_graphql_resolver(Resolvers::MetadataResolver) is_expected.to have_graphql_resolver(Resolvers::MetadataResolver)
end end
it 'authorizes with read_instance_metadata' do
is_expected.to require_graphql_authorizations(:read_instance_metadata)
end
end end
end end
...@@ -7,35 +7,39 @@ require 'spec_helper' ...@@ -7,35 +7,39 @@ require 'spec_helper'
describe Gitlab::Graphql::Authorize::AuthorizeFieldService do describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
def type(type_authorizations = []) def type(type_authorizations = [])
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
graphql_name "TestType" graphql_name 'TestType'
authorize type_authorizations authorize type_authorizations
end end
end end
def type_with_field(field_type, field_authorizations = [], resolved_value = "Resolved value") def type_with_field(field_type, field_authorizations = [], resolved_value = 'Resolved value', **options)
Class.new(Types::BaseObject) do Class.new(Types::BaseObject) do
graphql_name "TestTypeWithField" graphql_name 'TestTypeWithField'
field :test_field, field_type, null: true, authorize: field_authorizations, resolve: -> (_, _, _) { resolved_value} options.reverse_merge!(null: true)
field :test_field, field_type,
authorize: field_authorizations,
resolve: -> (_, _, _) { resolved_value },
**options
end end
end end
let(:current_user) { double(:current_user) } let(:current_user) { double(:current_user) }
subject(:service) { described_class.new(field) } subject(:service) { described_class.new(field) }
describe "#authorized_resolve" do describe '#authorized_resolve' do
let(:presented_object) { double("presented object") } let(:presented_object) { double('presented object') }
let(:presented_type) { double("parent type", object: presented_object) } let(:presented_type) { double('parent type', object: presented_object) }
subject(:resolved) { service.authorized_resolve.call(presented_type, {}, { current_user: current_user }) } subject(:resolved) { service.authorized_resolve.call(presented_type, {}, { current_user: current_user }) }
context "scalar types" do context 'scalar types' do
shared_examples "checking permissions on the presented object" do shared_examples 'checking permissions on the presented object' do
it "checks the abilities on the object being presented and returns the value" do it 'checks the abilities on the object being presented and returns the value' do
expected_permissions.each do |permission| expected_permissions.each do |permission|
spy_ability_check_for(permission, presented_object, passed: true) spy_ability_check_for(permission, presented_object, passed: true)
end end
expect(resolved).to eq("Resolved value") expect(resolved).to eq('Resolved value')
end end
it "returns nil if the value wasn't authorized" do it "returns nil if the value wasn't authorized" do
...@@ -45,61 +49,71 @@ describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -45,61 +49,71 @@ describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
end end
end end
context "when the field is a built-in scalar type" do context 'when the field is a built-in scalar type' do
let(:field) { type_with_field(GraphQL::STRING_TYPE, :read_field).fields["testField"].to_graphql } let(:field) { type_with_field(GraphQL::STRING_TYPE, :read_field).fields['testField'].to_graphql }
let(:expected_permissions) { [:read_field] } let(:expected_permissions) { [:read_field] }
it_behaves_like "checking permissions on the presented object" it_behaves_like 'checking permissions on the presented object'
end end
context "when the field is a list of scalar types" do context 'when the field is a list of scalar types' do
let(:field) { type_with_field([GraphQL::STRING_TYPE], :read_field).fields["testField"].to_graphql } let(:field) { type_with_field([GraphQL::STRING_TYPE], :read_field).fields['testField'].to_graphql }
let(:expected_permissions) { [:read_field] } let(:expected_permissions) { [:read_field] }
it_behaves_like "checking permissions on the presented object" it_behaves_like 'checking permissions on the presented object'
end end
context "when the field is sub-classed scalar type" do context 'when the field is sub-classed scalar type' do
let(:field) { type_with_field(Types::TimeType, :read_field).fields["testField"].to_graphql } let(:field) { type_with_field(Types::TimeType, :read_field).fields['testField'].to_graphql }
let(:expected_permissions) { [:read_field] } let(:expected_permissions) { [:read_field] }
it_behaves_like "checking permissions on the presented object" it_behaves_like 'checking permissions on the presented object'
end end
context "when the field is a list of sub-classed scalar types" do context 'when the field is a list of sub-classed scalar types' do
let(:field) { type_with_field([Types::TimeType], :read_field).fields["testField"].to_graphql } let(:field) { type_with_field([Types::TimeType], :read_field).fields['testField'].to_graphql }
let(:expected_permissions) { [:read_field] } let(:expected_permissions) { [:read_field] }
it_behaves_like "checking permissions on the presented object" it_behaves_like 'checking permissions on the presented object'
end end
end end
context "when the field is a specific type" do context 'when the field is a specific type' do
let(:custom_type) { type(:read_type) } let(:custom_type) { type(:read_type) }
let(:object_in_field) { double("presented in field") } let(:object_in_field) { double('presented in field') }
let(:field) { type_with_field(custom_type, :read_field, object_in_field).fields["testField"].to_graphql } let(:field) { type_with_field(custom_type, :read_field, object_in_field).fields['testField'].to_graphql }
it "checks both field & type permissions" do it 'checks both field & type permissions' do
spy_ability_check_for(:read_field, object_in_field, passed: true) spy_ability_check_for(:read_field, object_in_field, passed: true)
spy_ability_check_for(:read_type, object_in_field, passed: true) spy_ability_check_for(:read_type, object_in_field, passed: true)
expect(resolved).to eq(object_in_field) expect(resolved).to eq(object_in_field)
end end
it "returns nil if viewing was not allowed" do it 'returns nil if viewing was not allowed' do
spy_ability_check_for(:read_field, object_in_field, passed: false) spy_ability_check_for(:read_field, object_in_field, passed: false)
spy_ability_check_for(:read_type, object_in_field, passed: true) spy_ability_check_for(:read_type, object_in_field, passed: true)
expect(resolved).to be_nil expect(resolved).to be_nil
end end
context "when the field is a list" do context 'when the field is not nullable' do
let(:object_1) { double("presented in field 1") } let(:field) { type_with_field(custom_type, [], object_in_field, null: false).fields['testField'].to_graphql }
let(:object_2) { double("presented in field 2") }
it 'returns nil when viewing is not allowed' do
spy_ability_check_for(:read_type, object_in_field, passed: false)
expect(resolved).to be_nil
end
end
context 'when the field is a list' do
let(:object_1) { double('presented in field 1') }
let(:object_2) { double('presented in field 2') }
let(:presented_types) { [double(object: object_1), double(object: object_2)] } let(:presented_types) { [double(object: object_1), double(object: object_2)] }
let(:field) { type_with_field([custom_type], :read_field, presented_types).fields["testField"].to_graphql } let(:field) { type_with_field([custom_type], :read_field, presented_types).fields['testField'].to_graphql }
it "checks all permissions" do it 'checks all permissions' do
allow(Ability).to receive(:allowed?) { true } allow(Ability).to receive(:allowed?) { true }
spy_ability_check_for(:read_field, object_1, passed: true) spy_ability_check_for(:read_field, object_1, passed: true)
...@@ -110,7 +124,7 @@ describe Gitlab::Graphql::Authorize::AuthorizeFieldService do ...@@ -110,7 +124,7 @@ describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
expect(resolved).to eq(presented_types) expect(resolved).to eq(presented_types)
end end
it "filters out objects that the user cannot see" do it 'filters out objects that the user cannot see' do
allow(Ability).to receive(:allowed?) { true } allow(Ability).to receive(:allowed?) { true }
spy_ability_check_for(:read_type, object_1, passed: false) spy_ability_check_for(:read_type, object_1, passed: false)
......
...@@ -58,9 +58,7 @@ describe 'getting projects', :nested_groups do ...@@ -58,9 +58,7 @@ describe 'getting projects', :nested_groups do
it 'finds only public projects' do it 'finds only public projects' do
post_graphql(query, current_user: nil) post_graphql(query, current_user: nil)
expect(graphql_data['namespace']['projects']['edges'].size).to eq(1) expect(graphql_data['namespace']).to be_nil
project = graphql_data['namespace']['projects']['edges'][0]['node']
expect(project['id']).to eq(public_project.to_global_id.to_s)
end end
end end
end end
......
...@@ -34,4 +34,28 @@ describe 'getting a repository in a project' do ...@@ -34,4 +34,28 @@ describe 'getting a repository in a project' do
expect(graphql_data['project']).to be(nil) expect(graphql_data['project']).to be(nil)
end end
end end
context 'when the repository is only accessible to members' do
let(:project) do
create(:project, :public, :repository, repository_access_level: ProjectFeature::PRIVATE)
end
it 'returns a repository for the owner' do
post_graphql(query, current_user: current_user)
expect(graphql_data['project']['repository']).not_to be_nil
end
it 'returns nil for the repository for other users' do
post_graphql(query, current_user: create(:user))
expect(graphql_data['project']['repository']).to be_nil
end
it 'returns nil for the repository for other users' do
post_graphql(query, current_user: nil)
expect(graphql_data['project']['repository']).to be_nil
end
end
end end
# frozen_string_literal: true
require 'fast_spec_helper'
require 'rubocop'
require 'rubocop/rspec/support'
require_relative '../../../../rubocop/cop/graphql/authorize_types'
describe RuboCop::Cop::Graphql::AuthorizeTypes do
include RuboCop::RSpec::ExpectOffense
include CopHelper
subject(:cop) { described_class.new }
context 'when in a type folder' do
before do
allow(cop).to receive(:in_type?).and_return(true)
end
it 'adds an offense when there is no authorize call' do
inspect_source(<<~TYPE)
module Types
class AType < BaseObject
field :a_thing
field :another_thing
end
end
TYPE
expect(cop.offenses.size).to eq 1
end
it 'does not add an offense for classes that have an authorize call' do
expect_no_offenses(<<~TYPE.strip)
module Types
class AType < BaseObject
graphql_name 'ATypeName'
authorize :an_ability, :second_ability
field :a_thing
end
end
TYPE
end
it 'does not add an offense for classes that only have an authorize call' do
expect_no_offenses(<<~TYPE.strip)
module Types
class AType < SuperClassWithFields
authorize :an_ability
end
end
TYPE
end
it 'does not add an offense for base types' do
expect_no_offenses(<<~TYPE)
module Types
class AType < BaseEnum
field :a_thing
end
end
TYPE
end
end
end
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