├── config ├── dev.exs ├── test.exs ├── config.exs └── .credo.exs ├── test ├── test_helper.exs └── middlewares │ ├── rate_limiter_test.exs │ ├── query_authorization_test.exs │ ├── field_authorization_test.exs │ ├── object_authorization_test.exs │ ├── query_scope_authorization_test.exs │ ├── schema_test.exs │ └── object_scope_authorization_test.exs ├── .vscode └── settings.json ├── .travis.yml ├── .gitignore ├── lib ├── introspection.ex ├── authorization.ex ├── middlewares │ ├── query_authorization.ex │ ├── rate_limiter.ex │ ├── field_authorization.ex │ ├── object_authorization.ex │ ├── object_scope_authorization.ex │ └── query_scope_authorization.ex ├── schema.ex └── rajska.ex ├── LICENSE ├── mix.exs ├── mix.lock └── README.md /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "elixirLS.projectDir": "." 3 | } -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :info 4 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :debug 4 | 5 | import_config "#{Mix.env()}.exs" 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.10.3 4 | otp_release: 5 | - 22.0 6 | env: 7 | - MIX_ENV=test 8 | script: mix coveralls.travis 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /.fetch 6 | erl_crash.dump 7 | *.ez 8 | *.beam 9 | /config/*.secret.exs 10 | .elixir_ls 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /lib/introspection.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.Introspection do 2 | @moduledoc false 3 | 4 | alias Absinthe.Type 5 | 6 | @doc """ 7 | Introspect the Absinthe Type to get the underlying object type 8 | """ 9 | def get_object_type(%Type.List{of_type: object_type}), do: get_object_type(object_type) 10 | def get_object_type(%Type.NonNull{of_type: object_type}), do: get_object_type(object_type) 11 | def get_object_type(object_type), do: object_type 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rafael Scheffer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.MixProject do 2 | use Mix.Project 3 | 4 | @github_url "https://github.com/jungsoft/rajska" 5 | 6 | def project do 7 | [ 8 | app: :rajska, 9 | version: "1.3.2", 10 | elixir: "~> 1.8", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | name: "Rajska", 14 | source_url: @github_url, 15 | description: "Rajska is an authorization library for Absinthe.", 16 | package: package(), 17 | elixirc_paths: elixirc_paths(Mix.env()), 18 | aliases: aliases(), 19 | test_coverage: [tool: ExCoveralls], 20 | preferred_cli_env: [ 21 | coveralls: :test, 22 | "coveralls.detail": :test, 23 | "coveralls.post": :test, 24 | "coveralls.html": :test, 25 | "test.all": :test 26 | ] 27 | ] 28 | end 29 | 30 | def elixirc_paths(:test), do: ["lib", "test/support"] 31 | def elixirc_paths(_), do: ["lib"] 32 | 33 | def application do 34 | [ 35 | extra_applications: [:logger] 36 | ] 37 | end 38 | 39 | defp package do 40 | [ 41 | files: ~w(lib mix.exs README* LICENSE*), 42 | licenses: ["MIT"], 43 | links: %{ 44 | "GitHub" => @github_url, 45 | "Docs" => "https://hexdocs.pm/rajska/" 46 | } 47 | ] 48 | end 49 | 50 | defp deps do 51 | [ 52 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 53 | {:credo, "~> 1.5.0", only: [:dev, :test], runtime: false}, 54 | {:absinthe, "~> 1.4.0 or ~> 1.5.4 or ~> 1.6.0"}, 55 | {:excoveralls, "~> 0.11", only: :test}, 56 | {:hammer, "~> 6.0", optional: true}, 57 | {:mock, "~> 0.3.0", only: :test}, 58 | ] 59 | end 60 | 61 | defp aliases do 62 | [ 63 | "test.all": [ 64 | "credo --strict", 65 | "test" 66 | ] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.Authorization do 2 | @moduledoc """ 3 | Behaviour of an Authorization module. 4 | """ 5 | 6 | alias Absinthe.Resolution 7 | alias Absinthe.Type.Object 8 | 9 | @type current_user :: any() 10 | @type role :: atom() 11 | @type current_user_role :: role 12 | @type context :: map() 13 | @type scoped_struct :: struct() 14 | @type rule :: atom() 15 | 16 | @callback get_current_user(context) :: current_user 17 | 18 | @callback get_ip(context) :: String.t() 19 | 20 | @callback get_user_role(current_user) :: role 21 | 22 | @callback not_scoped_roles() :: list(role) 23 | 24 | @callback role_authorized?(current_user_role, allowed_role :: role) :: boolean() 25 | 26 | @callback has_user_access?(current_user, scoped_struct, rule) :: boolean() 27 | 28 | @callback unauthorized_message(resolution :: Resolution.t()) :: String.t() | map() | list() 29 | 30 | @callback unauthorized_query_scope_message(resolution :: Resolution.t(), atom()) :: String.t() 31 | 32 | @callback unauthorized_object_scope_message(object_result :: Absinthe.Blueprint.Result.Object.t(), atom()) :: String.t() 33 | 34 | @callback unauthorized_object_message(resolution :: Resolution.t(), Object.t) :: String.t() 35 | 36 | @callback unauthorized_field_message(resolution :: Resolution.t(), atom()) :: String.t() 37 | 38 | @callback context_role_authorized?(context, allowed_role :: role) :: boolean() 39 | 40 | @callback context_user_authorized?(context, scoped_struct, rule) :: boolean() 41 | 42 | @optional_callbacks get_current_user: 1, 43 | get_ip: 1, 44 | get_user_role: 1, 45 | not_scoped_roles: 0, 46 | role_authorized?: 2, 47 | has_user_access?: 3, 48 | unauthorized_message: 1, 49 | unauthorized_query_scope_message: 2, 50 | unauthorized_object_scope_message: 2, 51 | unauthorized_object_message: 2, 52 | unauthorized_field_message: 2, 53 | context_role_authorized?: 2, 54 | context_user_authorized?: 3 55 | end 56 | -------------------------------------------------------------------------------- /lib/middlewares/query_authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.QueryAuthorization do 2 | @moduledoc """ 3 | Absinthe middleware to ensure query permissions. 4 | 5 | ## Usage 6 | 7 | [Create your Authorization module and add it and QueryAuthorization to your Absinthe.Schema](https://hexdocs.pm/rajska/Rajska.html#module-usage). Then set the permitted role to access a query or mutation: 8 | 9 | ```elixir 10 | mutation do 11 | field :create_user, :user do 12 | arg :params, non_null(:user_params) 13 | 14 | middleware Rajska.QueryAuthorization, permit: :all 15 | resolve &AccountsResolver.create_user/2 16 | end 17 | 18 | field :update_user, :user do 19 | arg :id, non_null(:integer) 20 | arg :params, non_null(:user_params) 21 | 22 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id] 23 | resolve &AccountsResolver.update_user/2 24 | end 25 | 26 | field :delete_user, :user do 27 | arg :id, non_null(:integer) 28 | 29 | middleware Rajska.QueryAuthorization, permit: :admin 30 | resolve &AccountsResolver.delete_user/2 31 | end 32 | end 33 | ``` 34 | 35 | Query authorization will call `c:Rajska.Authorization.role_authorized?/2` to check if the [user](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:get_current_user/1) [role](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:get_user_role/1) is authorized to perform the query. 36 | """ 37 | alias Absinthe.Resolution 38 | 39 | alias Rajska.QueryScopeAuthorization 40 | 41 | @behaviour Absinthe.Middleware 42 | 43 | def call(%{context: context} = resolution, [{:permit, permission} | _scope] = config) do 44 | validate_permission!(context, permission) 45 | 46 | context 47 | |> Rajska.apply_auth_mod(:context_role_authorized?, [context, permission]) 48 | |> update_result(resolution) 49 | |> QueryScopeAuthorization.call(config) 50 | end 51 | 52 | defp validate_permission!(context, permitted_roles) do 53 | valid_roles = Rajska.apply_auth_mod(context, :valid_roles) 54 | 55 | unless permission_valid?(valid_roles, permitted_roles) do 56 | raise """ 57 | Invalid permission passed to QueryAuthorization: #{inspect(permitted_roles)}. 58 | Allowed permission: #{inspect(valid_roles)}. 59 | """ 60 | end 61 | end 62 | 63 | defp permission_valid?(valid_roles, permitted_roles) when is_list(permitted_roles) do 64 | Enum.all?(permitted_roles, & permission_valid?(valid_roles, &1)) 65 | end 66 | 67 | defp permission_valid?(valid_roles, permitted_role) when is_atom(permitted_role) do 68 | Enum.member?(valid_roles, permitted_role) 69 | end 70 | 71 | defp update_result(true, resolution), do: resolution 72 | 73 | defp update_result(false, %{context: context} = resolution) do 74 | Resolution.put_result(resolution, {:error, Rajska.apply_auth_mod(context, :unauthorized_message, [resolution])}) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/middlewares/rate_limiter.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.RateLimiter do 2 | @moduledoc """ 3 | Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer). 4 | 5 | ## Usage 6 | 7 | First configure Hammer, following its documentation. For example: 8 | 9 | config :hammer, 10 | backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, 11 | cleanup_interval_ms: 60_000 * 10]} 12 | 13 | Add your middleware to the query that should be limited: 14 | 15 | field :default_config, :string do 16 | middleware Rajska.RateLimiter 17 | resolve fn _, _ -> {:ok, "ok"} end 18 | end 19 | 20 | You can also configure it and use multiple rules for limiting in one query: 21 | 22 | field :login_user, :session do 23 | arg :email, non_null(:string) 24 | arg :password, non_null(:string) 25 | 26 | middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP) 27 | middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg 28 | resolve &AccountsResolver.login_user/2 29 | end 30 | 31 | The allowed configuration are: 32 | 33 | * `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000. 34 | * `limit`: The maximum number of actions in the specified timespan. Defaults to 10. 35 | * `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. 36 | * `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested. 37 | * `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`. 38 | 39 | 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 40 | `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 41 | absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content) 42 | for more information. 43 | """ 44 | @behaviour Absinthe.Middleware 45 | 46 | alias Absinthe.Resolution 47 | 48 | def call(%Resolution{state: :resolved} = resolution, _config), do: resolution 49 | 50 | def call(%Resolution{} = resolution, config) do 51 | scale_ms = Keyword.get(config, :scale_ms, 60_000) 52 | limit = Keyword.get(config, :limit, 10) 53 | identifier = get_identifier(resolution, config[:keys], config[:id]) 54 | error_msg = Keyword.get(config, :error_msg, "Too many requests") 55 | 56 | case Hammer.check_rate("query:#{identifier}", scale_ms, limit) do 57 | {:allow, _count} -> resolution 58 | {:deny, _limit} -> Resolution.put_result(resolution, {:error, error_msg}) 59 | end 60 | end 61 | 62 | defp get_identifier(%Resolution{context: context}, nil, nil), 63 | do: Rajska.apply_auth_mod(context, :get_ip, [context]) 64 | 65 | defp get_identifier(%Resolution{arguments: arguments}, keys, nil), 66 | do: get_in(arguments, List.wrap(keys)) || raise "Invalid configuration in Rate Limiter. Key not found in arguments." 67 | 68 | defp get_identifier(%Resolution{}, nil, id), do: id 69 | 70 | defp get_identifier(%Resolution{}, _keys, _id), do: raise "Invalid configuration in Rate Limiter. If key is defined, then id must not be defined" 71 | end 72 | -------------------------------------------------------------------------------- /lib/middlewares/field_authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.FieldAuthorization do 2 | @moduledoc """ 3 | Absinthe middleware to ensure field permissions. 4 | 5 | Authorizes Absinthe's object [field](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#field/4) according to the result of the `c:Rajska.Authorization.has_user_access?/3` function, which receives the user role, the `source` object that is resolving the field and the field rule. 6 | 7 | ## Usage 8 | 9 | [Create your Authorization module and add it and FieldAuthorization to your Absinthe.Schema](https://hexdocs.pm/rajska/Rajska.html#module-usage). 10 | 11 | ```elixir 12 | object :user do 13 | # Turn on both Object and Field scoping, but if the ObjectScope Phase is not included, this is the same as using `scope_field?` 14 | meta :scope?, true 15 | 16 | field :name, :string 17 | field :is_email_public, :boolean 18 | 19 | field :phone, :string, meta: [private: true] 20 | field :email, :string, meta: [private: & !&1.is_email_public] 21 | 22 | # Can also use custom rules for each field 23 | field :always_private, :string, meta: [private: true, rule: :private] 24 | end 25 | 26 | object :field_scope_user do 27 | meta :scope_field?, true 28 | 29 | field :name, :string 30 | field :phone, :string, meta: [private: true] 31 | end 32 | ``` 33 | 34 | As seen in the example above, a function can also be passed as value to the meta `:private` key, in order to check if a field is private dynamically, depending of the value of another field. 35 | """ 36 | 37 | @behaviour Absinthe.Middleware 38 | 39 | alias Absinthe.{ 40 | Resolution, 41 | Type 42 | } 43 | 44 | def call(resolution, [object: %Type.Object{fields: fields} = object, field: field]) do 45 | {private_config, _binding} = fields[field] |> Type.meta(:private) |> Code.eval_quoted() 46 | field_private? = field_private?(private_config, resolution.source) 47 | scope? = get_scope!(object) 48 | 49 | default_rule = Rajska.apply_auth_mod(resolution.context, :default_rule) 50 | rule = Type.meta(fields[field], :rule) || default_rule 51 | 52 | resolution 53 | |> Map.get(:context) 54 | |> authorized?(scope? && field_private?, resolution.source, rule) 55 | |> put_result(resolution, field) 56 | end 57 | 58 | defp field_private?(true, _source), do: true 59 | defp field_private?(private, source) when is_function(private), do: private.(source) 60 | defp field_private?(_private, _source), do: false 61 | 62 | defp get_scope!(object) do 63 | scope? = Type.meta(object, :scope?) 64 | scope_field? = Type.meta(object, :scope_field?) 65 | 66 | case {scope?, scope_field?} do 67 | {nil, nil} -> true 68 | {nil, scope_field?} -> scope_field? 69 | {scope?, nil} -> scope? 70 | {_, _} -> raise "Error in #{inspect object.identifier}. If scope_field? is defined, then scope? must not be defined" 71 | end 72 | end 73 | 74 | defp authorized?(_context, false, _source, _rule), do: true 75 | 76 | defp authorized?(context, true, source, rule) do 77 | Rajska.apply_auth_mod(context, :context_user_authorized?, [context, source, rule]) 78 | end 79 | 80 | defp put_result(true, resolution, _field), do: resolution 81 | 82 | defp put_result(false, %{context: context} = resolution, field) do 83 | Resolution.put_result( 84 | resolution, 85 | {:error, Rajska.apply_auth_mod(context, :unauthorized_field_message, [resolution, field])} 86 | ) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/middlewares/object_authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.ObjectAuthorization do 2 | @moduledoc """ 3 | Absinthe middleware to ensure object permissions. 4 | 5 | Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the permission defined in each object meta `authorize`. 6 | 7 | ## Usage 8 | 9 | [Create your Authorization module and add it and QueryAuthorization to your Absinthe.Schema](https://hexdocs.pm/rajska/Rajska.html#module-usage). Then set the permitted role to access an object: 10 | 11 | ```elixir 12 | object :wallet_balance do 13 | meta :authorize, :admin 14 | 15 | field :total, :integer 16 | end 17 | 18 | object :company do 19 | meta :authorize, :user 20 | 21 | field :name, :string 22 | 23 | field :wallet_balance, :wallet_balance 24 | end 25 | 26 | object :user do 27 | meta :authorize, :all 28 | 29 | field :email, :string 30 | 31 | field :company, :company 32 | end 33 | ``` 34 | 35 | With the permissions above, a query like the following would only be allowed by an admin user: 36 | 37 | ```graphql 38 | { 39 | userQuery { 40 | name 41 | email 42 | company { 43 | name 44 | walletBalance { total } 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | Object Authorization middleware runs after Query Authorization middleware (if added) and before the query is resolved by recursively checking the requested objects permissions in the `c:Rajska.Authorization.role_authorized?/2` function (which is also used by Query Authorization). It can be overridden by your own implementation. 51 | """ 52 | 53 | @behaviour Absinthe.Middleware 54 | 55 | alias Absinthe.{ 56 | Resolution, 57 | Schema, 58 | Type 59 | } 60 | alias Absinthe.Blueprint.Document.Fragment.Spread 61 | alias Rajska.Introspection 62 | alias Type.{Custom, Scalar} 63 | 64 | def call(%Resolution{state: :resolved} = resolution, _config), do: resolution 65 | 66 | def call(%Resolution{definition: definition} = resolution, _config) do 67 | authorize(definition.schema_node.type, definition.selections, resolution) 68 | end 69 | 70 | defp authorize(type, fields, resolution) do 71 | type 72 | |> Introspection.get_object_type() 73 | |> lookup_object(resolution.schema) 74 | |> authorize_object(fields, resolution) 75 | end 76 | 77 | defp lookup_object(object_type, schema) do 78 | Schema.lookup_type(schema, object_type) 79 | end 80 | 81 | # When is a Scalar, Custom or Enum type, authorize. 82 | defp authorize_object(%type{} = object, fields, resolution) 83 | when type in [Scalar, Custom, Type.Enum, Type.Enum.Value, Type.Union] do 84 | put_result(true, fields, resolution, object) 85 | end 86 | 87 | # When is an user defined object, lookup the authorize meta tag. 88 | defp authorize_object(object, fields, resolution) do 89 | object 90 | |> Type.meta(:authorize) 91 | |> authorized?(resolution.context, object) 92 | |> put_result(fields, resolution, object) 93 | end 94 | 95 | defp authorized?(nil, _, object), do: raise "No meta authorize defined for object #{inspect object.identifier}" 96 | 97 | defp authorized?(permission, context, _object) do 98 | Rajska.apply_auth_mod(context, :context_role_authorized?, [context, permission]) 99 | end 100 | 101 | defp put_result(true, fields, resolution, _type), do: find_associations(fields, resolution) 102 | 103 | defp put_result(false, _fields, %{context: context} = resolution, object) do 104 | Resolution.put_result( 105 | resolution, 106 | {:error, Rajska.apply_auth_mod(context, :unauthorized_object_message, [resolution, object])} 107 | ) 108 | end 109 | 110 | defp find_associations([%{selections: []} | tail], resolution) do 111 | find_associations(tail, resolution) 112 | end 113 | 114 | defp find_associations( 115 | [%{schema_node: %Type.Object{} = schema_node, selections: selections} | tail], 116 | resolution 117 | ) do 118 | authorize(schema_node, selections ++ tail, resolution) 119 | end 120 | 121 | defp find_associations( 122 | [%{schema_node: schema_node, selections: selections} | tail], 123 | resolution 124 | ) do 125 | authorize(schema_node.type, selections ++ tail, resolution) 126 | end 127 | 128 | defp find_associations( 129 | [%Spread{name: fragment_name} | tail], 130 | %{fragments: fragments} = resolution 131 | ) do 132 | fragment = Map.fetch!(fragments, fragment_name) 133 | find_associations([fragment | tail], resolution) 134 | end 135 | 136 | defp find_associations([], resolution), do: resolution 137 | end 138 | -------------------------------------------------------------------------------- /test/middlewares/rate_limiter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.RateLimiterTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Mock 5 | 6 | defmodule Authorization do 7 | use Rajska, 8 | valid_roles: [:user, :admin], 9 | super_role: :admin 10 | end 11 | 12 | defmodule Schema do 13 | use Absinthe.Schema 14 | 15 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 16 | 17 | input_object :keys_params do 18 | field :id, :string 19 | end 20 | 21 | query do 22 | field :default_config, :string do 23 | middleware Rajska.RateLimiter 24 | resolve fn _, _ -> {:ok, "ok"} end 25 | end 26 | 27 | field :scale_limit, :string do 28 | middleware Rajska.RateLimiter, scale_ms: 30_000, limit: 5 29 | resolve fn _, _ -> {:ok, "ok"} end 30 | end 31 | 32 | field :id, :string do 33 | middleware Rajska.RateLimiter, id: :custom_id 34 | resolve fn _, _ -> {:ok, "ok"} end 35 | end 36 | 37 | field :key, :string do 38 | arg :id, :string 39 | 40 | middleware Rajska.RateLimiter, keys: :id 41 | resolve fn _, _ -> {:ok, "ok"} end 42 | end 43 | 44 | field :keys, :string do 45 | arg :params, :keys_params 46 | middleware Rajska.RateLimiter, keys: [:params, :id] 47 | resolve fn _, _ -> {:ok, "ok"} end 48 | end 49 | 50 | field :id_and_key, :string do 51 | middleware Rajska.RateLimiter, id: :id, keys: :keys 52 | resolve fn _, _ -> {:ok, "ok"} end 53 | end 54 | 55 | field :error_msg, :string do 56 | middleware Rajska.RateLimiter, error_msg: "Rate limit exceeded" 57 | resolve fn _, _ -> {:ok, "ok"} end 58 | end 59 | end 60 | end 61 | 62 | @default_context [context: %{ip: "ip"}] 63 | 64 | setup_with_mocks([{Hammer, [], [check_rate: fn _a, _b, _c -> {:allow, 1} end]}]) do 65 | :ok 66 | end 67 | 68 | test "works with default configs" do 69 | {:ok, _} = Absinthe.run(query(:default_config), __MODULE__.Schema, @default_context) 70 | assert_called Hammer.check_rate("query:ip", 60_000, 10) 71 | end 72 | 73 | test "accepts scale and limit configuration" do 74 | {:ok, _} = Absinthe.run(query(:scale_limit), __MODULE__.Schema, @default_context) 75 | assert_called Hammer.check_rate("query:ip", 30_000, 5) 76 | end 77 | 78 | test "accepts id configuration" do 79 | {:ok, _} = Absinthe.run(query(:id), __MODULE__.Schema, @default_context) 80 | assert_called Hammer.check_rate("query:custom_id", 60_000, 10) 81 | end 82 | 83 | test "accepts key configuration" do 84 | {:ok, _} = Absinthe.run(query(:key, :id, "id_key"), __MODULE__.Schema, @default_context) 85 | assert_called Hammer.check_rate("query:id_key", 60_000, 10) 86 | end 87 | 88 | test "throws error if key is not present" do 89 | assert_raise RuntimeError, ~r/Invalid configuration in Rate Limiter. Key not found in arguments./, fn -> 90 | Absinthe.run(query(:key), __MODULE__.Schema, @default_context) 91 | end 92 | end 93 | 94 | test "accepts key configuration for nested parameters" do 95 | {:ok, _} = Absinthe.run(query(:keys, :params, %{id: "id_key"}), __MODULE__.Schema, @default_context) 96 | assert_called Hammer.check_rate("query:id_key", 60_000, 10) 97 | end 98 | 99 | test "throws error when id and key are provided as configuration" do 100 | assert_raise RuntimeError, ~r/Invalid configuration in Rate Limiter. If key is defined, then id must not be defined/, fn -> 101 | Absinthe.run(query(:id_and_key), __MODULE__.Schema, @default_context) 102 | end 103 | end 104 | 105 | test "accepts error msg configuration" do 106 | with_mock Hammer, [check_rate: fn _a, _b, _c -> {:deny, 1} end] do 107 | assert {:ok, %{errors: errors}} = Absinthe.run(query(:error_msg), __MODULE__.Schema, @default_context) 108 | assert [ 109 | %{ 110 | locations: [%{column: 3, line: 1}], 111 | message: "Rate limit exceeded", 112 | path: ["error_msg"] 113 | } 114 | ] == errors 115 | end 116 | end 117 | 118 | test "does not apply when resolution is already resolved" do 119 | resolution = %Absinthe.Resolution{state: :resolved} 120 | assert resolution == Rajska.RateLimiter.call(resolution, []) 121 | end 122 | 123 | defp query(name), do: "{ #{name} }" 124 | defp query(name, key, value) when is_binary(value), do: "{ #{name}(#{key}: \"#{value}\") }" 125 | defp query(name, key, %{} = value), do: "{ #{name}(#{key}: {#{build_arguments(value)}}) }" 126 | 127 | defp build_arguments(arguments) do 128 | arguments 129 | |> Enum.map(fn {k, v} -> if is_nil(v), do: nil, else: "#{k}: #{inspect(v, [charlists: :as_lists])}" end) 130 | |> Enum.reject(&is_nil/1) 131 | |> Enum.join(", ") 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /test/middlewares/query_authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.QueryAuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Authorization do 5 | use Rajska, 6 | valid_roles: [:viewer, :user, :admin], 7 | super_role: :admin 8 | end 9 | 10 | defmodule Schema do 11 | use Absinthe.Schema 12 | 13 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 14 | 15 | def middleware(middleware, field, %Absinthe.Type.Object{identifier: identifier}) 16 | when identifier in [:query, :mutation] do 17 | Rajska.add_query_authorization(middleware, field, Authorization) 18 | end 19 | 20 | def middleware(middleware, _field, _object), do: middleware 21 | 22 | query do 23 | field :all_query, :user do 24 | middleware Rajska.QueryAuthorization, permit: :all 25 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 26 | end 27 | 28 | field :user_query, :user do 29 | middleware Rajska.QueryAuthorization, [permit: :user, scope: false] 30 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 31 | end 32 | 33 | field :user_viewer_query, :user do 34 | middleware Rajska.QueryAuthorization, [permit: [:viewer, :user], scope: false] 35 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 36 | end 37 | 38 | field :admin_query, :user do 39 | middleware Rajska.QueryAuthorization, permit: :admin 40 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 41 | end 42 | end 43 | 44 | object :user do 45 | field :email, :string 46 | field :name, :string 47 | end 48 | end 49 | 50 | test "Admin query fails for unauthenticated user" do 51 | assert {:ok, %{errors: errors}} = Absinthe.run(admin_query(), __MODULE__.Schema, context: %{current_user: nil}) 52 | assert [ 53 | %{ 54 | locations: [%{column: 3, line: 1}], 55 | message: "unauthorized", 56 | path: ["adminQuery"] 57 | } 58 | ] == errors 59 | end 60 | 61 | test "Admin query fails for user" do 62 | assert {:ok, %{errors: errors}} = Absinthe.run(admin_query(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 63 | assert [ 64 | %{ 65 | locations: [%{column: 3, line: 1}], 66 | message: "unauthorized", 67 | path: ["adminQuery"] 68 | } 69 | ] == errors 70 | end 71 | 72 | test "Admin query works for admin" do 73 | {:ok, result} = Absinthe.run(admin_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 74 | 75 | assert %{data: %{"adminQuery" => %{}}} = result 76 | refute Map.has_key?(result, :errors) 77 | end 78 | 79 | test "User query fails for unauthenticated user" do 80 | assert {:ok, %{errors: errors}} = Absinthe.run(user_query(), __MODULE__.Schema, context: %{current_user: nil}) 81 | assert [ 82 | %{ 83 | locations: [%{column: 3, line: 1}], 84 | message: "unauthorized", 85 | path: ["userQuery"] 86 | } 87 | ] == errors 88 | end 89 | 90 | test "User query works for user" do 91 | {:ok, result} = Absinthe.run(user_query(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 92 | 93 | assert %{data: %{"userQuery" => %{}}} = result 94 | refute Map.has_key?(result, :errors) 95 | end 96 | 97 | test "User and viewer query works for both viewer and user" do 98 | {:ok, result} = Absinthe.run(user_viewer_query(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 99 | 100 | assert %{data: %{"userViewerQuery" => %{}}} = result 101 | refute Map.has_key?(result, :errors) 102 | end 103 | 104 | test "User query works for admin" do 105 | {:ok, result} = Absinthe.run(user_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 106 | 107 | assert %{data: %{"userQuery" => %{}}} = result 108 | refute Map.has_key?(result, :errors) 109 | end 110 | 111 | test "All query works for unauthenticated user" do 112 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: nil}) 113 | 114 | assert %{data: %{"allQuery" => %{}}} = result 115 | refute Map.has_key?(result, :errors) 116 | end 117 | 118 | test "All query works for user" do 119 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 120 | 121 | assert %{data: %{"allQuery" => %{}}} = result 122 | refute Map.has_key?(result, :errors) 123 | end 124 | 125 | test "All query works for admin" do 126 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 127 | 128 | assert %{data: %{"allQuery" => %{}}} = result 129 | refute Map.has_key?(result, :errors) 130 | end 131 | 132 | defp admin_query, do: "{ adminQuery { name email } }" 133 | 134 | defp user_query, do: "{ userQuery { name email } }" 135 | 136 | defp user_viewer_query, do: "{ userViewerQuery { name email } }" 137 | 138 | defp all_query, do: "{ allQuery { name email } }" 139 | end 140 | -------------------------------------------------------------------------------- /lib/middlewares/object_scope_authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.ObjectScopeAuthorization do 2 | @moduledoc """ 3 | Absinthe Phase to perform object scoping. 4 | 5 | Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the underlying struct. 6 | 7 | ## Usage 8 | 9 | [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: 10 | 11 | ```elixir 12 | object :user do 13 | # Turn on Object and Field scoping, but if the FieldAuthorization middleware is not included, this is the same as using `scope_object?` 14 | meta :scope?, true 15 | 16 | field :id, :integer 17 | field :email, :string 18 | field :name, :string 19 | 20 | field :company, :company 21 | end 22 | 23 | object :company do 24 | meta :scope_object?, true 25 | 26 | field :id, :integer 27 | field :user_id, :integer 28 | field :name, :string 29 | field :wallet, :wallet 30 | end 31 | 32 | object :wallet do 33 | meta :scope?, true 34 | meta :rule, :object_authorization 35 | 36 | field :total, :integer 37 | end 38 | ``` 39 | 40 | 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: 41 | 42 | ```elixir 43 | defmodule Authorization do 44 | use Rajska, 45 | valid_roles: [:user, :admin], 46 | super_role: :admin 47 | 48 | @impl true 49 | def has_user_access?(%{role: :admin}, %User{}, _rule), do: true 50 | def has_user_access?(%{id: user_id}, %User{id: id}, _rule) when user_id === id, do: true 51 | def has_user_access?(_current_user, %User{}, _rule), do: false 52 | 53 | def has_user_access?(%{id: user_id}, %Wallet{user_id: id}, :object_authorization), do: user_id == id 54 | def has_user_access?(%{id: user_id}, %Wallet{user_id: id}, :always_block), do: false 55 | end 56 | ``` 57 | 58 | This way different rules can be set to the same struct. 59 | See `Rajska.Authorization` for `rule` default settings. 60 | """ 61 | 62 | alias Absinthe.{Blueprint, Phase, Type} 63 | alias Rajska.Introspection 64 | use Absinthe.Phase 65 | 66 | @spec run(Blueprint.t() | Phase.Error.t(), Keyword.t()) :: {:ok, map} 67 | def run(%Blueprint{execution: execution} = bp, _options \\ []) do 68 | {:ok, %{bp | execution: process(execution)}} 69 | end 70 | 71 | defp process(%{validation_errors: [], result: result} = execution), do: %{execution | result: result(result, execution.context)} 72 | defp process(execution), do: execution 73 | 74 | # Introspection 75 | defp result(%{emitter: %{schema_node: %{identifier: identifier}}} = result, _context) 76 | when identifier in [:query_type, :__schema, nil] do 77 | result 78 | end 79 | 80 | # Root 81 | defp result(%{fields: fields, emitter: %{schema_node: %{identifier: identifier}}} = result, context) 82 | when identifier in [:query, :mutation, :subscription] do 83 | %{result | fields: walk_result(fields, context)} 84 | end 85 | 86 | # Object 87 | defp result(%{fields: fields, emitter: %{schema_node: schema_node}, root_value: root_value} = result, context) do 88 | type = Introspection.get_object_type(schema_node.type) 89 | scope? = get_scope!(type) 90 | default_rule = Rajska.apply_auth_mod(context, :default_rule) 91 | rule = Type.meta(type, :rule) || default_rule 92 | 93 | case authorized?(scope?, context, root_value, rule) do 94 | true -> %{result | fields: walk_result(fields, context)} 95 | false -> Map.put(result, :errors, [error(result, context)]) 96 | end 97 | end 98 | 99 | # List 100 | defp result(%{values: values} = result, context) do 101 | %{result | values: walk_result(values, context)} 102 | end 103 | 104 | # Leafs 105 | defp result(result, _context), do: result 106 | 107 | defp walk_result(fields, context, new_fields \\ []) 108 | 109 | defp walk_result([], _context, new_fields), do: Enum.reverse(new_fields) 110 | 111 | defp walk_result([field | fields], context, new_fields) do 112 | new_fields = [result(field, context) | new_fields] 113 | walk_result(fields, context, new_fields) 114 | end 115 | 116 | defp get_scope!(object) do 117 | scope? = Type.meta(object, :scope?) 118 | scope_object? = Type.meta(object, :scope_object?) 119 | 120 | case {scope?, scope_object?} do 121 | {nil, nil} -> true 122 | {nil, scope_object?} -> scope_object? 123 | {scope?, nil} -> scope? 124 | {_, _} -> raise "Error in #{inspect object.identifier}. If scope_object? is defined, then scope? must not be defined" 125 | end 126 | end 127 | 128 | defp authorized?(false, _context, _scoped_struct, _rule), do: true 129 | 130 | defp authorized?(true, context, scoped_struct, rule) do 131 | Rajska.apply_auth_mod(context, :context_user_authorized?, [context, scoped_struct, rule]) 132 | end 133 | 134 | defp error(%{emitter: %{source_location: location, schema_node: %{type: type}}} = result, context) do 135 | object_type = Rajska.Introspection.get_object_type(type) 136 | %Phase.Error{ 137 | phase: __MODULE__, 138 | message: Rajska.apply_auth_mod(context, :unauthorized_object_scope_message, [result, object_type]), 139 | locations: [location] 140 | } 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.Schema do 2 | @moduledoc """ 3 | Concatenates Rajska middlewares with Absinthe middlewares and validates Query Authorization configuration. 4 | """ 5 | 6 | alias Absinthe.Middleware 7 | alias Absinthe.Type.{Field, Object} 8 | 9 | alias Rajska.{ 10 | FieldAuthorization, 11 | ObjectAuthorization, 12 | QueryAuthorization 13 | } 14 | 15 | @modules_to_skip [Absinthe.Phase.Schema.Introspection] 16 | 17 | @spec add_query_authorization( 18 | [Middleware.spec(), ...], 19 | Field.t(), 20 | module() 21 | ) :: [Middleware.spec(), ...] 22 | def add_query_authorization(middlewares, %Field{name: query_name, definition: definition_module}, authorization) 23 | when definition_module not in @modules_to_skip do 24 | middlewares 25 | |> Enum.find(&find_middleware/1) 26 | |> case do 27 | {{QueryAuthorization, :call}, config} -> 28 | validate_query_auth_config!(config, authorization, query_name) 29 | 30 | {{Absinthe.Resolution, :call}, _config} -> 31 | raise "No permission specified for query #{query_name}" 32 | end 33 | 34 | middlewares 35 | end 36 | 37 | def add_query_authorization(middlewares, _field, _authorization), do: middlewares 38 | 39 | def find_middleware({{QueryAuthorization, :call}, _config}), do: true 40 | def find_middleware({{Absinthe.Resolution, :call}, _config}), do: true 41 | def find_middleware({_middleware, _config}), do: false 42 | 43 | @spec add_object_authorization([Middleware.spec(), ...]) :: [Middleware.spec(), ...] 44 | def add_object_authorization(middlewares) do 45 | middlewares 46 | |> Enum.reduce([], fn 47 | {{QueryAuthorization, :call}, _config} = query_authorization, new_middlewares -> 48 | [ObjectAuthorization, query_authorization] ++ new_middlewares 49 | 50 | {{Absinthe.Resolution, :call}, _config} = resolution, new_middlewares -> 51 | add_object_authorization_if_not_yet_present(resolution, new_middlewares) 52 | 53 | middleware, new_middlewares -> 54 | [middleware | new_middlewares] 55 | end) 56 | |> Enum.reverse() 57 | end 58 | 59 | defp add_object_authorization_if_not_yet_present(resolution, new_middlewares) do 60 | case Enum.member?(new_middlewares, ObjectAuthorization) do 61 | true -> [resolution | new_middlewares] 62 | false -> [resolution, ObjectAuthorization] ++ new_middlewares 63 | end 64 | end 65 | 66 | @spec add_field_authorization( 67 | [Middleware.spec(), ...], 68 | Field.t(), 69 | Object.t() 70 | ) :: [Middleware.spec(), ...] 71 | def add_field_authorization(middleware, %Field{identifier: field}, object) do 72 | [{{FieldAuthorization, :call}, object: object, field: field} | middleware] 73 | end 74 | 75 | @spec validate_query_auth_config!( 76 | [ 77 | permit: atom(), 78 | scope: false | module(), 79 | args: %{} | [] | atom(), 80 | optional: false | true, 81 | rule: atom() 82 | ], 83 | module(), 84 | String.t() 85 | ) :: :ok | Exception.t() 86 | 87 | def validate_query_auth_config!(config, authorization, query_name) do 88 | permit = Keyword.get(config, :permit) 89 | scope = Keyword.get(config, :scope) 90 | args = Keyword.get(config, :args, :id) 91 | rule = Keyword.get(config, :rule, :default_rule) 92 | optional = Keyword.get(config, :optional, false) 93 | 94 | try do 95 | validate_presence!(permit, :permit) 96 | validate_boolean!(optional, :optional) 97 | validate_atom!(rule, :rule) 98 | 99 | validate_scope!(scope, permit, authorization) 100 | validate_args!(args) 101 | rescue 102 | e in RuntimeError -> reraise "Query #{query_name} is configured incorrectly, #{e.message}", __STACKTRACE__ 103 | end 104 | end 105 | 106 | defp validate_presence!(nil, option), do: raise "#{inspect(option)} option must be present." 107 | defp validate_presence!(_value, _option), do: :ok 108 | 109 | defp validate_boolean!(value, _option) when is_boolean(value), do: :ok 110 | defp validate_boolean!(_value, option), do: raise "#{inspect(option)} option must be a boolean." 111 | 112 | defp validate_atom!(value, _option) when is_atom(value), do: :ok 113 | defp validate_atom!(_value, option), do: raise "#{inspect(option)} option must be an atom." 114 | 115 | defp validate_scope!(nil, role, authorization) do 116 | unless Enum.member?(authorization.not_scoped_roles(), role), 117 | do: raise ":scope option must be present for role #{inspect(role)}." 118 | end 119 | 120 | defp validate_scope!(false, _role, _authorization), do: :ok 121 | 122 | defp validate_scope!(scope, _role, _authorization) when is_atom(scope) do 123 | struct!(scope) 124 | rescue 125 | UndefinedFunctionError -> reraise ":scope option #{inspect(scope)} is not a struct.", __STACKTRACE__ 126 | end 127 | 128 | defp validate_args!(args) when is_map(args) do 129 | Enum.each(args, fn 130 | {field, value} when is_atom(field) and is_atom(value) -> :ok 131 | {field, values} when is_atom(field) and is_list(values) -> validate_list_of_atoms_or_function!(values) 132 | 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." 133 | end) 134 | end 135 | 136 | defp validate_args!(args) when is_list(args), do: validate_list_of_atoms!(args) 137 | defp validate_args!(args) when is_atom(args), do: :ok 138 | defp validate_args!(args), do: raise "the following args option is invalid: #{inspect(args)}" 139 | 140 | defp validate_list_of_atoms!(args) do 141 | Enum.each(args, fn 142 | arg when is_atom(arg) -> :ok 143 | arg -> raise "the following args option is invalid: #{inspect(args)}. Expected a list of atoms, but found #{inspect(arg)}" 144 | end) 145 | end 146 | 147 | defp validate_list_of_atoms_or_function!(args) do 148 | Enum.each(args, fn 149 | arg when is_atom(arg) or is_function(arg) -> :ok 150 | arg -> raise "the following args option is invalid: #{inspect(args)}. Expected a list of atoms or functions, but found #{inspect(arg)}" 151 | end) 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "absinthe": {:hex, :absinthe, "1.6.4", "d2958908b72ce146698de8ccbc03622630471eb0e354e06823aaef183e5067bd", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e9c1cf36d86c704cb9a9c78db62d1c2676b03e0f61a28a23fc42749e8cd41ae"}, 3 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 4 | "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, 5 | "credo": {:hex, :credo, "1.5.1", "4fe303cc828412b9d21eed4eab60914c401e71f117f40243266aafb66f30d036", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "0b219ca4dcc89e4e7bc6ae7e6539c313e738e192e10b85275fa1e82b5203ecd7"}, 6 | "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm", "5e8806285d8a3a8999bd38e4a73c58d28534c856bc38c44818e5ba85bbda16fb"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.12", "b245e875ec0a311a342320da0551da407d9d2b65d98f7a9597ae078615af3449", [:mix], [], "hexpm", "711e2cc4d64abb7d566d43f54b78f7dc129308a63bc103fbd88550d2174b3160"}, 8 | "ex_doc": {:hex, :ex_doc, "0.23.0", "a069bc9b0bf8efe323ecde8c0d62afc13d308b1fa3d228b65bca5cf8703a529d", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f5e2c4702468b2fd11b10d39416ddadd2fcdd173ba2a0285ebd92c39827a5a16"}, 9 | "excoveralls": {:hex, :excoveralls, "0.13.3", "edc5f69218f84c2bf61b3609a22ddf1cec0fbf7d1ba79e59f4c16d42ea4347ed", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cc26f48d2f68666380b83d8aafda0fffc65dafcc8d8650358e0b61f6a99b1154"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, 12 | "hammer": {:hex, :hammer, "6.0.0", "72ec6fff10e9d63856968988a22ee04c4d6d5248071ddccfbda50aa6c455c1d7", [:mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "d8e1ec2e534c4aae508b906759e077c3c1eb3e2b9425235d4b7bbab0b016210a"}, 13 | "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, 14 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 15 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.0", "98312c9f0d3730fde4049985a1105da5155bfe5c11e47bdc7406d88e01e4219b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "75ffa34ab1056b7e24844c90bfc62aaf6f3a37a15faa76b07bc5eba27e4a8b4a"}, 17 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 20 | "mock": {:hex, :mock, "0.3.6", "e810a91fabc7adf63ab5fdbec5d9d3b492413b8cda5131a2a8aa34b4185eb9b4", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "bcf1d0a6826fb5aee01bae3d74474669a3fa8b2df274d094af54a25266a1ebd2"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 22 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 23 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 26 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, 27 | } 28 | -------------------------------------------------------------------------------- /config/.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/"], 25 | }, 26 | # 27 | # Load and configure plugins here: 28 | # 29 | plugins: [], 30 | # 31 | # If you create your own checks, you must specify the source files for 32 | # them here, so they can be loaded by Credo before running the analysis. 33 | # 34 | requires: [], 35 | # 36 | # If you want to enforce a style guide and need a more traditional linting 37 | # experience, you can change `strict` to `true` below: 38 | # 39 | strict: [], 40 | # 41 | # If you want to use uncolored output by default, you can change `color` 42 | # to `[]` below: 43 | # 44 | color: true, 45 | # 46 | # You can customize the parameters of any check by adding a second element 47 | # to the tuple. 48 | # 49 | # To disable a check put `[]` as second element: 50 | # 51 | # {Credo.Check.Design.DuplicatedCode, []} 52 | # 53 | checks: [ 54 | # 55 | ## Consistency Checks 56 | # 57 | {Credo.Check.Consistency.ExceptionNames, []}, 58 | {Credo.Check.Consistency.LineEndings, []}, 59 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 60 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 61 | {Credo.Check.Consistency.SpaceInParentheses, []}, 62 | {Credo.Check.Consistency.TabsOrSpaces, []}, 63 | 64 | # 65 | ## Design Checks 66 | # 67 | # You can customize the priority of any check 68 | # Priority values are: `low, normal, high, higher` 69 | # 70 | {Credo.Check.Design.AliasUsage, 71 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 72 | # You can also customize the exit_status of each check. 73 | # If you don't want TODO comments to cause `mix credo` to fail, just 74 | # set this value to 0 (zero). 75 | # 76 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 77 | {Credo.Check.Design.TagFIXME, []}, 78 | 79 | # 80 | ## Readability Checks 81 | # 82 | {Credo.Check.Readability.AliasOrder, []}, 83 | {Credo.Check.Readability.FunctionNames, []}, 84 | {Credo.Check.Readability.LargeNumbers, []}, 85 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 140]}, 86 | {Credo.Check.Readability.ModuleAttributeNames, []}, 87 | {Credo.Check.Readability.ModuleDoc, []}, 88 | {Credo.Check.Readability.ModuleNames, []}, 89 | {Credo.Check.Readability.ParenthesesInCondition, []}, 90 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 91 | {Credo.Check.Readability.PredicateFunctionNames, []}, 92 | {Credo.Check.Readability.PreferImplicitTry, []}, 93 | {Credo.Check.Readability.RedundantBlankLines, []}, 94 | {Credo.Check.Readability.Semicolons, []}, 95 | {Credo.Check.Readability.SpaceAfterCommas, []}, 96 | {Credo.Check.Readability.StringSigils, []}, 97 | {Credo.Check.Readability.TrailingBlankLine, []}, 98 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 99 | # TODO: enable by default in Credo 1.1 100 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 101 | {Credo.Check.Readability.VariableNames, []}, 102 | 103 | # 104 | ## Refactoring Opportunities 105 | # 106 | {Credo.Check.Refactor.CondStatements, []}, 107 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 108 | {Credo.Check.Refactor.FunctionArity, []}, 109 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 110 | {Credo.Check.Refactor.MapInto, []}, 111 | {Credo.Check.Refactor.MatchInCondition, []}, 112 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 113 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 114 | {Credo.Check.Refactor.Nesting, []}, 115 | {Credo.Check.Refactor.UnlessWithElse, []}, 116 | {Credo.Check.Refactor.WithClauses, []}, 117 | 118 | # 119 | ## Warnings 120 | # 121 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 122 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 123 | {Credo.Check.Warning.IExPry, []}, 124 | {Credo.Check.Warning.IoInspect, []}, 125 | {Credo.Check.Warning.LazyLogging, []}, 126 | {Credo.Check.Warning.OperationOnSameValues, []}, 127 | {Credo.Check.Warning.OperationWithConstantResult, []}, 128 | {Credo.Check.Warning.RaiseInsideRescue, []}, 129 | {Credo.Check.Warning.UnusedEnumOperation, []}, 130 | {Credo.Check.Warning.UnusedFileOperation, []}, 131 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 132 | {Credo.Check.Warning.UnusedListOperation, []}, 133 | {Credo.Check.Warning.UnusedPathOperation, []}, 134 | {Credo.Check.Warning.UnusedRegexOperation, []}, 135 | {Credo.Check.Warning.UnusedStringOperation, []}, 136 | {Credo.Check.Warning.UnusedTupleOperation, []}, 137 | 138 | # 139 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 140 | # 141 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 142 | {Credo.Check.Design.DuplicatedCode, false}, 143 | {Credo.Check.Readability.MultiAlias, false}, 144 | {Credo.Check.Readability.Specs, false}, 145 | {Credo.Check.Readability.SinglePipe, []}, 146 | {Credo.Check.Refactor.ABCSize, [max_size: 40]}, 147 | {Credo.Check.Refactor.AppendSingleItem, false}, 148 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 149 | {Credo.Check.Refactor.ModuleDependencies, false}, 150 | {Credo.Check.Refactor.PipeChainStart, []}, 151 | {Credo.Check.Refactor.VariableRebinding, []}, 152 | {Credo.Check.Warning.MapGetUnsafePass, []}, 153 | {Credo.Check.Warning.UnsafeToAtom, false} 154 | 155 | # 156 | # Custom checks can be created using `mix credo.gen.check`. 157 | # 158 | ] 159 | } 160 | ] 161 | } 162 | -------------------------------------------------------------------------------- /lib/middlewares/query_scope_authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska.QueryScopeAuthorization do 2 | @moduledoc """ 3 | Absinthe middleware to perform query scoping. 4 | 5 | ## Usage 6 | 7 | [Create your Authorization module and add it and QueryAuthorization to your Absinthe.Schema](https://hexdocs.pm/rajska/Rajska.html#module-usage). Since Scope Authorization middleware must be used with Query Authorization, it is automatically called when adding the former. Then set the scoped module and argument field: 8 | 9 | ```elixir 10 | mutation do 11 | field :create_user, :user do 12 | arg :params, non_null(:user_params) 13 | 14 | # all does not require scoping, since it means anyone can execute this query, even without being logged in. 15 | middleware Rajska.QueryAuthorization, permit: :all 16 | resolve &AccountsResolver.create_user/2 17 | end 18 | 19 | field :update_user, :user do 20 | arg :id, non_null(:integer) 21 | arg :params, non_null(:user_params) 22 | 23 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id] 24 | resolve &AccountsResolver.update_user/2 25 | end 26 | 27 | field :delete_user, :user do 28 | arg :user_id, non_null(:integer) 29 | 30 | # Providing a map for args is useful to map query argument to struct field. 31 | middleware Rajska.QueryAuthorization, [permit: [:user, :manager], scope: User, args: %{id: :user_id}] 32 | resolve &AccountsResolver.delete_user/2 33 | end 34 | 35 | input_object :user_params do 36 | field :id, non_null(:integer) 37 | end 38 | 39 | field :accept_user, :user do 40 | arg :params, non_null(:user_params) 41 | 42 | middleware Rajska.QueryAuthorization, [ 43 | permit: :user, 44 | scope: User, 45 | args: %{id: [:params, :id]}, 46 | rule: :accept_user 47 | ] 48 | resolve &AccountsResolver.invite_user/2 49 | end 50 | end 51 | ``` 52 | 53 | In the above example, `:all` and `:admin` permissions don't require the `:scope` keyword, as defined in the `c:Rajska.Authorization.not_scoped_roles/0` function, but you can modify this behavior by overriding it. 54 | 55 | ## Options 56 | 57 | All the following options are sent to `c:Rajska.Authorization.has_user_access?/3`: 58 | 59 | * `:scope` 60 | - `false`: disables scoping 61 | - `User`: a module that will be passed to `c:Rajska.Authorization.has_user_access?/3`. It must define a struct. 62 | * `:args` 63 | - `%{user_id: [:params, :id]}`: where `user_id` is the scoped field and `id` is an argument nested inside the `params` argument. 64 | This form also accepts a function in the array (the same way as described in the [Kernel.get_in/2](https://hexdocs.pm/elixir/Kernel.html#get_in/2-functions-as-keys)). 65 | This way, we can also use `%{user_id: [:params, Access.all(), :id]}` and for an input arg like `params: [%{id: 1}, %{id: 2}]`, 66 | the builded struct will have the value `%User{user_id: [1, 2]}`. 67 | - `:id`: this is the same as `%{id: :id}`, where `:id` is both the query argument and the scoped field that will be passed to `c:Rajska.Authorization.has_user_access?/3` 68 | - `[:code, :user_group_id]`: this is the same as `%{code: :code, user_group_id: :user_group_id}`, where `code` and `user_group_id` are both query arguments and scoped fields. 69 | * `:optional` (optional) - when set to true the arguments are optional, so if no argument is provided, the query will be authorized. Defaults to false. 70 | * `:rule` (optional) - allows the same struct to have different rules. See `Rajska.Authorization` for `rule` default settings. 71 | """ 72 | 73 | @behaviour Absinthe.Middleware 74 | 75 | alias Absinthe.Resolution 76 | 77 | alias Rajska.Introspection 78 | 79 | def call(%Resolution{state: :resolved} = resolution, _config), do: resolution 80 | 81 | def call(resolution, [_ | [scope: false]]), do: resolution 82 | 83 | def call(resolution, [{:permit, permission} | scope_config]) do 84 | not_scoped_roles = Rajska.apply_auth_mod(resolution.context, :not_scoped_roles) 85 | 86 | case Enum.member?(not_scoped_roles, permission) do 87 | true -> resolution 88 | false -> scope_user!(resolution, scope_config) 89 | end 90 | end 91 | 92 | def scope_user!(%{context: context} = resolution, config) do 93 | default_rule = Rajska.apply_auth_mod(context, :default_rule) 94 | rule = Keyword.get(config, :rule, default_rule) 95 | scope = Keyword.get(config, :scope) 96 | arg_fields = config |> Keyword.get(:args, :id) |> arg_fields_to_map() 97 | optional = Keyword.get(config, :optional, false) 98 | arguments_source = get_arguments_source!(resolution, scope) 99 | 100 | arg_fields 101 | |> Enum.map(& get_scoped_struct_field(arguments_source, &1, optional, resolution.definition.name)) 102 | |> Enum.reject(&is_nil/1) 103 | |> has_user_access?(scope, resolution.context, rule, optional) 104 | |> update_result(resolution) 105 | end 106 | 107 | defp arg_fields_to_map(field) when is_atom(field), do: Map.new([{field, field}]) 108 | defp arg_fields_to_map(fields) when is_list(fields), do: fields |> Enum.map(& {&1, &1}) |> Map.new() 109 | defp arg_fields_to_map(field) when is_map(field), do: field 110 | 111 | defp get_arguments_source!(%Resolution{definition: %{name: name}}, nil) do 112 | raise "Error in query #{name}: no scope argument found in middleware Scope Authorization" 113 | end 114 | 115 | defp get_arguments_source!(%Resolution{arguments: args}, _scope), do: args 116 | 117 | def get_scoped_struct_field(arguments_source, {scope_field, arg_field}, optional, query_name) do 118 | case get_scope_field_value(arguments_source, arg_field) do 119 | nil when optional === true -> nil 120 | nil when optional === false -> raise "Error in query #{query_name}: no argument #{inspect arg_field} found in #{inspect arguments_source}" 121 | field_value -> {scope_field, field_value} 122 | end 123 | end 124 | 125 | defp get_scope_field_value(arguments_source, fields) when is_list(fields), do: get_in(arguments_source, fields) 126 | defp get_scope_field_value(arguments_source, field) when is_atom(field), do: Map.get(arguments_source, field) 127 | 128 | defp has_user_access?([], _scope, _context, _rule, true), do: true 129 | 130 | defp has_user_access?(scoped_struct_fields, scope, context, rule, _optional) do 131 | scoped_struct = scope.__struct__(scoped_struct_fields) 132 | 133 | Rajska.apply_auth_mod(context, :context_user_authorized?, [context, scoped_struct, rule]) 134 | end 135 | 136 | defp update_result(true, resolution), do: resolution 137 | 138 | defp update_result(false, %{context: context, definition: %{schema_node: %{type: object_type}}} = resolution) do 139 | object_type = Introspection.get_object_type(object_type) 140 | 141 | Resolution.put_result( 142 | resolution, 143 | {:error, Rajska.apply_auth_mod(context, :unauthorized_query_scope_message, [resolution, object_type])} 144 | ) 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /test/middlewares/field_authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.FieldAuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule User do 5 | defstruct [ 6 | id: 1, 7 | name: "User", 8 | email: "email@user.com", 9 | phone: "123456", 10 | is_email_public: true, 11 | always_private: "private!" 12 | ] 13 | end 14 | 15 | defmodule Authorization do 16 | use Rajska, 17 | valid_roles: [:user, :admin], 18 | super_role: :admin 19 | 20 | def has_user_access?(_current_user, %User{}, :private), do: false 21 | def has_user_access?(%{role: :admin}, %User{}, :default), do: true 22 | def has_user_access?(%{id: user_id}, %User{id: id}, :default) when user_id === id, do: true 23 | def has_user_access?(_current_user, %User{}, :default), do: false 24 | end 25 | 26 | defmodule Schema do 27 | use Absinthe.Schema 28 | 29 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 30 | 31 | def middleware(middleware, field, %{identifier: identifier} = object) 32 | when identifier not in [:query, :mutation] do 33 | Rajska.add_field_authorization(middleware, field, object) 34 | end 35 | 36 | def middleware(middleware, _field, _object), do: middleware 37 | 38 | query do 39 | field :get_user, :user do 40 | arg :id, non_null(:integer) 41 | arg :is_email_public, non_null(:boolean) 42 | 43 | resolve fn args, _ -> 44 | {:ok, %User{ 45 | id: args.id, 46 | name: "bob", 47 | is_email_public: args.is_email_public, 48 | phone: "123456", 49 | email: "bob@email.com", 50 | always_private: "private!", 51 | }} end 52 | end 53 | 54 | field :get_field_scope_user, :field_scope_user do 55 | arg :id, non_null(:integer) 56 | 57 | resolve fn args, _ -> 58 | {:ok, %User{ 59 | id: args.id, 60 | name: "bob", 61 | phone: "123456", 62 | }} end 63 | end 64 | 65 | field :get_not_scoped, :not_scoped do 66 | resolve fn _args, _ -> {:ok, %{phone: "123456"}} end 67 | end 68 | 69 | field :get_both_scopes, :both_scopes do 70 | resolve fn _args, _ -> {:ok, %{phone: "123456"}} end 71 | end 72 | end 73 | 74 | object :user do 75 | meta :scope?, true 76 | 77 | field :name, :string 78 | field :is_email_public, :boolean 79 | 80 | field :phone, :string, meta: [private: true] 81 | field :email, :string, meta: [private: & !&1.is_email_public] 82 | field :always_private, :string, meta: [private: true, rule: :private] 83 | end 84 | 85 | object :field_scope_user do 86 | meta :scope_field?, true 87 | 88 | field :name, :string 89 | field :phone, :string, meta: [private: true] 90 | end 91 | 92 | object :not_scoped do 93 | meta :scope?, false 94 | 95 | field :phone, :string, meta: [private: true] 96 | end 97 | 98 | object :both_scopes do 99 | meta :scope?, true 100 | meta :scope_field?, false 101 | 102 | field :phone, :string, meta: [private: true] 103 | end 104 | end 105 | 106 | test "User can access own fields" do 107 | get_user_query = get_user_query(1, false) 108 | 109 | {:ok, result} = Absinthe.run(get_user_query, __MODULE__.Schema, context(:user, 1)) 110 | 111 | assert %{data: %{"getUser" => data}} = result 112 | refute Map.has_key?(result, :errors) 113 | 114 | assert is_binary(data["name"]) 115 | assert is_binary(data["email"]) 116 | assert is_binary(data["phone"]) 117 | end 118 | 119 | test "Custom rules are applied" do 120 | {:ok, %{ 121 | errors: errors, 122 | data: %{"getUser" => data} 123 | }} = Absinthe.run(get_user_private_query(1), __MODULE__.Schema, context(:user, 1)) 124 | 125 | error_messages = Enum.map(errors, & &1.message) 126 | assert Enum.member?(error_messages, "Not authorized to access field always_private") 127 | 128 | assert is_nil(data["alwaysPrivate"]) 129 | end 130 | 131 | test "User cannot access other user private fields" do 132 | get_user_query = get_user_query(2, false) 133 | 134 | {:ok, %{ 135 | errors: errors, 136 | data: %{"getUser" => data} 137 | }} = Absinthe.run(get_user_query, __MODULE__.Schema, context(:user, 1)) 138 | 139 | error_messages = Enum.map(errors, & &1.message) 140 | assert Enum.member?(error_messages, "Not authorized to access field phone") 141 | assert Enum.member?(error_messages, "Not authorized to access field email") 142 | 143 | assert is_binary(data["name"]) 144 | assert data["phone"] === nil 145 | assert data["email"] === nil 146 | end 147 | 148 | test "Admin can access all fields" do 149 | get_user_query = get_user_query(2, false) 150 | {:ok, result} = Absinthe.run(get_user_query, __MODULE__.Schema, context(:admin, 3)) 151 | 152 | assert %{data: %{"getUser" => data}} = result 153 | refute Map.has_key?(result, :errors) 154 | 155 | assert is_binary(data["name"]) 156 | assert is_binary(data["email"]) 157 | assert is_binary(data["phone"]) 158 | end 159 | 160 | test "Works when defining scope_field?" do 161 | user = %{role: :user, id: 1} 162 | get_user_query = get_field_scope_user(2) 163 | 164 | {:ok, %{ 165 | errors: errors, 166 | data: %{"getFieldScopeUser" => data} 167 | }} = Absinthe.run(get_user_query, __MODULE__.Schema, context: %{current_user: user}) 168 | 169 | error_messages = Enum.map(errors, & &1.message) 170 | assert Enum.member?(error_messages, "Not authorized to access field phone") 171 | 172 | assert is_binary(data["name"]) 173 | assert data["phone"] === nil 174 | end 175 | 176 | test "Works when scoping is disabled" do 177 | {:ok, result} = Absinthe.run("{ getNotScoped { phone } }", __MODULE__.Schema, context(:user, 2)) 178 | 179 | assert %{data: %{"getNotScoped" => data}} = result 180 | assert is_binary(data["phone"]) 181 | refute Map.has_key?(result, :errors) 182 | end 183 | 184 | test "Raises when both scope metas are defined for an object" do 185 | assert_raise RuntimeError, ~r/Error in :both_scopes. If scope_field\? is defined, then scope\? must not be defined/, fn -> 186 | Absinthe.run("{ getBothScopes { phone } }", __MODULE__.Schema, context(:user, 2)) 187 | end 188 | end 189 | 190 | defp get_user_query(id, is_email_public) do 191 | """ 192 | { 193 | getUser(id: #{id}, isEmailPublic: #{is_email_public}) { 194 | name 195 | email 196 | phone 197 | isEmailPublic 198 | } 199 | } 200 | """ 201 | end 202 | 203 | defp get_field_scope_user(id) do 204 | """ 205 | { 206 | getFieldScopeUser(id: #{id}) { 207 | name 208 | phone 209 | } 210 | } 211 | """ 212 | end 213 | 214 | defp get_user_private_query(id) do 215 | """ 216 | { 217 | getUser(id: #{id}, isEmailPublic: true) { 218 | alwaysPrivate 219 | } 220 | } 221 | """ 222 | end 223 | 224 | defp context(role, id), do: [context: %{current_user: %{role: role, id: id}}] 225 | end 226 | -------------------------------------------------------------------------------- /lib/rajska.ex: -------------------------------------------------------------------------------- 1 | defmodule Rajska do 2 | @moduledoc """ 3 | Rajska is an elixir authorization library for [Absinthe](https://github.com/absinthe-graphql/absinthe). 4 | 5 | It provides the following middlewares: 6 | - `Rajska.QueryAuthorization` 7 | - `Rajska.QueryScopeAuthorization` 8 | - `Rajska.ObjectAuthorization` 9 | - `Rajska.ObjectScopeAuthorization` 10 | - `Rajska.FieldAuthorization` 11 | 12 | ## Installation 13 | 14 | The package can be installed by adding `rajska` to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:rajska, "~> 1.3.2"}, 20 | ] 21 | end 22 | ``` 23 | 24 | ## Usage 25 | 26 | Create your Authorization module, which will implement the `Rajska.Authorization` behaviour and contain the logic to validate user permissions and will be called by Rajska middlewares. Rajska provides some helper functions by default, such as `c:Rajska.Authorization.role_authorized?/2` and `c:Rajska.Authorization.has_user_access?/3`, but you can override them with your application needs. 27 | 28 | ```elixir 29 | defmodule Authorization do 30 | use Rajska, 31 | valid_roles: [:user, :admin] 32 | end 33 | ``` 34 | 35 | Available options and their default values: 36 | 37 | ```elixir 38 | valid_roles: [:admin], 39 | super_role: :admin, 40 | default_rule: :default 41 | ``` 42 | 43 | Add your Authorization module to your `Absinthe.Schema` [context/1](https://hexdocs.pm/absinthe/Absinthe.Schema.html#c:context/1) callback and the desired middlewares to the [middleware/3](https://hexdocs.pm/absinthe/Absinthe.Middleware.html#module-the-middleware-3-callback) callback: 44 | 45 | ```elixir 46 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 47 | 48 | def middleware(middleware, field, %Absinthe.Type.Object{identifier: identifier}) 49 | when identifier in [:query, :mutation] do 50 | middleware 51 | |> Rajska.add_query_authorization(field, Authorization) 52 | |> Rajska.add_object_authorization() 53 | end 54 | 55 | def middleware(middleware, field, object) do 56 | Rajska.add_field_authorization(middleware, field, object) 57 | end 58 | ``` 59 | 60 | 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: 61 | 62 | ```elixir 63 | # router.ex 64 | alias Absinthe.Phase.Document.Execution.Resolution 65 | alias Absinthe.Pipeline 66 | alias Rajska.ObjectScopeAuthorization 67 | 68 | forward "/graphql", Absinthe.Plug, 69 | schema: MyProjectWeb.Schema, 70 | socket: MyProjectWeb.UserSocket, 71 | pipeline: {__MODULE__, :pipeline} # Add this line 72 | 73 | def pipeline(config, pipeline_opts) do 74 | config 75 | |> Map.fetch!(:schema_mod) 76 | |> Pipeline.for_document(pipeline_opts) 77 | |> Pipeline.insert_after(Resolution, ObjectScopeAuthorization) 78 | end 79 | ``` 80 | 81 | Since Scope Authorization middleware must be used with Query Authorization, it is automatically called when adding the former. 82 | """ 83 | 84 | alias Rajska.Authorization 85 | 86 | defmacro __using__(opts \\ []) do 87 | super_role = Keyword.get(opts, :super_role, :admin) 88 | valid_roles = Keyword.get(opts, :valid_roles, [super_role]) 89 | default_rule = Keyword.get(opts, :default_rule, :default) 90 | 91 | quote do 92 | @behaviour Authorization 93 | 94 | @spec config() :: Keyword.t() 95 | def config do 96 | Keyword.merge(unquote(opts), [ 97 | valid_roles: unquote(valid_roles), 98 | super_role: unquote(super_role), 99 | default_rule: unquote(default_rule) 100 | ]) 101 | end 102 | 103 | def get_current_user(%{current_user: current_user}), do: current_user 104 | 105 | def get_ip(%{ip: ip}), do: ip 106 | 107 | def get_user_role(%{role: role}), do: role 108 | def get_user_role(nil), do: nil 109 | 110 | def default_rule, do: unquote(default_rule) 111 | 112 | def valid_roles, do: [:all | unquote(valid_roles)] 113 | 114 | def not_scoped_roles, do: [:all, unquote(super_role)] 115 | 116 | defguard is_super_role(role) when role === unquote(super_role) 117 | 118 | def super_role?(role) when is_super_role(role), do: true 119 | def super_role?(_user_role), do: false 120 | 121 | def role_authorized?(_user_role, :all), do: true 122 | def role_authorized?(role, _allowed_role) when is_super_role(role), do: true 123 | def role_authorized?(user_role, allowed_role) when is_atom(allowed_role), do: user_role === allowed_role 124 | def role_authorized?(user_role, allowed_roles) when is_list(allowed_roles), do: user_role in allowed_roles 125 | 126 | def has_user_access?(%user_struct{id: user_id} = current_user, %scope{} = struct, unquote(default_rule)) do 127 | super_user? = current_user |> get_user_role() |> super_role?() 128 | owner? = (user_struct === scope) && (user_id === struct.id) 129 | 130 | super_user? || owner? 131 | end 132 | 133 | def unauthorized_message(_resolution), do: "unauthorized" 134 | 135 | def unauthorized_query_scope_message(_resolution, object_type) do 136 | "Not authorized to access this #{replace_underscore(object_type)}" 137 | end 138 | 139 | defp replace_underscore(string) when is_binary(string), do: String.replace(string, "_", " ") 140 | 141 | defp replace_underscore(atom) when is_atom(atom) do 142 | atom 143 | |> Atom.to_string() 144 | |> replace_underscore() 145 | end 146 | 147 | def unauthorized_object_scope_message(_result_object, object) do 148 | "Not authorized to access object #{object.identifier}" 149 | end 150 | 151 | def unauthorized_object_message(_resolution, object), do: "Not authorized to access object #{object.identifier}" 152 | 153 | def unauthorized_field_message(_resolution, field), do: "Not authorized to access field #{field}" 154 | 155 | def super_user?(context) do 156 | context 157 | |> get_current_user() 158 | |> get_user_role() 159 | |> super_role?() 160 | end 161 | 162 | def context_role_authorized?(context, allowed_role) do 163 | context 164 | |> get_current_user() 165 | |> get_user_role() 166 | |> role_authorized?(allowed_role) 167 | end 168 | 169 | def context_user_authorized?(context, scoped_struct, rule) do 170 | context 171 | |> get_current_user() 172 | |> has_user_access?(scoped_struct, rule) 173 | end 174 | 175 | defoverridable Authorization 176 | end 177 | end 178 | 179 | @doc false 180 | def apply_auth_mod(context, fnc_name, args \\ []) 181 | 182 | def apply_auth_mod(%{authorization: authorization}, fnc_name, args) do 183 | apply(authorization, fnc_name, args) 184 | end 185 | 186 | def apply_auth_mod(_context, _fnc_name, _args) do 187 | raise "Rajska authorization module not found in Absinthe's context" 188 | end 189 | 190 | defdelegate add_query_authorization(middleware, field, authorization), to: Rajska.Schema 191 | defdelegate add_object_authorization(middleware), to: Rajska.Schema 192 | defdelegate add_field_authorization(middleware, field, object), to: Rajska.Schema 193 | end 194 | -------------------------------------------------------------------------------- /test/middlewares/object_authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.ObjectAuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Authorization do 5 | use Rajska, 6 | valid_roles: [:user, :admin], 7 | super_role: :admin 8 | end 9 | 10 | defmodule Schema do 11 | use Absinthe.Schema 12 | 13 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 14 | 15 | def middleware(middleware, field, %Absinthe.Type.Object{identifier: identifier}) 16 | when identifier in [:query, :mutation] do 17 | middleware 18 | |> Rajska.add_query_authorization(field, Authorization) 19 | |> Rajska.add_object_authorization() 20 | end 21 | 22 | def middleware(middleware, _field, _object), do: middleware 23 | 24 | query do 25 | field :all_query, :user do 26 | middleware Rajska.QueryAuthorization, permit: :all 27 | resolve fn _, _ -> 28 | {:ok, %{ 29 | name: "bob", 30 | company: %{name: "company"}, 31 | wallet_balance: %{total: 10} 32 | }} 33 | end 34 | end 35 | 36 | field :user_query, :user do 37 | middleware Rajska.QueryAuthorization, [permit: :user, scope: false] 38 | resolve fn _, _ -> 39 | {:ok, %{ 40 | name: "bob", 41 | company: %{name: "company"}, 42 | wallet_balance: %{total: 10} 43 | }} 44 | end 45 | end 46 | 47 | field :enum_query, :role_enum do 48 | middleware Rajska.QueryAuthorization, [permit: :all, scope: false] 49 | resolve fn _, _ -> 50 | {:ok, :user} 51 | end 52 | end 53 | 54 | field :union_query, :union do 55 | middleware Rajska.QueryAuthorization, [permit: :all, scope: false] 56 | resolve fn _, _ -> 57 | {:ok, %{name: "bob"}} 58 | end 59 | end 60 | end 61 | 62 | object :wallet_balance do 63 | meta :authorize, :admin 64 | 65 | field :total, :integer 66 | end 67 | 68 | object :company do 69 | meta :authorize, :user 70 | 71 | field :name, :string 72 | 73 | field :wallet_balance, :wallet_balance 74 | end 75 | 76 | enum :role_enum do 77 | value :user 78 | value :admin 79 | end 80 | 81 | object :user do 82 | meta :authorize, :all 83 | 84 | field :email, :string 85 | field :name, :string 86 | 87 | field :company, :company 88 | end 89 | 90 | union :union do 91 | types [:wallet_balance, :user] 92 | resolve_type fn 93 | %{name: _}, _ -> :user 94 | %{total: _}, _ -> :wallet_balance 95 | end 96 | end 97 | end 98 | 99 | test "Public query with public object works for everyone" do 100 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: nil}) 101 | assert %{data: %{"allQuery" => %{}}} = result 102 | refute Map.has_key?(result, :errors) 103 | 104 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 105 | assert %{data: %{"allQuery" => %{}}} = result 106 | refute Map.has_key?(result, :errors) 107 | 108 | {:ok, result} = Absinthe.run(all_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 109 | assert %{data: %{"allQuery" => %{}}} = result 110 | refute Map.has_key?(result, :errors) 111 | end 112 | 113 | test "Public query with user object works for user" do 114 | {:ok, result} = Absinthe.run(all_query_with_user_object(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 115 | 116 | assert %{data: %{"allQuery" => %{}}} = result 117 | refute Map.has_key?(result, :errors) 118 | end 119 | 120 | test "Public query with user object fails for unauthenticated user" do 121 | assert {:ok, %{errors: errors}} = Absinthe.run(all_query_with_user_object(), __MODULE__.Schema, context: %{current_user: nil}) 122 | assert [ 123 | %{ 124 | locations: [%{column: 3, line: 2}], 125 | message: "Not authorized to access object company", 126 | path: ["allQuery"] 127 | } 128 | ] == errors 129 | end 130 | 131 | test "User query with user object works for user" do 132 | {:ok, result} = Absinthe.run(user_query_with_user_object(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 133 | 134 | assert %{data: %{"userQuery" => %{}}} = result 135 | refute Map.has_key?(result, :errors) 136 | end 137 | 138 | test "User query with user object works for admin" do 139 | {:ok, result} = Absinthe.run(user_query_with_user_object(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 140 | 141 | assert %{data: %{"userQuery" => %{}}} = result 142 | refute Map.has_key?(result, :errors) 143 | end 144 | 145 | test "User query with admin object fails for user" do 146 | assert {:ok, %{errors: errors}} = Absinthe.run(user_query_with_admin_object(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 147 | assert [ 148 | %{ 149 | locations: [%{column: 3, line: 2}], 150 | message: "Not authorized to access object wallet_balance", 151 | path: ["userQuery"] 152 | } 153 | ] == errors 154 | end 155 | 156 | test "User query with admin object works for admin" do 157 | {:ok, result} = Absinthe.run(user_query_with_admin_object(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 158 | 159 | assert %{data: %{"userQuery" => %{}}} = result 160 | refute Map.has_key?(result, :errors) 161 | end 162 | 163 | test "Query that returns an enum doesn't return errors" do 164 | {:ok, result} = Absinthe.run(enum_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 165 | 166 | assert %{data: %{"enumQuery" => "USER"}} = result 167 | refute Map.has_key?(result, :errors) 168 | end 169 | 170 | test "Works for union types" do 171 | {:ok, result} = Absinthe.run(union_query(), __MODULE__.Schema, context: %{current_user: %{role: :admin}}) 172 | 173 | assert %{data: %{"unionQuery" => %{"name" => "bob"}}} = result 174 | refute Map.has_key?(result, :errors) 175 | end 176 | 177 | test "Works when using fragments and user has access" do 178 | {:ok, result} = Absinthe.run(fragment_query_user(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 179 | 180 | assert %{data: %{"userQuery" => %{"name" => "bob", "company" => %{}}}} = result 181 | refute Map.has_key?(result, :errors) 182 | end 183 | 184 | test "Returns error when using fragments and user does not have access" do 185 | assert {:ok, %{errors: errors}} = Absinthe.run(fragment_query_admin(), __MODULE__.Schema, context: %{current_user: %{role: :user}}) 186 | assert [ 187 | %{ 188 | locations: [%{column: 3, line: 13}], 189 | message: "Not authorized to access object wallet_balance", 190 | path: ["userQuery"] 191 | } 192 | ] == errors 193 | end 194 | 195 | test "does not apply when resolution is already resolved" do 196 | resolution = %Absinthe.Resolution{state: :resolved} 197 | assert resolution == Rajska.ObjectAuthorization.call(resolution, []) 198 | end 199 | 200 | defp all_query do 201 | """ 202 | { 203 | allQuery { 204 | name 205 | email 206 | } 207 | } 208 | """ 209 | end 210 | 211 | defp all_query_with_user_object do 212 | """ 213 | { 214 | allQuery { 215 | name 216 | email 217 | company { 218 | name 219 | } 220 | } 221 | } 222 | """ 223 | end 224 | 225 | defp user_query_with_user_object do 226 | """ 227 | { 228 | userQuery { 229 | name 230 | email 231 | company { name } 232 | } 233 | } 234 | """ 235 | end 236 | 237 | defp user_query_with_admin_object do 238 | """ 239 | { 240 | userQuery { 241 | name 242 | email 243 | company { 244 | name 245 | walletBalance { total } 246 | } 247 | } 248 | } 249 | """ 250 | end 251 | 252 | defp enum_query, do: "{ enumQuery }" 253 | 254 | defp union_query do 255 | """ 256 | { 257 | unionQuery { 258 | ... on User { 259 | name 260 | } 261 | ... on WalletBalance { 262 | total 263 | } 264 | } 265 | } 266 | """ 267 | end 268 | 269 | defp fragment_query_user do 270 | """ 271 | fragment userFields on User { 272 | name 273 | company { 274 | name 275 | } 276 | } 277 | { 278 | userQuery { 279 | ...userFields 280 | } 281 | } 282 | """ 283 | end 284 | 285 | defp fragment_query_admin do 286 | """ 287 | fragment companyFields on Company { 288 | walletBalance { 289 | total 290 | } 291 | } 292 | fragment userFields on User { 293 | name 294 | company { 295 | ...companyFields 296 | } 297 | } 298 | { 299 | userQuery { 300 | ...userFields 301 | } 302 | } 303 | """ 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /test/middlewares/query_scope_authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.QueryScopeAuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule User do 5 | defstruct [ 6 | id: 1, 7 | name: "User", 8 | email: "email@user.com" 9 | ] 10 | end 11 | 12 | defmodule BankAccount do 13 | defstruct [ 14 | id: 1, 15 | total: 5, 16 | ] 17 | end 18 | 19 | defmodule Authorization do 20 | use Rajska, 21 | valid_roles: [:user, :admin], 22 | super_role: :admin 23 | 24 | def has_user_access?(%{role: :admin}, %User{}, :default), do: true 25 | def has_user_access?(%{id: user_id}, %User{id: id}, :default) when user_id === id, do: true 26 | def has_user_access?(_current_user, %User{}, :default), do: false 27 | 28 | def has_user_access?(_current_user, %BankAccount{}, :edit), do: false 29 | def has_user_access?(_current_user, %BankAccount{}, :read_only), do: true 30 | end 31 | 32 | defmodule Schema do 33 | use Absinthe.Schema 34 | 35 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 36 | 37 | def middleware(middleware, field, %Absinthe.Type.Object{identifier: identifier}) 38 | when identifier in [:query, :mutation] do 39 | Rajska.add_query_authorization(middleware, field, Authorization) 40 | end 41 | 42 | def middleware(middleware, _field, _object), do: middleware 43 | 44 | query do 45 | field :user_scoped_query, :user do 46 | arg :id, non_null(:integer) 47 | 48 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User] 49 | resolve fn _, _ -> 50 | {:ok, %{ 51 | name: "bob", 52 | bank_account: %{id: 1, total: 10} 53 | }} end 54 | end 55 | 56 | field :custom_arg_scoped_query, :user do 57 | arg :user_id, non_null(:integer) 58 | 59 | middleware Rajska.QueryAuthorization, [ 60 | permit: :user, 61 | scope: User, 62 | args: %{id: :user_id} 63 | ] 64 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 65 | end 66 | 67 | field :custom_nested_arg_scoped_query, :user do 68 | arg :params, non_null(:user_params) 69 | 70 | middleware Rajska.QueryAuthorization, [ 71 | permit: :user, 72 | scope: User, 73 | args: %{id: [:params, :id]} 74 | ] 75 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 76 | end 77 | 78 | field :custom_nested_optional_arg_scoped_query, :user do 79 | arg :params, non_null(:user_params) 80 | 81 | middleware Rajska.QueryAuthorization, [ 82 | permit: :user, 83 | scope: User, 84 | args: %{id: [:params, :id]}, 85 | optional: true 86 | ] 87 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 88 | end 89 | 90 | field :not_scoped_query, :user do 91 | arg :id, non_null(:integer) 92 | 93 | middleware Rajska.QueryAuthorization, [permit: :user, scope: false] 94 | resolve fn _, _ -> {:ok, %{name: "bob"}} end 95 | end 96 | 97 | field :scoped_bank_account_update_mutation, :bank_account do 98 | arg :id, :integer 99 | arg :params, :bank_account_params 100 | 101 | middleware Rajska.QueryAuthorization, [permit: :user, scope: BankAccount, rule: :edit] 102 | resolve fn _, _ -> {:ok, %{total: 100}} end 103 | end 104 | end 105 | 106 | object :user do 107 | field :email, :string 108 | field :name, :string 109 | field :bank_account, :bank_account 110 | end 111 | 112 | object :bank_account do 113 | meta :scope, {BankAccount, :user_id} 114 | meta :rule, :read_only 115 | 116 | field :total, :integer 117 | end 118 | 119 | input_object :user_params do 120 | field :id, :integer 121 | end 122 | 123 | input_object :bank_account_params do 124 | field :total, :integer 125 | end 126 | end 127 | 128 | test "User can see bank_account but not edit it" do 129 | user = %{role: :user, id: 1} 130 | 131 | {:ok, success_result} = Absinthe.run(user_scoped_query(1), __MODULE__.Schema, context: %{current_user: user}) 132 | 133 | refute Map.has_key?(success_result, :errors) 134 | 135 | assert {:ok, 136 | %{ 137 | errors: [ 138 | %{ 139 | message: "Not authorized to access this bank account", 140 | } 141 | ] 142 | } 143 | } = Absinthe.run(scoped_bank_account_update_mutation(1), __MODULE__.Schema, context: %{current_user: user}) 144 | end 145 | 146 | test "User scoped query works for own user" do 147 | user = %{role: :user, id: 1} 148 | user_scoped_query = user_scoped_query(1) 149 | 150 | {:ok, result} = Absinthe.run(user_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 151 | 152 | assert %{data: %{"userScopedQuery" => %{}}} = result 153 | refute Map.has_key?(result, :errors) 154 | end 155 | 156 | test "User scoped query works for admin user" do 157 | user = %{role: :admin, id: 3} 158 | user_scoped_query = user_scoped_query(1) 159 | 160 | {:ok, result} = Absinthe.run(user_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 161 | 162 | assert %{data: %{"userScopedQuery" => %{}}} = result 163 | refute Map.has_key?(result, :errors) 164 | end 165 | 166 | test "User scoped query fails for different user" do 167 | user = %{role: :user, id: 2} 168 | user_scoped_query = user_scoped_query(1) 169 | 170 | assert {:ok, %{errors: errors}} = Absinthe.run(user_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 171 | assert [ 172 | %{ 173 | locations: [%{column: 3, line: 1}], 174 | message: "Not authorized to access this user", 175 | path: ["userScopedQuery"] 176 | } 177 | ] == errors 178 | end 179 | 180 | test "User scoped query with custom argument works for own user" do 181 | user = %{role: :user, id: 1} 182 | custom_arg_scoped_query = custom_arg_scoped_query(1) 183 | 184 | {:ok, result} = Absinthe.run(custom_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 185 | 186 | assert %{data: %{"customArgScopedQuery" => %{}}} = result 187 | refute Map.has_key?(result, :errors) 188 | end 189 | 190 | test "User scoped query with custom nested argument works for own user" do 191 | user = %{role: :user, id: 1} 192 | custom_nested_arg_scoped_query = custom_nested_arg_scoped_query(1) 193 | 194 | {:ok, result} = Absinthe.run(custom_nested_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 195 | 196 | assert %{data: %{"customNestedArgScopedQuery" => %{}}} = result 197 | refute Map.has_key?(result, :errors) 198 | end 199 | 200 | test "User scoped query with custom nested argument fails for own user if argument is not provided" do 201 | user = %{role: :user, id: 1} 202 | custom_nested_arg_scoped_query = custom_nested_arg_scoped_query(nil) 203 | 204 | assert_raise RuntimeError, "Error in query customNestedArgScopedQuery: no argument [:params, :id] found in %{params: %{id: nil}}", fn -> 205 | Absinthe.run(custom_nested_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 206 | end 207 | end 208 | 209 | test "User scoped query with custom optional nested argument works for own user if argument is not provided" do 210 | user = %{role: :user, id: 1} 211 | custom_nested_optional_arg_scoped_query = custom_nested_optional_arg_scoped_query(nil) 212 | 213 | {:ok, result} = Absinthe.run(custom_nested_optional_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 214 | 215 | assert %{data: %{"customNestedOptionalArgScopedQuery" => %{}}} = result 216 | refute Map.has_key?(result, :errors) 217 | end 218 | 219 | test "User scoped query with custom optional nested argument works for own user if argument is provided" do 220 | user = %{role: :user, id: 1} 221 | custom_nested_optional_arg_scoped_query = custom_nested_optional_arg_scoped_query(1) 222 | 223 | {:ok, result} = Absinthe.run(custom_nested_optional_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 224 | 225 | assert %{data: %{"customNestedOptionalArgScopedQuery" => %{}}} = result 226 | refute Map.has_key?(result, :errors) 227 | end 228 | 229 | test "User scoped query with custom argument works for admin user" do 230 | user = %{role: :admin, id: 3} 231 | custom_arg_scoped_query = custom_arg_scoped_query(1) 232 | 233 | {:ok, result} = Absinthe.run(custom_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 234 | 235 | assert %{data: %{"customArgScopedQuery" => %{}}} = result 236 | refute Map.has_key?(result, :errors) 237 | end 238 | 239 | test "User scoped query with custom argument fails for different user" do 240 | user = %{role: :user, id: 2} 241 | custom_arg_scoped_query = custom_arg_scoped_query(1) 242 | 243 | assert {:ok, %{errors: errors}} = Absinthe.run(custom_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 244 | assert [ 245 | %{ 246 | locations: [%{column: 3, line: 1}], 247 | message: "Not authorized to access this user", 248 | path: ["customArgScopedQuery"] 249 | } 250 | ] == errors 251 | end 252 | 253 | test "User scoped query with custom nested argument fails for different user" do 254 | user = %{role: :user, id: 2} 255 | custom_nested_arg_scoped_query = custom_nested_arg_scoped_query(1) 256 | 257 | assert {:ok, %{errors: errors}} = Absinthe.run(custom_nested_arg_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 258 | assert [ 259 | %{ 260 | locations: [%{column: 3, line: 1}], 261 | message: "Not authorized to access this user", 262 | path: ["customNestedArgScopedQuery"] 263 | } 264 | ] == errors 265 | end 266 | 267 | test "Not scoped query works for any user" do 268 | not_scoped_query = not_scoped_query(1) 269 | 270 | user = %{role: :user, id: 1} 271 | assert {:ok, result} = Absinthe.run(not_scoped_query, __MODULE__.Schema, context: %{current_user: user}) 272 | assert %{data: %{"notScopedQuery" => %{}}} = result 273 | refute Map.has_key?(result, :errors) 274 | 275 | user2 = %{role: :user, id: 2} 276 | assert {:ok, result2} = Absinthe.run(not_scoped_query, __MODULE__.Schema, context: %{current_user: user2}) 277 | assert %{data: %{"notScopedQuery" => %{}}} = result2 278 | refute Map.has_key?(result2, :errors) 279 | 280 | admin = %{role: :admin, id: 3} 281 | assert {:ok, admin_result} = Absinthe.run(not_scoped_query, __MODULE__.Schema, context: %{current_user: admin}) 282 | assert %{data: %{"notScopedQuery" => %{}}} = admin_result 283 | refute Map.has_key?(admin_result, :errors) 284 | end 285 | 286 | defp user_scoped_query(user_id) do 287 | """ 288 | { userScopedQuery(id: #{user_id}) { name email bank_account { total } } } 289 | """ 290 | end 291 | 292 | def custom_arg_scoped_query(user_id) do 293 | """ 294 | { customArgScopedQuery(userId: #{user_id}) { name email } } 295 | """ 296 | end 297 | 298 | def custom_nested_arg_scoped_query(user_id) do 299 | user_id = if user_id == nil, do: "null", else: user_id 300 | """ 301 | { customNestedArgScopedQuery(params: {id: #{user_id}}) { name email } } 302 | """ 303 | end 304 | 305 | def custom_nested_optional_arg_scoped_query(user_id) do 306 | user_id = if user_id == nil, do: "null", else: user_id 307 | """ 308 | { customNestedOptionalArgScopedQuery(params: {id: #{user_id}}) { name email } } 309 | """ 310 | end 311 | 312 | def not_scoped_query(user_id) do 313 | """ 314 | { notScopedQuery(id: #{user_id}) { name email } } 315 | """ 316 | end 317 | 318 | def scoped_bank_account_update_mutation(bank_account_id) do 319 | bank_account_id = if bank_account_id == nil, do: "null", else: bank_account_id 320 | """ 321 | { scopedBankAccountUpdateMutation(id: #{bank_account_id}, params: {total: 100}) { total } } 322 | """ 323 | end 324 | end 325 | -------------------------------------------------------------------------------- /test/middlewares/schema_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.SchemaTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Authorization do 5 | use Rajska, 6 | valid_roles: [:user, :admin], 7 | super_role: :admin 8 | end 9 | 10 | defmodule User do 11 | defstruct [ 12 | id: 1, 13 | name: "User", 14 | email: "email@user.com" 15 | ] 16 | end 17 | 18 | defmodule NotStruct do 19 | def hello, do: :world 20 | end 21 | 22 | test "Raises if no permission is specified for a query" do 23 | assert_raise RuntimeError, ~r/No permission specified for query get_user/, fn -> 24 | defmodule SchemaNoPermission do 25 | use Absinthe.Schema 26 | 27 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 28 | 29 | def middleware(middleware, field, %{identifier: identifier}) 30 | when identifier in [:query, :mutation] do 31 | Rajska.add_query_authorization(middleware, field, Authorization) 32 | end 33 | 34 | def middleware(middleware, _field, _object), do: middleware 35 | 36 | query do 37 | field :get_user, :string do 38 | resolve fn _args, _info -> {:ok, "bob"} end 39 | end 40 | end 41 | end 42 | end 43 | end 44 | 45 | test "Raises in compile time if no scope key is specified for a scope role" do 46 | assert_raise( 47 | RuntimeError, 48 | ~r/Query get_user is configured incorrectly, :scope option must be present for role :user/, 49 | fn -> 50 | defmodule SchemaNoScope do 51 | use Absinthe.Schema 52 | 53 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 54 | 55 | def middleware(middleware, field, %{identifier: identifier}) 56 | when identifier in [:query, :mutation] do 57 | Rajska.add_query_authorization(middleware, field, Authorization) 58 | end 59 | 60 | def middleware(middleware, _field, _object), do: middleware 61 | 62 | query do 63 | field :get_user, :string do 64 | middleware Rajska.QueryAuthorization, permit: :user 65 | resolve fn _args, _info -> {:ok, "bob"} end 66 | end 67 | end 68 | end 69 | end 70 | ) 71 | end 72 | 73 | test "Raises in runtime if no scope key is specified for a scope role" do 74 | assert_raise( 75 | RuntimeError, 76 | ~r/Error in query getUser: no scope argument found in middleware Scope Authorization/, 77 | fn -> 78 | defmodule SchemaNoScopeRuntime do 79 | use Absinthe.Schema 80 | 81 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 82 | 83 | query do 84 | field :get_user, :string do 85 | middleware Rajska.QueryAuthorization, permit: :user 86 | resolve fn _args, _info -> {:ok, "bob"} end 87 | end 88 | end 89 | end 90 | 91 | {:ok, _result} = Absinthe.run("{ getUser }", SchemaNoScopeRuntime, context: %{current_user: %{role: :user}}) 92 | end 93 | ) 94 | end 95 | 96 | test "Raises if no permit key is specified for a query" do 97 | assert_raise RuntimeError, ~r/Query get_user is configured incorrectly, :permit option must be present/, fn -> 98 | defmodule SchemaNoPermit do 99 | use Absinthe.Schema 100 | 101 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 102 | 103 | def middleware(middleware, field, %{identifier: identifier}) 104 | when identifier in [:query, :mutation] do 105 | Rajska.add_query_authorization(middleware, field, Authorization) 106 | end 107 | 108 | def middleware(middleware, _field, _object), do: middleware 109 | 110 | query do 111 | field :get_user, :string do 112 | middleware Rajska.QueryAuthorization, permt: :all 113 | resolve fn _args, _info -> {:ok, "bob"} end 114 | end 115 | end 116 | end 117 | end 118 | end 119 | 120 | test "Raises if scope module is not a struct" do 121 | assert_raise( 122 | RuntimeError, 123 | ~r/Query get_user is configured incorrectly, :scope option Rajska.SchemaTest.NotStruct is not a struct/, 124 | fn -> 125 | defmodule SchemaNoStruct do 126 | use Absinthe.Schema 127 | 128 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 129 | 130 | def middleware(middleware, field, %{identifier: identifier}) 131 | when identifier in [:query, :mutation] do 132 | Rajska.add_query_authorization(middleware, field, Authorization) 133 | end 134 | 135 | def middleware(middleware, _field, _object), do: middleware 136 | 137 | query do 138 | field :get_user, :string do 139 | middleware Rajska.QueryAuthorization, [permit: :user, scope: NotStruct] 140 | resolve fn _args, _info -> {:ok, "bob"} end 141 | end 142 | end 143 | end 144 | end 145 | ) 146 | end 147 | 148 | @args_option_test_cases [ 149 | {false, :arg}, 150 | {false, [:arg1, :arg2]}, 151 | {false, %{arg: :arg}}, 152 | {false, %{arg: [:arg]}}, 153 | # This is not the correct usage of &Access.all/0 that is going to be used in the Kernel.get_in/2 in 154 | # Rajska.QueryScopeAuthorization.get_scope_field_value/2, but this is necessary because it's not possible to use 155 | # Access.all() (the correct usage). But for testing purpose only this is fine. 156 | {false, %{arg: [&Access.all/0, :arg]}}, 157 | {true, "args"}, 158 | {true, 1}, 159 | {true, ["args"]}, 160 | {true, [1]}, 161 | {true, %{arg: ["arg"]}}, 162 | {true, %{arg: [1]}}, 163 | {true, [&Access.all/0, :arg]}, 164 | ] 165 | 166 | for {{should_raise, args_value}, index} <- Enum.with_index(@args_option_test_cases) do 167 | @should_raise should_raise 168 | @args_value args_value 169 | @index index 170 | @base_message "if args option is #{inspect(args_value)}" 171 | @message if should_raise, do: "Raises #{@base_message}", else: "Not raises #{@base_message}" 172 | 173 | test @message do 174 | args_value = @args_value 175 | index = @index 176 | 177 | define_query_fn = fn -> 178 | defmodule String.to_atom("SchemaInvalidArgs#{index}") do 179 | use Absinthe.Schema 180 | 181 | @args_value args_value 182 | 183 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 184 | 185 | def middleware(middleware, field, %{identifier: identifier}) 186 | when identifier in [:query, :mutation] do 187 | Rajska.add_query_authorization(middleware, field, Authorization) 188 | end 189 | 190 | def middleware(middleware, _field, _object), do: middleware 191 | 192 | query do 193 | field :get_user, :string do 194 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User, args: @args_value] 195 | resolve fn _args, _info -> {:ok, "bob"} end 196 | end 197 | end 198 | end 199 | end 200 | 201 | if @should_raise do 202 | invalid_value = if is_map(@args_value), do: @args_value[:arg], else: @args_value 203 | escaped_invalid_value = invalid_value |> inspect() |> Regex.escape() 204 | 205 | assert_raise( 206 | RuntimeError, 207 | ~r/Query get_user is configured incorrectly, the following args option is invalid: #{escaped_invalid_value}/, 208 | define_query_fn 209 | ) 210 | else 211 | define_query_fn.() 212 | end 213 | end 214 | end 215 | 216 | test "Raises if optional option is not a boolean" do 217 | assert_raise( 218 | RuntimeError, 219 | ~r/Query get_user is configured incorrectly, :optional option must be a boolean./, 220 | fn -> 221 | defmodule SchemaInvalidOptional do 222 | use Absinthe.Schema 223 | 224 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 225 | 226 | def middleware(middleware, field, %{identifier: identifier}) 227 | when identifier in [:query, :mutation] do 228 | Rajska.add_query_authorization(middleware, field, Authorization) 229 | end 230 | 231 | def middleware(middleware, _field, _object), do: middleware 232 | 233 | query do 234 | field :get_user, :string do 235 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User, optional: :invalid] 236 | resolve fn _args, _info -> {:ok, "bob"} end 237 | end 238 | end 239 | end 240 | end 241 | ) 242 | end 243 | 244 | test "Raises if rule option is not an atom" do 245 | assert_raise( 246 | RuntimeError, 247 | ~r/Query get_user is configured incorrectly, :rule option must be an atom./, 248 | fn -> 249 | defmodule SchemaInvalidRule do 250 | use Absinthe.Schema 251 | 252 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 253 | 254 | def middleware(middleware, field, %{identifier: identifier}) 255 | when identifier in [:query, :mutation] do 256 | Rajska.add_query_authorization(middleware, field, Authorization) 257 | end 258 | 259 | def middleware(middleware, _field, _object), do: middleware 260 | 261 | query do 262 | field :get_user, :string do 263 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User, rule: 4] 264 | resolve fn _args, _info -> {:ok, "bob"} end 265 | end 266 | end 267 | end 268 | end 269 | ) 270 | end 271 | 272 | test "Raises if no authorization module is found in absinthe's context" do 273 | assert_raise RuntimeError, ~r/Rajska authorization module not found in Absinthe's context/, fn -> 274 | defmodule Schema do 275 | use Absinthe.Schema 276 | 277 | def middleware(middleware, field, %{identifier: identifier}) 278 | when identifier in [:query, :mutation] do 279 | Rajska.add_query_authorization(middleware, field, Authorization) 280 | end 281 | 282 | def middleware(middleware, _field, _object), do: middleware 283 | 284 | query do 285 | field :get_user, :string do 286 | middleware Rajska.QueryAuthorization, permit: :all 287 | resolve fn _args, _info -> {:ok, "bob"} end 288 | end 289 | end 290 | end 291 | 292 | {:ok, _result} = Absinthe.run("{ getUser }", Schema, context: %{current_user: nil}) 293 | end 294 | end 295 | 296 | test "Does not break and skips middleware check for subscriptions" do 297 | defmodule SchemaWithSubscription do 298 | use Absinthe.Schema 299 | 300 | def middleware(middleware, field, %{identifier: identifier}) 301 | when identifier in [:query, :mutation] do 302 | Rajska.add_query_authorization(middleware, field, Authorization) 303 | end 304 | 305 | def middleware(middleware, _field, _object), do: middleware 306 | 307 | object :user do 308 | field :email, :string 309 | field :name, :string 310 | end 311 | 312 | mutation do 313 | field :create_user, :user do 314 | middleware Rajska.QueryAuthorization, permit: :user, scope: false 315 | resolve fn _args, _info -> {:ok, %{email: "email", name: "name"}} end 316 | end 317 | end 318 | 319 | query do 320 | field :get_user, :user do 321 | middleware Rajska.QueryAuthorization, permit: :user, scope: false 322 | resolve fn _args, _info -> {:ok, %{email: "email", name: "name"}} end 323 | end 324 | end 325 | 326 | subscription do 327 | field :new_users, :user do 328 | arg :email, non_null(:string) 329 | 330 | config fn args, _info -> {:ok, topic: args.email} end 331 | end 332 | end 333 | end 334 | end 335 | 336 | test "Adds object authorization after query authorization" do 337 | defmodule SchemaQueryAndObjectAuthorization do 338 | use Absinthe.Schema 339 | 340 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 341 | 342 | def middleware(middleware, field, %{identifier: identifier}) 343 | when identifier in [:query, :mutation] do 344 | middleware 345 | |> Rajska.add_query_authorization(field, Authorization) 346 | |> Rajska.add_object_authorization() 347 | |> check_middlewares() 348 | end 349 | 350 | def middleware(middleware, _field, _object), do: middleware 351 | 352 | def check_middlewares(middlewares) do 353 | assert [ 354 | {Absinthe.Middleware.Telemetry, []}, 355 | {{Rajska.QueryAuthorization, :call}, [permit: :all]}, 356 | Rajska.ObjectAuthorization, 357 | {{Absinthe.Resolution, :call}, _fn} 358 | ] = middlewares 359 | end 360 | 361 | query do 362 | field :get_user, :string do 363 | middleware Rajska.QueryAuthorization, permit: :all 364 | resolve fn _args, _info -> {:ok, "bob"} end 365 | end 366 | end 367 | end 368 | end 369 | 370 | test "Adds object authorization before resolution when there is no query authorization" do 371 | defmodule SchemaObjectAuthorization do 372 | use Absinthe.Schema 373 | 374 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 375 | 376 | def middleware(middleware, _field, %{identifier: identifier}) 377 | when identifier in [:query, :mutation] do 378 | middleware 379 | |> Rajska.add_object_authorization() 380 | |> check_middlewares() 381 | end 382 | 383 | def middleware(middleware, _field, _object), do: middleware 384 | 385 | def check_middlewares(middlewares) do 386 | assert [ 387 | {Absinthe.Middleware.Telemetry, []}, 388 | Rajska.ObjectAuthorization, 389 | {{Absinthe.Resolution, :call}, _fn} 390 | ] = middlewares 391 | end 392 | 393 | query do 394 | field :get_user, :string do 395 | resolve fn _args, _info -> {:ok, "bob"} end 396 | end 397 | end 398 | end 399 | end 400 | end 401 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rajska 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/jungsoft/rajska/badge.svg?branch=master)](https://coveralls.io/github/jungsoft/rajska?branch=master) 4 | 5 | Rajska is an elixir authorization library for [Absinthe](https://github.com/absinthe-graphql/absinthe). 6 | 7 | It provides the following middlewares: 8 | 9 | - [Query Authorization](#query-authorization) 10 | - [Query Scope Authorization](#query-scope-authorization) 11 | - [Object Authorization](#object-authorization) 12 | - [Object Scope Authorization](#object-scope-authorization) 13 | - [Field Authorization](#field-authorization) 14 | - [Rate Limiter](#rate-limiter) 15 | 16 | Documentation can be found at [https://hexdocs.pm/rajska/](https://hexdocs.pm/rajska). 17 | 18 | ## Installation 19 | 20 | The package can be installed by adding `rajska` to your list of dependencies in `mix.exs`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:rajska, "~> 1.3.2"}, 26 | ] 27 | end 28 | ``` 29 | 30 | ## Usage 31 | 32 | Create your Authorization module, which will implement the [Rajska Authorization](https://hexdocs.pm/rajska/Rajska.Authorization.html) behaviour and contain the logic to validate user permissions and will be called by Rajska middlewares. Rajska provides some helper functions by default, such as [role_authorized?/2](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:role_authorized?/2) and [has_user_access?/3](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/3), but you can override them with your application needs. 33 | 34 | ```elixir 35 | defmodule Authorization do 36 | use Rajska, 37 | valid_roles: [:user, :admin], 38 | super_role: :admin, 39 | default_rule: :default 40 | end 41 | ``` 42 | 43 | Add your [Authorization](https://hexdocs.pm/rajska/Rajska.Authorization.html) module to your `Absinthe.Schema` [context/1](https://hexdocs.pm/absinthe/Absinthe.Schema.html#c:context/1) callback and the desired middlewares to the [middleware/3](https://hexdocs.pm/absinthe/Absinthe.Middleware.html#module-the-middleware-3-callback) callback: 44 | 45 | ```elixir 46 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 47 | 48 | def middleware(middleware, field, %Absinthe.Type.Object{identifier: identifier}) 49 | when identifier in [:query, :mutation] do 50 | middleware 51 | |> Rajska.add_query_authorization(field, Authorization) 52 | |> Rajska.add_object_authorization() 53 | end 54 | 55 | def middleware(middleware, field, object) do 56 | Rajska.add_field_authorization(middleware, field, object) 57 | end 58 | ``` 59 | 60 | 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: 61 | 62 | ```elixir 63 | # router.ex 64 | alias Absinthe.Phase.Document.Execution.Resolution 65 | alias Absinthe.Pipeline 66 | alias Rajska.ObjectScopeAuthorization 67 | 68 | forward "/graphql", Absinthe.Plug, 69 | schema: MyProjectWeb.Schema, 70 | socket: MyProjectWeb.UserSocket, 71 | pipeline: {__MODULE__, :pipeline} # Add this line 72 | 73 | def pipeline(config, pipeline_opts) do 74 | config 75 | |> Map.fetch!(:schema_mod) 76 | |> Pipeline.for_document(pipeline_opts) 77 | |> Pipeline.insert_after(Resolution, ObjectScopeAuthorization) 78 | end 79 | ``` 80 | 81 | Since Query Scope Authorization middleware must be used with Query Authorization, it is automatically called when adding the former. 82 | 83 | Middlewares usage can be found below. 84 | 85 | ## Middlewares 86 | 87 | ### Query Authorization 88 | 89 | Ensures Absinthe's queries can only be accessed by determined users. 90 | 91 | #### Usage: 92 | 93 | [Create your Authorization module and add it and QueryAuthorization to your Absinthe.Schema](#usage). Then set the permitted role to access a query or mutation: 94 | 95 | ```elixir 96 | mutation do 97 | field :create_user, :user do 98 | arg :params, non_null(:user_params) 99 | 100 | middleware Rajska.QueryAuthorization, permit: :all 101 | resolve &AccountsResolver.create_user/2 102 | end 103 | 104 | field :update_user, :user do 105 | arg :id, non_null(:integer) 106 | arg :params, non_null(:user_params) 107 | 108 | middleware Rajska.QueryAuthorization, [permit: [:user, :manager], scope: false] 109 | resolve &AccountsResolver.update_user/2 110 | end 111 | 112 | field :invite_user, :user do 113 | arg :email, non_null(:string) 114 | 115 | middleware Rajska.QueryAuthorization, permit: :admin 116 | resolve &AccountsResolver.invite_user/2 117 | end 118 | end 119 | ``` 120 | 121 | Query authorization will call [role_authorized?/2](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:role_authorized?/2) to check if the [user](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:get_current_user/1) [role](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:get_user_role/1) is authorized to perform the query. 122 | 123 | ### Query Scope Authorization 124 | 125 | Provides scoping to Absinthe's queries, allowing for more complex authorization rules. It is used together with [Query Authorization](#query-authorization). 126 | 127 | ```elixir 128 | mutation do 129 | field :create_user, :user do 130 | arg :params, non_null(:user_params) 131 | 132 | # all does not require scoping, since it means anyone can execute this query, even without being logged in. 133 | middleware Rajska.QueryAuthorization, permit: :all 134 | resolve &AccountsResolver.create_user/2 135 | end 136 | 137 | field :update_user, :user do 138 | arg :id, non_null(:integer) 139 | arg :params, non_null(:user_params) 140 | 141 | middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id] 142 | resolve &AccountsResolver.update_user/2 143 | end 144 | 145 | field :delete_user, :user do 146 | arg :user_id, non_null(:integer) 147 | 148 | # Providing a map for args is useful to map query argument to struct field. 149 | middleware Rajska.QueryAuthorization, [permit: [:user, :manager], scope: User, args: %{id: :user_id}] 150 | resolve &AccountsResolver.delete_user/2 151 | end 152 | 153 | input_object :user_params do 154 | field :id, non_null(:integer) 155 | end 156 | 157 | field :accept_user, :user do 158 | arg :params, non_null(:user_params) 159 | 160 | middleware Rajska.QueryAuthorization, [ 161 | permit: :user, 162 | scope: User, 163 | args: %{id: [:params, :id]}, 164 | rule: :accept_user 165 | ] 166 | resolve &AccountsResolver.invite_user/2 167 | end 168 | end 169 | ``` 170 | 171 | In the above example, `:all` and `:admin` (`super_role`) permissions don't require the `:scope` keyword, but you can modify this behavior by overriding the [not_scoped_roles/0](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:not_scoped_roles/0) function. 172 | 173 | There are also extra options for this middleware, supporting the definition of custom rules, access of nested parameters and allowing optional parameters. All possibilities are listed below: 174 | 175 | #### Options 176 | 177 | All the following options are sent to [has_user_access?/3](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/3): 178 | 179 | * `:scope` 180 | - `false`: disables scoping 181 | - `User`: a module that will be passed to `c:Rajska.Authorization.has_user_access?/3`. It must define a struct. 182 | * `:args` 183 | - `%{user_id: [:params, :id]}`: where `user_id` is the scoped field and `id` is an argument nested inside the `params` argument. 184 | - `:id`: this is the same as `%{id: :id}`, where `:id` is both the query argument and the scoped field that will be passed to [has_user_access?/3](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/3) 185 | - `[:code, :user_group_id]`: this is the same as `%{code: :code, user_group_id: :user_group_id}`, where `code` and `user_group_id` are both query arguments and scoped fields. 186 | * `:optional` (optional) - when set to true the arguments are optional, so if no argument is provided, the query will be authorized. Defaults to false. 187 | * `:rule` (optional) - allows the same struct to have different rules. See `Rajska.Authorization` for `rule` default settings. 188 | 189 | ### Object Authorization 190 | 191 | Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the permission defined in each object meta `authorize`. 192 | 193 | #### Usage: 194 | 195 | [Create your Authorization module and add it and ObjectAuthorization to your Absinthe.Schema](#usage). Then set the permitted role to access an object: 196 | 197 | ```elixir 198 | object :wallet_balance do 199 | meta :authorize, :admin 200 | 201 | field :total, :integer 202 | end 203 | 204 | object :company do 205 | meta :authorize, :user 206 | 207 | field :name, :string 208 | field :wallet_balance, :wallet_balance 209 | end 210 | 211 | object :user do 212 | meta :authorize, :all 213 | 214 | field :email, :string 215 | field :company, :company 216 | end 217 | ``` 218 | 219 | With the permissions above, a query like the following would only be allowed by an admin user: 220 | 221 | ```graphql 222 | { 223 | userQuery { 224 | name 225 | email 226 | company { 227 | name 228 | walletBalance { total } 229 | } 230 | } 231 | } 232 | ``` 233 | 234 | Object Authorization middleware runs after Query Authorization middleware (if added) and before the query is resolved by recursively checking the requested objects permissions in the [role_authorized?/2](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:role_authorized?/2) function (which is also used by Query Authorization). It can be overridden by your own implementation. 235 | 236 | ### Object Scope Authorization 237 | 238 | Absinthe Phase to perform object scoping. 239 | 240 | Authorizes all Absinthe's [objects](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#object/3) requested in a query by checking the underlying struct. 241 | 242 | #### Usage: 243 | 244 | [Create your Authorization module and add it and ObjectScopeAuthorization to your Absinthe pipeline](#usage). Then set the scope of an object: 245 | 246 | ```elixir 247 | object :user do 248 | # Turn on both Object and Field scoping, but if the FieldAuthorization middleware is not included, this is the same as using `scope_object?` 249 | meta :scope?, true 250 | 251 | field :id, :integer 252 | field :email, :string 253 | field :name, :string 254 | 255 | field :company, :company 256 | end 257 | 258 | object :company do 259 | meta :scope_object?, true 260 | 261 | field :id, :integer 262 | field :user_id, :integer 263 | field :name, :string 264 | field :wallet, :wallet 265 | end 266 | 267 | object :wallet do 268 | meta :scope?, true 269 | meta :rule, :object_authorization 270 | 271 | field :total, :integer 272 | end 273 | ``` 274 | 275 | 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: 276 | 277 | ```elixir 278 | defmodule Authorization do 279 | use Rajska, 280 | valid_roles: [:user, :admin], 281 | super_role: :admin 282 | 283 | @impl true 284 | def has_user_access?(%{role: :admin}, %User{}, _rule), do: true 285 | def has_user_access?(%{id: user_id}, %User{id: id}, _rule) when user_id === id, do: true 286 | def has_user_access?(_current_user, %User{}, _rule), do: false 287 | 288 | def has_user_access?(%{id: user_id}, %Wallet{user_id: id}, :object_authorization), do: user_id == id 289 | end 290 | ``` 291 | 292 | This way different rules can be set to the same struct. 293 | 294 | ### Field Authorization 295 | 296 | Authorizes Absinthe's object [field](https://hexdocs.pm/absinthe/Absinthe.Schema.Notation.html#field/4) according to the result of the [has_user_access?/3](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/3) function, which receives the user role, the `source` object that is resolving the field and the field rule. 297 | 298 | #### Usage: 299 | 300 | [Create your Authorization module and add it and FieldAuthorization to your Absinthe.Schema](#usage). 301 | 302 | ```elixir 303 | object :user do 304 | # Turn on both Object and Field scoping, but if the ObjectScope Phase is not included, this is the same as using `scope_field?` 305 | meta :scope?, true 306 | 307 | field :name, :string 308 | field :is_email_public, :boolean 309 | 310 | field :phone, :string, meta: [private: true] 311 | field :email, :string, meta: [private: & !&1.is_email_public] 312 | 313 | # Can also use custom rules for each field 314 | field :always_private, :string, meta: [private: true, rule: :private] 315 | end 316 | 317 | object :field_scope_user do 318 | meta :scope_field?, true 319 | 320 | field :name, :string 321 | field :phone, :string, meta: [private: true] 322 | end 323 | ``` 324 | 325 | As seen in the example above, a function can also be passed as value to the meta `:private` key, in order to check if a field is private dynamically, depending of the value of another field. 326 | 327 | ### Rate Limiter 328 | 329 | Rate limiter absinthe middleware. Uses [Hammer](https://github.com/ExHammer/hammer). 330 | 331 | #### Usage 332 | 333 | First configure Hammer, following its documentation. For example: 334 | 335 | ```elixir 336 | config :hammer, 337 | backend: {Hammer.Backend.ETS, [expiry_ms: 60_000 * 60 * 4, 338 | cleanup_interval_ms: 60_000 * 10]} 339 | ``` 340 | 341 | Add your middleware to the query that should be limited: 342 | 343 | ```elixir 344 | field :default_config, :string do 345 | middleware Rajska.RateLimiter 346 | resolve fn _, _ -> {:ok, "ok"} end 347 | end 348 | ``` 349 | 350 | You can also configure it and use multiple rules for limiting in one query: 351 | 352 | ```elixir 353 | field :login_user, :session do 354 | arg :email, non_null(:string) 355 | arg :password, non_null(:string) 356 | 357 | middleware Rajska.RateLimiter, limit: 10 # Using the default identifier (user IP) 358 | middleware Rajska.RateLimiter, keys: :email, limit: 5 # Using the value provided in the email arg 359 | resolve &AccountsResolver.login_user/2 360 | end 361 | ``` 362 | 363 | The allowed configuration are: 364 | 365 | * `scale_ms`: The timespan for the maximum number of actions. Defaults to 60_000. 366 | * `limit`: The maximum number of actions in the specified timespan. Defaults to 10. 367 | * `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. 368 | * `keys`: An atom or a list of atoms to get a query argument as identifier. Use a list when the argument is nested. 369 | * `error_msg`: The error message to be displayed when rate limit exceeds. Defaults to `"Too many requests"`. 370 | 371 | 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 372 | `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 373 | absinthe context before using it as an identifier. See the [absinthe docs](https://hexdocs.pm/absinthe/context-and-authentication.html#content) 374 | for more information. 375 | 376 | ## Related Projects 377 | 378 | [Crudry](https://github.com/jungsoft/crudry) is an elixir library for DRYing CRUD of Phoenix Contexts and Absinthe Resolvers. 379 | 380 | ## License 381 | 382 | MIT License. 383 | 384 | See [LICENSE](./LICENSE) for more information. 385 | -------------------------------------------------------------------------------- /test/middlewares/object_scope_authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Rajska.ObjectScopeAuthorizationTest do 2 | use ExUnit.Case, async: true 3 | 4 | defmodule Wallet do 5 | defstruct [ 6 | id: 1, 7 | total: 10, 8 | user_id: nil 9 | ] 10 | end 11 | 12 | defmodule Company do 13 | defstruct [ 14 | id: 1, 15 | name: "User", 16 | user_id: 1, 17 | wallet: %Wallet{} 18 | ] 19 | end 20 | 21 | defmodule User do 22 | defstruct [ 23 | id: 1, 24 | name: "User", 25 | email: "email@user.com", 26 | company: nil, 27 | companies: [], 28 | not_scoped: nil, 29 | ] 30 | end 31 | 32 | defmodule NotScoped do 33 | defstruct [ 34 | id: 1, 35 | ] 36 | end 37 | 38 | defmodule Authorization do 39 | use Rajska, 40 | valid_roles: [:user, :admin], 41 | super_role: :admin 42 | 43 | def has_user_access?(%{role: :admin}, %User{}, :default), do: true 44 | def has_user_access?(%{id: user_id}, %User{id: id}, :default) when user_id === id, do: true 45 | def has_user_access?(_current_user, %User{}, :default), do: false 46 | def has_user_access?(_current_user, %User{}, :object), do: false 47 | 48 | def has_user_access?(%{role: :admin}, %Company{}, :default), do: true 49 | def has_user_access?(%{id: user_id}, %Company{user_id: company_user_id}, :default) when user_id === company_user_id, do: true 50 | def has_user_access?(_current_user, %Company{}, :default), do: false 51 | 52 | def has_user_access?(%{role: :admin}, %Wallet{}, :default), do: true 53 | def has_user_access?(%{id: user_id}, %Wallet{user_id: id}, :default) when user_id === id, do: true 54 | def has_user_access?(_current_user, %Wallet{}, :default), do: false 55 | end 56 | 57 | defmodule Schema do 58 | use Absinthe.Schema 59 | 60 | def context(ctx), do: Map.put(ctx, :authorization, Authorization) 61 | 62 | def middleware(middleware, _field, _object), do: middleware 63 | 64 | query do 65 | field :all_query, non_null(:user) do 66 | arg :user_id, non_null(:integer) 67 | 68 | resolve fn args, _ -> 69 | {:ok, %User{ 70 | id: args.user_id, 71 | name: "bob", 72 | company: %Company{ 73 | id: 5, 74 | user_id: args.user_id, 75 | name: "company", 76 | wallet: %Wallet{id: 1, total: 10} 77 | } 78 | }} 79 | end 80 | end 81 | 82 | field :all_query_no_company, :user do 83 | arg :user_id, non_null(:integer) 84 | 85 | resolve fn args, _ -> 86 | {:ok, %User{ 87 | id: args.user_id, 88 | name: "bob" 89 | }} 90 | end 91 | end 92 | 93 | field :all_query_companies_list, :user do 94 | arg :user_id, non_null(:integer) 95 | 96 | resolve fn args, _ -> 97 | {:ok, %User{ 98 | id: args.user_id, 99 | name: "bob", 100 | companies: [ 101 | %Company{id: 1, user_id: args.user_id, wallet: %Wallet{id: 2, total: 10}}, 102 | %Company{id: 2, user_id: args.user_id, wallet: %Wallet{id: 1, total: 10}}, 103 | ] 104 | }} 105 | end 106 | end 107 | 108 | field :users_query, list_of(:user) do 109 | resolve fn _args, _ -> 110 | {:ok, [ 111 | %User{id: 1, name: "bob"}, 112 | %User{id: 2, name: "bob"}, 113 | ]} 114 | end 115 | end 116 | 117 | field :nil_user_query, :user do 118 | resolve fn _args, _ -> 119 | {:ok, nil} 120 | end 121 | end 122 | 123 | field :user_query_with_rule, :user_rule do 124 | resolve fn _args, _ -> 125 | {:ok, %User{id: 1}} 126 | end 127 | end 128 | 129 | field :string_query, :string do 130 | resolve fn _args, _ -> 131 | {:ok, "STRING"} 132 | end 133 | end 134 | 135 | field :get_both_scopes, :both_scopes do 136 | resolve fn _args, _ -> {:ok, %User{}} end 137 | end 138 | 139 | field :get_object_scope_user, :object_scope_user do 140 | arg :id, non_null(:integer) 141 | resolve fn args, _ -> {:ok, %User{id: args.id}} end 142 | end 143 | end 144 | 145 | object :user do 146 | meta :scope?, true 147 | 148 | field :id, :integer 149 | field :email, :string 150 | field :name, :string 151 | 152 | field :company, :company 153 | field :companies, list_of(:company) 154 | field :not_scoped, :not_scoped 155 | end 156 | 157 | object :company do 158 | field :id, :integer 159 | field :user_id, :integer 160 | field :name, :string 161 | field :wallet, :wallet 162 | end 163 | 164 | object :wallet do 165 | field :total, :integer 166 | end 167 | 168 | object :not_scoped do 169 | meta :scope?, false 170 | field :name, :string 171 | end 172 | 173 | object :user_rule do 174 | meta :rule, :object 175 | 176 | field :id, :integer 177 | end 178 | 179 | object :both_scopes do 180 | meta :scope?, true 181 | meta :scope_object?, false 182 | 183 | field :name, :string 184 | end 185 | 186 | object :object_scope_user do 187 | meta :scope_object?, true 188 | 189 | field :id, :integer 190 | end 191 | end 192 | 193 | test "Only user with same ID and admin has access to scoped user" do 194 | {:ok, result} = run_pipeline(all_query(1), context(:user, 1)) 195 | assert %{data: %{"allQuery" => %{}}} = result 196 | refute Map.has_key?(result, :errors) 197 | 198 | {:ok, result} = run_pipeline(all_query(1), context(:admin, 2)) 199 | assert %{data: %{"allQuery" => %{}}} = result 200 | refute Map.has_key?(result, :errors) 201 | 202 | assert {:ok, %{errors: errors}} = run_pipeline(all_query(1), context(:user, 2)) 203 | assert [ 204 | %{ 205 | locations: [%{column: 3, line: 2}], 206 | message: "Not authorized to access object user", 207 | } 208 | ] == errors 209 | end 210 | 211 | test "Only user that owns the company and admin can access it" do 212 | {:ok, result} = run_pipeline(all_query_with_company(1), context(:user, 1)) 213 | assert %{data: %{"allQuery" => %{}}} = result 214 | refute Map.has_key?(result, :errors) 215 | 216 | {:ok, result} = run_pipeline(all_query_with_company(1), context(:admin, 2)) 217 | assert %{data: %{"allQuery" => %{}}} = result 218 | refute Map.has_key?(result, :errors) 219 | 220 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_with_company(1), context(:user, 2)) 221 | assert [ 222 | %{ 223 | locations: [%{column: 3, line: 2}], 224 | message: "Not authorized to access object user", 225 | } 226 | ] == errors 227 | end 228 | 229 | test "Works when defining scope_object? and not scope?" do 230 | query = "{ getObjectScopeUser(id: 1) { id } }" 231 | {:ok, result} = run_pipeline(query, context(:user, 1)) 232 | assert %{data: %{"getObjectScopeUser" => %{}}} = result 233 | refute Map.has_key?(result, :errors) 234 | 235 | {:ok, result} = run_pipeline(query, context(:admin, 2)) 236 | assert %{data: %{"getObjectScopeUser" => %{}}} = result 237 | refute Map.has_key?(result, :errors) 238 | 239 | assert {:ok, %{errors: errors}} = run_pipeline(query, context(:user, 2)) 240 | assert [ 241 | %{ 242 | locations: [%{column: 3, line: 1}], 243 | message: "Not authorized to access object object_scope_user", 244 | } 245 | ] == errors 246 | end 247 | 248 | test "Works for deeply nested objects" do 249 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_company_wallet(2), context(:user, 2)) 250 | assert [ 251 | %{ 252 | locations: [%{column: 7, line: 8}], 253 | message: "Not authorized to access object wallet", 254 | } 255 | ] == errors 256 | 257 | {:ok, result} = run_pipeline(all_query_company_wallet(2), context(:admin, 2)) 258 | assert %{data: %{"allQuery" => %{}}} = result 259 | refute Map.has_key?(result, :errors) 260 | 261 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_company_wallet(2), context(:user, 1)) 262 | assert [ 263 | %{ 264 | locations: [%{column: 3, line: 2}], 265 | message: "Not authorized to access object user", 266 | } 267 | ] == errors 268 | end 269 | 270 | test "Works when returned nested object is nil" do 271 | assert {:ok, result} = run_pipeline(all_query_no_company(2), context(:user, 2)) 272 | assert %{data: %{"allQueryNoCompany" => %{}}} = result 273 | refute Map.has_key?(result, :errors) 274 | 275 | {:ok, result} = run_pipeline(all_query_no_company(2), context(:admin, 2)) 276 | assert %{data: %{"allQueryNoCompany" => %{}}} = result 277 | refute Map.has_key?(result, :errors) 278 | 279 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_no_company(2), context(:user, 1)) 280 | assert [ 281 | %{ 282 | locations: [%{column: 3, line: 2}], 283 | message: "Not authorized to access object user", 284 | } 285 | ] == errors 286 | end 287 | 288 | test "Works when query returns nil" do 289 | assert {:ok, result} = run_pipeline(nil_user_query(), context(:user, 1)) 290 | assert %{data: %{"nilUserQuery" => nil}} = result 291 | refute Map.has_key?(result, :errors) 292 | 293 | {:ok, result} = run_pipeline(nil_user_query(), context(:admin, 2)) 294 | assert %{data: %{"nilUserQuery" => nil}} = result 295 | refute Map.has_key?(result, :errors) 296 | end 297 | 298 | test "Works when returned nested object is a list" do 299 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_companies_list(2), context(:user, 2)) 300 | assert [ 301 | %{ 302 | locations: [%{column: 7, line: 8}], 303 | message: "Not authorized to access object wallet", 304 | } 305 | ] == errors 306 | 307 | {:ok, result} = run_pipeline(all_query_companies_list(2), context(:admin, 2)) 308 | assert %{data: %{"allQueryCompaniesList" => %{}}} = result 309 | refute Map.has_key?(result, :errors) 310 | 311 | assert {:ok, %{errors: errors}} = run_pipeline(all_query_companies_list(2), context(:user, 1)) 312 | assert [ 313 | %{ 314 | locations: [%{column: 3, line: 2}], 315 | message: "Not authorized to access object user", 316 | } 317 | ] == errors 318 | end 319 | 320 | test "Works when query returns a list" do 321 | assert {:ok, %{errors: errors}} = run_pipeline(users_query(), context(:user, 2)) 322 | assert [ 323 | %{ 324 | locations: [%{column: 3, line: 2}], 325 | message: "Not authorized to access object user", 326 | } 327 | ] == errors 328 | 329 | {:ok, result} = run_pipeline(users_query(), context(:admin, 2)) 330 | assert %{data: %{"usersQuery" => [_ | _]}} = result 331 | refute Map.has_key?(result, :errors) 332 | end 333 | 334 | test "accepts a meta rule" do 335 | assert {:ok, %{errors: errors}} = run_pipeline(user_query_with_rule(), context(:admin, 1)) 336 | assert [ 337 | %{ 338 | locations: [%{column: 3, line: 2}], 339 | message: "Not authorized to access object user_rule", 340 | } 341 | ] == errors 342 | end 343 | 344 | test "ignores object when is a primitive" do 345 | assert {:ok, result} = run_pipeline(string_query(), context(:user, 1)) 346 | assert %{data: %{"stringQuery" => "STRING"}} = result 347 | refute Map.has_key?(result, :errors) 348 | end 349 | 350 | test "Raises when both scope? metas are defined for an object" do 351 | assert_raise RuntimeError, ~r/Error in :both_scopes. If scope_object\? is defined, then scope\? must not be defined/, fn -> 352 | run_pipeline("{ getBothScopes { name } }", context(:user, 2)) 353 | end 354 | end 355 | 356 | test "Skips introspection query" do 357 | {:ok, result} = run_pipeline(introspection_query(), context(:admin, 2)) 358 | assert %{data: %{}} = result 359 | refute Map.has_key?(result, :errors) 360 | end 361 | 362 | defp all_query(id) do 363 | """ 364 | { 365 | allQuery(userId: #{id}) { 366 | name 367 | email 368 | } 369 | } 370 | """ 371 | end 372 | 373 | defp all_query_with_company(id) do 374 | """ 375 | { 376 | allQuery(userId: #{id}) { 377 | name 378 | email 379 | company { 380 | id 381 | name 382 | } 383 | } 384 | } 385 | """ 386 | end 387 | 388 | defp all_query_company_wallet(id) do 389 | """ 390 | { 391 | allQuery(userId: #{id}) { 392 | name 393 | email 394 | company { 395 | id 396 | name 397 | wallet { 398 | total 399 | } 400 | } 401 | } 402 | } 403 | """ 404 | end 405 | 406 | defp all_query_no_company(id) do 407 | """ 408 | { 409 | allQueryNoCompany(userId: #{id}) { 410 | name 411 | email 412 | company { 413 | id 414 | name 415 | wallet { 416 | total 417 | } 418 | } 419 | } 420 | } 421 | """ 422 | end 423 | 424 | defp all_query_companies_list(id) do 425 | """ 426 | { 427 | allQueryCompaniesList(userId: #{id}) { 428 | name 429 | email 430 | companies { 431 | id 432 | name 433 | wallet { 434 | total 435 | } 436 | } 437 | } 438 | } 439 | """ 440 | end 441 | 442 | defp users_query do 443 | """ 444 | { 445 | usersQuery { 446 | name 447 | email 448 | } 449 | } 450 | """ 451 | end 452 | 453 | defp nil_user_query do 454 | """ 455 | { 456 | nilUserQuery { 457 | name 458 | email 459 | } 460 | } 461 | """ 462 | end 463 | 464 | defp user_query_with_rule do 465 | """ 466 | { 467 | userQueryWithRule { 468 | id 469 | } 470 | } 471 | """ 472 | end 473 | 474 | defp string_query do 475 | """ 476 | { 477 | stringQuery 478 | } 479 | """ 480 | end 481 | 482 | defp introspection_query do 483 | """ 484 | query IntrospectionQuery { 485 | __schema { 486 | queryType { name } 487 | mutationType { name } 488 | subscriptionType { name } 489 | types { 490 | ...FullType 491 | } 492 | directives { 493 | name 494 | description 495 | locations 496 | args { 497 | ...InputValue 498 | } 499 | } 500 | } 501 | } 502 | fragment FullType on __Type { 503 | kind 504 | name 505 | description 506 | fields(includeDeprecated: true) { 507 | name 508 | description 509 | args { 510 | ...InputValue 511 | } 512 | type { 513 | ...TypeRef 514 | } 515 | isDeprecated 516 | deprecationReason 517 | } 518 | inputFields { 519 | ...InputValue 520 | } 521 | interfaces { 522 | ...TypeRef 523 | } 524 | enumValues(includeDeprecated: true) { 525 | name 526 | description 527 | isDeprecated 528 | deprecationReason 529 | } 530 | possibleTypes { 531 | ...TypeRef 532 | } 533 | } 534 | 535 | fragment InputValue on __InputValue { 536 | name 537 | description 538 | type { ...TypeRef } 539 | defaultValue 540 | } 541 | 542 | fragment TypeRef on __Type { 543 | kind 544 | name 545 | ofType { 546 | kind 547 | name 548 | ofType { 549 | kind 550 | name 551 | ofType { 552 | kind 553 | name 554 | ofType { 555 | kind 556 | name 557 | ofType { 558 | kind 559 | name 560 | ofType { 561 | kind 562 | name 563 | ofType { 564 | kind 565 | name 566 | } 567 | } 568 | } 569 | } 570 | } 571 | } 572 | } 573 | } 574 | """ 575 | end 576 | 577 | defp context(role, id), do: [context: %{current_user: %{role: role, id: id}}] 578 | 579 | defp run_pipeline(document, opts) do 580 | case Absinthe.Pipeline.run(document, pipeline(opts)) do 581 | {:ok, %{result: result}, _phases} -> 582 | {:ok, result} 583 | 584 | {:error, msg, _phases} -> 585 | {:error, msg} 586 | end 587 | end 588 | 589 | defp pipeline(options) do 590 | __MODULE__.Schema 591 | |> Absinthe.Pipeline.for_document(options) 592 | |> Absinthe.Pipeline.insert_after(Absinthe.Phase.Document.Execution.Resolution, Rajska.ObjectScopeAuthorization) 593 | end 594 | end 595 | --------------------------------------------------------------------------------