Skip to content

Commit f942891

Browse files
committed
subgraph authorizations.
1 parent 7ad025f commit f942891

File tree

19 files changed

+1201
-21
lines changed

19 files changed

+1201
-21
lines changed

lib/graphql/stitching.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def visibility_directive
5858

5959
attr_writer :visibility_directive
6060

61+
# Name of the directive used to denote member authorizations.
62+
# @returns [String] name of the authorization directive.
63+
def authorization_directive
64+
@authorization_directive ||= "authorization"
65+
end
66+
67+
attr_writer :authorization_directive
68+
6169
MIN_VISIBILITY_VERSION = "2.5.3"
6270

6371
# @returns Boolean true if GraphQL::Schema::Visibility is fully supported

lib/graphql/stitching/composer.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
require_relative "composer/validate_interfaces"
55
require_relative "composer/validate_type_resolvers"
66
require_relative "composer/type_resolver_config"
7+
require_relative "composer/authorization"
78

89
module GraphQL
910
module Stitching
1011
# Composer receives many individual `GraphQL::Schema` instances
1112
# representing various graph locations and merges them into one
1213
# combined Supergraph that is validated for integrity.
1314
class Composer
15+
include Authorization
16+
1417
# @api private
1518
NO_DEFAULT_VALUE = begin
1619
t = Class.new(GraphQL::Schema::Object) do
@@ -74,6 +77,7 @@ def initialize(
7477
@resolver_configs = {}
7578
@mapped_type_names = {}
7679
@visibility_profiles = Set.new(visibility_profiles)
80+
@authorizations_by_type_and_field = {}
7781
@subgraph_directives_by_name_and_location = nil
7882
@subgraph_types_by_name_and_location = nil
7983
@schema_directives = nil
@@ -88,6 +92,7 @@ def perform(locations_input)
8892

8993
directives_to_omit = [
9094
GraphQL::Stitching.stitch_directive,
95+
GraphQL::Stitching.authorization_directive,
9196
Directives::SupergraphKey.graphql_name,
9297
Directives::SupergraphResolver.graphql_name,
9398
Directives::SupergraphSource.graphql_name,
@@ -183,6 +188,7 @@ def perform(locations_input)
183188
select_root_field_locations(schema)
184189
expand_abstract_resolvers(schema, schemas)
185190
apply_supergraph_directives(schema, @resolver_map, @field_map)
191+
apply_authorization_directives(schema, @authorizations_by_type_and_field)
186192

187193
if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive])
188194
visibility_def.get_argument("profiles").default_value(@visibility_profiles.to_a.sort)
@@ -215,6 +221,10 @@ def prepare_locations_input(locations_input)
215221
@resolver_configs.merge!(TypeResolverConfig.extract_directive_assignments(schema, location, input[:stitch]))
216222
@resolver_configs.merge!(TypeResolverConfig.extract_federation_entities(schema, location))
217223

224+
if schema.directives[GraphQL::Stitching.authorization_directive]
225+
SubgraphAuthorization.new(schema).reverse_merge!(@authorizations_by_type_and_field)
226+
end
227+
218228
schemas[location.to_s] = schema
219229
executables[location.to_s] = input[:executable] || schema
220230
end
@@ -772,6 +782,24 @@ def apply_supergraph_directives(schema, resolvers_by_type_name, locations_by_typ
772782

773783
schema_directives.each_value { |directive_class| schema.directive(directive_class) }
774784
end
785+
786+
def apply_authorization_directives(schema, authorizations_by_type_and_field)
787+
return if authorizations_by_type_and_field.empty?
788+
789+
schema.types.each_value do |type|
790+
authorizations_by_field = authorizations_by_type_and_field[type.graphql_name]
791+
next if authorizations_by_field.nil? || !type.kind.fields?
792+
793+
type.fields.each_value do |field|
794+
scopes = authorizations_by_field[field.graphql_name]
795+
next if scopes.nil?
796+
797+
field.directive(Directives::Authorization, scopes: scopes)
798+
end
799+
end
800+
801+
schema.directive(Directives::Authorization)
802+
end
775803
end
776804
end
777805
end
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL::Stitching
4+
class Composer
5+
module Authorization
6+
private
7+
8+
def merge_authorization_scopes(*scopes)
9+
merged_scopes = scopes.reduce([]) do |acc, or_scopes|
10+
expanded_scopes = []
11+
or_scopes.each do |and_scopes|
12+
if acc.any?
13+
acc.each do |acc_scopes|
14+
expanded_scopes << acc_scopes + and_scopes
15+
end
16+
else
17+
expanded_scopes << and_scopes.dup
18+
end
19+
end
20+
21+
expanded_scopes
22+
end
23+
24+
merged_scopes.each { _1.tap(&:sort!).tap(&:uniq!) }
25+
merged_scopes.tap(&:uniq!).tap(&:sort!)
26+
end
27+
end
28+
29+
class SubgraphAuthorization
30+
include Authorization
31+
32+
EMPTY_SCOPES = [EMPTY_ARRAY].freeze
33+
34+
def initialize(schema)
35+
@schema = schema
36+
end
37+
38+
def reverse_merge!(collector)
39+
@schema.types.each_value.with_object(collector) do |type, memo|
40+
next if type.introspection? || !type.kind.fields?
41+
42+
type.fields.each_value do |field|
43+
field_scopes = scopes_for_field(type, field)
44+
if field_scopes.any?(&:any?)
45+
memo[type.graphql_name] ||= {}
46+
47+
existing = memo[type.graphql_name][field.graphql_name]
48+
memo[type.graphql_name][field.graphql_name] = if existing
49+
merge_authorization_scopes(existing, field_scopes)
50+
else
51+
field_scopes
52+
end
53+
end
54+
end
55+
end
56+
end
57+
58+
def collect
59+
reverse_merge!({})
60+
end
61+
62+
private
63+
64+
def scopes_for_field(parent_type, field)
65+
parent_type_scopes = scopes_from_directives(parent_type.directives)
66+
field_scopes = scopes_from_directives(field.directives)
67+
field_scopes = merge_authorization_scopes(parent_type_scopes, field_scopes)
68+
69+
return_type = field.type.unwrap
70+
if return_type.kind.scalar? || return_type.kind.enum?
71+
return_type_scopes = scopes_from_directives(return_type.directives)
72+
field_scopes = merge_authorization_scopes(field_scopes, return_type_scopes)
73+
end
74+
75+
each_corresponding_interface_field(parent_type, field.graphql_name) do |interface_type, interface_field|
76+
field_scopes = merge_authorization_scopes(field_scopes, scopes_from_directives(interface_type.directives))
77+
field_scopes = merge_authorization_scopes(field_scopes, scopes_from_directives(interface_field.directives))
78+
end
79+
80+
field_scopes
81+
end
82+
83+
def each_corresponding_interface_field(parent_type, field_name, &block)
84+
parent_type.interfaces.each do |interface_type|
85+
interface_field = interface_type.get_field(field_name)
86+
next if interface_field.nil?
87+
88+
yield(interface_type, interface_field)
89+
each_corresponding_interface_field(interface_type, field_name, &block)
90+
end
91+
end
92+
93+
def scopes_from_directives(directives)
94+
authorization = directives.find { _1.graphql_name == GraphQL::Stitching.authorization_directive }
95+
return EMPTY_SCOPES if authorization.nil?
96+
97+
authorization.arguments.keyword_arguments[:scopes] || EMPTY_SCOPES
98+
end
99+
end
100+
end
101+
end

lib/graphql/stitching/directives.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class Visibility < GraphQL::Schema::Directive
2020
argument :profiles, [String, null: false], required: true
2121
end
2222

23+
class Authorization < GraphQL::Schema::Directive
24+
graphql_name "authorization"
25+
locations(FIELD_DEFINITION, OBJECT, INTERFACE, ENUM, SCALAR)
26+
argument :scopes, [[String, null: false], null: false], required: true
27+
end
28+
2329
class SupergraphKey < GraphQL::Schema::Directive
2430
graphql_name "key"
2531
locations OBJECT, INTERFACE, UNION

lib/graphql/stitching/executor.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def perform(raw: false)
4545
result["data"] = raw ? @data : Shaper.new(@request).perform!(@data)
4646
end
4747

48+
@request.plan.errors.each do |error|
49+
case error.code
50+
when "unauthorized"
51+
@errors << {
52+
"message" => "Unauthorized access",
53+
"path" => error.path,
54+
}
55+
end
56+
end
57+
4858
if @errors.length > 0
4959
result["errors"] = @errors
5060
end

lib/graphql/stitching/plan.rb

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,25 @@ def ==(other)
6565
end
6666
end
6767

68+
class Error
69+
attr_reader :code, :path
70+
71+
def initialize(code:, path:)
72+
@code = code
73+
@path = path
74+
end
75+
76+
def as_json
77+
{
78+
code: code,
79+
path: path,
80+
}
81+
end
82+
end
83+
6884
class << self
6985
def from_json(json)
70-
ops = json["ops"]
71-
ops = ops.map do |op|
86+
ops = json["ops"].map do |op|
7287
Op.new(
7388
step: op["step"],
7489
after: op["after"],
@@ -81,19 +96,37 @@ def from_json(json)
8196
resolver: op["resolver"],
8297
)
8398
end
84-
new(ops: ops)
99+
100+
errors = json["errors"]&.map do |err|
101+
Error.new(
102+
code: err["code"],
103+
path: err["path"],
104+
)
105+
end
106+
107+
new(
108+
ops: ops,
109+
claims: json["claims"] || EMPTY_ARRAY,
110+
errors: errors || EMPTY_ARRAY,
111+
)
85112
end
86113
end
87114

88-
attr_reader :ops
115+
attr_reader :ops, :claims, :errors
89116

90-
def initialize(ops: [])
117+
def initialize(ops: EMPTY_ARRAY, claims: nil, errors: nil)
91118
@ops = ops
119+
@claims = claims || EMPTY_ARRAY
120+
@errors = errors || EMPTY_ARRAY
92121
end
93122

94123
def as_json
95-
{ ops: @ops.map(&:as_json) }
124+
{
125+
ops: @ops.map(&:as_json),
126+
claims: @claims,
127+
errors: @errors.map(&:as_json),
128+
}.tap(&:compact!)
96129
end
97130
end
98131
end
99-
end
132+
end

lib/graphql/stitching/planner.rb

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,17 @@ def initialize(request)
2424
@supergraph = request.supergraph
2525
@planning_index = ROOT_INDEX
2626
@steps_by_entrypoint = {}
27+
@errors = nil
2728
end
2829

2930
def perform
3031
build_root_entrypoints
3132
expand_abstract_resolvers
32-
Plan.new(ops: steps.map!(&:to_plan_op))
33+
Plan.new(
34+
ops: steps.map!(&:to_plan_op),
35+
claims: @request.claims&.to_a || EMPTY_ARRAY,
36+
errors: @errors || EMPTY_ARRAY,
37+
)
3338
end
3439

3540
def steps
@@ -115,6 +120,14 @@ def add_step(
115120
end
116121
end
117122

123+
def add_unauthorized(path)
124+
@errors ||= []
125+
@errors << Plan::Error.new(
126+
code: "unauthorized",
127+
path: path,
128+
)
129+
end
130+
118131
# A) Group all root selections by their preferred entrypoint locations.
119132
def build_root_entrypoints
120133
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)
185198
input_selections.each do |node|
186199
case node
187200
when GraphQL::Language::Nodes::Field
188-
yield(node)
201+
if @request.authorized?(parent_type.graphql_name, node.name)
202+
yield(node)
203+
else
204+
add_unauthorized([node.alias || node.name])
205+
end
189206

190207
when GraphQL::Language::Nodes::InlineFragment
191208
next unless node.type.nil? || parent_type.graphql_name == node.type.name
@@ -228,6 +245,10 @@ def extract_locale_selections(
228245
elsif node.name == TYPENAME
229246
locale_selections << node
230247
next
248+
elsif !@request.authorized?(parent_type.graphql_name, node.name)
249+
requires_typename = true
250+
add_unauthorized([*path, node.alias || node.name])
251+
next
231252
end
232253

233254
possible_locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS

lib/graphql/stitching/request.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@ class Request
2020
# @return [Hash] contextual object passed through resolver flows.
2121
attr_reader :context
2222

23+
# @return [Array[String]] authorization claims provided for the request.
24+
attr_reader :claims
25+
2326
# Creates a new supergraph request.
2427
# @param supergraph [Supergraph] supergraph instance that resolves the request.
2528
# @param source [String, GraphQL::Language::Nodes::Document] the request string or parsed AST.
2629
# @param operation_name [String, nil] operation name selected for the request.
2730
# @param variables [Hash, nil] input variables for the request.
2831
# @param context [Hash, nil] a contextual object passed through resolver flows.
29-
def initialize(supergraph, source, operation_name: nil, variables: nil, context: nil)
32+
def initialize(supergraph, source, operation_name: nil, variables: nil, context: nil, claims: nil)
3033
@supergraph = supergraph
34+
@claims = claims&.to_set&.freeze
3135
@prepared_document = nil
3236
@string = nil
3337
@digest = nil
@@ -122,6 +126,17 @@ def subscription?
122126
@query.subscription?
123127
end
124128

129+
# @return [Boolean] true if authorized to access field on type
130+
def authorized?(type_name, field_name)
131+
or_scopes = @supergraph.authorizations_by_type_and_field.dig(type_name, field_name)
132+
return true unless or_scopes&.any?
133+
return false unless @claims&.any?
134+
135+
or_scopes.any? do |and_scopes|
136+
and_scopes.all? { |scope| @claims.include?(scope) }
137+
end
138+
end
139+
125140
# @return [Hash<String, Any>] provided variables hash filled in with default values from definitions
126141
def variables
127142
@variables || with_prepared_document { @variables }

0 commit comments

Comments
 (0)