From c60f88c0c67191e2cd26408a37e16cfbdb977800 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:30:06 +0200 Subject: [PATCH 1/9] Only compile RateLimiter when Hammer is available --- lib/middlewares/rate_limiter.ex | 102 ++++++++++++++++---------------- 1 file changed, 52 insertions(+), 50 deletions(-) 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 From 760d3b27a1a635e2ad8f81071b8bf85f1de36547 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:30:25 +0200 Subject: [PATCH 2/9] Allow Absinthe 1.5.0 --- mix.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.exs b/mix.exs index 63826fa..b1d37e5 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"}, {:excoveralls, "~> 0.11", only: :test}, {:hammer, "~> 6.0", optional: true}, - {:mock, "~> 0.3.0", only: :test}, + {:mock, "~> 0.3.0", only: :test} ] end From 3320148a9d205ec5deac8eb489fdd5723a717ad5 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:32:59 +0200 Subject: [PATCH 3/9] Project fields to support named fragment spreads --- lib/middlewares/object_authorization.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/middlewares/object_authorization.ex b/lib/middlewares/object_authorization.ex index a356bf5..feafc8d 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 @@ -114,6 +115,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 From 2e595561dc3c2bc29bfbc2f51a6c30fe3b60853e Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:33:29 +0200 Subject: [PATCH 4/9] Add updated Introspection exception --- lib/middlewares/object_scope_authorization.ex | 3 +++ lib/schema.ex | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lib/middlewares/object_scope_authorization.ex b/lib/middlewares/object_scope_authorization.ex index 3c2132b..be32d60 100644 --- a/lib/middlewares/object_scope_authorization.ex +++ b/lib/middlewares/object_scope_authorization.ex @@ -76,6 +76,9 @@ 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 # Root defp result(%{fields: fields, emitter: %{schema_node: %{identifier: identifier}}} = result, context) diff --git a/lib/schema.ex b/lib/schema.ex index ad04e32..f2156fd 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, @@ -17,6 +18,8 @@ defmodule Rajska.Schema do Field.t(), module() ) :: [Middleware.spec(), ...] + def add_query_authorization(middleware, %{definition: Introspection}, _authorizaton), + do: middleware def add_query_authorization( [{{QueryAuthorization, :call}, config} = query_authorization | middleware] = _middleware, %Field{name: query_name}, From 18c91b68700a80ad14a7b24cde5e8228e3dedaf8 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:37:36 +0200 Subject: [PATCH 5/9] Don't require QueryAuthorization to be first This would break usage in Relay. This can be improved to check if any Resolution middleware, and if those are found, compilation could still be halted like before. This ensures that there are no accidental 'open doors' when middleware isn't added before QueryAuthorization. --- lib/schema.ex | 101 +++++++++++++++++++++++++++++--------------------- 1 file changed, 59 insertions(+), 42 deletions(-) diff --git a/lib/schema.ex b/lib/schema.ex index f2156fd..fcff15b 100644 --- a/lib/schema.ex +++ b/lib/schema.ex @@ -14,53 +14,56 @@ defmodule Rajska.Schema do } @spec add_query_authorization( - [Middleware.spec(), ...], - Field.t(), - module() - ) :: [Middleware.spec(), ...] + [Middleware.spec(), ...], + Field.t(), + module() + ) :: [Middleware.spec(), ...] def add_query_authorization(middleware, %{definition: Introspection}, _authorizaton), do: middleware - 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 - def add_query_authorization(_middleware, %Field{name: name}, _authorization) do - raise "No permission specified for query #{name}" + 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 + + 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) @@ -77,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 @@ -100,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 @@ -115,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 From 22edb56d6c059ac77d6c084c0edf98660c058836 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 00:40:54 +0200 Subject: [PATCH 6/9] Add optional default_authorize callback This allows setting the `authorize: ...` value for all fields if none was set. This allows for an easier adoption for APIs that might previously not have any role-based authorization. Defaulting to their default role, for instance, :admin, and only adding `authorize: ...` where authorization can be relaxed. --- lib/authorization.ex | 5 ++++- lib/middlewares/object_authorization.ex | 7 +++++++ lib/rajska.ex | 5 ++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/authorization.ex b/lib/authorization.ex index dc9accb..e02a8fe 100644 --- a/lib/authorization.ex +++ b/lib/authorization.ex @@ -30,6 +30,8 @@ defmodule Rajska.Authorization do @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 +40,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 feafc8d..2340805 100644 --- a/lib/middlewares/object_authorization.ex +++ b/lib/middlewares/object_authorization.ex @@ -88,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 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 From f0de2309c06f885ee7c691d24864b5684ec137a6 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 01:14:38 +0200 Subject: [PATCH 7/9] Allow custom errors in unauthorized_message --- lib/authorization.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/authorization.ex b/lib/authorization.ex index e02a8fe..3b927d3 100644 --- a/lib/authorization.ex +++ b/lib/authorization.ex @@ -24,7 +24,8 @@ 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() From a5d8a0646631113b51bd693366234839559692cd Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Thu, 16 Jul 2020 19:00:14 +0200 Subject: [PATCH 8/9] Add plugin implementation This can be used as an alternative to pipeline modification. --- lib/middlewares/object_scope_authorization.ex | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/middlewares/object_scope_authorization.ex b/lib/middlewares/object_scope_authorization.ex index be32d60..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 @@ -80,6 +85,9 @@ defmodule Rajska.ObjectScopeAuthorization 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) when identifier in [:query, :mutation, :subscription] do From 9c9ac9c076e46a0bdd8980ec8c493d5059975cf7 Mon Sep 17 00:00:00 2001 From: Jeroen Visser Date: Mon, 15 Feb 2021 20:04:56 +0100 Subject: [PATCH 9/9] Add 1.6 compatibility --- mix.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.exs b/mix.exs index b1d37e5..e354ced 100644 --- a/mix.exs +++ b/mix.exs @@ -51,7 +51,7 @@ 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 or ~> 1.5.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}