Commit ccb4edbc authored by Luke Duncalfe's avatar Luke Duncalfe

Improve GraphQL Authorization DSL

Previously GraphQL field authorization happened like this:

    class ProjectType
      field :my_field, MyFieldType do
        authorize :permission
      end
    end

This change allowed us to authorize like this instead:

    class ProjectType
      field :my_field, MyFieldType, authorize: :permission
    end

A new initializer registers the `authorize` metadata keyword on GraphQL
Schema Objects and Fields, and we can collect this data within the
context of Instrumentation like this:

    field.metadata[:authorize]

The previous functionality of authorize is still being used for
mutations, as the #authorize method here is called at during the code
that executes during the mutation, rather than when a field resolves.

https://gitlab.com/gitlab-org/gitlab-ce/issues/57828
parent 7ff0c8ae
...@@ -15,18 +15,16 @@ module Types ...@@ -15,18 +15,16 @@ module Types
field :author, Types::UserType, field :author, Types::UserType,
null: false, null: false,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find },
authorize :read_user authorize: :read_user
end
field :assignees, Types::UserType.connection_type, null: true field :assignees, Types::UserType.connection_type, null: true
field :labels, Types::LabelType.connection_type, null: true field :labels, Types::LabelType.connection_type, null: true
field :milestone, Types::MilestoneType, field :milestone, Types::MilestoneType,
null: true, null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find },
authorize :read_milestone authorize: :read_milestone
end
field :due_date, Types::TimeType, null: true field :due_date, Types::TimeType, null: true
field :confidential, GraphQL::BOOLEAN_TYPE, null: false field :confidential, GraphQL::BOOLEAN_TYPE, null: false
......
...@@ -48,9 +48,7 @@ module Types ...@@ -48,9 +48,7 @@ module Types
field :downvotes, GraphQL::INT_TYPE, null: false field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, authorize: :read_pipeline
authorize :read_pipeline
end
field :pipelines, Types::Ci::PipelineType.connection_type, field :pipelines, Types::Ci::PipelineType.connection_type,
resolver: Resolvers::MergeRequestPipelinesResolver resolver: Resolvers::MergeRequestPipelinesResolver
end end
......
...@@ -69,16 +69,14 @@ module Types ...@@ -69,16 +69,14 @@ module Types
field :merge_requests, field :merge_requests,
Types::MergeRequestType.connection_type, Types::MergeRequestType.connection_type,
null: true, null: true,
resolver: Resolvers::MergeRequestsResolver do resolver: Resolvers::MergeRequestsResolver,
authorize :read_merge_request authorize: :read_merge_request
end
field :merge_request, field :merge_request,
Types::MergeRequestType, Types::MergeRequestType,
null: true, null: true,
resolver: Resolvers::MergeRequestsResolver.single do resolver: Resolvers::MergeRequestsResolver.single,
authorize :read_merge_request authorize: :read_merge_request
end
field :issues, field :issues,
Types::IssueType.connection_type, Types::IssueType.connection_type,
......
...@@ -7,9 +7,8 @@ module Types ...@@ -7,9 +7,8 @@ module Types
field :project, Types::ProjectType, field :project, Types::ProjectType,
null: true, null: true,
resolver: Resolvers::ProjectResolver, resolver: Resolvers::ProjectResolver,
description: "Find a project" do description: "Find a project",
authorize :read_project authorize: :read_project
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
GraphQL::Field.accepts_definitions(authorize: GraphQL::Define.assign_metadata_key(:authorize))
Types::BaseField.accepts_definition(:authorize)
...@@ -12,24 +12,34 @@ add a `HTTP_PRIVATE_TOKEN` header. ...@@ -12,24 +12,34 @@ add a `HTTP_PRIVATE_TOKEN` header.
### Authorization ### Authorization
Fields can be authorized using the same abilities used in the Rails Fields can be authorized using the same abilities used in the Rails
app. This can be done using the `authorize` helper: app. This can be done by supplying the `authorize` option:
```ruby ```ruby
module Types module Types
class QueryType < BaseObject class QueryType < BaseObject
graphql_name 'Query' graphql_name 'Query'
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, authorize: :read_project
authorize :read_project
end
end end
end
```
Fields can be authorized against multiple abilities, in which case all
ability checks must pass. This requires explicitly passing a block to `field`:
```ruby
field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver do
authorize [:read_project, :another_ability]
end
``` ```
The object found by the resolve call is used for authorization. The object found by the resolve call is used for authorization.
This works for authorizing a single record, for authorizing TIP: **Tip:**
collections, we should only load what the currently authenticated user When authorizing collections, try to load only what the currently
is allowed to view. Preferably we use our existing finders for that. authenticated user is allowed to view with our existing finders first.
This minimizes database queries and unnecessary authorization checks of
the loaded records.
## Types ## Types
......
...@@ -10,21 +10,6 @@ module Gitlab ...@@ -10,21 +10,6 @@ module Gitlab
def self.use(schema_definition) def self.use(schema_definition)
schema_definition.instrument(:field, Instrumentation.new) schema_definition.instrument(:field, Instrumentation.new)
end end
def required_permissions
# If the `#authorize` call is used on multiple classes, we add the
# permissions specified on a subclass, to the ones that were specified
# on it's superclass.
@required_permissions ||= if self.respond_to?(:superclass) && superclass.respond_to?(:required_permissions)
superclass.required_permissions.dup
else
[]
end
end
def authorize(*permissions)
required_permissions.concat(permissions)
end
end end
end end
end end
...@@ -6,8 +6,21 @@ module Gitlab ...@@ -6,8 +6,21 @@ module Gitlab
module AuthorizeResource module AuthorizeResource
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do class_methods do
extend Gitlab::Graphql::Authorize def required_permissions
# If the `#authorize` call is used on multiple classes, we add the
# permissions specified on a subclass, to the ones that were specified
# on it's superclass.
@required_permissions ||= if self.respond_to?(:superclass) && superclass.respond_to?(:required_permissions)
superclass.required_permissions.dup
else
[]
end
end
def authorize(*permissions)
required_permissions.concat(permissions)
end
end end
def find_object(*args) def find_object(*args)
......
...@@ -6,19 +6,15 @@ module Gitlab ...@@ -6,19 +6,15 @@ module Gitlab
class Instrumentation class Instrumentation
# Replace the resolver for the field with one that will only return the # Replace the resolver for the field with one that will only return the
# resolved object if the permissions check is successful. # resolved object if the permissions check is successful.
#
# Collections are not supported. Apply permissions checks for those at the
# database level instead, to avoid loading superfluous data from the DB
def instrument(_type, field) def instrument(_type, field)
field_definition = field.metadata[:type_class] required_permissions = Array.wrap(field.metadata[:authorize])
return field unless field_definition.respond_to?(:required_permissions) return field if required_permissions.empty?
return field if field_definition.required_permissions.empty?
old_resolver = field.resolve_proc old_resolver = field.resolve_proc
new_resolver = -> (obj, args, ctx) do new_resolver = -> (obj, args, ctx) do
resolved_obj = old_resolver.call(obj, args, ctx) resolved_obj = old_resolver.call(obj, args, ctx)
checker = build_checker(ctx[:current_user], field_definition.required_permissions) checker = build_checker(ctx[:current_user], required_permissions)
if resolved_obj.respond_to?(:then) if resolved_obj.respond_to?(:then)
resolved_obj.then(&checker) resolved_obj.then(&checker)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Gitlab::Graphql::Authorization' do
set(:user) { create(:user) }
let(:test_object) { double(name: 'My name') }
let(:object_type) { object_type_class }
let(:query_type) { query_type_class(object_type, test_object) }
let(:schema) { schema_class(query_type) }
let(:execute) do
schema.execute(
query_string,
context: { current_user: user },
variables: {}
)
end
let(:result) { execute['data'] }
before do
# By default, disallow all permissions.
allow(Ability).to receive(:allowed?).and_return(false)
end
describe 'authorizing with a single permission' do
let(:query_string) { '{ singlePermission() { name } }' }
subject { result['singlePermission'] }
it 'should return the protected field when user has permission' do
permit(:foo)
expect(subject['name']).to eq(test_object.name)
end
it 'should return nil when user is not authorized' do
expect(subject).to be_nil
end
end
describe 'authorizing with an Array of permissions' do
let(:query_string) { '{ permissionCollection() { name } }' }
subject { result['permissionCollection'] }
it 'should return the protected field when user has all permissions' do
permit(:foo, :bar)
expect(subject['name']).to eq(test_object.name)
end
it 'should return nil when user only has one of the permissions' do
permit(:foo)
expect(subject).to be_nil
end
it 'should return nil when user only has none of the permissions' do
expect(subject).to be_nil
end
end
private
def permit(*permissions)
permissions.each do |permission|
allow(Ability).to receive(:allowed?).with(user, permission, test_object).and_return(true)
end
end
def object_type_class
Class.new(Types::BaseObject) do
graphql_name 'TestObject'
field :name, GraphQL::STRING_TYPE, null: true
end
end
def query_type_class(type, object)
Class.new(Types::BaseObject) do
graphql_name 'TestQuery'
field :single_permission, type,
null: true,
authorize: :foo,
resolve: ->(obj, args, ctx) { object }
field :permission_collection, type,
null: true,
resolve: ->(obj, args, ctx) { object } do
authorize [:foo, :bar]
end
end
end
def schema_class(query)
Class.new(GraphQL::Schema) do
use Gitlab::Graphql::Authorize
query(query)
end
end
end
...@@ -100,4 +100,22 @@ describe Gitlab::Graphql::Authorize::AuthorizeResource do ...@@ -100,4 +100,22 @@ describe Gitlab::Graphql::Authorize::AuthorizeResource do
expect { fake_class.new.find_object }.to raise_error(/Implement #find_object in #{fake_class.name}/) expect { fake_class.new.find_object }.to raise_error(/Implement #find_object in #{fake_class.name}/)
end end
end end
describe '#authorize' do
it 'adds permissions from subclasses to those of superclasses when used on classes' do
base_class = Class.new do
include Gitlab::Graphql::Authorize::AuthorizeResource
authorize :base_authorization
end
sub_class = Class.new(base_class) do
authorize :sub_authorization
end
expect(base_class.required_permissions).to contain_exactly(:base_authorization)
expect(sub_class.required_permissions)
.to contain_exactly(:base_authorization, :sub_authorization)
end
end
end end
require 'spec_helper'
describe Gitlab::Graphql::Authorize do
describe '#authorize' do
it 'adds permissions from subclasses to those of superclasses when used on classes' do
base_class = Class.new do
extend Gitlab::Graphql::Authorize
authorize :base_authorization
end
sub_class = Class.new(base_class) do
authorize :sub_authorization
end
expect(base_class.required_permissions).to contain_exactly(:base_authorization)
expect(sub_class.required_permissions)
.to contain_exactly(:base_authorization, :sub_authorization)
end
end
end
RSpec::Matchers.define :require_graphql_authorizations do |*expected| RSpec::Matchers.define :require_graphql_authorizations do |*expected|
match do |field| match do |field|
field_definition = field.metadata[:type_class] expect(field.metadata[:authorize]).to eq(*expected)
expect(field_definition).to respond_to(:required_permissions)
expect(field_definition.required_permissions).to contain_exactly(*expected)
end 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