Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add args option to query scope authorization #15

Merged
merged 9 commits into from
Oct 9, 2019
36 changes: 21 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Usage:
arg :id, non_null(:integer)
arg :params, non_null(:user_params)

middleware Rajska.QueryAuthorization, [permit: :user, scoped: User] # same as {User, :id}
middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id]
resolve &AccountsResolver.update_user/2
end

Expand All @@ -123,15 +123,21 @@ Query authorization will call [role_authorized?/2](https://hexdocs.pm/rajska/Raj

Provides scoping to Absinthe's queries, as seen above in [Query Authorization](#query-authorization).

In the above example, `:all` and `:admin` (`super_role`) permissions don't require the `:scoped` 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.
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.

Valid values for the `:scoped` keyword are:
## Options

- `false`: disables scoping
- `User`: a module that will be passed to [has_user_access?/4](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/4). It must implement a [Authorization behaviour](https://hexdocs.pm/rajska/Rajska.Authorization.html) and a `__schema__(:source)` function (used to check if the module is valid in [validate_query_auth_config!/2](https://hexdocs.pm/rajska/Rajska.Schema.html#validate_query_auth_config!/2))
- `{User, :id}`: where `:id` is the query argument that will also be passed to [has_user_access?/4](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/4)
- `{User, [:params, :id]}`: where `id` is the query argument as above, but it's not defined directly as an `arg` for the query. Instead, it's nested inside the `params` argument.
- `{User, :user_group_id, :optional}`: where `user_group_id` (it could also be a nested argument) is an optional argument for the query. If it's present, the scoping will be applied, otherwise no scoping is applied.
All the following options are sent to [has_user_access?/4](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/4):

* `:scope`
- `false`: disables scoping
- `User`: a module that will be passed to `c:Rajska.Authorization.has_user_access?/4`. It must implement a `Rajska.Authorization` behaviour and a `__schema__(:source)` function (used to check if the module is valid in `Rajska.Schema.validate_query_auth_config!/2`)
* `:args`
- `%{user_id: [:params, :id]}`: where `user_id` is the scoped field and `id` is an argument nested inside the `params` argument.
- `: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?/4](https://hexdocs.pm/rajska/Rajska.Authorization.html#c:has_user_access?/4)
- `[: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.
* `:optional` (optional) - when set to true the arguments are optional, so if no argument is provided, the query will be authorized. Defaults to false.
* `:rule` (optional) - allows the same struct to have different rules. See `Rajska.Authorization` for `rule` default settings.

### Object Authorization

Expand Down Expand Up @@ -228,9 +234,9 @@ defmodule Authorization do
super_role: :admin

@impl true
def has_user_access?(%{role: :admin}, User, _id, _rule), do: true
def has_user_access?(%{id: user_id}, User, id, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _id, _rule), do: false
def has_user_access?(%{role: :admin}, User, _field, _rule), do: true
def has_user_access?(%{id: user_id}, User, {:id, id}, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _field, _rule), do: false
end
```

Expand All @@ -244,11 +250,11 @@ defmodule Authorization do
super_role: :admin

@impl true
def has_user_access?(_user, _, nil), do: true
def has_user_access?(_user, _scope, {_field, nil}, _rule), do: true

def has_user_access?(%{role: :admin}, User, _id, _rule), do: true
def has_user_access?(%{id: user_id}, User, id, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _id, _rule), do: false
def has_user_access?(%{role: :admin}, User, _field, _rule), do: true
def has_user_access?(%{id: user_id}, User, {:id, id}, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _field, _rule), do: false
end
```

Expand Down
7 changes: 6 additions & 1 deletion lib/authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ defmodule Rajska.Authorization do

@callback field_authorized?(current_user_role, scope_by :: atom(), source :: map()) :: boolean()

@callback has_user_access?(current_user, scoped_struct :: module(), field_value :: any(), rule :: any()) :: boolean()
@callback has_user_access?(
current_user,
scope :: module(),
{field :: any(), field_value :: any()},
rule :: any()
) :: boolean()

@callback unauthorized_msg(resolution :: Resolution.t()) :: String.t()

Expand Down
30 changes: 15 additions & 15 deletions lib/middlewares/object_scope_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ defmodule Rajska.ObjectScopeAuthorization do
valid_roles: [:user, :admin]

@impl true
def has_user_access?(%{role: :admin}, _struct, _field_value, _rule), do: true
def has_user_access?(%{id: user_id}, User, id, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _field_value, _rule), do: false
def has_user_access?(%{role: :admin}, _struct, {_field, _field_value}, _rule), do: true
def has_user_access?(%{id: user_id}, User, {:id, id}, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, {_field, _field_value}, _rule), do: false
end
```

Expand All @@ -61,11 +61,11 @@ defmodule Rajska.ObjectScopeAuthorization do
valid_roles: [:user, :admin]

@impl true
def has_user_access?(_user, _, nil, _), do: true
def has_user_access?(_user, _scope, {_field, nil}, _rule), do: true

def has_user_access?(%{role: :admin}, User, _field_value, _rule), do: true
def has_user_access?(%{id: user_id}, User, id, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, _field_value, _rule), do: false
def has_user_access?(%{role: :admin}, User, {_field, _field_value}, _rule), do: true
def has_user_access?(%{id: user_id}, User, {:id, id}, _rule) when user_id === id, do: true
def has_user_access?(_current_user, User, {_field, _field_value}, _rule), do: false
end
```

Expand All @@ -77,8 +77,8 @@ defmodule Rajska.ObjectScopeAuthorization do
valid_roles: [:user, :admin]

@impl true
def has_user_access?(%{id: user_id}, Wallet, _field_value, :read_only), do: true
def has_user_access?(%{id: user_id}, Wallet, _field_value, :default), do: false
def has_user_access?(%{id: user_id}, Wallet, {_field, _field_value}, :read_only), do: true
def has_user_access?(%{id: user_id}, Wallet, {_field, _field_value}, :default), do: false
end
```

Expand Down Expand Up @@ -145,14 +145,14 @@ defmodule Rajska.ObjectScopeAuthorization do

defp authorized?(false, _values, _context, _, _object), do: true

defp authorized?({scoped_struct, field}, values, context, rule, _object) do
scoped_field_value = Map.get(values, field)
Rajska.apply_auth_mod(context, :has_context_access?, [context, scoped_struct, scoped_field_value, rule])
defp authorized?({scope, scope_field}, values, context, rule, _object) do
field_value = Map.get(values, scope_field)

Rajska.apply_auth_mod(context, :has_context_access?, [context, scope, {scope_field, field_value}, rule])
end

defp authorized?(scoped_struct, values, context, rule, _object) do
scoped_field_value = Map.get(values, :id)
Rajska.apply_auth_mod(context, :has_context_access?, [context, scoped_struct, scoped_field_value, rule])
defp authorized?(scope, values, context, rule, object) do
authorized?({scope, :id}, values, context, rule, object)
end

defp error(%{source_location: location, schema_node: %{type: type}}) do
Expand Down
4 changes: 2 additions & 2 deletions lib/middlewares/query_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ defmodule Rajska.QueryAuthorization do
arg :id, non_null(:integer)
arg :params, non_null(:user_params)

middleware Rajska.QueryAuthorization, [permit: :user, scoped: User] # same as {User, :id}
middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id]
resolve &AccountsResolver.update_user/2
end

Expand All @@ -40,7 +40,7 @@ defmodule Rajska.QueryAuthorization do

@behaviour Absinthe.Middleware

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

context
Expand Down
101 changes: 53 additions & 48 deletions lib/middlewares/query_scope_authorization.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ defmodule Rajska.QueryScopeAuthorization do
arg :id, non_null(:integer)
arg :params, non_null(:user_params)

middleware Rajska.QueryAuthorization, [permit: :user, scoped: User] # same as {User, :id}
middleware Rajska.QueryAuthorization, [permit: :user, scope: User] # same as [permit: :user, scope: User, args: :id]
resolve &AccountsResolver.update_user/2
end

Expand All @@ -33,22 +33,27 @@ defmodule Rajska.QueryScopeAuthorization do
field :invite_user, :user do
arg :email, non_null(:string)

middleware Rajska.QueryAuthorization, [permit: :user, scoped: User, rule: :invitation]
middleware Rajska.QueryAuthorization, [permit: :user, scope: User, rule: :invitation]
resolve &AccountsResolver.invite_user/2
end
end
```

In the above example, `:all` and `:admin` permissions don't require the `:scoped` keyword, as defined in the `c:Rajska.Authorization.not_scoped_roles/0` function, but you can modify this behavior by overriding it.
The `rule` keyword is not mandatory and will be pattern matched in `c:Rajska.Authorization.has_user_access?/4`. This way different rules can be set to the same struct.
See `Rajska.Authorization` for `rule` default settings.
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.

Valid values for the `:scoped` keyword are:
- `false`: disables scoping
- `User`: a module that will be passed to `c:Rajska.Authorization.has_user_access?/4`. It must implement a `Rajska.Authorization` behaviour and a `__schema__(:source)` function (used to check if the module is valid in `Rajska.Schema.validate_query_auth_config!/2`)
- `{User, :id}`: where `:id` is the query argument that will also be passed to `c:Rajska.Authorization.has_user_access?/4`
- `{User, [:params, :id]}`: where `id` is the query argument as above, but it's not defined directly as an `arg` for the query. Instead, it's nested inside the `params` argument.
- `{User, :user_group_id, :optional}`: where `user_group_id` (it could also be a nested argument) is an optional argument for the query. If it's present, the scoping will be applied, otherwise no scoping is applied.
## Options

All the following options are sent to `c:Rajska.Authorization.has_user_access?/4`:

* `:scope`
- `false`: disables scoping
- `User`: a module that will be passed to `c:Rajska.Authorization.has_user_access?/4`. It must implement a `Rajska.Authorization` behaviour and a `__schema__(:source)` function (used to check if the module is valid in `Rajska.Schema.validate_query_auth_config!/2`)
* `:args`
- `%{user_id: [:params, :id]}`: where `user_id` is the scoped field and `id` is an argument nested inside the `params` argument.
- `: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?/4`
- `[: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.
* `:optional` (optional) - when set to true the arguments are optional, so if no argument is provided, the query will be authorized. Defaults to false.
* `:rule` (optional) - allows the same struct to have different rules. See `Rajska.Authorization` for `rule` default settings.
"""

@behaviour Absinthe.Middleware
Expand All @@ -59,61 +64,61 @@ defmodule Rajska.QueryScopeAuthorization do

def call(%Resolution{state: :resolved} = resolution, _config), do: resolution

def call(resolution, [_ | [scoped: false]]), do: resolution
def call(resolution, [_ | [scope: false]]), do: resolution

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

case Enum.member?(not_scoped_roles, permission) do
true -> resolution
false ->
default_rule = Rajska.apply_auth_mod(resolution.context, :default_rule)
scope_user!(
resolution,
Keyword.get(scoped_config, :rule, default_rule),
Keyword.delete(scoped_config, :rule)
)
false -> scope_user!(resolution, scope_config)
end
end

def scope_user!(%Resolution{source: source} = resolution, rule, scoped: :source) do
apply_scope_authorization(resolution, get_scoped_field_value(source, :id), source.__struct__, rule)
end
def scope_user!(%{context: context} = resolution, config) do
default_rule = Rajska.apply_auth_mod(context, :default_rule)
rule = Keyword.get(config, :rule, default_rule)
scope = Keyword.get(config, :scope)
arg_fields = config |> Keyword.get(:args, :id) |> arg_fields_to_map()
optional = Keyword.get(config, :optional, false)
arguments_source = get_arguments_source!(resolution, scope)

def scope_user!(%Resolution{source: source} = resolution, rule, scoped: {:source, scoped_field}) do
apply_scope_authorization(resolution, get_scoped_field_value(source, scoped_field), source.__struct__, rule)
arg_fields
|> Enum.all?(& apply_scope_authorization(resolution, scope, arguments_source, &1, rule, optional))
|> update_result(resolution)
end

def scope_user!(%Resolution{arguments: args} = resolution, rule, scoped: {scoped_struct, scoped_field}) do
apply_scope_authorization(resolution, get_scoped_field_value(args, scoped_field), scoped_struct, rule)
end
defp arg_fields_to_map(field) when is_atom(field), do: Map.new([{field, field}])
defp arg_fields_to_map(fields) when is_list(fields), do: fields |> Enum.map(& {&1, &1}) |> Map.new()
defp arg_fields_to_map(field) when is_map(field), do: field

def scope_user!(%Resolution{arguments: args} = resolution, rule, scoped: {scoped_struct, scoped_field, :optional}) do
case get_scoped_field_value(args, scoped_field) do
nil -> update_result(true, resolution)
field_value -> apply_scope_authorization(resolution, field_value, scoped_struct, rule)
end
defp get_arguments_source!(%Resolution{definition: %{name: name}}, nil) do
raise "Error in query #{name}: no scope argument found in middleware Scope Authorization"
end

def scope_user!(%Resolution{arguments: args} = resolution, rule, scoped: scoped_struct) do
apply_scope_authorization(resolution, get_scoped_field_value(args, :id), scoped_struct, rule)
end
defp get_arguments_source!(%Resolution{source: source}, :source), do: source

def scope_user!(%Resolution{definition: %{name: name}}, _, _scoped_config) do
raise "Error in query #{name}: no scoped argument found in middleware Scope Authorization"
end

defp get_scoped_field_value(args, fields) when is_list(fields), do: get_in(args, fields)
defp get_scoped_field_value(args, field) when is_atom(field), do: Map.get(args, field)
defp get_arguments_source!(%Resolution{arguments: args}, _scope), do: args

def apply_scope_authorization(%Resolution{definition: %{name: name}}, nil, _scoped_struct, _) do
raise "Error in query #{name}: no argument found in middleware Scope Authorization"
def apply_scope_authorization(
%Resolution{definition: definition, context: context},
scope,
arguments_source,
{scope_field, arg_field},
rule,
optional
) do
case get_scope_field_value(arguments_source, arg_field) do
nil -> optional || raise "Error in query #{definition.name}: no argument #{inspect arg_field} found in #{inspect arguments_source}"
field_value -> has_context_access?(context, scope, {scope_field, field_value}, rule)
rschef marked this conversation as resolved.
Show resolved Hide resolved
end
end

def apply_scope_authorization(%{context: context} = resolution, field_value, scoped_struct, rule) do
context
|> Rajska.apply_auth_mod(:has_context_access?, [context, scoped_struct, field_value, rule])
|> update_result(resolution)
defp get_scope_field_value(arguments_source, fields) when is_list(fields), do: get_in(arguments_source, fields)
defp get_scope_field_value(arguments_source, field) when is_atom(field), do: Map.get(arguments_source, field)

defp has_context_access?(context, scope, {scope_field, field_value}, rule) do
Rajska.apply_auth_mod(context, :has_context_access?, [context, scope, {scope_field, field_value}, rule])
end

defp update_result(true, resolution), do: resolution
Expand Down
11 changes: 7 additions & 4 deletions lib/rajska.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,12 @@ defmodule Rajska do
def field_authorized?(nil, _scope_by, _source), do: false
def field_authorized?(%{id: user_id}, scope_by, source), do: user_id === Map.get(source, scope_by)

def has_user_access?(%user_struct{id: user_id} = current_user, scoped_struct, field_value, unquote(default_rule)) do
def has_user_access?(%user_struct{id: user_id} = current_user, scope, {field, field_value}, unquote(default_rule)) do
super_user? = current_user |> get_user_role() |> super_role?()
owner? = (user_struct === scoped_struct) && (user_id === field_value)
owner? =
(user_struct === scope)
&& (field === :id)
&& (user_id === field_value)

super_user? || owner?
end
Expand All @@ -132,10 +135,10 @@ defmodule Rajska do
|> field_authorized?(scope_by, source)
end

def has_context_access?(context, scoped_struct, field_value, rule) do
def has_context_access?(context, scope, {scope_field, field_value}, rule) do
context
|> get_current_user()
|> has_user_access?(scoped_struct, field_value, rule)
|> has_user_access?(scope, {scope_field, field_value}, rule)
end

defoverridable Authorization
Expand Down
Loading