diff --git a/lib/authorization.ex b/lib/authorization.ex index dc9accb..3b927d3 100644 --- a/lib/authorization.ex +++ b/lib/authorization.ex @@ -24,12 +24,15 @@ defmodule Rajska.Authorization do @callback has_user_access?(current_user, scoped_struct, rule) :: boolean() - @callback unauthorized_message(resolution :: Resolution.t()) :: String.t() + @callback unauthorized_message(resolution :: Resolution.t()) :: + Absinthe.Type.Field.error_value() @callback context_role_authorized?(context, allowed_role :: role) :: boolean() @callback context_user_authorized?(context, scoped_struct, rule) :: boolean() + @callback default_authorize(context, scoped_struct) :: role() | nil + @optional_callbacks get_current_user: 1, get_ip: 1, get_user_role: 1, @@ -38,5 +41,6 @@ defmodule Rajska.Authorization do has_user_access?: 3, unauthorized_message: 1, context_role_authorized?: 2, - context_user_authorized?: 3 + context_user_authorized?: 3, + default_authorize: 2 end diff --git a/lib/middlewares/object_authorization.ex b/lib/middlewares/object_authorization.ex index a356bf5..2340805 100644 --- a/lib/middlewares/object_authorization.ex +++ b/lib/middlewares/object_authorization.ex @@ -63,7 +63,8 @@ defmodule Rajska.ObjectAuthorization do def call(%Resolution{state: :resolved} = resolution, _config), do: resolution def call(%Resolution{definition: definition} = resolution, _config) do - authorize(definition.schema_node.type, definition.selections, resolution) + fields = Resolution.project(resolution) + authorize(definition.schema_node.type, fields, resolution) end defp authorize(type, fields, resolution) do @@ -87,10 +88,17 @@ defmodule Rajska.ObjectAuthorization do defp authorize_object(object, fields, resolution) do object |> Type.meta(:authorize) + |> default_authorize(resolution.context, object) |> authorized?(resolution.context, object) |> put_result(fields, resolution, object) end + defp default_authorize(nil, context, object) do + Rajska.apply_auth_mod(context, :default_authorize, [context, object]) + end + + defp default_authorize(authorize, _context, _object), do: authorize + defp authorized?(nil, _, object), do: raise "No meta authorize defined for object #{inspect object.identifier}" defp authorized?(permission, context, _object) do @@ -114,6 +122,13 @@ defmodule Rajska.ObjectAuthorization do authorize(schema_node, selections ++ tail, resolution) end + defp find_associations( + [%Absinthe.Blueprint.Document.Fragment.Spread{} | tail], + resolution + ) do + find_associations(tail, resolution) + end + defp find_associations( [%{schema_node: schema_node, selections: selections} | tail], resolution diff --git a/lib/middlewares/object_scope_authorization.ex b/lib/middlewares/object_scope_authorization.ex index 3c2132b..5192557 100644 --- a/lib/middlewares/object_scope_authorization.ex +++ b/lib/middlewares/object_scope_authorization.ex @@ -62,12 +62,17 @@ defmodule Rajska.ObjectScopeAuthorization do alias Absinthe.{Blueprint, Phase, Type} alias Rajska.Introspection use Absinthe.Phase + @behaviour Absinthe.Plugin @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 + def pipeline(pipeline, _execution), do: pipeline + def before_resolution(execution), do: execution + def after_resolution(execution), do: process(execution) + defp process(%{validation_errors: [], result: result} = execution), do: %{execution | result: result(result, execution.context)} defp process(execution), do: execution @@ -76,6 +81,12 @@ defmodule Rajska.ObjectScopeAuthorization do when identifier in [:query_type, nil] do result end + defp result(%{emitter: %{schema_node: %{definition: Absinthe.Phase.Schema.Introspection}}} = result, _context) do + result + end + + # No fields because of non_null violation further down the tree + defp result(%{fields: nil} = result, _context), do: result # Root defp result(%{fields: fields, emitter: %{schema_node: %{identifier: identifier}}} = result, context) diff --git a/lib/middlewares/rate_limiter.ex b/lib/middlewares/rate_limiter.ex index aa20855..5a65a57 100644 --- a/lib/middlewares/rate_limiter.ex +++ b/lib/middlewares/rate_limiter.ex @@ -1,71 +1,73 @@ -defmodule Rajska.RateLimiter do - @moduledoc """ - Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer). +if Code.ensure_loaded?(Hammer) do + defmodule Rajska.RateLimiter do + @moduledoc """ + Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer). - ## Usage + ## Usage - First configure Hammer, following its documentation. For example: + First configure Hammer, following its documentation. For example: - config :hammer, - backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, - cleanup_interval_ms: 60_000 * 10]} + config :hammer, + backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, + cleanup_interval_ms: 60_000 * 10]} - Add your middleware to the query that should be limited: + Add your middleware to the query that should be limited: - field :default_config, :string do - middleware Rajska.RateLimiter - resolve fn _, _ -> {:ok, "ok"} end - end + field :default_config, :string do + middleware Rajska.RateLimiter + resolve fn _, _ -> {:ok, "ok"} end + end - You can also configure it and use multiple rules for limiting in one query: + You can also configure it and use multiple rules for limiting in one query: - field :login_user, :session do - arg :email, non_null(:string) - arg :password, non_null(:string) + field :login_user, :session do + arg :email, non_null(:string) + arg :password, non_null(:string) - middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP) - middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg - resolve &AccountsResolver.login_user/2 - end + middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP) + middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg + resolve &AccountsResolver.login_user/2 + end - The allowed configuration are: + The allowed configuration are: - * `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000. - * `limit`: The maximum number of actions in the specified timespan. Defaults to 10. - * `id`: An atom or string to be used as the bucket identifier. Note that this will always be the same, so by using this the limit will be global instead of by user. - * `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested. - * `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`. + * `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000. + * `limit`: The maximum number of actions in the specified timespan. Defaults to 10. + * `id`: An atom or string to be used as the bucket identifier. Note that this will always be the same, so by using this the limit will be global instead of by user. + * `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested. + * `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`. - Note that when neither `id` or `keys` is provided, the default is to use the user's IP. For that, the default behaviour is to use - `c:Rajska.Authorization.get_ip/1` to fetch the IP from the absinthe context. That means you need to manually insert the user's IP in the - absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content) - for more information. - """ - @behaviour Absinthe.Middleware + Note that when neither `id` or `keys` is provided, the default is to use the user's IP. For that, the default behaviour is to use + `c:Rajska.Authorization.get_ip/1` to fetch the IP from the absinthe context. That means you need to manually insert the user's IP in the + absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content) + for more information. + """ + @behaviour Absinthe.Middleware - alias Absinthe.Resolution + alias Absinthe.Resolution - def call(%Resolution{state: :resolved} = resolution, _config), do: resolution + def call(%Resolution{state: :resolved} = resolution, _config), do: resolution - def call(%Resolution{} = resolution, config) do - scale_ms = Keyword.get(config, :scale_ms, 60_000) - limit = Keyword.get(config, :limit, 10) - identifier = get_identifier(resolution, config[:keys], config[:id]) - error_msg = Keyword.get(config, :error_msg, "Too many requests") + def call(%Resolution{} = resolution, config) do + scale_ms = Keyword.get(config, :scale_ms, 60_000) + limit = Keyword.get(config, :limit, 10) + identifier = get_identifier(resolution, config[:keys], config[:id]) + error_msg = Keyword.get(config, :error_msg, "Too many requests") - case Hammer.check_rate("query:#{identifier}", scale_ms, limit) do - {:allow, _count} -> resolution - {:deny, _limit} -> Resolution.put_result(resolution, {:error, error_msg}) + case Hammer.check_rate("query:#{identifier}", scale_ms, limit) do + {:allow, _count} -> resolution + {:deny, _limit} -> Resolution.put_result(resolution, {:error, error_msg}) + end end - end - defp get_identifier(%Resolution{context: context}, nil, nil), - do: Rajska.apply_auth_mod(context, :get_ip, [context]) + defp get_identifier(%Resolution{context: context}, nil, nil), + do: Rajska.apply_auth_mod(context, :get_ip, [context]) - defp get_identifier(%Resolution{arguments: arguments}, keys, nil), - do: get_in(arguments, List.wrap(keys)) || raise "Invalid configuration in Rate Limiter. Key not found in arguments." + defp get_identifier(%Resolution{arguments: arguments}, keys, nil), + do: get_in(arguments, List.wrap(keys)) || raise "Invalid configuration in Rate Limiter. Key not found in arguments." - defp get_identifier(%Resolution{}, nil, id), do: id + defp get_identifier(%Resolution{}, nil, id), do: id - defp get_identifier(%Resolution{}, _keys, _id), do: raise "Invalid configuration in Rate Limiter. If key is defined, then id must not be defined" + defp get_identifier(%Resolution{}, _keys, _id), do: raise "Invalid configuration in Rate Limiter. If key is defined, then id must not be defined" + end end diff --git a/lib/rajska.ex b/lib/rajska.ex index 28c2497..173cac1 100644 --- a/lib/rajska.ex +++ b/lib/rajska.ex @@ -65,7 +65,8 @@ defmodule Rajska do defmacro __using__(opts \\ []) do super_role = Keyword.get(opts, :super_role, :admin) valid_roles = Keyword.get(opts, :valid_roles, [super_role]) - default_rule = Keyword.get(opts, :default_rule, :default) + default_rule = Keyword.get(opts, :default_rule, :default) + default_authorize = Keyword.get(opts, :default_authorize, nil) quote do @behaviour Authorization @@ -130,6 +131,8 @@ defmodule Rajska do |> get_current_user() |> has_user_access?(scoped_struct, rule) end + + def default_authorize(_context, _object), do: unquote(default_authorize) defoverridable Authorization end diff --git a/lib/schema.ex b/lib/schema.ex index ad04e32..fcff15b 100644 --- a/lib/schema.ex +++ b/lib/schema.ex @@ -5,6 +5,7 @@ defmodule Rajska.Schema do alias Absinthe.Middleware alias Absinthe.Type.{Field, Object} + alias Absinthe.Phase.Schema.Introspection alias Rajska.{ FieldAuthorization, @@ -13,51 +14,56 @@ defmodule Rajska.Schema do } @spec add_query_authorization( - [Middleware.spec(), ...], - Field.t(), - module() - ) :: [Middleware.spec(), ...] - def add_query_authorization( - [{{QueryAuthorization, :call}, config} = query_authorization | middleware] = _middleware, - %Field{name: query_name}, - authorization - ) do - validate_query_auth_config!(config, authorization, query_name) - - [query_authorization | middleware] - end + [Middleware.spec(), ...], + Field.t(), + module() + ) :: [Middleware.spec(), ...] + def add_query_authorization(middleware, %{definition: Introspection}, _authorizaton), + do: middleware + + def add_query_authorization(middleware, %Field{name: query_name}, authorization) do + middleware + |> Enum.find(&match?({{QueryAuthorization, :call}, _config}, &1)) + |> case do + {{QueryAuthorization, :call}, config} -> + validate_query_auth_config!(config, authorization, query_name) + + nil -> + raise "No permission specified for query #{query_name}" + end - def add_query_authorization(_middleware, %Field{name: name}, _authorization) do - raise "No permission specified for query #{name}" + middleware end @spec add_object_authorization([Middleware.spec(), ...]) :: [Middleware.spec(), ...] - def add_object_authorization([{{QueryAuthorization, :call}, _} = query_authorization | middleware]) do + def add_object_authorization([ + {{QueryAuthorization, :call}, _} = query_authorization | middleware + ]) do [query_authorization, ObjectAuthorization] ++ middleware end def add_object_authorization(middleware), do: [ObjectAuthorization | middleware] @spec add_field_authorization( - [Middleware.spec(), ...], - Field.t(), - Object.t() - ) :: [Middleware.spec(), ...] + [Middleware.spec(), ...], + Field.t(), + Object.t() + ) :: [Middleware.spec(), ...] def add_field_authorization(middleware, %Field{identifier: field}, object) do [{{FieldAuthorization, :call}, object: object, field: field} | middleware] end @spec validate_query_auth_config!( - [ - permit: atom(), - scope: false | module(), - args: %{} | [] | atom(), - optional: false | true, - rule: atom() - ], - module(), - String.t() - ) :: :ok | Exception.t() + [ + permit: atom(), + scope: false | module(), + args: %{} | [] | atom(), + optional: false | true, + rule: atom() + ], + module(), + String.t() + ) :: :ok | Exception.t() def validate_query_auth_config!(config, authorization, query_name) do permit = Keyword.get(config, :permit) @@ -74,22 +80,25 @@ defmodule Rajska.Schema do validate_scope!(scope, permit, authorization) validate_args!(args) rescue - e in RuntimeError -> reraise "Query #{query_name} is configured incorrectly, #{e.message}", __STACKTRACE__ + e in RuntimeError -> + reraise "Query #{query_name} is configured incorrectly, #{e.message}", __STACKTRACE__ end end - defp validate_presence!(nil, option), do: raise "#{inspect(option)} option must be present." + defp validate_presence!(nil, option), do: raise("#{inspect(option)} option must be present.") defp validate_presence!(_value, _option), do: :ok defp validate_boolean!(value, _option) when is_boolean(value), do: :ok - defp validate_boolean!(_value, option), do: raise "#{inspect(option)} option must be a boolean." + + defp validate_boolean!(_value, option), + do: raise("#{inspect(option)} option must be a boolean.") defp validate_atom!(value, _option) when is_atom(value), do: :ok - defp validate_atom!(_value, option), do: raise "#{inspect(option)} option must be an atom." + defp validate_atom!(_value, option), do: raise("#{inspect(option)} option must be an atom.") defp validate_scope!(nil, role, authorization) do unless Enum.member?(authorization.not_scoped_roles(), role), - do: raise ":scope option must be present for role #{inspect(role)}." + do: raise(":scope option must be present for role #{inspect(role)}.") end defp validate_scope!(false, _role, _authorization), do: :ok @@ -97,14 +106,20 @@ defmodule Rajska.Schema do defp validate_scope!(scope, _role, _authorization) when is_atom(scope) do struct!(scope) rescue - UndefinedFunctionError -> reraise ":scope option #{inspect(scope)} is not a struct.", __STACKTRACE__ + UndefinedFunctionError -> + reraise ":scope option #{inspect(scope)} is not a struct.", __STACKTRACE__ end defp validate_args!(args) when is_map(args) do Enum.each(args, fn - {field, value} when is_atom(field) and is_atom(value) -> :ok - {field, values} when is_atom(field) and is_list(values) -> validate_list_of_atoms!(values) - field_value -> raise "the following args option is invalid: #{inspect(field_value)}. Since the provided args is a map, you should provide an atom key and an atom or list of atoms value." + {field, value} when is_atom(field) and is_atom(value) -> + :ok + + {field, values} when is_atom(field) and is_list(values) -> + validate_list_of_atoms!(values) + + field_value -> + raise "the following args option is invalid: #{inspect(field_value)}. Since the provided args is a map, you should provide an atom key and an atom or list of atoms value." end) end @@ -112,12 +127,17 @@ defmodule Rajska.Schema do defp validate_args!(args) when is_atom(args), do: :ok - defp validate_args!(args), do: raise "the following args option is invalid: #{inspect(args)}" + defp validate_args!(args), do: raise("the following args option is invalid: #{inspect(args)}") defp validate_list_of_atoms!(args) do Enum.each(args, fn - arg when is_atom(arg) -> :ok - arg -> raise "the following args option is invalid: #{inspect(args)}. Expected a list of atoms, but found #{inspect(arg)}" + arg when is_atom(arg) -> + :ok + + arg -> + raise "the following args option is invalid: #{inspect(args)}. Expected a list of atoms, but found #{ + inspect(arg) + }" end) end end diff --git a/mix.exs b/mix.exs index 63826fa..e354ced 100644 --- a/mix.exs +++ b/mix.exs @@ -51,10 +51,10 @@ defmodule Rajska.MixProject do [ {:ex_doc, "~> 0.19", only: :dev, runtime: false}, {:credo, "~> 1.1.0", only: [:dev, :test], runtime: false}, - {:absinthe, "~> 1.4.0"}, + {:absinthe, "~> 1.4.0 or ~> 1.5.0 or ~> 1.6.0"}, {:excoveralls, "~> 0.11", only: :test}, {:hammer, "~> 6.0", optional: true}, - {:mock, "~> 0.3.0", only: :test}, + {:mock, "~> 0.3.0", only: :test} ] end