From e4c4a9b207ed7d4347bde89330a6f54a174df9af Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Wed, 14 May 2025 00:19:45 -0400 Subject: [PATCH 1/6] subgraph authorizations. --- lib/graphql/stitching.rb | 8 + lib/graphql/stitching/composer.rb | 28 ++ .../stitching/composer/authorization.rb | 101 ++++++ lib/graphql/stitching/directives.rb | 6 + lib/graphql/stitching/executor.rb | 10 + lib/graphql/stitching/plan.rb | 47 ++- lib/graphql/stitching/planner.rb | 25 +- lib/graphql/stitching/request.rb | 17 +- lib/graphql/stitching/supergraph.rb | 4 +- .../stitching/supergraph/from_definition.rb | 22 +- .../composer/merge_authorization_test.rb | 170 ++++++++++ .../merge_subgraph_authorization_test.rb | 302 ++++++++++++++++++ .../integration/authorizations_test.rb | 30 ++ test/graphql/stitching/plan_test.rb | 16 +- .../planner/plan_authorizations_test.rb | 269 ++++++++++++++++ .../supergraph/from_definition_test.rb | 38 +++ test/graphql/stitching_test.rb | 5 + test/schemas/authorizations.rb | 120 +++++++ test/test_helper.rb | 4 +- 19 files changed, 1201 insertions(+), 21 deletions(-) create mode 100644 lib/graphql/stitching/composer/authorization.rb create mode 100644 test/graphql/stitching/composer/merge_authorization_test.rb create mode 100644 test/graphql/stitching/composer/merge_subgraph_authorization_test.rb create mode 100644 test/graphql/stitching/integration/authorizations_test.rb create mode 100644 test/graphql/stitching/planner/plan_authorizations_test.rb create mode 100644 test/schemas/authorizations.rb diff --git a/lib/graphql/stitching.rb b/lib/graphql/stitching.rb index 780cc4a4..7afbb759 100644 --- a/lib/graphql/stitching.rb +++ b/lib/graphql/stitching.rb @@ -58,6 +58,14 @@ def visibility_directive attr_writer :visibility_directive + # Name of the directive used to denote member authorizations. + # @returns [String] name of the authorization directive. + def authorization_directive + @authorization_directive ||= "authorization" + end + + attr_writer :authorization_directive + MIN_VISIBILITY_VERSION = "2.5.3" # @returns Boolean true if GraphQL::Schema::Visibility is fully supported diff --git a/lib/graphql/stitching/composer.rb b/lib/graphql/stitching/composer.rb index 79ecc6fe..8701bbe9 100644 --- a/lib/graphql/stitching/composer.rb +++ b/lib/graphql/stitching/composer.rb @@ -4,6 +4,7 @@ require_relative "composer/validate_interfaces" require_relative "composer/validate_type_resolvers" require_relative "composer/type_resolver_config" +require_relative "composer/authorization" module GraphQL module Stitching @@ -11,6 +12,8 @@ module Stitching # representing various graph locations and merges them into one # combined Supergraph that is validated for integrity. class Composer + include Authorization + # @api private NO_DEFAULT_VALUE = begin t = Class.new(GraphQL::Schema::Object) do @@ -60,6 +63,7 @@ def initialize( @resolver_configs = {} @mapped_type_names = {} @visibility_profiles = Set.new(visibility_profiles) + @authorizations_by_type_and_field = {} @subgraph_directives_by_name_and_location = nil @subgraph_types_by_name_and_location = nil @schema_directives = nil @@ -74,6 +78,7 @@ def perform(locations_input) directives_to_omit = [ GraphQL::Stitching.stitch_directive, + GraphQL::Stitching.authorization_directive, Directives::SupergraphKey.graphql_name, Directives::SupergraphResolver.graphql_name, Directives::SupergraphSource.graphql_name, @@ -169,6 +174,7 @@ def perform(locations_input) select_root_field_locations(schema) expand_abstract_resolvers(schema, schemas) apply_supergraph_directives(schema, @resolver_map, @field_map) + apply_authorization_directives(schema, @authorizations_by_type_and_field) if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive]) visibility_def.get_argument("profiles").default_value(@visibility_profiles.to_a.sort) @@ -201,6 +207,10 @@ def prepare_locations_input(locations_input) @resolver_configs.merge!(TypeResolverConfig.extract_directive_assignments(schema, location, input[:stitch])) @resolver_configs.merge!(TypeResolverConfig.extract_federation_entities(schema, location)) + if schema.directives[GraphQL::Stitching.authorization_directive] + SubgraphAuthorization.new(schema).reverse_merge!(@authorizations_by_type_and_field) + end + schemas[location.to_s] = schema executables[location.to_s] = input[:executable] || schema end @@ -750,6 +760,24 @@ def apply_supergraph_directives(schema, resolvers_by_type_name, locations_by_typ schema_directives.each_value { |directive_class| schema.directive(directive_class) } end + + def apply_authorization_directives(schema, authorizations_by_type_and_field) + return if authorizations_by_type_and_field.empty? + + schema.types.each_value do |type| + authorizations_by_field = authorizations_by_type_and_field[type.graphql_name] + next if authorizations_by_field.nil? || !type.kind.fields? + + type.fields.each_value do |field| + scopes = authorizations_by_field[field.graphql_name] + next if scopes.nil? + + field.directive(Directives::Authorization, scopes: scopes) + end + end + + schema.directive(Directives::Authorization) + end end end end diff --git a/lib/graphql/stitching/composer/authorization.rb b/lib/graphql/stitching/composer/authorization.rb new file mode 100644 index 00000000..96866c65 --- /dev/null +++ b/lib/graphql/stitching/composer/authorization.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module GraphQL::Stitching + class Composer + module Authorization + private + + def merge_authorization_scopes(*scopes) + merged_scopes = scopes.reduce([]) do |acc, or_scopes| + expanded_scopes = [] + or_scopes.each do |and_scopes| + if acc.any? + acc.each do |acc_scopes| + expanded_scopes << acc_scopes + and_scopes + end + else + expanded_scopes << and_scopes.dup + end + end + + expanded_scopes + end + + merged_scopes.each { _1.tap(&:sort!).tap(&:uniq!) } + merged_scopes.tap(&:uniq!).tap(&:sort!) + end + end + + class SubgraphAuthorization + include Authorization + + EMPTY_SCOPES = [EMPTY_ARRAY].freeze + + def initialize(schema) + @schema = schema + end + + def reverse_merge!(collector) + @schema.types.each_value.with_object(collector) do |type, memo| + next if type.introspection? || !type.kind.fields? + + type.fields.each_value do |field| + field_scopes = scopes_for_field(type, field) + if field_scopes.any?(&:any?) + memo[type.graphql_name] ||= {} + + existing = memo[type.graphql_name][field.graphql_name] + memo[type.graphql_name][field.graphql_name] = if existing + merge_authorization_scopes(existing, field_scopes) + else + field_scopes + end + end + end + end + end + + def collect + reverse_merge!({}) + end + + private + + def scopes_for_field(parent_type, field) + parent_type_scopes = scopes_from_directives(parent_type.directives) + field_scopes = scopes_from_directives(field.directives) + field_scopes = merge_authorization_scopes(parent_type_scopes, field_scopes) + + return_type = field.type.unwrap + if return_type.kind.scalar? || return_type.kind.enum? + return_type_scopes = scopes_from_directives(return_type.directives) + field_scopes = merge_authorization_scopes(field_scopes, return_type_scopes) + end + + each_corresponding_interface_field(parent_type, field.graphql_name) do |interface_type, interface_field| + field_scopes = merge_authorization_scopes(field_scopes, scopes_from_directives(interface_type.directives)) + field_scopes = merge_authorization_scopes(field_scopes, scopes_from_directives(interface_field.directives)) + end + + field_scopes + end + + def each_corresponding_interface_field(parent_type, field_name, &block) + parent_type.interfaces.each do |interface_type| + interface_field = interface_type.get_field(field_name) + next if interface_field.nil? + + yield(interface_type, interface_field) + each_corresponding_interface_field(interface_type, field_name, &block) + end + end + + def scopes_from_directives(directives) + authorization = directives.find { _1.graphql_name == GraphQL::Stitching.authorization_directive } + return EMPTY_SCOPES if authorization.nil? + + authorization.arguments.keyword_arguments[:scopes] || EMPTY_SCOPES + end + end + end +end diff --git a/lib/graphql/stitching/directives.rb b/lib/graphql/stitching/directives.rb index 5030c78a..0f126dce 100644 --- a/lib/graphql/stitching/directives.rb +++ b/lib/graphql/stitching/directives.rb @@ -20,6 +20,12 @@ class Visibility < GraphQL::Schema::Directive argument :profiles, [String, null: false], required: true end + class Authorization < GraphQL::Schema::Directive + graphql_name "authorization" + locations(FIELD_DEFINITION, OBJECT, INTERFACE, ENUM, SCALAR) + argument :scopes, [[String, null: false], null: false], required: true + end + class SupergraphKey < GraphQL::Schema::Directive graphql_name "key" locations OBJECT, INTERFACE, UNION diff --git a/lib/graphql/stitching/executor.rb b/lib/graphql/stitching/executor.rb index 17f3131a..a13e944d 100644 --- a/lib/graphql/stitching/executor.rb +++ b/lib/graphql/stitching/executor.rb @@ -45,6 +45,16 @@ def perform(raw: false) result["data"] = raw ? @data : Shaper.new(@request).perform!(@data) end + @request.plan.errors.each do |error| + case error.code + when "unauthorized" + @errors << { + "message" => "Unauthorized access", + "path" => error.path, + } + end + end + if @errors.length > 0 result["errors"] = @errors end diff --git a/lib/graphql/stitching/plan.rb b/lib/graphql/stitching/plan.rb index 9e07adf2..ece7b0fe 100644 --- a/lib/graphql/stitching/plan.rb +++ b/lib/graphql/stitching/plan.rb @@ -65,10 +65,25 @@ def ==(other) end end + class Error + attr_reader :code, :path + + def initialize(code:, path:) + @code = code + @path = path + end + + def as_json + { + code: code, + path: path, + } + end + end + class << self def from_json(json) - ops = json["ops"] - ops = ops.map do |op| + ops = json["ops"].map do |op| Op.new( step: op["step"], after: op["after"], @@ -81,19 +96,37 @@ def from_json(json) resolver: op["resolver"], ) end - new(ops: ops) + + errors = json["errors"]&.map do |err| + Error.new( + code: err["code"], + path: err["path"], + ) + end + + new( + ops: ops, + claims: json["claims"] || EMPTY_ARRAY, + errors: errors || EMPTY_ARRAY, + ) end end - attr_reader :ops + attr_reader :ops, :claims, :errors - def initialize(ops: []) + def initialize(ops: EMPTY_ARRAY, claims: nil, errors: nil) @ops = ops + @claims = claims || EMPTY_ARRAY + @errors = errors || EMPTY_ARRAY end def as_json - { ops: @ops.map(&:as_json) } + { + ops: @ops.map(&:as_json), + claims: @claims, + errors: @errors.map(&:as_json), + }.tap(&:compact!) end end end -end \ No newline at end of file +end diff --git a/lib/graphql/stitching/planner.rb b/lib/graphql/stitching/planner.rb index 2a4dc288..c68d0c9a 100644 --- a/lib/graphql/stitching/planner.rb +++ b/lib/graphql/stitching/planner.rb @@ -24,12 +24,17 @@ def initialize(request) @supergraph = request.supergraph @planning_index = ROOT_INDEX @steps_by_entrypoint = {} + @errors = nil end def perform build_root_entrypoints expand_abstract_resolvers - Plan.new(ops: steps.map!(&:to_plan_op)) + Plan.new( + ops: steps.map!(&:to_plan_op), + claims: @request.claims&.to_a || EMPTY_ARRAY, + errors: @errors || EMPTY_ARRAY, + ) end def steps @@ -115,6 +120,14 @@ def add_step( end end + def add_unauthorized(path) + @errors ||= [] + @errors << Plan::Error.new( + code: "unauthorized", + path: path, + ) + end + # A) Group all root selections by their preferred entrypoint locations. def build_root_entrypoints parent_type = @request.query.root_type_for_operation(@request.operation.operation_type) @@ -185,7 +198,11 @@ def each_field_in_scope(parent_type, input_selections, &block) input_selections.each do |node| case node when GraphQL::Language::Nodes::Field - yield(node) + if @request.authorized?(parent_type.graphql_name, node.name) + yield(node) + else + add_unauthorized([node.alias || node.name]) + end when GraphQL::Language::Nodes::InlineFragment next unless node.type.nil? || parent_type.graphql_name == node.type.name @@ -228,6 +245,10 @@ def extract_locale_selections( elsif node.name == TYPENAME locale_selections << node next + elsif !@request.authorized?(parent_type.graphql_name, node.name) + requires_typename = true + add_unauthorized([*path, node.alias || node.name]) + next end possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS diff --git a/lib/graphql/stitching/request.rb b/lib/graphql/stitching/request.rb index 5ebc4db6..3dd95aa3 100644 --- a/lib/graphql/stitching/request.rb +++ b/lib/graphql/stitching/request.rb @@ -20,14 +20,18 @@ class Request # @return [Hash] contextual object passed through resolver flows. attr_reader :context + # @return [Array[String]] authorization claims provided for the request. + attr_reader :claims + # Creates a new supergraph request. # @param supergraph [Supergraph] supergraph instance that resolves the request. # @param source [String, GraphQL::Language::Nodes::Document] the request string or parsed AST. # @param operation_name [String, nil] operation name selected for the request. # @param variables [Hash, nil] input variables for the request. # @param context [Hash, nil] a contextual object passed through resolver flows. - def initialize(supergraph, source, operation_name: nil, variables: nil, context: nil) + def initialize(supergraph, source, operation_name: nil, variables: nil, context: nil, claims: nil) @supergraph = supergraph + @claims = claims&.to_set&.freeze @prepared_document = nil @string = nil @digest = nil @@ -122,6 +126,17 @@ def subscription? @query.subscription? end + # @return [Boolean] true if authorized to access field on type + def authorized?(type_name, field_name) + or_scopes = @supergraph.authorizations_by_type_and_field.dig(type_name, field_name) + return true unless or_scopes&.any? + return false unless @claims&.any? + + or_scopes.any? do |and_scopes| + and_scopes.all? { |scope| @claims.include?(scope) } + end + end + # @return [Hash] provided variables hash filled in with default values from definitions def variables @variables || with_prepared_document { @variables } diff --git a/lib/graphql/stitching/supergraph.rb b/lib/graphql/stitching/supergraph.rb index a25d1d67..9f0312bc 100644 --- a/lib/graphql/stitching/supergraph.rb +++ b/lib/graphql/stitching/supergraph.rb @@ -21,13 +21,15 @@ class Supergraph attr_reader :memoized_schema_types attr_reader :memoized_introspection_types attr_reader :locations_by_type_and_field + attr_reader :authorizations_by_type_and_field - def initialize(schema:, fields: {}, resolvers: {}, visibility_profiles: [], executables: {}) + def initialize(schema:, fields: {}, resolvers: {}, executables: {}, authorizations: {}, visibility_profiles: []) @schema = schema @resolvers = resolvers @resolvers_by_version = nil @fields_by_type_and_location = nil @locations_by_type = nil + @authorizations_by_type_and_field = authorizations @memoized_introspection_types = @schema.introspection_system.types @memoized_schema_types = @schema.types @memoized_schema_fields = {} diff --git a/lib/graphql/stitching/supergraph/from_definition.rb b/lib/graphql/stitching/supergraph/from_definition.rb index b7295bb9..de9bfb91 100644 --- a/lib/graphql/stitching/supergraph/from_definition.rb +++ b/lib/graphql/stitching/supergraph/from_definition.rb @@ -21,6 +21,7 @@ def from_definition(schema, executables:) field_map = {} resolver_map = {} possible_locations = {} + authorizations = {} visibility_definition = schema.directives[GraphQL::Stitching.visibility_directive] visibility_profiles = visibility_definition&.get_argument("profiles")&.default_value || EMPTY_ARRAY @@ -59,15 +60,19 @@ def from_definition(schema, executables:) next unless type.kind.fields? type.fields.each do |field_name, field| - # Collection locations for each field definition field.directives.each do |d| - next unless d.graphql_name == Directives::SupergraphSource.graphql_name - - location = d.arguments.keyword_arguments[:location] - field_map[type_name] ||= {} - field_map[type_name][field_name] ||= [] - field_map[type_name][field_name] << location - possible_locations[location] = true + case d.graphql_name + when Directives::SupergraphSource.graphql_name + location = d.arguments.keyword_arguments[:location] + field_map[type_name] ||= {} + field_map[type_name][field_name] ||= [] + field_map[type_name][field_name] << location + possible_locations[location] = true + when Directives::Authorization.graphql_name + scopes = d.arguments.keyword_arguments[:scopes] + authorizations[type.graphql_name] ||= {} + authorizations[type.graphql_name][field.graphql_name] = scopes + end end end end @@ -83,6 +88,7 @@ def from_definition(schema, executables:) schema: schema, fields: field_map, resolvers: resolver_map, + authorizations: authorizations, visibility_profiles: visibility_profiles, executables: executables, ) diff --git a/test/graphql/stitching/composer/merge_authorization_test.rb b/test/graphql/stitching/composer/merge_authorization_test.rb new file mode 100644 index 00000000..34c8748e --- /dev/null +++ b/test/graphql/stitching/composer/merge_authorization_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "test_helper" + +describe 'GraphQL::Stitching::Composer, authorization' do + class AuthTester + extend GraphQL::Stitching::Composer::Authorization + def self.merge(*scopes) + merge_authorization_scopes(*scopes) + end + end + + def test_merges_multiple_unique_permission_scopes + result = { + "alpha" => [["a", "b"], ["c"]], + "bravo" => [["x", "y"], ["z"]], + "delta" => [["q"]], + }.each_value.reduce([]) do |base, scopes| + AuthTester.merge(base, scopes) + end + + expected = [ + ["a", "b", "q", "x", "y"], + ["a", "b", "q", "z"], + ["c", "q", "x", "y"], + ["c", "q", "z"], + ] + + assert_equal expected, result + end + + def test_merges_multiple_permission_scopes_with_duplicates + result = { + "alpha" => [["a", "b"], ["x"]], + "bravo" => [["a", "c"], ["y"]], + }.each_value.reduce([]) do |base, scopes| + AuthTester.merge(base, scopes) + end + + expected = [ + ["a", "b", "c"], + ["a", "b", "y"], + ["a", "c", "x"], + ["x", "y"], + ] + + assert_equal expected, result + end + + def test_merges_and_deduplicates_multiple_or_scopes + result = { + "alpha" => [["a", "b", "c"]], + "bravo" => [["a"], ["b"], ["c"]], + }.each_value.reduce([]) do |base, scopes| + AuthTester.merge(base, scopes) + end + + expected = [ + ["a", "b", "c"], + ] + + assert_equal expected, result + end + + def test_identical_merges_are_idempotent + result = { + "alpha" => [["a", "b"], ["c"]], + "bravo" => [["a", "b"], ["c"]], + }.each_value.reduce([]) do |base, scopes| + AuthTester.merge(base, scopes) + end + + result = { + "alpha" => result, + "bravo" => [["a", "b"], ["c"]], + }.each_value.reduce([]) do |base, scopes| + AuthTester.merge(base, scopes) + end + + expected = [ + ["a", "b"], ["a", "b", "c"], ["c"], + ] + + assert_equal expected, result + end + + def test_merges_field_authorizations + a = %| + #{AUTHORIZATION_DEFINITION} + type Query { + test: String @authorization(scopes: [["a"]]) + } + | + + b = %| + #{AUTHORIZATION_DEFINITION} + type Query { + test: String @authorization(scopes: [["b"]]) + } + | + + sg = compose_definitions({ "a" => a, "b" => b }) + assert_equal [["a", "b"]], get_scopes(sg.schema.query.get_field("test")) + end + + def test_merges_leaf_authorizations + a = %| + #{AUTHORIZATION_DEFINITION} + scalar URL @authorization(scopes: [["a"]]) + enum Enum @authorization(scopes: [["a"]]) { YES } + type Query { + url: URL + enum: Enum + } + | + + b = %| + #{AUTHORIZATION_DEFINITION} + scalar URL @authorization(scopes: [["b"]]) + enum Enum @authorization(scopes: [["b"]]) { YES } + type Query { + url: URL + enum: Enum + } + | + + sg = compose_definitions({ "a" => a, "b" => b }) + assert_equal [["a", "b"]], get_scopes(sg.schema.query.get_field("url")) + assert_equal [["a", "b"]], get_scopes(sg.schema.query.get_field("enum")) + end + + def test_merges_object_and_field_authorizations + a = %| + #{STITCH_DEFINITION} + #{AUTHORIZATION_DEFINITION} + type T @authorization(scopes: [["a"]]) { + id: ID! + x: String + } + type Query { + t(id: ID!): T @stitch(key: "id") + } + | + + b = %| + #{STITCH_DEFINITION} + #{AUTHORIZATION_DEFINITION} + type T { + id: ID! @authorization(scopes: [["b"]]) + } + type Query { + t(id: ID!): T @stitch(key: "id") + } + | + + sg = compose_definitions({ "a" => a, "b" => b }) + assert_equal [["a", "b"]], get_scopes(sg.schema.get_type("T").get_field("id")) + assert_equal [["a"]], get_scopes(sg.schema.get_type("T").get_field("x")) + assert_nil get_scopes(sg.schema.query.get_field("t")) + end + + private + + def get_scopes(element) + authorization = element.directives.find { _1.graphql_name == GraphQL::Stitching.authorization_directive } + return if authorization.nil? + + authorization.arguments.keyword_arguments[:scopes] + end +end diff --git a/test/graphql/stitching/composer/merge_subgraph_authorization_test.rb b/test/graphql/stitching/composer/merge_subgraph_authorization_test.rb new file mode 100644 index 00000000..0725ef04 --- /dev/null +++ b/test/graphql/stitching/composer/merge_subgraph_authorization_test.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +require "test_helper" + +describe 'GraphQL::Stitching::Composer, SubgraphAuthorization' do + SubgraphAuthorization = GraphQL::Stitching::Composer::SubgraphAuthorization + + def test_applies_scalar_scopes_to_returning_fields + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + scalar T @authorization(scopes: [["s"]]) + type Query { + a: String + t: T + } + |) + + expected = { + "Query" => { + "t" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_applies_enum_scopes_to_returning_fields + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + enum T @authorization(scopes: [["s"]]) { YES } + type Query { + a: String + t: T + } + |) + + expected = { + "Query" => { + "t" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_applies_object_scopes_to_child_and_returning_fields + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type T @authorization(scopes: [["s"]]) { + a: String + b: String + } + type Query { + a: String + t: T + } + |) + + expected = { + "T" => { + "a" => [["s"]], + "b" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_applies_interface_scopes_to_child_implementing_and_returning_fields + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + interface I @authorization(scopes: [["s"]]) { + a: String + b: String + } + type T implements I { + a: String + b: String + c: String + } + type Query { + i: I + t: T + } + |) + + expected = { + "I" => { + "a" => [["s"]], + "b" => [["s"]], + }, + "T" => { + "a" => [["s"]], + "b" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_applies_object_field_scopes + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type Query { + a: String @authorization(scopes: [["s"]]) + b: String + } + |) + + expected = { + "Query" => { + "a" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_applies_interface_field_scopes + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + interface I { + a: String @authorization(scopes: [["s"]]) + b: String + } + type T implements I { + a: String + b: String + } + type Query { + i: I + t: T + } + |) + + expected = { + "I" => { + "a" => [["s"]], + }, + "T" => { + "a" => [["s"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_merges_object_and_field_scopes + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type T @authorization(scopes: [["s1"]]) { + a: String @authorization(scopes: [["s2"]]) + b: String + } + type Query { + t: T @authorization(scopes: [["s3"]]) + } + |) + + expected = { + "T" => { + "a" => [["s1", "s2"]], + "b" => [["s1"]], + }, + "Query" => { + "t" => [["s3"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_merges_interface_object_leaf_and_field_scopes + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + scalar Widget @authorization(scopes: [["s0"]]) + interface I @authorization(scopes: [["s1"]]) { + a: Widget @authorization(scopes: [["s2"]]) + b: String + } + type T implements I @authorization(scopes: [["s3"]]) { + a: Widget @authorization(scopes: [["s4"]]) + b: String + c: String + } + type Query { + i: I @authorization(scopes: [["s5"]]) + t: T @authorization(scopes: [["s6"]]) + } + |) + + expected = { + "I" => { + "a" => [["s0", "s1", "s2"]], + "b" => [["s1"]], + }, + "T" => { + "a" => [["s0", "s1", "s2", "s3", "s4"]], + "b" => [["s1", "s3"]], + "c" => [["s3"]], + }, + "Query" => { + "i" => [["s5"]], + "t" => [["s6"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_merges_inherited_interfaces + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + interface IX @authorization(scopes: [["s1"]]) { + a: String @authorization(scopes: [["s2"]]) + } + interface IY implements IX { + a: String + b: String + } + type T implements IY { + a: String + b: String + c: String + } + type Query { + ix: IX + iy: IY + t: T + } + |) + + expected = { + "IX" => { + "a" => [["s1", "s2"]], + }, + "IY" => { + "a" => [["s1", "s2"]], + }, + "T" => { + "a" => [["s1", "s2"]], + }, + } + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_merges_or_scopes_via_matrix_multiplication + schema = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type Query @authorization(scopes: [["read:query"], ["read:root"]]) { + e: Enum! @authorization(scopes: [ + ["read:private", "read:field"], + ["read:private", "read:object"] + ]) + } + enum Enum @authorization(scopes: [["read:enum"]]) { + VALUE + } + |) + + expected = { + "Query" => { + "e" => [ + ["read:enum", "read:field", "read:private", "read:query"], + ["read:enum", "read:field", "read:private", "read:root"], + ["read:enum", "read:object", "read:private", "read:query"], + ["read:enum", "read:object", "read:private", "read:root"], + ], + }, + } + + assert_equal expected, SubgraphAuthorization.new(schema).collect + end + + def test_merge_authorizations_across_subgraph_compositions + schema1 = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type T @authorization(scopes: [["s1"]]) { + a: String + } + type Query { + t: T @authorization(scopes: [["s2"]]) + } + |) + + schema2 = GraphQL::Schema.from_definition(%| + #{AUTHORIZATION_DEFINITION} + type T { + a: String @authorization(scopes: [["s3"]]) + } + type Query @authorization(scopes: [["s4"]]) { + t: T + } + |) + + expected = { + "T" => { + "a" => [["s1", "s3"]], + }, + "Query" => { + "t" => [["s2", "s4"]], + }, + } + + acc = [schema1, schema2].each_with_object({}) do |schema, memo| + SubgraphAuthorization.new(schema).reverse_merge!(memo) + end + assert_equal expected, acc + end +end diff --git a/test/graphql/stitching/integration/authorizations_test.rb b/test/graphql/stitching/integration/authorizations_test.rb new file mode 100644 index 00000000..4f810752 --- /dev/null +++ b/test/graphql/stitching/integration/authorizations_test.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "test_helper" +require_relative "../../../schemas/authorizations" + +describe 'GraphQL::Stitching, authorizations' do + def setup + @supergraph = compose_definitions({ + "alpha" => Schemas::Authorizations::Alpha, + "bravo" => Schemas::Authorizations::Bravo, + }) + end + + def test_responds_with_error + query = %|{ + orderA(id: "1") { + customer1 { + phone + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders"]) do |plan| + pp plan.as_json + end + + pp result.to_h + assert true + end +end diff --git a/test/graphql/stitching/plan_test.rb b/test/graphql/stitching/plan_test.rb index 8a8d6676..9bf7004a 100644 --- a/test/graphql/stitching/plan_test.rb +++ b/test/graphql/stitching/plan_test.rb @@ -25,7 +25,16 @@ def setup resolver: @resolver.version, ) - @plan = GraphQL::Stitching::Plan.new(ops: [@op]) + @error = GraphQL::Stitching::Plan::Error.new( + code: "unauthorized", + path: ["product"], + ) + + @plan = GraphQL::Stitching::Plan.new( + ops: [@op], + claims: ["superuser"], + errors: [@error], + ) @serialized = { "ops" => [{ @@ -39,6 +48,11 @@ def setup "if_type" => "Storefront", "resolver" => @resolver.version, }], + "claims" => ["superuser"], + "errors" => [{ + "code" => "unauthorized", + "path" => ["product"], + }], } end diff --git a/test/graphql/stitching/planner/plan_authorizations_test.rb b/test/graphql/stitching/planner/plan_authorizations_test.rb new file mode 100644 index 00000000..4d31eedb --- /dev/null +++ b/test/graphql/stitching/planner/plan_authorizations_test.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "test_helper" + +describe "GraphQL::Stitching::Planner, authorizations" do + def setup + a = %| + #{AUTHORIZATION_DEFINITION} + type Customer @authorization(scopes: [["customers"]]) { + email: String + } + type Order @authorization(scopes: [["orders"]]) { + id: ID! + shippingAddress: String! + product: Product! + customer1: Customer + customer2: Customer @authorization(scopes: [["customers"]]) + } + type Product @authorization(scopes: [["products"]]) { + id: ID! + price: Float! + } + type Query { + orderA(id: ID!): Order @stitch(key: "id") @authorization(scopes: [["orders"]]) + productA(id: ID!): Product @stitch(key: "id") + } + | + b = %| + type Order { + id: ID! + open: String + } + type Product { + id: ID! + open: String + } + type Query { + orderB(id: ID!): Order @stitch(key: "id") + productB(id: ID!): Product @stitch(key: "id") + } + | + + @supergraph = compose_definitions({ "a" => a, "b" => b }) + end + + def test_selects_root_fields_without_authorization + query = %|{ + productA(id: "1") { + id + price + } + }| + + plan = GraphQL::Stitching::Request.new(@supergraph, query).plan + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ productA(id: "1") { _export___typename: __typename } }|, + variables: {}, + path: [], + }], + claims: [], + errors: [ + { code: "unauthorized", path: ["productA", "id"] }, + { code: "unauthorized", path: ["productA", "price"] }, + ], + } + + assert_equal expected, plan.as_json + end + + def test_selects_root_fields_with_authorization + query = %|{ + productA(id: "1") { + id + price + } + }| + + plan = GraphQL::Stitching::Request.new(@supergraph, query, claims: ["products"]).plan + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ productA(id: "1") { id price } }|, + variables: {}, + path: [], + }], + claims: ["products"], + errors: [], + } + + assert_equal expected, plan.as_json + end + + def test_selects_merged_object_fields_without_authorization + query = %|{ + orderA(id: "1") { + open + product { + id + open + } + } + }| + + plan = with_static_resolver_version do + GraphQL::Stitching::Request.new(@supergraph, query, claims: ["orders"]).plan + end + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ orderA(id: "1") { product { _export___typename: __typename _export_id: id } _export_id: id _export___typename: __typename } }|, + variables: {}, + path: [], + }, { + step: 2, + after: 1, + location: "b", + operation_type: "query", + selections: %|{ open }|, + variables: {}, + path: ["orderA", "product"], + if_type: "Product", + resolver: "b.productB.id.Product", + }, { + step: 3, + after: 1, + location: "b", + operation_type: "query", + selections: %|{ open }|, + variables: {}, + path: ["orderA"], + if_type: "Order", + resolver: "b.orderB.id.Order", + }], + claims: ["orders"], + errors: [ + { code: "unauthorized", path: ["orderA", "product", "id"] }, + ], + } + + assert_equal expected, plan.as_json + end + + def test_selects_merged_object_fields_with_authorization + query = %|{ + orderA(id: "1") { + open + product { + id + open + } + } + }| + + plan = with_static_resolver_version do + GraphQL::Stitching::Request.new(@supergraph, query, claims: ["orders", "products"]).plan + end + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ orderA(id: "1") { product { id _export_id: id _export___typename: __typename } _export_id: id _export___typename: __typename } }|, + variables: {}, + path: [], + }, { + step: 2, + after: 1, + location: "b", + operation_type: "query", + selections: %|{ open }|, + variables: {}, + path: ["orderA", "product"], + if_type: "Product", + resolver: "b.productB.id.Product", + }, { + step: 3, + after: 1, + location: "b", + operation_type: "query", + selections: %|{ open }|, + variables: {}, + path: ["orderA"], + if_type: "Order", + resolver: "b.orderB.id.Order", + }], + claims: ["orders", "products"], + errors: [], + } + + assert_equal expected, plan.as_json + end + + def test_selects_unmerged_object_fields_without_authorization + query = %|{ + orderA(id: "1") { + customer1 { email } + customer2 { email } + } + }| + + plan = with_static_resolver_version do + GraphQL::Stitching::Request.new(@supergraph, query, claims: ["orders"]).plan + end + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ orderA(id: "1") { customer1 { _export___typename: __typename } _export___typename: __typename } }|, + variables: {}, + path: [], + }], + claims: ["orders"], + errors: [ + { code: "unauthorized", path: ["orderA", "customer1", "email"] }, + { code: "unauthorized", path: ["orderA", "customer2"] }, + ], + } + + assert_equal expected, plan.as_json + end + + def test_selects_unmerged_object_fields_with_authorization + query = %|{ + orderA(id: "1") { + customer1 { email } + customer2 { email } + } + }| + + plan = with_static_resolver_version do + GraphQL::Stitching::Request.new(@supergraph, query, claims: ["orders", "customers"]).plan + end + + expected = { + ops: [{ + step: 1, + after: 0, + location: "a", + operation_type: "query", + selections: %|{ orderA(id: "1") { customer1 { email } customer2 { email } } }|, + variables: {}, + path: [], + }], + claims: ["orders", "customers"], + errors: [], + } + + assert_equal expected, plan.as_json + end +end diff --git a/test/graphql/stitching/supergraph/from_definition_test.rb b/test/graphql/stitching/supergraph/from_definition_test.rb index 631f85f5..cb0f2978 100644 --- a/test/graphql/stitching/supergraph/from_definition_test.rb +++ b/test/graphql/stitching/supergraph/from_definition_test.rb @@ -80,4 +80,42 @@ def test_errors_for_missing_executables }) end end + + def test_collects_authorizations + alpha = %| + #{AUTHORIZATION_DEFINITION} + interface I @authorization(scopes: [["s1"]]) { + id:ID! + } + type T implements I @authorization(scopes: [["s2"]]) { + id:ID! + a:String + } + type Query { + a(id:ID!):I @stitch(key: "id") + } + | + bravo = %| + type T { + id:ID! + b:String + } + type Query { + b(id:ID!):T @stitch(key: "id") + } + | + + expected = { + "I" => { + "id" => [["s1"]], + }, + "T" => { + "id" => [["s1", "s2"]], + "a" => [["s2"]], + }, + } + + supergraph = compose_definitions({ "alpha" => alpha, "bravo" => bravo }) + assert_equal expected, supergraph.authorizations_by_type_and_field + end end diff --git a/test/graphql/stitching_test.rb b/test/graphql/stitching_test.rb index 054155ef..e5ea374d 100644 --- a/test/graphql/stitching_test.rb +++ b/test/graphql/stitching_test.rb @@ -22,6 +22,7 @@ def test_digest_gets_and_sets_hashing_implementation def test_gets_and_sets_library_directive_names stitch_name = GraphQL::Stitching.stitch_directive visibility_name = GraphQL::Stitching.visibility_directive + authorization_name = GraphQL::Stitching.authorization_directive begin GraphQL::Stitching.stitch_directive = "test" @@ -29,9 +30,13 @@ def test_gets_and_sets_library_directive_names GraphQL::Stitching.visibility_directive = "test" assert_equal "test", GraphQL::Stitching.visibility_directive + + GraphQL::Stitching.authorization_directive = "test" + assert_equal "test", GraphQL::Stitching.authorization_directive ensure GraphQL::Stitching.stitch_directive = stitch_name GraphQL::Stitching.visibility_directive = visibility_name + GraphQL::Stitching.authorization_directive = authorization_name end end diff --git a/test/schemas/authorizations.rb b/test/schemas/authorizations.rb new file mode 100644 index 00000000..3476be9d --- /dev/null +++ b/test/schemas/authorizations.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Schemas + module Authorizations + PRODUCTS = [ + { id: "1", name: "iPhone", price: 699.99, description: "cool" }, + { id: "2", name: "Apple Watch", price: 399.99 }, + { id: "3", name: "Super Baking Cookbook", price: 15.99 }, + ].freeze + + ORDERS = [ + { id: "1", shipping_address: "123 Main", product_id: "1", customer: { email: "pete.cat@gmail.com" } }, + { id: "2", shipping_address: "456 Market", product_id: "2", customer: { email: "grumpytoad@gmail.com" } }, + ].freeze + + class Alpha < GraphQL::Schema + class Customer < GraphQL::Schema::Object + directive GraphQL::Stitching::Directives::Authorization, scopes: [["customers"]] + field :email, String, null: false + field :phone, String, null: true + end + + class Product < GraphQL::Schema::Object + directive GraphQL::Stitching::Directives::Authorization, scopes: [["products"]] + field :id, ID, null: false + field :name, String, null: false + field :description, String, null: true + field :price, Float, null: false + end + + class Order < GraphQL::Schema::Object + directive GraphQL::Stitching::Directives::Authorization, scopes: [["orders"]] + field :id, ID, null: false + field :shipping_address, String, null: false + field :product, Product, null: false + field :customer1, Customer, null: true + field :customer2, Customer, null: true do + directive GraphQL::Stitching::Directives::Authorization, scopes: [["customers"]] + end + + def product + PRODUCTS.find { _1[:id] == object[:product_id] } + end + + def customer1 + object[:customer] + end + + def customer2 + object[:customer] + end + end + + class Query < GraphQL::Schema::Object + field :product_a, Product, null: false do + directive GraphQL::Stitching::Directives::Stitch, key: "id" + argument :id, ID, required: true + end + + def product_a(id:) + PRODUCTS.find { _1[:id] == id } + end + + field :order_a, Order, null: false do + directive GraphQL::Stitching::Directives::Authorization, scopes: [["orders"]] + directive GraphQL::Stitching::Directives::Stitch, key: "id" + argument :id, ID, required: true + end + + def order_a(id:) + ORDERS.find { _1[:id] == id } + end + end + + query Query + end + + class Bravo < GraphQL::Schema + class Product < GraphQL::Schema::Object + field :id, ID, null: false + field :open, Boolean, null: false + + def open + true + end + end + + class Order < GraphQL::Schema::Object + field :id, ID, null: false + field :open, Boolean, null: false + + def open + true + end + end + + class Query < GraphQL::Schema::Object + field :product_b, Product, null: false do + directive GraphQL::Stitching::Directives::Stitch, key: "id" + argument :id, ID, required: true + end + + def product_b(id:) + PRODUCTS.find { _1[:id] == id } + end + + field :order_b, Order, null: false do + directive GraphQL::Stitching::Directives::Stitch, key: "id" + argument :id, ID, required: true + end + + def order_b(id:) + ORDERS.find { _1[:id] == id } + end + end + + query Query + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 6dc00cec..0e815810 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,6 +21,7 @@ ValidationError = GraphQL::Stitching::ValidationError STITCH_DEFINITION = "directive @stitch(key: String!, arguments: String, typeName: String) repeatable on FIELD_DEFINITION\n" VISIBILITY_DEFINITION = "directive @visibility(profiles: [String!]!) on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | FIELD_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | SCALAR | UNION\n" +AUTHORIZATION_DEFINITION = "directive @authorization(scopes: [[String!]!]!) on ENUM | FIELD_DEFINITION | INTERFACE | OBJECT | SCALAR\n" class Matcher def match?(value) @@ -127,11 +128,12 @@ def supergraph_from_schema(schema, fields: {}, resolvers: {}, executables: {}) ) end -def plan_and_execute(supergraph, query, variables={}, raw: false) +def plan_and_execute(supergraph, query, variables={}, claims: nil, raw: false) request = GraphQL::Stitching::Request.new( supergraph, query, variables: variables, + claims: claims, ) assert request.valid?, "Expected request to be valid: #{request.validate.map(&:message)}" From c9da5ae81b93c37432d401a7352ee7e706655567 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Tue, 3 Jun 2025 08:11:16 -0400 Subject: [PATCH 2/6] add more tests. --- lib/graphql/stitching/executor.rb | 10 +-- lib/graphql/stitching/plan.rb | 12 +++ .../integration/authorizations_test.rb | 77 +++++++++++++++++-- test/schemas/authorizations.rb | 1 + 4 files changed, 86 insertions(+), 14 deletions(-) diff --git a/lib/graphql/stitching/executor.rb b/lib/graphql/stitching/executor.rb index a13e944d..15f5814b 100644 --- a/lib/graphql/stitching/executor.rb +++ b/lib/graphql/stitching/executor.rb @@ -45,14 +45,8 @@ def perform(raw: false) result["data"] = raw ? @data : Shaper.new(@request).perform!(@data) end - @request.plan.errors.each do |error| - case error.code - when "unauthorized" - @errors << { - "message" => "Unauthorized access", - "path" => error.path, - } - end + @request.plan.errors.each do |err| + @errors << err.to_h end if @errors.length > 0 diff --git a/lib/graphql/stitching/plan.rb b/lib/graphql/stitching/plan.rb index ece7b0fe..05ed1a32 100644 --- a/lib/graphql/stitching/plan.rb +++ b/lib/graphql/stitching/plan.rb @@ -66,6 +66,10 @@ def ==(other) end class Error + MESSAGE_BY_CODE = { + "unauthorized" => "Unauthorized access", + }.freeze + attr_reader :code, :path def initialize(code:, path:) @@ -79,6 +83,14 @@ def as_json path: path, } end + + def to_h + { + "message" => MESSAGE_BY_CODE[@code], + "path" => @path, + "extensions" => { "code" => @code }, + } + end end class << self diff --git a/test/graphql/stitching/integration/authorizations_test.rb b/test/graphql/stitching/integration/authorizations_test.rb index 4f810752..fdb6c8e4 100644 --- a/test/graphql/stitching/integration/authorizations_test.rb +++ b/test/graphql/stitching/integration/authorizations_test.rb @@ -11,20 +11,85 @@ def setup }) end - def test_responds_with_error + def test_responds_with_errors_for_each_unauthorized_child_field query = %|{ orderA(id: "1") { customer1 { phone + slack } } }| - result = plan_and_execute(@supergraph, query, claims: ["orders"]) do |plan| - pp plan.as_json - end + result = plan_and_execute(@supergraph, query, claims: ["orders"]) + expected = { + "data" => { + "orderA" => { + "customer1" => { + "phone" => nil, + "slack" => nil, + }, + }, + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["orderA", "customer1", "phone"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["orderA", "customer1", "slack"], + "extensions" => { "code" => "unauthorized" }, + }], + } - pp result.to_h - assert true + assert_equal expected, result.to_h + end + + def test_errors_of_non_null_child_fields_bubble + query = %|{ + orderA(id: "1") { + customer1 { + email + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders"]) + expected = { + "data" => { + "orderA" => { "customer1" => nil }, + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["orderA", "customer1", "email"], + "extensions" => { "code" => "unauthorized" }, + }], + } + + assert_equal expected, result.to_h + end + + def test_responds_with_error_for_unauthorized_parent_field + query = %|{ + orderA(id: "1") { + customer2 { + phone + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders"]) + expected = { + "data" => { + "orderA" => { "customer2" => nil }, + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["orderA", "customer2"], + "extensions" => { "code" => "unauthorized" }, + }], + } + + assert_equal expected, result.to_h end end diff --git a/test/schemas/authorizations.rb b/test/schemas/authorizations.rb index 3476be9d..1eabc1e2 100644 --- a/test/schemas/authorizations.rb +++ b/test/schemas/authorizations.rb @@ -18,6 +18,7 @@ class Customer < GraphQL::Schema::Object directive GraphQL::Stitching::Directives::Authorization, scopes: [["customers"]] field :email, String, null: false field :phone, String, null: true + field :slack, String, null: true end class Product < GraphQL::Schema::Object From a848a1a04931e35d063b999106c7ebdafb1f6e7e Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Tue, 3 Jun 2025 08:14:40 -0400 Subject: [PATCH 3/6] more tests. --- .../integration/authorizations_test.rb | 29 ++++++++++++++++++- test/schemas/authorizations.rb | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/test/graphql/stitching/integration/authorizations_test.rb b/test/graphql/stitching/integration/authorizations_test.rb index fdb6c8e4..9c7c1226 100644 --- a/test/graphql/stitching/integration/authorizations_test.rb +++ b/test/graphql/stitching/integration/authorizations_test.rb @@ -68,7 +68,7 @@ def test_errors_of_non_null_child_fields_bubble assert_equal expected, result.to_h end - + def test_responds_with_error_for_unauthorized_parent_field query = %|{ orderA(id: "1") { @@ -92,4 +92,31 @@ def test_responds_with_error_for_unauthorized_parent_field assert_equal expected, result.to_h end + + def test_expected_results_with_proper_permissions + query = %|{ + orderA(id: "1") { + customer2 { + email + phone + slack + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders", "customers"]) + expected = { + "data" => { + "orderA" => { + "customer2" => { + "email" => "pete.cat@gmail.com", + "phone" => "123.456.7890", + "slack" => nil, + }, + }, + }, + } + + assert_equal expected, result.to_h + end end diff --git a/test/schemas/authorizations.rb b/test/schemas/authorizations.rb index 1eabc1e2..ae3635e0 100644 --- a/test/schemas/authorizations.rb +++ b/test/schemas/authorizations.rb @@ -9,7 +9,7 @@ module Authorizations ].freeze ORDERS = [ - { id: "1", shipping_address: "123 Main", product_id: "1", customer: { email: "pete.cat@gmail.com" } }, + { id: "1", shipping_address: "123 Main", product_id: "1", customer: { email: "pete.cat@gmail.com", phone: "123.456.7890" } }, { id: "2", shipping_address: "456 Market", product_id: "2", customer: { email: "grumpytoad@gmail.com" } }, ].freeze From 8ed3589dd37f673fa555d9b17d90c2fbab8a88ac Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Wed, 4 Jun 2025 21:00:42 -0400 Subject: [PATCH 4/6] more tests more better. --- lib/graphql/stitching/request.rb | 11 +- .../integration/authorizations_test.rb | 151 ++++++++++++++++++ test/schemas/authorizations.rb | 4 +- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/lib/graphql/stitching/request.rb b/lib/graphql/stitching/request.rb index 3dd95aa3..fb2fc028 100644 --- a/lib/graphql/stitching/request.rb +++ b/lib/graphql/stitching/request.rb @@ -77,12 +77,12 @@ def normalized_string # @return [String] a digest of the original document string. Generally faster but less consistent. def digest - @digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{string}") + @digest ||= Stitching.digest.call(digest_base(string)) end # @return [String] a digest of the normalized document string. Slower but more consistent. def normalized_digest - @normalized_digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{normalized_string}") + @normalized_digest ||= Stitching.digest.call(digest_base(normalized_string)) end # @return [GraphQL::Language::Nodes::OperationDefinition] The selected root operation for the request. @@ -235,6 +235,13 @@ def add_subscription_update_handler result } end + + def digest_base(content) + base = String.new(Stitching::VERSION) + base << "/" << content + @claims.each { |claim| base << "/" << claim } if @claims + base + end end end end diff --git a/test/graphql/stitching/integration/authorizations_test.rb b/test/graphql/stitching/integration/authorizations_test.rb index 9c7c1226..facadb9c 100644 --- a/test/graphql/stitching/integration/authorizations_test.rb +++ b/test/graphql/stitching/integration/authorizations_test.rb @@ -119,4 +119,155 @@ def test_expected_results_with_proper_permissions assert_equal expected, result.to_h end + + def test_errors_unauthorized_root_field_selections + query = %|{ + a1: orderA(id: "1") { shippingAddress } + a2: productA(id: "1") { name } + ...on Query { + b1: orderA(id: "1") { shippingAddress } + b2: productA(id: "1") { description } + ... QueryAttrs + } + } + fragment QueryAttrs on Query { + c1: orderA(id: "1") { shippingAddress } + c2: productA(id: "1") { price } + }| + + result = plan_and_execute(@supergraph, query) + expected = { + "data" => { + "a1" => nil, + "a2" => nil, + "b1" => nil, + "b2" => { "description" => nil }, + "c1" => nil, + "c2" => nil, + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["a1"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["b1"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["c1"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["a2", "name"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["b2", "description"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["c2", "price"], + "extensions" => { "code" => "unauthorized" }, + }], + } + + assert_equal expected, result.to_h + end + + def test_stitches_around_unauthorized_access + query = %|{ + orderA(id: "1") { + open + customer1 { + email + } + customer2 { + email + } + product { + description + open + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders"]) + expected = { + "data" => { + "orderA" => { + "open" => true, + "customer1" => nil, + "customer2" => nil, + "product" => { + "description" => nil, + "open" => true, + } + } + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["orderA", "customer1", "email"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["orderA", "customer2"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["orderA", "product", "description"], + "extensions" => { "code" => "unauthorized" }, + }], + } + + assert_equal expected, result.to_h + end + + def test_stitches_around_unauthorized_access_from_opposing_entrypoint + query = %|{ + orderB(id: "1") { + open + customer1 { + email + } + customer2 { + email + } + product { + description + open + } + } + }| + + result = plan_and_execute(@supergraph, query, claims: ["orders"]) + expected = { + "data" => { + "orderB" => { + "open" => true, + "customer1" => nil, + "customer2" => nil, + "product" => { + "description" => nil, + "open" => true, + } + } + }, + "errors" => [{ + "message" => "Unauthorized access", + "path" => ["orderB", "customer2"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["orderB", "customer1", "email"], + "extensions" => { "code" => "unauthorized" }, + }, { + "message" => "Unauthorized access", + "path" => ["orderB", "product", "description"], + "extensions" => { "code" => "unauthorized" }, + }], + } + + assert_equal expected, result.to_h + end end diff --git a/test/schemas/authorizations.rb b/test/schemas/authorizations.rb index ae3635e0..5e034b76 100644 --- a/test/schemas/authorizations.rb +++ b/test/schemas/authorizations.rb @@ -53,7 +53,7 @@ def customer2 end class Query < GraphQL::Schema::Object - field :product_a, Product, null: false do + field :product_a, Product, null: true do directive GraphQL::Stitching::Directives::Stitch, key: "id" argument :id, ID, required: true end @@ -62,7 +62,7 @@ def product_a(id:) PRODUCTS.find { _1[:id] == id } end - field :order_a, Order, null: false do + field :order_a, Order, null: true do directive GraphQL::Stitching::Directives::Authorization, scopes: [["orders"]] directive GraphQL::Stitching::Directives::Stitch, key: "id" argument :id, ID, required: true From 21520211903adefe6aee366161148b2677cec4a7 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Thu, 5 Jun 2025 08:07:24 -0400 Subject: [PATCH 5/6] digest tests. --- lib/graphql/stitching/request.rb | 2 +- .../graphql/stitching/request/request_test.rb | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/graphql/stitching/request.rb b/lib/graphql/stitching/request.rb index fb2fc028..db5012e6 100644 --- a/lib/graphql/stitching/request.rb +++ b/lib/graphql/stitching/request.rb @@ -31,7 +31,7 @@ class Request # @param context [Hash, nil] a contextual object passed through resolver flows. def initialize(supergraph, source, operation_name: nil, variables: nil, context: nil, claims: nil) @supergraph = supergraph - @claims = claims&.to_set&.freeze + @claims = claims&.sort&.to_set&.freeze @prepared_document = nil @string = nil @digest = nil diff --git a/test/graphql/stitching/request/request_test.rb b/test/graphql/stitching/request/request_test.rb index 4e59d651..bda35d5c 100644 --- a/test/graphql/stitching/request/request_test.rb +++ b/test/graphql/stitching/request/request_test.rb @@ -145,6 +145,29 @@ def test_provides_digest_and_normalized_digest end end + def test_digests_include_claims + query = "{ widget { id } }" + request1 = GraphQL::Stitching::Request.new(@supergraph, query) + request2 = GraphQL::Stitching::Request.new(@supergraph, query) + assert_equal request1.digest, request2.digest + assert_equal request1.normalized_digest, request2.normalized_digest + + request3 = GraphQL::Stitching::Request.new(@supergraph, query, claims: ["a"]) + request4 = GraphQL::Stitching::Request.new(@supergraph, query, claims: ["a"]) + assert_equal request3.digest, request4.digest + assert_equal request3.normalized_digest, request4.normalized_digest + + request5 = GraphQL::Stitching::Request.new(@supergraph, query, claims: ["a", "b"]) + request6 = GraphQL::Stitching::Request.new(@supergraph, query, claims: ["b", "a"]) + assert_equal request5.digest, request6.digest + assert_equal request5.normalized_digest, request6.normalized_digest + + assert request1.digest != request3.digest + assert request3.digest != request5.digest + assert request1.normalized_digest != request3.normalized_digest + assert request3.normalized_digest != request5.normalized_digest + end + def test_prepare_variables_collects_variable_defaults query = %| query($a: String! = "defaultA", $b: String = "defaultB") { From d2f578007be500245b1487692f417484b149dd1d Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 7 Jun 2025 17:46:18 -0400 Subject: [PATCH 6/6] setup formatter. --- lib/graphql/stitching/composer.rb | 9 +++++---- .../stitching/composer/authorization.rb | 19 +++++++++++++++++++ .../planner/plan_authorizations_test.rb | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/graphql/stitching/composer.rb b/lib/graphql/stitching/composer.rb index 8701bbe9..f7a334dc 100644 --- a/lib/graphql/stitching/composer.rb +++ b/lib/graphql/stitching/composer.rb @@ -174,7 +174,7 @@ def perform(locations_input) select_root_field_locations(schema) expand_abstract_resolvers(schema, schemas) apply_supergraph_directives(schema, @resolver_map, @field_map) - apply_authorization_directives(schema, @authorizations_by_type_and_field) + apply_authorization_directives(schema) if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive]) visibility_def.get_argument("profiles").default_value(@visibility_profiles.to_a.sort) @@ -536,6 +536,7 @@ def merge_descriptions(type_name, members_by_location, field_name: nil, argument @formatter.merge_descriptions(strings_by_location, Formatter::Info.new( type_name: type_name, field_name: field_name, + field_scopes: field_name ? @authorizations_by_type_and_field.dig(type_name, field_name) : nil, argument_name: argument_name, enum_value: enum_value, )) @@ -761,11 +762,11 @@ def apply_supergraph_directives(schema, resolvers_by_type_name, locations_by_typ schema_directives.each_value { |directive_class| schema.directive(directive_class) } end - def apply_authorization_directives(schema, authorizations_by_type_and_field) - return if authorizations_by_type_and_field.empty? + def apply_authorization_directives(schema) + return if @authorizations_by_type_and_field.empty? schema.types.each_value do |type| - authorizations_by_field = authorizations_by_type_and_field[type.graphql_name] + authorizations_by_field = @authorizations_by_type_and_field[type.graphql_name] next if authorizations_by_field.nil? || !type.kind.fields? type.fields.each_value do |field| diff --git a/lib/graphql/stitching/composer/authorization.rb b/lib/graphql/stitching/composer/authorization.rb index 96866c65..e150f174 100644 --- a/lib/graphql/stitching/composer/authorization.rb +++ b/lib/graphql/stitching/composer/authorization.rb @@ -3,6 +3,25 @@ module GraphQL::Stitching class Composer module Authorization + class << self + def print_scopes(or_scopes) + or_scopes.map do |and_scopes| + and_scopes = and_scopes.map { "`#{_1}`" } + if and_scopes.length > 2 + "#{and_scopes[0..-1].join(",")}, and #{and_scopes.last}" + else + and_scopes.join(" and ") + end + end + + or_scopes.join("; or ") + end + + def print_description(scopes) + "Required authorization scopes: #{print_scopes(scopes)}." + end + end + private def merge_authorization_scopes(*scopes) diff --git a/test/graphql/stitching/planner/plan_authorizations_test.rb b/test/graphql/stitching/planner/plan_authorizations_test.rb index 4d31eedb..13d16498 100644 --- a/test/graphql/stitching/planner/plan_authorizations_test.rb +++ b/test/graphql/stitching/planner/plan_authorizations_test.rb @@ -260,7 +260,7 @@ def test_selects_unmerged_object_fields_with_authorization variables: {}, path: [], }], - claims: ["orders", "customers"], + claims: ["customers", "orders"], errors: [], }