Commit 54b56f20 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Expose permissions on types in GraphQL

This adds a reusable way to expose permissions for a user to types in
GraphQL.
parent 627236c9
module Types module Types
class BaseObject < GraphQL::Schema::Object class BaseObject < GraphQL::Schema::Object
prepend Gitlab::Graphql::Present prepend Gitlab::Graphql::Present
prepend Gitlab::Graphql::ExposePermissions
field_class Types::BaseField field_class Types::BaseField
end end
......
module Types module Types
class MergeRequestType < BaseObject class MergeRequestType < BaseObject
expose_permissions Types::PermissionTypes::MergeRequest
present_using MergeRequestPresenter present_using MergeRequestPresenter
graphql_name 'MergeRequest' graphql_name 'MergeRequest'
......
module Types
module PermissionTypes
class BasePermissionType < BaseObject
extend Gitlab::Allowable
RESOLVING_KEYWORDS = [:resolver, :method, :hash_key, :function].to_set.freeze
def self.abilities(*abilities)
abilities.each { |ability| ability_field(ability) }
end
def self.ability_field(ability, **kword_args)
unless resolving_keywords?(kword_args)
kword_args[:resolve] ||= -> (object, args, context) do
can?(context[:current_user], ability, object, args.to_h)
end
end
permission_field(ability, **kword_args)
end
def self.permission_field(name, **kword_args)
kword_args = kword_args.reverse_merge(
name: name,
type: GraphQL::BOOLEAN_TYPE,
description: "Whether or not a user can perform `#{name}` on this resource",
null: false)
field(**kword_args)
end
def self.resolving_keywords?(arguments)
RESOLVING_KEYWORDS.intersect?(arguments.keys.to_set)
end
private_class_method :resolving_keywords?
end
end
end
module Types
module PermissionTypes
class MergeRequest < BasePermissionType
present_using MergeRequestPresenter
description 'Check permissions for the current user on a merge request'
graphql_name 'MergeRequestPermissions'
abilities :read_merge_request, :admin_merge_request,
:update_merge_request, :create_note
permission_field :push_to_source_branch, method: :can_push_to_source_branch?
permission_field :remove_source_branch, method: :can_remove_source_branch?
permission_field :cherry_pick_on_current_merge_request, method: :can_cherry_pick_on_current_merge_request?
permission_field :revert_on_current_merge_request, method: :can_revert_on_current_merge_request?
end
end
end
module Types
module PermissionTypes
class Project < BasePermissionType
graphql_name 'ProjectPermissions'
abilities :change_namespace, :change_visibility_level, :rename_project,
:remove_project, :archive_project, :remove_fork_project,
:remove_pages, :read_project, :create_merge_request_in,
:read_wiki, :read_project_member, :create_issue, :upload_file,
:read_cycle_analytics, :download_code, :download_wiki_code,
:fork_project, :create_project_snippet, :read_commit_status,
:request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code,
:create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages,
:admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki,
:create_pages, :destroy_pages
end
end
end
module Types module Types
class ProjectType < BaseObject class ProjectType < BaseObject
expose_permissions Types::PermissionTypes::Project
graphql_name 'Project' graphql_name 'Project'
field :id, GraphQL::ID_TYPE, null: false field :id, GraphQL::ID_TYPE, null: false
......
...@@ -168,6 +168,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated ...@@ -168,6 +168,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
.can_push_to_branch?(source_branch) .can_push_to_branch?(source_branch)
end end
def can_remove_source_branch?
source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
def mergeable_discussions_state def mergeable_discussions_state
# This avoids calling MergeRequest#mergeable_discussions_state without # This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can # considering the state of the MR first. If a MR isn't mergeable, we can
......
...@@ -109,7 +109,7 @@ class MergeRequestWidgetEntity < IssuableEntity ...@@ -109,7 +109,7 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :current_user do expose :current_user do
expose :can_remove_source_branch do |merge_request| expose :can_remove_source_branch do |merge_request|
merge_request.source_branch_exists? && merge_request.can_remove_source_branch?(current_user) presenter(merge_request).can_remove_source_branch?
end end
expose :can_revert_on_current_merge_request do |merge_request| expose :can_revert_on_current_merge_request do |merge_request|
......
---
title: 'Expose permissions of the current user on resources in GraphQL'
merge_request: 20152
author:
type: added
# GraphQL API (Beta) # GraphQL API (Alpha)
> [Introduced][ce-19008] in GitLab 11.0. > [Introduced][ce-19008] in GitLab 11.0.
......
...@@ -54,6 +54,51 @@ a new presenter specifically for GraphQL. ...@@ -54,6 +54,51 @@ a new presenter specifically for GraphQL.
The presenter is initialized using the object resolved by a field, and The presenter is initialized using the object resolved by a field, and
the context. the context.
### Exposing permissions for a type
To expose permissions the current user has on a resource, you can call
the `expose_permissions` passing in a separate type representing the
permissions for the resource.
For example:
```ruby
module Types
class MergeRequestType < BaseObject
expose_permissions Types::MergeRequestPermissionsType
end
end
```
The permission type inherits from `BasePermissionType` which includes
some helper methods, that allow exposing permissions as non-nullable
booleans:
```ruby
class MergeRequestPermissionsType < BasePermissionType
present_using MergeRequestPresenter
graphql_name 'MergeRequestPermissions'
abilities :admin_merge_request, :update_merge_request, :create_note
ability_field :resolve_note,
description: 'Whether or not the user can resolve disussions on the merge request'
permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
```
- **`permission_field`**: Will act the same as `graphql-ruby`'s
`field` method but setting a default description and type and making
them non-nullable. These options can still be overridden by adding
them as arguments.
- **`ability_field`**: Expose an ability defined in our policies. This
takes behaves the same way as `permission_field` and the same
arguments can be overridden.
- **`abilities`**: Allows exposing several abilities defined in our
policies at once. The fields for these will all have be non-nullable
booleans with a default description.
## Resolvers ## Resolvers
To find objects to display in a field, we can add resolvers to To find objects to display in a field, we can add resolvers to
......
module Gitlab
module Graphql
module ExposePermissions
extend ActiveSupport::Concern
prepended do
def self.expose_permissions(permission_type, description: 'Permissions for the current user on the resource')
field :user_permissions, permission_type,
description: description,
null: false,
resolve: -> (obj, _, _) { obj }
end
end
end
end
end
...@@ -10,9 +10,18 @@ module Gitlab ...@@ -10,9 +10,18 @@ module Gitlab
old_resolver = field.resolve_proc old_resolver = field.resolve_proc
resolve_with_presenter = -> (presented_type, args, context) do resolve_with_presenter = -> (presented_type, args, context) do
# We need to wrap the original presentation type into a type that
# uses the presenter as an object.
object = presented_type.object object = presented_type.object
if object.is_a?(presented_in.presenter_class)
next old_resolver.call(presented_type, args, context)
end
presenter = presented_in.presenter_class.new(object, **context.to_h) presenter = presented_in.presenter_class.new(object, **context.to_h)
old_resolver.call(presenter, args, context) wrapped = presented_type.class.new(presenter, context)
old_resolver.call(wrapped, args, context)
end end
field.redefine do field.redefine do
......
require 'spec_helper'
describe Types::MergeRequestType do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
end
require 'spec_helper'
describe Types::PermissionTypes::BasePermissionType do
let(:permitable) { double('permittable') }
let(:current_user) { build(:user) }
let(:context) { { current_user: current_user } }
subject(:test_type) do
Class.new(described_class) do
graphql_name 'TestClass'
permission_field :do_stuff, resolve: -> (_, _, _) { true }
ability_field(:read_issue)
abilities :admin_issue
end
end
describe '.permission_field' do
it 'adds a field for the required permission' do
is_expected.to have_graphql_field(:do_stuff)
end
end
describe '.ability_field' do
it 'adds a field for the required permission' do
is_expected.to have_graphql_field(:read_issue)
end
it 'does not add a resolver block if another resolving param is passed' do
expected_keywords = {
name: :resolve_using_hash,
hash_key: :the_key,
type: GraphQL::BOOLEAN_TYPE,
description: "custom description",
null: false
}
expect(test_type).to receive(:field).with(expected_keywords)
test_type.ability_field :resolve_using_hash, hash_key: :the_key, description: "custom description"
end
end
describe '.abilities' do
it 'adds a field for the passed permissions' do
is_expected.to have_graphql_field(:admin_issue)
end
end
end
require 'spec_helper'
describe Types::PermissionTypes::MergeRequest do
it do
expected_permissions = [
:read_merge_request, :admin_merge_request, :update_merge_request,
:create_note, :push_to_source_branch, :remove_source_branch,
:cherry_pick_on_current_merge_request, :revert_on_current_merge_request
]
expect(described_class).to have_graphql_fields(expected_permissions)
end
end
require 'spec_helper'
describe Types::MergeRequestType do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::MergeRequest) }
end
require 'spec_helper'
describe Types::PermissionTypes::Project do
it do
expected_permissions = [
:change_namespace, :change_visibility_level, :rename_project, :remove_project, :archive_project,
:remove_fork_project, :remove_pages, :read_project, :create_merge_request_in,
:read_wiki, :read_project_member, :create_issue, :upload_file, :read_cycle_analytics,
:download_code, :download_wiki_code, :fork_project, :create_project_snippet,
:read_commit_status, :request_access, :create_pipeline, :create_pipeline_schedule,
:create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch,
:admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label,
:update_wiki, :destroy_wiki, :create_pages, :destroy_pages
]
expect(described_class).to have_graphql_fields(expected_permissions)
end
end
require 'spec_helper' require 'spec_helper'
describe GitlabSchema.types['Project'] do describe GitlabSchema.types['Project'] do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
it { expect(described_class.graphql_name).to eq('Project') } it { expect(described_class.graphql_name).to eq('Project') }
describe 'nested merge request' do describe 'nested merge request' do
......
require 'spec_helper'
describe 'getting merge request information nested in a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:current_user) { create(:user) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let!(:merge_request) { create(:merge_request, source_project: project) }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'contains merge request information' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data).not_to be_nil
end
# This is a field coming from the `MergeRequestPresenter`
it 'includes a web_url' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['webUrl']).to be_present
end
context 'permissions on the merge request' do
it 'includes the permissions for the current user on a public project' do
expected_permissions = {
'readMergeRequest' => true,
'adminMergeRequest' => false,
'createNote' => true,
'pushToSourceBranch' => false,
'removeSourceBranch' => false,
'cherryPickOnCurrentMergeRequest' => false,
'revertOnCurrentMergeRequest' => false,
'updateMergeRequest' => false
}
post_graphql(query, current_user: current_user)
permission_data = merge_request_graphql_data['userPermissions']
expect(permission_data).to be_present
expect(permission_data).to eq(expected_permissions)
end
end
context 'when the user does not have access to the merge request' do
let(:project) { create(:project, :public, :repository) }
it 'returns nil' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
post_graphql(query)
expect(merge_request_graphql_data).to be_nil
end
end
end
...@@ -26,50 +26,6 @@ describe 'getting project information' do ...@@ -26,50 +26,6 @@ describe 'getting project information' do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
end end
end end
context 'when requesting a nested merge request' do
let(:merge_request) { create(:merge_request, source_project: project) }
let(:merge_request_graphql_data) { graphql_data['project']['mergeRequest'] }
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('mergeRequest', iid: merge_request.iid)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'contains merge request information' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data).not_to be_nil
end
# This is a field coming from the `MergeRequestPresenter`
it 'includes a web_url' do
post_graphql(query, current_user: current_user)
expect(merge_request_graphql_data['webUrl']).to be_present
end
context 'when the user does not have access to the merge request' do
let(:project) { create(:project, :public, :repository) }
it 'returns nil' do
project.project_feature.update!(merge_requests_access_level: ProjectFeature::PRIVATE)
post_graphql(query)
expect(merge_request_graphql_data).to be_nil
end
end
end
end end
context 'when the user does not have access to the project' do context 'when the user does not have access to the project' do
......
...@@ -7,9 +7,24 @@ RSpec::Matchers.define :require_graphql_authorizations do |*expected| ...@@ -7,9 +7,24 @@ RSpec::Matchers.define :require_graphql_authorizations do |*expected|
end end
RSpec::Matchers.define :have_graphql_fields do |*expected| RSpec::Matchers.define :have_graphql_fields do |*expected|
def expected_field_names
expected.map { |name| GraphqlHelpers.fieldnamerize(name) }
end
match do |kls| match do |kls|
field_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) } expect(kls.fields.keys).to contain_exactly(*expected_field_names)
expect(kls.fields.keys).to contain_exactly(*field_names) end
failure_message do |kls|
missing = expected_field_names - kls.fields.keys
extra = kls.fields.keys - expected_field_names
message = []
message << "is missing fields: <#{missing.inspect}>" if missing.any?
message << "contained unexpected fields: <#{extra.inspect}>" if extra.any?
message.join("\n")
end end
end end
...@@ -44,3 +59,13 @@ RSpec::Matchers.define :have_graphql_resolver do |expected| ...@@ -44,3 +59,13 @@ RSpec::Matchers.define :have_graphql_resolver do |expected|
end end
end end
end end
RSpec::Matchers.define :expose_permissions_using do |expected|
match do |type|
permission_field = type.fields['userPermissions']
expect(permission_field).not_to be_nil
expect(permission_field.type).to be_non_null
expect(permission_field.type.of_type.graphql_name).to eq(expected.graphql_name)
end
end
...@@ -3,8 +3,8 @@ require 'spec_helper' ...@@ -3,8 +3,8 @@ require 'spec_helper'
shared_examples 'a working graphql query' do shared_examples 'a working graphql query' do
include GraphqlHelpers include GraphqlHelpers
it 'is returns a successfull response', :aggregate_failures do it 'returns a successful response', :aggregate_failures do
expect(response).to be_success expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors['errors']).to be_nil expect(graphql_errors['errors']).to be_nil
expect(json_response.keys).to include('data') expect(json_response.keys).to include('data')
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