Skip to content
This repository has been archived by the owner on Jan 30, 2020. It is now read-only.

Commit

Permalink
Class API (#50)
Browse files Browse the repository at this point in the history
* Add support for class based api

* Implement scopes

* Bump version

* Get started on Docs and make api more flexible

* Allow legacy api to use authorize with only keyword arguments

* Some more docs

* Fixing style errors.

* Bump graphql dependency version

* Fix style issues

* Create common module

* Add more tests

* Add documentation on scopes
  • Loading branch information
phyrog authored Jun 17, 2018
1 parent 2f5a132 commit 0e2fb3e
Show file tree
Hide file tree
Showing 15 changed files with 853 additions and 10 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/pkg/
/spec/reports/
/tmp/
/.vscode/

# rspec failure tracking
.rspec_status
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Naming/FileName:
- lib/ontohub-models.rb
- spec/lib/git-shell_spec.rb

Naming/UncommunicativeMethodParamName:
Exclude:
- 'spec/**/*'

Style/Documentation:
Exclude:
- 'app/indexers/**/*'
Expand Down
219 changes: 214 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

# GraphQL::Pundit



## Installation

Add this line to your application's Gemfile:
Expand All @@ -24,7 +22,218 @@ $ bundle

## Usage

### Add the authorization middleware
### Class based API (`graphql-ruby >= 1.8`)

To use `graphql-pundit` with the class based API introduced in `graphql`
version 1.8, the used `Field` class must be changed:

It is recommended to have application-specific base classes, from which the
other types inherit (similar to having an `ApplicationController` from which
all other controllers inherit). That base class can be used to define a
custom field class, on which the new `graphql-pundit` API builds.

```ruby
class BaseObject < GraphQL::Schema::Object
field_class GraphQL::Pundit::Field
end
```

All other object types now inherit from `BaseObject`, and that is all that is
needed to get `graphql-pundit` working with the class based API.

In case you already use a custom field type, or if you want to use a context
key other than `:current_user` to make your current user available, you can
include `graphql-pundit`'s functionality into your field type:

```ruby
class MyFieldType < GraphQL::Schema::Field
prepend GraphQL::Pundit::Scope
prepend GraphQL::Pundit::Authorization

current_user :me # if the current_user is passed in as context[:me]
end
```

When using this, make sure the order of `prepend`s is correct, as you usually want the authorization to happen **first**, which means that it needs to be `prepend`ed **after** the scopes (if you need them).

#### Usage

```ruby
class Car < BaseObject
field :trunk, CarContent, null: true,
authorize: true
end
```

The above example shows the most basic usage of this gem. The example would
use `CarPolicy#trunk?` for authorizing access to the field, passing in the
parent object (in this case probably a `Car` model).

##### Options

Two styles of declaring fields is supported:

1. the inline style, passing all the options as a hash to the field method
2. the block style

Both styles are presented below side by side.

###### `authorize` and `authorize!`

To use authorization on a field, you **must** pass either the `authorize` or
`authorize!` option. Both options will cause the field to return `nil` if the
access is unauthorized, but `authorize!` will also add an error message (e.g.
for usage with mutations).

`authorize` and `authorize!` can be passed three different things:

```ruby
class User < BaseObject
# will use the `UserPolicy#display_name?` method
field :display_name, ..., authorize: true
field :display_name, ... do
authorize
end

# will use the passed lambda instead of a policy method
field :password_hash, ..., authorize: ->(obj, args, ctx) { ... }
field :password_hash, ... do
authorize ->(obj, args, ctx) { ... }
end

# will use the `UserPolicy#personal_info?` method
field :email, ..., authorize: :personal_info
field :email, ... do
authorize :personal_info
end
end
```

- `true` will trigger the inference mechanism, meaning that the method that will be called on the policy class will be inferred from the (snake_case) field name.
- a lambda function that will be called with the parent object, the arguments of the field and the context object; if the lambda returns a truthy value, authorization succeeds; otherwise (including thrown exceptions), authorization fails
- a string or a symbol that corresponds to the policy method that should be called **minus the "?"**

###### `policy`

`policy` is an optional argument that can also be passed three different values:

```ruby
class User < BaseObject
# will use the `UserPolicy#display_name?` method (default inference)
field :display_name, ..., authorize: true, policy: nil
field :display_name do
authorize policy: nil
end

# will use OtherUserPolicy#password_hash?
field :password_hash, ...,
authorize: true,
policy: ->(obj, args, ctx) { OtherUserPolicy }
field :password_hash, ... do
authorize policy: ->(obj, args, ctx) { OtherUserPolicy }
end

# will use MemberPolicy#email?
field :email, ..., authorize: true, policy: MemberPolicy
field :email, ... do
authorize policy: MemberPolicy
end
end
```

- `nil` is the default behavior and results in inferring the policy class from the record (see below)
- a lambda function that will be called with the parent object, the arguments of the field and the context object; the return value of this function will be used as the policy class
- an actual policy class

###### `record`

`record` can be used to pass a different value to the policy. Like `policy`,
this argument also can receive three different values:

```ruby
class User < BaseObject
# will use the parent object
field :display_name, ..., authorize: true, record: nil
field :display_name do
authorize record: nil
end

# will use the current user as the record
field :password_hash, ...,
authorize: true,
record: ->(obj, args, ctx) { ctx[:current_user] }
field :password_hash, ... do
authorize policy: ->(obj, args, ctx) { ctx[:current_user] }
end

# will use AccountPolicy#email? with the first account as the record (the policy was inferred from the record class)
field :email, ..., authorize: true, record: Account.first
field :email, ... do
authorize record: Account.first
end
end
```

- `nil` is again used for the inference; in this case, the parent object is used
- a lambda function, again called with the parent object, the field arguments and the context object; the result will be used as the record
- any other value that will be used as the record

Using `record` can be helpful for e.g. mutations, where you need a value to
initialize the policy with, but for mutations there is no parent object.

###### `before_scope` and `after_scope`

`before_scope` and `after_scope` can be used to apply Pundit scopes to the
fields. Both options can be combined freely within one field.

```ruby
class User < BaseObject
# will use the `PostPolicy::Scope` before the resolver
field :posts, ..., before_scope: true
field :posts, ... do
before_scope
end

# will use the passed lambda after the resolver
field :comments, ..., after_scope: ->(comments, args, ctx) { ... }
field :comments, ... do
after_scope ->(comments, args, ctx) { ... }
end

# will use the `FriendPolicy::Scope`
field :friends, ..., after_scope: FriendPolicy
field :friends, ... do
after_scope FriendPolicy
end
end
```

- `true` will trigger the inference mechanism, where the policy class, which contains the scope class, is inferred based on either the parent object (for `before_scope`) or the result of the resolver (for `after_scope`).
- a lambda function, that will be called with the parent object (for `before_scope`) or the result of the resolver (for `after_scope`), the field arguments and the context
- a policy class that contains a `Scope` class (this does not actually have to be a policy class, but could also be a module containing a `Scope` class)

###### Combining options

All options can be combined with one another (except `authorize` and `authorize!`; please don't do that). Examples:

```ruby
# MemberPolicy#name? initialized with the parent
field :display_name, ..., authorize: :name,
policy: MemberPolicy

# UserPolicy#display_name? initialized with user.account_data
field :display_name, ..., do
authorize policy: UserPolicy,
record: ->(obj, args, ctx) { obj.account_data }
end
```

### Legacy `define` API

The legacy `define` based API will be supported until it is removed from the
`graphql` gem (as planned for version 1.10).

#### Add the authorization middleware

Add the following to your GraphQL schema:

Expand All @@ -42,7 +251,7 @@ By default, `ctx[:current_user]` will be used as the user to authorize. To chang
GraphQL::Pundit::Instrumenter.new(:me) # will use ctx[:me]
```

### Authorize fields
#### Authorize fields

For each field you want to authorize via Pundit, add the following code to the field definition:

Expand Down Expand Up @@ -102,7 +311,7 @@ end

If the lambda returns a falsy value or raises a `Pundit::UnauthorizedError` the field will resolve to `nil`, if it returns a truthy value, control will be passed to the resolve function. Of course, this can be used with `authorize!` as well.

### Scopes
#### Scopes

Pundit scopes are supported by using `before_scope` and `after_scope` in the field definition

Expand Down
4 changes: 2 additions & 2 deletions graphql-pundit.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true

lib = File.expand_path('../lib', __FILE__)
lib = File.expand_path('lib', __dir__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'graphql-pundit/version'

Expand All @@ -22,7 +22,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']

spec.add_dependency 'graphql', '>= 1.6.4', '< 1.9.0'
spec.add_dependency 'graphql', '>= 1.6.4', '< 1.10.0'
spec.add_dependency 'pundit', '~> 1.1.0'

spec.add_development_dependency 'bundler', '~> 1.14'
Expand Down
8 changes: 6 additions & 2 deletions lib/graphql-pundit.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# frozen_string_literal: true

require 'graphql-pundit/instrumenter'
require 'graphql-pundit/field'
require 'graphql-pundit/authorization'
require 'graphql-pundit/scope'
require 'graphql-pundit/version'

require 'graphql'
Expand All @@ -15,9 +18,10 @@ def initialize(raise_unauthorized)
@raise_unauthorized = raise_unauthorized
end

def call(defn, query = nil, policy: nil, record: nil)
def call(defn, *args, policy: nil, record: nil)
query = args[0] || defn.name
opts = {record: record,
query: query || defn.name,
query: query,
policy: policy,
raise: raise_unauthorized}
if query.respond_to?(:call)
Expand Down
91 changes: 91 additions & 0 deletions lib/graphql-pundit/authorization.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

require 'graphql-pundit/common'

module GraphQL
module Pundit
# Authorization methods to be included in the used Field class
module Authorization
def self.prepended(base)
base.include(GraphQL::Pundit::Common)
end

# rubocop:disable Metrics/ParameterLists
def initialize(*args, authorize: nil,
record: nil,
policy: nil,
**kwargs, &block)
# rubocop:enable Metrics/ParameterLists
# authorize! is not a valid variable name
authorize_bang = kwargs.delete(:authorize!)
@record = record if record
@policy = policy if policy
@authorize = authorize_bang || authorize
@do_raise = !!authorize_bang
super(*args, **kwargs, &block)
end

def authorize(*args, record: nil, policy: nil)
@authorize = args[0] || true
@record = record if record
@policy = policy if policy
end

def authorize!(*args, record: nil, policy: nil)
@do_raise = true
authorize(*args, record: record, policy: policy)
end

def resolve_field(obj, args, ctx)
raise ::Pundit::NotAuthorizedError unless do_authorize(obj, args, ctx)
super(obj, args, ctx)
rescue ::Pundit::NotAuthorizedError
if @do_raise
raise GraphQL::ExecutionError, "You're not authorized to do this"
end
end

private

def do_authorize(root, arguments, context)
return true unless @authorize
return @authorize.call(root, arguments, context) if callable? @authorize

query = infer_query(@authorize)
record = infer_record(@record, root, arguments, context)
policy = infer_policy(@policy, record, arguments, context)

policy.new(context[self.class.current_user], record).public_send query
end

def infer_query(auth_value)
# authorize can be callable, true (for inference) or a policy query
query = auth_value.equal?(true) ? method_sym : auth_value
query.to_s + '?'
end

def infer_record(record, root, arguments, context)
# record can be callable, nil (for inference) or just any other value
if callable?(record)
record.call(root, arguments, context)
elsif record.equal?(nil)
root
else
record
end
end

def infer_policy(policy, record, arguments, context)
# policy can be callable, nil (for inference) or a policy class
if callable?(policy)
policy.call(record, arguments, context)
elsif policy.equal?(nil)
infer_from = model?(record) ? record.model : record
::Pundit::PolicyFinder.new(infer_from).policy!
else
policy
end
end
end
end
end
Loading

0 comments on commit 0e2fb3e

Please sign in to comment.