Skip to content

Authorization controls #182

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: formatter_class
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/graphql/stitching.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions lib/graphql/stitching/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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
# Composer receives many individual `GraphQL::Schema` instances
# 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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)

if (visibility_def = schema.directives[GraphQL::Stitching.visibility_directive])
visibility_def.get_argument("profiles").default_value(@visibility_profiles.to_a.sort)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -526,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,
))
Expand Down Expand Up @@ -750,6 +761,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)
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
120 changes: 120 additions & 0 deletions lib/graphql/stitching/composer/authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# frozen_string_literal: true

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)
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
6 changes: 6 additions & 0 deletions lib/graphql/stitching/directives.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions lib/graphql/stitching/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ def perform(raw: false)
result["data"] = raw ? @data : Shaper.new(@request).perform!(@data)
end

@request.plan.errors.each do |err|
@errors << err.to_h
end

if @errors.length > 0
result["errors"] = @errors
end
Expand Down
59 changes: 52 additions & 7 deletions lib/graphql/stitching/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,37 @@ def ==(other)
end
end

class Error
MESSAGE_BY_CODE = {
"unauthorized" => "Unauthorized access",
}.freeze

attr_reader :code, :path

def initialize(code:, path:)
@code = code
@path = path
end

def as_json
{
code: code,
path: path,
}
end

def to_h
{
"message" => MESSAGE_BY_CODE[@code],
"path" => @path,
"extensions" => { "code" => @code },
}
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"],
Expand All @@ -81,19 +108,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
end
25 changes: 23 additions & 2 deletions lib/graphql/stitching/planner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading