diff --git a/.gitignore b/.gitignore index 24391f51483ad062e87fc5482dd6171de0121de4..30cb231e83f1c598336dd4cfa06f74cb3226cecb 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,5 @@ apollo.config.js /tmp/matching_tests.txt ee/changelogs/unreleased-ee /sitespeed-result +tags.lock +tags.temp diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 2f5043f9ffa4f4f164ac91a1de0013e5b17afac9..b1f2ffb85035e7d65936a833d0f4c6657c898537 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -30,6 +30,8 @@ class GitlabSchema < GraphQL::Schema default_max_page_size 100 + lazy_resolve ::Gitlab::Graphql::Lazy, :force + class << self def multiplex(queries, **kwargs) kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) diff --git a/bin/feature-flag b/bin/feature-flag index 0de9b90681fcb2ce068a0080b2a945d26c4b5fc4..613ddc1d8cb663cb8112d7947a11d4f9f1cc5557 100755 --- a/bin/feature-flag +++ b/bin/feature-flag @@ -104,7 +104,7 @@ class FeatureFlagOptionParser end # Name is a first name - options.name = argv.first + options.name = argv.first.downcase.gsub(/-/, '_') options end diff --git a/config/feature_flags/development/graphql_lazy_authorization.yml b/config/feature_flags/development/graphql_lazy_authorization.yml new file mode 100644 index 0000000000000000000000000000000000000000..8277c2666ea4992662c2b661dfd7b4fdd33e0af6 --- /dev/null +++ b/config/feature_flags/development/graphql_lazy_authorization.yml @@ -0,0 +1,7 @@ +--- +name: graphql_lazy_authorization +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45263 +rollout_issue_url: +type: development +group: group::plan +default_enabled: false diff --git a/lib/gitlab/graphql/authorize/authorize_field_service.rb b/lib/gitlab/graphql/authorize/authorize_field_service.rb index cbf3e7b8429f4c0a811c14ad670660ff1db86fc6..e8db619f88ab995796779ec9c51f7e30885042f6 100644 --- a/lib/gitlab/graphql/authorize/authorize_field_service.rb +++ b/lib/gitlab/graphql/authorize/authorize_field_service.rb @@ -46,6 +46,8 @@ def type_authorizations # Returns any authorize metadata from @field def field_authorizations + return [] if @field.metadata[:authorize] == true + Array.wrap(@field.metadata[:authorize]) end @@ -54,7 +56,7 @@ def authorize_against(parent_typed_object, resolved_type) # The field is a built-in/scalar type, or a list of scalars # authorize using the parent's object parent_typed_object.object - elsif @field.connection? || resolved_type.is_a?(Array) + elsif @field.connection? || @field.type.list? || resolved_type.is_a?(Array) # The field is a connection or a list of non-built-in types, we'll # authorize each element when rendering nil @@ -75,16 +77,25 @@ def filter_allowed(current_user, resolved_type, authorizing_object) # no need to do anything elsif authorizing_object # Authorizing fields representing scalars, or a simple field with an object - resolved_type if allowed_access?(current_user, authorizing_object) + ::Gitlab::Graphql::Lazy.with_value(authorizing_object) do |object| + resolved_type if allowed_access?(current_user, object) + end elsif @field.connection? - # A connection with pagination, modify the visible nodes on the - # connection type in place - resolved_type.object.edge_nodes.to_a.keep_if { |node| allowed_access?(current_user, node) } - resolved_type - elsif resolved_type.is_a? Array + ::Gitlab::Graphql::Lazy.with_value(resolved_type) do |type| + # A connection with pagination, modify the visible nodes on the + # connection type in place + nodes = to_nodes(type) + nodes.keep_if { |node| allowed_access?(current_user, node) } if nodes + type + end + elsif @field.type.list? || resolved_type.is_a?(Array) # A simple list of rendered types each object being an object to authorize - resolved_type.select do |single_object_type| - allowed_access?(current_user, realized(single_object_type).object) + ::Gitlab::Graphql::Lazy.with_value(resolved_type) do |items| + items.select do |single_object_type| + object_type = realized(single_object_type) + object = object_type.try(:object) || object_type + allowed_access?(current_user, object) + end end else raise "Can't authorize #{@field}" @@ -93,18 +104,23 @@ def filter_allowed(current_user, resolved_type, authorizing_object) # Ensure that we are dealing with realized objects, not delayed promises def realized(thing) - case thing - when BatchLoader::GraphQL - thing.sync - when GraphQL::Execution::Lazy - thing.value # part of the private api, but we need to unwrap it here. + ::Gitlab::Graphql::Lazy.force(thing) + end + + # Try to get the connection + # can be at type.object or at type + def to_nodes(type) + if type.respond_to?(:nodes) + type.nodes + elsif type.respond_to?(:object) + to_nodes(type.object) else - thing + nil end end def allowed_access?(current_user, object) - object = object.sync if object.respond_to?(:sync) + object = realized(object) authorizations.all? do |ability| Ability.allowed?(current_user, ability, object) diff --git a/lib/gitlab/graphql/lazy.rb b/lib/gitlab/graphql/lazy.rb index a7f7610a041007640d1e715806996e432099d937..b25e6b33a768b3b3ab4b93c37a89f331733a415b 100644 --- a/lib/gitlab/graphql/lazy.rb +++ b/lib/gitlab/graphql/lazy.rb @@ -3,17 +3,45 @@ module Gitlab module Graphql class Lazy + include Gitlab::Utils::StrongMemoize + + def initialize(&block) + @proc = block + end + + def force + strong_memoize(:force) { self.class.force(@proc.call) } + end + + def then(&block) + self.class.new { yield force } + end + # Force evaluation of a (possibly) lazy value def self.force(value) case value + when ::Gitlab::Graphql::Lazy + value.force when ::BatchLoader::GraphQL value.sync + when ::GraphQL::Execution::Lazy + value.value # part of the private api, but we can force this as well when ::Concurrent::Promise - value.execute.value + value.execute if value.state == :unscheduled + + value.value # value.value(10.seconds) else value end end + + def self.with_value(unforced, &block) + if Feature.enabled?(:graphql_lazy_authorization) + self.new { unforced }.then(&block) + else + block.call(unforced) + end + end end end end diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb index 8e0bed5b769053a6621bb6898c767a2723b9b7ca..710b16069235e918b862069aac192cee63a8b804 100644 --- a/spec/bin/feature_flag_spec.rb +++ b/spec/bin/feature_flag_spec.rb @@ -13,7 +13,7 @@ let(:options) { FeatureFlagOptionParser.parse(argv) } let(:creator) { described_class.new(options) } let(:existing_flags) do - { 'existing-feature-flag' => File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') } + { 'existing_feature_flag' => File.join('config', 'feature_flags', 'development', 'existing_feature_flag.yml') } end before do @@ -32,12 +32,12 @@ it 'properly creates a feature flag' do expect(File).to receive(:write).with( - File.join('config', 'feature_flags', 'development', 'feature-flag-name.yml'), + File.join('config', 'feature_flags', 'development', 'feature_flag_name.yml'), anything) expect do subject - end.to output(/name: feature-flag-name/).to_stdout + end.to output(/name: feature_flag_name/).to_stdout end context 'when running on master' do diff --git a/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb b/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb index 7576523ce52e2eeb4f255d8ac500e95cfb3b2eaf..fca08ebf48b69e4b5fa0a555a90c41cf1a698e05 100644 --- a/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb +++ b/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb @@ -27,13 +27,17 @@ def type_with_field(field_type, field_authorizations = [], resolved_value = 'Res end end + def resolve + service.authorized_resolve[type_instance, {}, context] + end + subject(:service) { described_class.new(field) } describe '#authorized_resolve' do let_it_be(:current_user) { build(:user) } let_it_be(:presented_object) { 'presented object' } let_it_be(:query_type) { GraphQL::ObjectType.new } - let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)} + let_it_be(:schema) { GitlabSchema } let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) } let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: { current_user: current_user }, object: nil) } @@ -41,125 +45,211 @@ def type_with_field(field_type, field_authorizations = [], resolved_value = 'Res let(:type_instance) { type_class.authorized_new(presented_object, context) } let(:field) { type_class.fields['testField'].to_graphql } - subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) } + subject(:resolved) { resolve&.force } - context 'scalar types' do - shared_examples 'checking permissions on the presented object' do - it 'checks the abilities on the object being presented and returns the value' do - expected_permissions.each do |permission| - spy_ability_check_for(permission, presented_object, passed: true) - end + context 'reading the field of a lazy value' do + let(:ability) { :read_field } + let(:presented_object) { lazy_upcase('a') } + let(:type_class) { type_with_field(GraphQL::STRING_TYPE, ability) } - expect(resolved).to eq('Resolved value') + let(:upcaser) do + Module.new do + def self.upcase(strs) + strs.map(&:upcase) + end end + end - it 'returns nil if the value was not authorized' do - allow(Ability).to receive(:allowed?).and_return false - - expect(resolved).to be_nil + def lazy_upcase(str) + ::BatchLoader::GraphQL.for(str).batch do |strs, found| + strs.zip(upcaser.upcase(strs)).each { |s, us| found[s, us] } end end - context 'when the field is a built-in scalar type' do - let(:type_class) { type_with_field(GraphQL::STRING_TYPE, :read_field) } - let(:expected_permissions) { [:read_field] } + it 'does not run authorizations until we force the resolved value' do + expect(Ability).not_to receive(:allowed?) - it_behaves_like 'checking permissions on the presented object' + expect(resolve).to respond_to(:force) end - context 'when the field is a list of scalar types' do - let(:type_class) { type_with_field([GraphQL::STRING_TYPE], :read_field) } - let(:expected_permissions) { [:read_field] } + it 'runs authorizations when we force the resolved value' do + spy_ability_check_for(ability, 'A') - it_behaves_like 'checking permissions on the presented object' + expect(resolved).to eq('Resolved value') end - context 'when the field is sub-classed scalar type' do - let(:type_class) { type_with_field(Types::TimeType, :read_field) } - let(:expected_permissions) { [:read_field] } + it 'redacts values that fail the permissions check' do + spy_ability_check_for(ability, 'A', passed: false) - it_behaves_like 'checking permissions on the presented object' + expect(resolved).to be_nil end - context 'when the field is a list of sub-classed scalar types' do - let(:type_class) { type_with_field([Types::TimeType], :read_field) } - let(:expected_permissions) { [:read_field] } + context 'we batch two calls' do + def resolve(value) + instance = type_class.authorized_new(lazy_upcase(value), context) + service.authorized_resolve[instance, {}, context] + end - it_behaves_like 'checking permissions on the presented object' - end - end + it 'batches resolution, but authorizes each object separately' do + expect(upcaser).to receive(:upcase).once.and_call_original + spy_ability_check_for(:read_field, 'A', passed: true) + spy_ability_check_for(:read_field, 'B', passed: false) + spy_ability_check_for(:read_field, 'C', passed: true) - context 'when the field is a connection' do - context 'when it resolves to nil' do - let(:type_class) { type_with_field(Types::QueryType.connection_type, :read_field, nil) } + a = resolve('a') + b = resolve('b') + c = resolve('c') - it 'does not fail when authorizing' do - expect(resolved).to be_nil + expect(a.force).to be_present + expect(b.force).to be_nil + expect(c.force).to be_present end end end - context 'when the field is a specific type' do - let(:custom_type) { type(:read_type) } - let(:object_in_field) { double('presented in field') } + shared_examples 'authorizing fields' do + context 'scalar types' do + shared_examples 'checking permissions on the presented object' do + it 'checks the abilities on the object being presented and returns the value' do + expected_permissions.each do |permission| + spy_ability_check_for(permission, presented_object, passed: true) + end - let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) } - let(:type_instance) { type_class.authorized_new(object_in_field, context) } + expect(resolved).to eq('Resolved value') + end - subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) } + it 'returns nil if the value was not authorized' do + allow(Ability).to receive(:allowed?).and_return false - it 'checks both field & type permissions' do - spy_ability_check_for(:read_field, object_in_field, passed: true) - spy_ability_check_for(:read_type, object_in_field, passed: true) + expect(resolved).to be_nil + end + end - expect(resolved).to eq(object_in_field) - end + context 'when the field is a built-in scalar type' do + let(:type_class) { type_with_field(GraphQL::STRING_TYPE, :read_field) } + let(:expected_permissions) { [:read_field] } - 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_type, object_in_field, passed: true) + it_behaves_like 'checking permissions on the presented object' + end - expect(resolved).to be_nil + context 'when the field is a list of scalar types' do + let(:type_class) { type_with_field([GraphQL::STRING_TYPE], :read_field) } + let(:expected_permissions) { [:read_field] } + + it_behaves_like 'checking permissions on the presented object' + end + + context 'when the field is sub-classed scalar type' do + let(:type_class) { type_with_field(Types::TimeType, :read_field) } + let(:expected_permissions) { [:read_field] } + + it_behaves_like 'checking permissions on the presented object' + end + + context 'when the field is a list of sub-classed scalar types' do + let(:type_class) { type_with_field([Types::TimeType], :read_field) } + let(:expected_permissions) { [:read_field] } + + it_behaves_like 'checking permissions on the presented object' + end end - context 'when the field is not nullable' do - let(:type_class) { type_with_field(custom_type, :read_field, object_in_field, null: false) } + context 'when the field is a connection' do + context 'when it resolves to nil' do + let(:type_class) { type_with_field(Types::QueryType.connection_type, :read_field, nil) } - it 'returns nil when viewing is not allowed' do - spy_ability_check_for(:read_type, object_in_field, passed: false) + it 'does not fail when authorizing' do + expect(resolved).to be_nil + end + end - expect(resolved).to be_nil + context 'when it returns values' do + let(:objects) { [1, 2, 3] } + let(:field_type) { type([:read_object]).connection_type } + let(:type_class) { type_with_field(field_type, [], objects) } + + it 'filters out unauthorized values' do + spy_ability_check_for(:read_object, 1, passed: true) + spy_ability_check_for(:read_object, 2, passed: false) + spy_ability_check_for(:read_object, 3, passed: true) + + expect(resolved.nodes).to eq [1, 3] + end 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)] } + context 'when the field is a specific type' do + let(:custom_type) { type(:read_type) } + let(:object_in_field) { double('presented in field') } + + let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) } + let(:type_instance) { type_class.authorized_new(object_in_field, context) } - let(:type_class) { type_with_field([custom_type], :read_field, presented_types) } - let(:type_instance) { type_class.authorized_new(presented_types, context) } + it 'checks both field & type permissions' do + spy_ability_check_for(:read_field, object_in_field, passed: true) + spy_ability_check_for(:read_type, object_in_field, passed: true) - it 'checks all permissions' do - allow(Ability).to receive(:allowed?) { true } + expect(resolved).to eq(object_in_field) + end - spy_ability_check_for(:read_field, object_1, passed: true) - spy_ability_check_for(:read_type, object_1, passed: true) - spy_ability_check_for(:read_field, object_2, passed: true) - spy_ability_check_for(:read_type, object_2, passed: true) + 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_type, object_in_field, passed: true) - expect(resolved).to eq(presented_types) + expect(resolved).to be_nil + end + + context 'when the field is not nullable' do + let(:type_class) { type_with_field(custom_type, :read_field, object_in_field, null: false) } + + 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 - it 'filters out objects that the user cannot see' do - allow(Ability).to receive(:allowed?) { true } + 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)] } - spy_ability_check_for(:read_type, object_1, passed: false) + let(:type_class) { type_with_field([custom_type], :read_field, presented_types) } + let(:type_instance) { type_class.authorized_new(presented_types, context) } - expect(resolved.map(&:object)).to contain_exactly(object_2) + it 'checks all permissions' do + allow(Ability).to receive(:allowed?) { true } + + spy_ability_check_for(:read_field, object_1, passed: true) + spy_ability_check_for(:read_type, object_1, passed: true) + spy_ability_check_for(:read_field, object_2, passed: true) + spy_ability_check_for(:read_type, object_2, passed: true) + + expect(resolved).to eq(presented_types) + end + + it 'filters out objects that the user cannot see' do + allow(Ability).to receive(:allowed?) { true } + + spy_ability_check_for(:read_type, object_1, passed: false) + + expect(resolved).to contain_exactly(have_attributes(object: object_2)) + end end end end + + it_behaves_like 'authorizing fields' + + context 'the graphql_lazy_authorization feature flag is disabled' do + before do + stub_feature_flags(graphql_lazy_authorization: false) + end + + subject(:resolved) { resolve } + + it_behaves_like 'authorizing fields' + end end private diff --git a/spec/lib/gitlab/graphql/lazy_spec.rb b/spec/lib/gitlab/graphql/lazy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..795978ab0a4140768b978886a86e339b2650e683 --- /dev/null +++ b/spec/lib/gitlab/graphql/lazy_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Lazy do + def load(key) + BatchLoader.for(key).batch do |keys, loader| + keys.each { |x| loader.call(x, x * x) } + end + end + + let(:value) { double(x: 1) } + + describe '#force' do + subject { described_class.new { value.x } } + + it 'can extract the value' do + expect(subject.force).to be 1 + end + + it 'can derive new lazy values' do + expect(subject.then { |x| x + 2 }.force).to be 3 + end + + it 'only evaluates once' do + expect(value).to receive(:x).once + + expect(subject.force).to eq(subject.force) + end + + it 'deals with nested laziness' do + expect(described_class.new { load(10) }.force).to eq(100) + expect(described_class.new { described_class.new { 5 } }.force).to eq 5 + end + end + + describe '.with_value' do + let(:inner) { described_class.new { value.x } } + + subject { described_class.with_value(inner) { |x| x.to_s } } + + it 'defers the application of a block to a value' do + expect(value).not_to receive(:x) + + expect(subject).to be_an_instance_of(described_class) + end + + it 'evaluates to the application of the block to the value' do + expect(value).to receive(:x).once + + expect(subject.force).to eq(inner.force.to_s) + end + end + + describe '.force' do + context 'when given a plain value' do + subject { described_class.force(1) } + + it 'unwraps the value' do + expect(subject).to be 1 + end + end + + context 'when given a wrapped lazy value' do + subject { described_class.force(described_class.new { 2 }) } + + it 'unwraps the value' do + expect(subject).to be 2 + end + end + + context 'when the value is from a batchloader' do + subject { described_class.force(load(3)) } + + it 'syncs the value' do + expect(subject).to be 9 + end + end + + context 'when the value is a GraphQL lazy' do + subject { described_class.force(GitlabSchema.after_lazy(load(3)) { |x| x + 1 } ) } + + it 'forces the evaluation' do + expect(subject).to be 10 + end + end + + context 'when the value is a promise' do + subject { described_class.force(::Concurrent::Promise.new { 4 }) } + + it 'executes the promise and waits for the value' do + expect(subject).to be 4 + end + end + end +end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 01f8bcd242c6e66a02b8611634229a7fde27b32c..1844e12ed7aefc7f42262082bc87e55282115748 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -478,6 +478,8 @@ def execute_query(query_type) use Gitlab::Graphql::Authorize use Gitlab::Graphql::Pagination::Connections + lazy_resolve ::Gitlab::Graphql::Lazy, :force + query(query_type) end diff --git a/spec/support/shared_examples/graphql/label_fields.rb b/spec/support/shared_examples/graphql/label_fields.rb index b1bfb395bc668d5ad1c14ccb3441e9f96663d7b0..caf5dae409a7361565f2169e9e42d3ca9fc1bd30 100644 --- a/spec/support/shared_examples/graphql/label_fields.rb +++ b/spec/support/shared_examples/graphql/label_fields.rb @@ -106,13 +106,11 @@ def query_for(*labels) end it 'batches queries for labels by title' do - pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/217767') - multi_selection = query_for(label_b, label_c) single_selection = query_for(label_d) expect { run_query(multi_selection) } - .to issue_same_number_of_queries_as { run_query(single_selection) } + .to issue_same_number_of_queries_as { run_query(single_selection) }.ignoring_cached_queries end end