Skip to content
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

Support nested objects scoping #9

Merged
merged 9 commits into from
Sep 2, 2019
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,34 @@ Add your [Authorization](https://hexdocs.pm/rajska/Rajska.Authorization.html) mo
middleware
|> Rajska.add_query_authorization(field, Authorization)
|> Rajska.add_object_authorization()
|> Rajska.add_object_scope_auhtorization()
end

def middleware(middleware, field, object) do
Rajska.add_field_authorization(middleware, field, object)
end
```

The only exception is [Object Scope Authorization](#object-scope-authorization), which isn't a middleware, but an [Absinthe Phase](https://hexdocs.pm/absinthe/Absinthe.Phase.html). To use it, add it to your pipeline after the resolution:

```elixir
# router.ex
alias Absinthe.Phase.Document.Execution.Resolution
alias Absinthe.Pipeline
alias Rajska.ObjectScopeAuthorization

forward "/graphql", Absinthe.Plug,
schema: MyProjectWeb.Schema,
socket: MyProjectWeb.UserSocket,
pipeline: {__MODULE__, :pipeline} # Add this line

def pipeline(config, pipeline_opts) do
config
|> Map.fetch!(:schema_mod)
|> Pipeline.for_document(pipeline_opts)
|> Pipeline.insert_after(Resolution, ObjectScopeAuthorization)
end
```

Since Query Scope Authorization middleware must be used with Query Authorization, it is automatically called when adding the former.

Middlewares usage can be found below.
Expand Down Expand Up @@ -164,13 +184,13 @@ Object Authorization middleware runs after Query Authorization middleware (if ad

### Object Scope Authorization

Absinthe middleware to perform object scoping.
Absinthe Phase to perform object scoping.

Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the value of the field defined in each object meta `scope`.

Usage:

[Create your Authorization module and add it and ObjectScopeAuthorization to your Absinthe.Schema](#usage). Then set the scope of an object:
[Create your Authorization module and add it and ObjectScopeAuthorization to your Absinthe pipeline](#usage). Then set the scope of an object:

```elixir
object :user do
Expand Down Expand Up @@ -199,13 +219,14 @@ object :wallet do
end
```

To define custom rules for the scoping, use `c:Rajska.Authorization.has_user_access?/3`. For example:
To define custom rules for the scoping, use [has_user_access?/3](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/3). For example:

```elixir
defmodule Authorization do
use Rajska,
roles: [:user, :admin]

@impl true
def has_user_access?(%{role: :admin}, User, _id), do: true
def has_user_access?(%{id: user_id}, User, id) when user_id === id, do: true
def has_user_access?(_current_user, User, _id), do: false
Expand All @@ -220,6 +241,7 @@ defmodule Authorization do
use Rajska,
roles: [:user, :admin]

@impl true
def has_user_access?(_user, _, nil), do: true

def has_user_access?(%{role: :admin}, User, _id), do: true
Expand Down
2 changes: 1 addition & 1 deletion lib/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ defmodule Rajska.Authorization do
@type role :: atom()
@type current_user_role :: role

@callback get_current_user(resolution :: Resolution.t()) :: current_user
@callback get_current_user(context :: map()) :: current_user

@callback get_user_role(current_user) :: role

Expand Down
9 changes: 5 additions & 4 deletions lib/middlewares/field_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ defmodule Rajska.FieldAuthorization do
scope_by = get_scope_by_field!(object, is_field_private?)

resolution
|> Map.get(:context)
|> authorized?(is_field_private?, scope_by, resolution.source)
|> put_result(resolution, field)
end
Expand All @@ -52,12 +53,12 @@ defmodule Rajska.FieldAuthorization do
end
end

defp authorized?(_resolution, false, _scope_by, _source), do: true
defp authorized?(_context, false, _scope_by, _source), do: true

defp authorized?(resolution, true, scope_by, source) do
case Rajska.apply_auth_mod(resolution, :is_super_user?, [resolution]) do
defp authorized?(context, true, scope_by, source) do
case Rajska.apply_auth_mod(context, :is_super_user?, [context]) do
true -> true
false -> Rajska.apply_auth_mod(resolution, :is_resolution_field_authorized?, [resolution, scope_by, source])
false -> Rajska.apply_auth_mod(context, :is_context_field_authorized?, [context, scope_by, source])
end
end

Expand Down
6 changes: 3 additions & 3 deletions lib/middlewares/object_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,14 @@ defmodule Rajska.ObjectAuthorization do
defp authorize_object(object, fields, resolution) do
object
|> Type.meta(:authorize)
|> is_authorized?(resolution, object)
|> is_authorized?(resolution.context, object)
|> put_result(fields, resolution, object)
end

defp is_authorized?(nil, _, object), do: raise "No meta authorize defined for object #{inspect object.identifier}"

defp is_authorized?(permission, resolution, _object) do
Rajska.apply_auth_mod(resolution, :is_resolution_authorized?, [resolution, permission])
defp is_authorized?(permission, context, _object) do
Rajska.apply_auth_mod(context, :is_context_authorized?, [context, permission])
end

defp put_result(true, fields, resolution, _type), do: find_associations(fields, resolution)
Expand Down
125 changes: 56 additions & 69 deletions lib/middlewares/object_scope_authorization.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
defmodule Rajska.ObjectScopeAuthorization do
@moduledoc """
Absinthe middleware to perform object scoping.
Absinthe Phase to perform object scoping.

Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the value of the field defined in each object meta `scope`.

## Usage

[Create your Authorization module and add it and ObjectScopeAuthorization to your Absinthe.Schema](https://hexdocs.pm/rajska/Rajska.html#module-usage). Then set the scope of an object:
[Create your Authorization module and add it and ObjectScopeAuthorization to your Absinthe Pipeline](https://hexdocs.pm/rajska/Rajska.html#module-usage). Then set the scope of an object:

```elixir
object :user do
Expand Down Expand Up @@ -42,6 +42,7 @@ defmodule Rajska.ObjectScopeAuthorization do
use Rajska,
roles: [:user, :admin]

@impl true
def has_user_access?(%{role: :admin}, User, _id), do: true
def has_user_access?(%{id: user_id}, User, id) when user_id === id, do: true
def has_user_access?(_current_user, User, _id), do: false
Expand All @@ -56,6 +57,7 @@ defmodule Rajska.ObjectScopeAuthorization do
use Rajska,
roles: [:user, :admin]

@impl true
def has_user_access?(_user, _, nil), do: true

def has_user_access?(%{role: :admin}, User, _id), do: true
Expand All @@ -64,97 +66,82 @@ defmodule Rajska.ObjectScopeAuthorization do
end
```
"""
@behaviour Absinthe.Middleware

alias Absinthe.{
Resolution,
Schema,
Type
}
alias Absinthe.{Blueprint, Phase, Type}
use Absinthe.Phase

def call(%Resolution{definition: definition} = resolution, _config) do
authorize(definition.schema_node.type, definition.selections, resolution)
@spec run(Blueprint.t() | Phase.Error.t(), Keyword.t()) :: {:ok, map}
def run(%Blueprint{execution: execution} = bp, _options \\ []) do
{:ok, %{bp | execution: process(execution)}}
end

defp authorize(type, fields, resolution, nested_keys \\ []) do
type
|> lookup_object(resolution.schema)
|> authorize_object(fields, resolution, nested_keys)
end
defp process(%{validation_errors: [], result: result} = execution), do: %{execution | result: result(result, execution.context)}
defp process(execution), do: execution

# When is a list, inspect object that composes the list.
defp lookup_object(%Type.List{of_type: object_type}, schema) do
lookup_object(object_type, schema)
# Introspection
defp result(%{emitter: %{schema_node: %{identifier: identifier}}} = result, _context)
when identifier in [:query_type, nil] do
result
end

defp lookup_object(object_type, schema) do
Schema.lookup_type(schema, object_type)
# Root
defp result(%{fields: fields, emitter: %{schema_node: %{identifier: identifier}}} = result, context)
when identifier in [:query, :mutation, :subscription] do
%{result | fields: walk_result(fields, context)}
end

# When is a Scalar, Custom or Enum type, authorize.
defp authorize_object(%type{} = object, fields, resolution, nested_keys)
when type in [Scalar, Custom, Type.Enum, Type.Enum.Value] do
put_result(true, fields, resolution, object, nested_keys)
end
# Object
defp result(%{fields: fields, emitter: %{schema_node: schema_node} = emitter} = result, context) do
type = get_object_type(schema_node.type)
scope = Type.meta(type, :scope)

# When is an user defined object, lookup the scope meta tag.
defp authorize_object(object, fields, resolution, nested_keys) do
object
|> Type.meta(:scope)
|> is_authorized?(resolution, object, nested_keys)
|> put_result(fields, resolution, object, nested_keys)
case is_authorized?(scope, result.root_value, context, type) do
true -> %{result | fields: walk_result(fields, context)}
false -> Map.put(result, :errors, [error(emitter)])
end
end

defp is_authorized?(nil, _, object, _nested_keys), do: raise "No meta scope defined for object #{inspect object.identifier}"

defp is_authorized?(false, _resolution, _object, _nested_keys), do: true

defp is_authorized?({scoped_struct, field}, resolution, _object, nested_keys) do
field_keys = nested_keys ++ [field]
apply_authorization!(resolution, scoped_struct, Map.get(resolution, :value), field_keys)
# List
defp result(%{values: values} = result, context) do
%{result | values: walk_result(values, context)}
end

defp is_authorized?(scoped_struct, resolution, _object, nested_keys) do
apply_authorization!(resolution, scoped_struct, Map.get(resolution, :value), nested_keys ++ [:id])
end
# Leafs
defp result(result, _context), do: result

defp apply_authorization!(resolution, scoped_struct, values, keys) when is_list(values) do
Enum.all?(values, fn value ->
apply_authorization!(resolution, scoped_struct, value, keys)
end)
end
# When is a list, inspect object that composes the list.
defp get_object_type(%Type.List{of_type: object_type}), do: object_type
defp get_object_type(%Type.NonNull{of_type: object_type}), do: object_type
defp get_object_type(object_type), do: object_type

defp apply_authorization!(resolution, scoped_struct, nil, _keys) do
Rajska.apply_auth_mod(resolution, :has_resolution_access?, [resolution, scoped_struct, nil])
end
defp walk_result(fields, context, new_fields \\ [])

defp apply_authorization!(resolution, scoped_struct, value, [first_key | remaining_keys]) when length(remaining_keys) > 0 do
nested_value = Map.get(value, first_key)
apply_authorization!(resolution, scoped_struct, nested_value, remaining_keys)
end
defp walk_result([], _context, new_fields), do: Enum.reverse(new_fields)

defp apply_authorization!(resolution, scoped_struct, value, [first_key]) do
scoped_field_value = Map.get(value, first_key)
Rajska.apply_auth_mod(resolution, :has_resolution_access?, [resolution, scoped_struct, scoped_field_value])
defp walk_result([field | fields], context, new_fields) do
new_fields = [result(field, context) | new_fields]
walk_result(fields, context, new_fields)
end

defp put_result(true, fields, resolution, _type, nested_keys), do: find_associations(fields, resolution, nested_keys)
defp is_authorized?(nil, _values, _context, object), do: raise "No meta scope defined for object #{inspect object.identifier}"

defp put_result(false, _fields, resolution, object, _nested_keys) do
Resolution.put_result(resolution, {:error, "Not authorized to access object #{object.identifier}"})
end
defp is_authorized?(false, _values, _context, _object), do: true

defp find_associations([%{selections: []} | tail], resolution, nested_keys) do
find_associations(tail, resolution, nested_keys)
defp is_authorized?({scoped_struct, field}, values, context, _object) do
scoped_field_value = Map.get(values, field)
Rajska.apply_auth_mod(context, :has_context_access?, [context, scoped_struct, scoped_field_value])
end

defp find_associations(
[%{schema_node: schema_node, selections: selections} | tail],
resolution,
nested_keys
) do
authorize(schema_node.type, selections ++ tail, resolution, nested_keys ++ [schema_node.identifier])
defp is_authorized?(scoped_struct, values, context, _object) do
scoped_field_value = Map.get(values, :id)
Rajska.apply_auth_mod(context, :has_context_access?, [context, scoped_struct, scoped_field_value])
end

defp find_associations([], resolution, _nested_keys), do: resolution
defp error(%{source_location: location, schema_node: %{type: type}}) do
%Phase.Error{
phase: __MODULE__,
message: "Not authorized to access object #{get_object_type(type).identifier}",
locations: [location]
}
end
end
16 changes: 8 additions & 8 deletions lib/middlewares/query_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,17 @@ defmodule Rajska.QueryAuthorization do

@behaviour Absinthe.Middleware

def call(resolution, [{:permit, permission} | _scoped] = config) do
validate_permission!(resolution, permission)
def call(%{context: context} = resolution, [{:permit, permission} | _scoped] = config) do
validate_permission!(context, permission)

resolution
|> Rajska.apply_auth_mod(:is_resolution_authorized?, [resolution, permission])
context
|> Rajska.apply_auth_mod(:is_context_authorized?, [context, permission])
|> update_result(resolution)
|> QueryScopeAuthorization.call(config)
end

defp validate_permission!(resolution, permitted_roles) do
valid_roles = Rajska.apply_auth_mod(resolution, :valid_roles)
defp validate_permission!(context, permitted_roles) do
valid_roles = Rajska.apply_auth_mod(context, :valid_roles)

unless permission_valid?(valid_roles, permitted_roles) do
raise """
Expand All @@ -70,7 +70,7 @@ defmodule Rajska.QueryAuthorization do

defp update_result(true, resolution), do: resolution

defp update_result(false, resolution) do
Resolution.put_result(resolution, {:error, Rajska.apply_auth_mod(resolution, :unauthorized_msg, [resolution])})
defp update_result(false, %{context: context} = resolution) do
Resolution.put_result(resolution, {:error, Rajska.apply_auth_mod(context, :unauthorized_msg, [resolution])})
end
end
8 changes: 4 additions & 4 deletions lib/middlewares/scope_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ defmodule Rajska.QueryScopeAuthorization do
def call(resolution, [_ | [scoped: false]]), do: resolution

def call(resolution, [{:permit, permission} | scoped_config]) do
not_scoped_roles = Rajska.apply_auth_mod(resolution, :not_scoped_roles)
not_scoped_roles = Rajska.apply_auth_mod(resolution.context, :not_scoped_roles)

case Enum.member?(not_scoped_roles, permission) do
true -> resolution
Expand Down Expand Up @@ -105,9 +105,9 @@ defmodule Rajska.QueryScopeAuthorization do
raise "Error in query #{name}: no argument found in middleware Scope Authorization"
end

def apply_scope_authorization(resolution, field_value, scoped_struct) do
resolution
|> Rajska.apply_auth_mod(:has_resolution_access?, [resolution, scoped_struct, field_value])
def apply_scope_authorization(%{context: context} = resolution, field_value, scoped_struct) do
context
|> Rajska.apply_auth_mod(:has_context_access?, [context, scoped_struct, field_value])
|> update_result(resolution)
end

Expand Down
Loading