Skip to content

Commit da1030a

Browse files
author
Robert Mosolgo
authored
Merge pull request rmosolgo#1494 from rmosolgo/integrated-auth
Integrated Authorization
2 parents c31e679 + 161e6e4 commit da1030a

40 files changed

+1488
-153
lines changed

guides/authorization/accessibility.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
layout: guide
3+
search: true
4+
section: Authorization
5+
title: Accessibility
6+
desc: Reject queries from unauthorized users if they access certain parts of the schema.
7+
index: 2
8+
---
9+
10+
With GraphQL-Ruby, you can inspect an incoming query, and return a custom error if that query accesses some unauthorized parts of the schema.
11+
12+
This is different from {% internal_link "visibility", "/authorization/visibility" %}, where unauthorized parts of the schema are treated as non-existent. It's also different from {% internal_link "authorization", "/authorization/authorization" %}, which makes checks _while running_, instead of _before running_.
13+
14+
## Preventing Access
15+
16+
You can override some `.accessible?(context)` methods to prevent access to certain members of the schema:
17+
18+
- Type and mutation classes have a `.accessible?(context)` class method
19+
- Arguments and fields have a `.accessible?(context)` instance method
20+
21+
These methods are called with the query context, based on the hash you pass as `context:`.
22+
23+
Whenever that method is implemented to return `false`, the currently-checked field will be collected as inaccessible. For example:
24+
25+
```ruby
26+
class BaseField < GraphQL::Schema::Field
27+
def initialize(preview:, **kwargs, &block)
28+
@preview = preview
29+
super(**kwargs, &block)
30+
end
31+
32+
# If this field was marked as preview, hide it unless the current viewer can see previews.
33+
def accessible?(context)
34+
if @preview && !context[:viewer].can_preview?
35+
false
36+
else
37+
super
38+
end
39+
end
40+
end
41+
```
42+
43+
Now, any fields created with `field(..., preview: true)` will be _visible_ to everyone, but only accessible to users where `.can_preview?` is `true`.
44+
45+
## Adding an Error
46+
47+
By default, GraphQL-Ruby will return a simple error to the client if any `.accessible?` checks return false.
48+
49+
You can customize this behavior by overriding {{ "Schema.inaccessible_fields" | api_docs }}, for example:
50+
51+
```ruby
52+
class MySchema < GraphQL::Schema
53+
# If you have a custom `permission_level` setting on your `GraphQL::Field` class,
54+
# you can access it here:
55+
def self.inaccessible_fields(error)
56+
required_permissions = error.fields.map(&:permission_level).uniq
57+
# Return a custom error
58+
GraphQL::AnalysisError.new("You need certain permissions: #{required_permissions.join(", ")}")
59+
end
60+
end
61+
```
62+
63+
Then, your custom error will be added to the response instead of the default one.

guides/authorization/authorization.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
layout: guide
3+
search: true
4+
section: Authorization
5+
title: Authorization
6+
desc: During execution, check if the current user has permission to access retrieved objects.
7+
index: 3
8+
---
9+
10+
While a query is running, you can check each object to see whether the current user is authorized to interact with that object. If the user is _not_ authorized, you can handle the case with an error.
11+
12+
## Adding Authorization Checks
13+
14+
Schema members have `.authorized?(value, context)` methods which will be called during execution:
15+
16+
- Type and mutation classes have `.authorized?(value, context)` class methods
17+
- Fields and arguments have `#authorized?(value, context)` instance methods
18+
19+
These methods are called with:
20+
21+
- `value`: the object from your application which was returned from a field
22+
- `context`: the query context, based on the hash passed as `context:`
23+
24+
When you implement this method to return `false`, the query will be halted, for example:
25+
26+
```ruby
27+
class Types::Friendship < Types::BaseObject
28+
# You can only see the details on a `Friendship`
29+
# if you're one of the people involved in it.
30+
def self.authorized?(object, context)
31+
super && (object.to_friend == context[:viewer] || object.from_friend == context[:viewer])
32+
end
33+
end
34+
```
35+
36+
(Always call `super` to get the default checks, too.)
37+
38+
Now, whenever an object of type `Friendship` is going to be returned to the client, it will first go through the `.authorized?` method. If that method returns false, the field will get `nil` instead of the original object, and you may handle that case with an error (see below).
39+
40+
## Handling Unauthorized Objects
41+
42+
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:
43+
44+
```ruby
45+
class MySchema < GraphQL::Schema
46+
# Override this hook to handle cases when `authorized?` returns false:
47+
def self.unauthorized_object(error)
48+
# Increment a metric somewhere:
49+
AppStats.increment("graphql:unauthorized:#{error.type.graphql_name}:#{error.object.class.name}")
50+
# Add a top-level error to the response instead of returning nil:
51+
raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions"
52+
end
53+
end
54+
```
55+
56+
Now, the custom hook will be called instead of the default one.

guides/authorization/overview.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
layout: guide
3+
search: true
4+
section: Authorization
5+
title: Overview
6+
desc: Overview of GraphQL authorization in general and an intro to the built-in framework.
7+
index: 0
8+
---
9+
10+
Here's a conceptual approach to GraphQL authorization, followed by an introduction to the built-in authorization framework. Each part of the framework is described in detail in its own guide.
11+
12+
## Authorization: GraphQL vs REST
13+
14+
In a REST API, the common authorization pattern is fairly simple. Before performing the requested action, the server asserts that the current client has the required permissions for that action. For example:
15+
16+
```ruby
17+
class PostsController < ApiController
18+
def create
19+
# First, check the client's permission level:
20+
if current_user.can?(:create_posts)
21+
# If the user is permitted, then perform the action:
22+
post = Post.create(params)
23+
render json: post
24+
else
25+
# Otherwise, return an error:
26+
render nothing: true, status: 403
27+
end
28+
end
29+
end
30+
```
31+
32+
However, this request-by-request mindset doesn't map well to GraphQL because there's only one controller and the requests that come to it may be _very_ different. To illustrate the problem:
33+
34+
```ruby
35+
class GraphqlController < ApplicationController
36+
def execute
37+
# What permission is required for `query_str`?
38+
# It depends on the string! So, you can't generalize at this level.
39+
if current_user.can?(:"???")
40+
MySchema.execute(query_str, context: ctx, variables: variables)
41+
end
42+
end
43+
end
44+
```
45+
46+
So, what new mindset will work with a GraphQL API?
47+
48+
For __mutations__, remember that each mutation is like an API request in itself. For example, `Posts#create` above would map to the `createPost(...)` mutation in GraphQL. So, each mutation should be authorized in its own right.
49+
50+
For __queries__, you can think of each individual _object_ like a `GET` request to a REST API. So, each object should be authorized for reading in its own right.
51+
52+
By applying this mindset, each part of the GraphQL query will be properly authorized before it is executed. Also, since the different units of code are each authorized on their own, you can be sure that each incoming query will be properly authorized, even if it's a brand new query that the server has never seen before.
53+
54+
## What About Authentication?
55+
56+
As a reminder:
57+
58+
- _Authentication_ is the process of determining what user is making the current request, for example, accepting a username and password, or finding a `User` in the database from `session[:current_user_id]`.
59+
- _Authorization_ is the process of verifying that the current user has permission to do something (or see something), for example, checking `admin?` status or looking up permission groups from the database.
60+
61+
In general, authentication is _not_ addressed in GraphQL at all. Instead, your controller should get the current user based on the HTTP request (eg, an HTTP header or a cookie) and provide that information to the GraphQL query. For example:
62+
63+
```ruby
64+
class GraphqlController < ApplicationController
65+
def execute
66+
# Somehow get the the current `User` from this HTTP request.
67+
current_user = get_logged_in_user(request)
68+
# Provide the current user in `context` for use during the query
69+
context = { current_user: current_user }
70+
MySchema.execute(query_str, context: context, ...)
71+
end
72+
end
73+
```
74+
75+
After your HTTP handler has loaded the current user, you can access it via `context[:current_user]` in your GraphQL code.
76+
77+
## Authorization in Your Business Logic
78+
79+
Before introducing GraphQL-specific authorization, consider the advantages of application-level authorization. (See the [GraphQL.org post](https://graphql.org/learn/authorization/) on the same topic.) For example, here's authorization mixed into the GraphQL API layer:
80+
81+
```ruby
82+
field :posts, [Types::Post], null: false
83+
84+
def posts
85+
# Perform an auth check in the GraphQL field code:
86+
if context[:current_user].admin?
87+
Post.all
88+
else
89+
Post.published
90+
end
91+
end
92+
```
93+
94+
The downside of this is that, when `Types::Post` is queried in other contexts, the same authorization check may not be applied. Additionally, since the authorization code is coupled with the GraphQL API, the only way to test it is via GraphQL queries, which adds some complexity to tests.
95+
96+
Alternatively, you could move the authorization to your business logic, the `Post` class:
97+
98+
```ruby
99+
class Post < ActiveRecord::Base
100+
# Return the list of posts which `user` may see
101+
def self.posts_for(user)
102+
if user.admin?
103+
self.all
104+
else
105+
self.published
106+
end
107+
end
108+
end
109+
```
110+
111+
Then, use this application method in your GraphQL code:
112+
113+
```ruby
114+
field :posts, [Types::Post], null: false
115+
116+
def posts
117+
# Fetch the posts this user can see:
118+
Post.posts_for(context[:current_user])
119+
end
120+
```
121+
122+
In this case, `Post.posts_for(user)` could be tested _independently_ from GraphQL. Then, you have less to worry about in your GraphQL tests. As a bonus, you can use `Post.posts_for(user)` in _other_ parts of the app, too, such as the web UI or REST API.
123+
124+
## GraphQL-Ruby's Authorization Framework
125+
126+
Despite the advantages of authorization at the application layer, as described above, there might be some reasons to authorize in the API layer:
127+
128+
- Have an extra assurance that your API layer is secure
129+
- Authorize the API request _before_ running it (see "visibility" below)
130+
- Integrate with code that doesn't have authorization built-in
131+
132+
To accomplish these, you can use GraphQL-Ruby's authorization framework. The framework has three levels, each of which is described in its own guide:
133+
134+
- {% internal_link "Visibility", "/authorization/visibility" %} hides parts of the GraphQL schema from users who don't have full permission.
135+
- {% internal_link "Accessibility", "/authorization/accessibility" %} prevents running queries which access parts of the GraphQL schema, unless users have the required permission.
136+
- {% internal_link "Authorization", "/authorization/authorization" %} checks application objects during execution to be sure the user has permission to access them.
137+
138+
Also, GraphQL::Pro has integrations for CanCan and Pundit.

guides/authorization/visibility.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
layout: guide
3+
search: true
4+
section: Authorization
5+
title: Visibility
6+
desc: Programatically hide parts of the GraphQL schema from some users.
7+
index: 1
8+
---
9+
10+
With GraphQL-Ruby, it's possible to _hide_ parts of your schema from some users. This isn't exactly part of the GraphQL spec, but it's roughly within the bounds of the spec.
11+
12+
Here are some reasons you might want to hide parts of your schema:
13+
14+
- You don't want non-admin users to know about administration functions of the schema.
15+
- You're developing a new feature and want to make a gradual release to only a few users first.
16+
17+
## Hiding Parts of the Schema
18+
19+
You can customize the visibility of parts of your schema by reimplementing various `visible?` methods:
20+
21+
- Type classes have a `.visible?(context)` class method
22+
- Fields and arguments have a `#visible?(context)` instance method
23+
- Enum values have `#visible?(context)` instance method
24+
- Mutation classes have a `.visible?(context)` class method
25+
26+
These methods are called with the query context, based on the hash you pass as `context:`. If the method returns false, then that member of the schema will be treated as though it doesn't exist for the entirety of the query. That is:
27+
28+
- In introspection, the member will _not_ be included in the result
29+
- In normal queries, if a query references that member, it will return a validation error, since that member doesn't exist
30+
31+
## For Example
32+
33+
Let's say you're working on a new feature which should remain secret for a while. You can implement `.visible?` in a type:
34+
35+
```ruby
36+
class Types::SecretFeature < Types::BaseObject
37+
def self.visible?(context)
38+
# only show it to users with the secret_feature enabled
39+
super && context[:viewer].feature_enabled?(:secret_feature)
40+
end
41+
end
42+
```
43+
44+
(Always call `super` to inherit the default behavior.)
45+
46+
Now, the following bits of GraphQL will return validation errors:
47+
48+
- Fields that return `SecretFeature`, eg `query { findSecretFeature { ... } }`
49+
- Fragments on `SecretFeature`, eg `Fragment SF on SecretFeature`
50+
51+
And in introspection:
52+
53+
- `__schema { types { ... } }` will not include `SecretFeature`
54+
- `__type(name: "SecretFeature")` will return `nil`
55+
- Any interfaces or unions which normally include `SecretFeature` will _not_ include it
56+
- Any fields that return `SecretFeature` will be excluded from introspection

guides/guides.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- name: Queries
66
- name: Types
77
- name: Type Definitions
8+
- name: Authorization
89
- name: Fields
910
- name: Mutations
1011
- name: Errors

lib/graphql.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,5 @@ def self.scan_with_ragel(graphql_string)
9999
require "graphql/backtrace"
100100

101101
require "graphql/deprecated_dsl"
102+
require "graphql/authorization"
103+
require "graphql/unauthorized_error"

lib/graphql/argument.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Argument
3838
accepts_definitions :name, :type, :description, :default_value, :as, :prepare
3939
attr_accessor :type, :description, :default_value, :name, :as
4040
attr_accessor :ast_node
41+
alias :graphql_name :name
4142

4243
ensure_defined(:name, :description, :default_value, :type=, :type, :as, :expose_as, :prepare)
4344

0 commit comments

Comments
 (0)