Skip to content

Commit

Permalink
Merge pull request #5058 from rmosolgo/authorized-enum-values
Browse files Browse the repository at this point in the history
Call .authorized? on incoming and outgoing enum values
  • Loading branch information
rmosolgo authored Aug 9, 2024
2 parents 4e07d3f + c50957f commit e866884
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 16 deletions.
9 changes: 9 additions & 0 deletions guides/authorization/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Schema members have `authorized?` methods which will be called during execution:
- Fields have `#authorized?(object, args, context)` instance methods
- Arguments have `#authorized?(object, arg_value, context)` instance methods
- Mutations and Resolvers have `.authorized?(object, context)` class methods and `#authorized?(args)` instance methods
- Enum values have `#authorized?(context)` instance methods

These methods are called with:

Expand Down Expand Up @@ -90,6 +91,14 @@ For this to work, the base argument class must be {% internal_link "configured w

See mutations/mutation_authorization.html#can-this-user-perform-this-action {% internal_link "Mutation Authorization", "/mutations/mutation_authorization.html#can-this-user-perform-this-action" %}) in the Mutation Guides.

## Enum Value Authorization

{{ "GraphQL::Schema::EnumValue#authorized?" | api_doc }} is called when client input is received and when the schema returns values to the client.

For authorizing input, if a value's `#authorized?` method returns false, then a {{ "GraphQL::UnauthorizedEnumValueError" | api_doc }} is raised. It passed to your schema's `.unauthorized_object` hook, where you can handle it another way if you want.

For authorizing return values, if an outgoing value's `#authorized?` method returns false, then a {{ "GraphQL::Schema::Enum::UnresolvedValueError" | api_doc }} is raised, which crashes the query. In this case, you should modify your field or resolver to _not_ return this value to an unauthorized viewer. (In this case, the error isn't returned to the viewer because the viewer can't do anything about it -- it's a developer-facing issue instead.)

## Handling Unauthorized Objects

By default, GraphQL-Ruby silently replaces unauthorized objects with `nil`, as if they didn't exist. You can customize this behavior by implementing {{ "Schema.unauthorized_object" | api_doc }} in your schema class, for example:
Expand Down
1 change: 1 addition & 0 deletions lib/graphql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class << self
require "graphql/backtrace"

require "graphql/unauthorized_error"
require "graphql/unauthorized_enum_value_error"
require "graphql/unauthorized_field_error"
require "graphql/load_application_object_failed_error"
require "graphql/testing"
Expand Down
55 changes: 41 additions & 14 deletions lib/graphql/schema/enum.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,21 @@ class Schema
class Enum < GraphQL::Schema::Member
extend GraphQL::Schema::Member::ValidatesInput

# This is raised when either:
#
# - A resolver returns a value which doesn't match any of the enum's configured values;
# - Or, the resolver returns a value which matches a value, but that value's `authorized?` check returns false.
#
# In either case, the field should be modified so that the invalid value isn't returned.
#
# {GraphQL::Schema::Enum} subclasses get their own subclass of this error, so that bug trackers can better show where they came from.
class UnresolvedValueError < GraphQL::Error
def initialize(value:, enum:, context:)
fix_message = ", but this isn't a valid value for `#{enum.graphql_name}`. Update the field or resolver to return one of `#{enum.graphql_name}`'s values instead."
def initialize(value:, enum:, context:, authorized:)
fix_message = if authorized == false
", but this value was unauthorized. Update the field or resolver to return a different value in this case (or return `nil`)."
else
", but this isn't a valid value for `#{enum.graphql_name}`. Update the field or resolver to return one of `#{enum.graphql_name}`'s values instead."
end
message = if (cp = context[:current_path]) && (cf = context[:current_field])
"`#{cf.path}` returned `#{value.inspect}` at `#{cp.join(".")}`#{fix_message}"
else
Expand All @@ -34,6 +46,8 @@ def initialize(value:, enum:, context:)
end
end

# Raised when a {GraphQL::Schema::Enum} is defined to have no values.
# This can also happen when all values return false for `.visible?`.
class MissingValuesError < GraphQL::Error
def initialize(enum_type)
@enum_type = enum_type
Expand All @@ -43,10 +57,10 @@ def initialize(enum_type)

class << self
# Define a value for this enum
# @param graphql_name [String, Symbol] the GraphQL value for this, usually `SCREAMING_CASE`
# @param description [String], the GraphQL description for this value, present in documentation
# @param value [Object], the translated Ruby value for this object (defaults to `graphql_name`)
# @param deprecation_reason [String] if this object is deprecated, include a message here
# @option kwargs [String, Symbol] :graphql_name the GraphQL value for this, usually `SCREAMING_CASE`
# @option kwargs [String] :description, the GraphQL description for this value, present in documentation
# @option kwargs [::Object] :value the translated Ruby value for this object (defaults to `graphql_name`)
# @option kwargs [String] :deprecation_reason if this object is deprecated, include a message here
# @return [void]
# @see {Schema::EnumValue} which handles these inputs by default
def value(*args, **kwargs, &block)
Expand Down Expand Up @@ -140,26 +154,39 @@ def validate_non_null_input(value_name, ctx, max_errors: nil)
end
end

# Called by the runtime when a field returns a value to give back to the client.
# This method checks that the incoming {value} matches one of the enum's defined values.
# @param value [Object] Any value matching the values for this enum.
# @param ctx [GraphQL::Query::Context]
# @raise [GraphQL::Schema::Enum::UnresolvedValueError] if {value} doesn't match a configured value or if the matching value isn't authorized.
# @return [String] The GraphQL-ready string for {value}
def coerce_result(value, ctx)
types = ctx.types
all_values = types ? types.enum_values(self) : values.each_value
enum_value = all_values.find { |val| val.value == value }
if enum_value
if enum_value && (was_authed = enum_value.authorized?(ctx))
enum_value.graphql_name
else
raise self::UnresolvedValueError.new(enum: self, value: value, context: ctx)
raise self::UnresolvedValueError.new(enum: self, value: value, context: ctx, authorized: was_authed)
end
end

# Called by the runtime with incoming string representations from a query.
# It will match the string to a configured by name or by Ruby value.
# @param value_name [String, Object] A string from a GraphQL query, or a Ruby value matching a `value(..., value: ...)` configuration
# @param ctx [GraphQL::Query::Context]
# @raise [GraphQL::UnauthorizedEnumValueError] if an {EnumValue} matches but returns false for `.authorized?`. Goes to {Schema.unauthorized_object}.
# @return [Object] The Ruby value for the matched {GraphQL::Schema::EnumValue}
def coerce_input(value_name, ctx)
all_values = ctx.types ? ctx.types.enum_values(self) : values.each_value

if v = all_values.find { |val| val.graphql_name == value_name }
v.value
elsif v = all_values.find { |val| val.value == value_name }
# this is for matching default values, which are "inputs", but they're
# the Ruby value, not the GraphQL string.
v.value
# This tries matching by incoming GraphQL string, then checks Ruby-defined values
if v = (all_values.find { |val| val.graphql_name == value_name } || all_values.find { |val| val.value == value_name })
if v.authorized?(ctx)
v.value
else
raise GraphQL::UnauthorizedEnumValueError.new(type: self, enum_value: v, context: ctx)
end
else
nil
end
Expand Down
13 changes: 13 additions & 0 deletions lib/graphql/unauthorized_enum_value_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module GraphQL
class UnauthorizedEnumValueError < GraphQL::UnauthorizedError
# @return [GraphQL::Schema::EnumValue] The value whose `#authorized?` check returned false
attr_accessor :enum_value

def initialize(type:, context:, enum_value:)
@enum_value = enum_value
message ||= "#{enum_value.path} failed authorization"
super(message, object: enum_value.value, type: type, context: context)
end
end
end
28 changes: 27 additions & 1 deletion spec/graphql/authorization_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def initialize(*args, role: nil, **kwargs)
def visible?(context)
super && (context[:hide] ? @role != :hidden : true)
end

def authorized?(context)
super && (context[:authorized] ? true : @role != :unauthorized)
end
end

class BaseEnum < GraphQL::Schema::Enum
Expand Down Expand Up @@ -477,7 +481,7 @@ def auth_execute(*args, **kwargs)

assert_equal ["Argument 'enums' on Field 'landscapeFeatures' has an invalid value ([STREAM, TAR_PIT]). Expected type '[LandscapeFeature!]'."], hidden_res_2["errors"].map { |e| e["message"] }

success_res = auth_execute <<-GRAPHQL, context: { hide: false }
success_res = auth_execute <<-GRAPHQL, context: { hide: false, authorized: true }
{
landscapeFeature(enum: TAR_PIT)
landscapeFeatures(enums: [STREAM, TAR_PIT])
Expand Down Expand Up @@ -507,6 +511,28 @@ def auth_execute(*args, **kwargs)
end
end

it "rejects incoming unauthorized enum values" do
res = auth_execute <<-GRAPHQL, context: { }
{
landscapeFeature(enum: STREAM)
}
GRAPHQL

assert_equal ["Unauthorized LandscapeFeature: \"STREAM\""], res["errors"].map { |e| e["message"] }
end

it "rejects outgoing unauthorized enum values" do
err = assert_raises(AuthTest::LandscapeFeature::UnresolvedValueError) do
auth_execute <<-GRAPHQL, context: { }
{
landscapeFeature(string: "STREAM")
}
GRAPHQL
end

assert_equal "`Query.landscapeFeature` returned `\"STREAM\"` at `landscapeFeature`, but this value was unauthorized. Update the field or resolver to return a different value in this case (or return `nil`).", err.message
end

it "works in introspection" do
res = auth_execute <<-GRAPHQL, context: { hide: true, hidden_mutation: true }
{
Expand Down
1 change: 0 additions & 1 deletion spec/graphql/cop/root_types_in_block_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

it "finds and autocorrects field corrections with inline types" do
result = run_rubocop_on("spec/fixtures/cop/root_types.rb")
puts result
assert_equal 3, rubocop_errors(result)

assert_includes result, <<-RUBY
Expand Down

0 comments on commit e866884

Please sign in to comment.