defmodule LetMe.Policy do
@moduledoc """
This module defines a DSL for authorization rules and compiles these rules
to authorization and introspection functions.
## Usage
defmodule MyApp.Policy do
use LetMe.Policy
object :article do
# Creating articles is allowed if the user role is `editor` or `writer`.
action :create do
allow role: :editor
allow role: :writer
end
# Viewing articles is always allowed, unless the user is banned.
action :read do
allow true
deny :banned
end
# Updating an article is allowed if (the user role is `editor`) OR
# (the user role is `writer` AND the article belongs to the user).
action :update do
allow role: :editor
allow [:own_resource, role: :writer]
end
# Deleting an article is allowed if the user is an editor.
action :delete do
allow role: :editor
end
end
end
### Options
These options can be passed when using this module:
- `check_module` - The module where the check functions are defined. Defaults
to `__MODULE__.Checks`.
- `error_reason` - The error reason used by the `c:authorize/3` callback.
Defaults to `:unauthorized`.
## Check module
The checks passed to `allow/1` and `deny/1` reference the names of functions
in the check module.
By default, LetMe tries to find the functions in `__MODULE__.Checks` (in the
example, this would be `MyApp.Policy.Checks`). However, you can override the
default check module:
use LetMe.Policy, check_module: MyApp.AuthChecks
Each check function has to take the subject (user), the object, and optionally
an additional argument, and must return a boolean value.
For example, this check determines whether a user is banned:
def banned(%User{banned: true}, _), do: true
def banned(%User{}, _), do: false
This check determines whether the user has the given role:
def role(%User{role: role}, _, role), do: true
def role(_, _, _), do: false
And this check determines whether the object belongs to the user:
def own_resource(%User{id: user_id}, %{user_id: user_id}), do: true
def own_resource(_, _), do: false
LetMe does not make any assumptions about your access control model, as long
as you can map your rules to subject, object and action. You can use the three
rules above with the `allow/1` and `deny/1` macros.
allow role: :admin
allow :own_resource
deny :banned
## Combining checks
Rules evaluate to `false` by default. These rules will always be `false`
because they don't have any `allow` clauses:
action :create do
end
action :update do
deny false
end
Trying to evaluate a rule name that does not exist also evaluates to `false`.
As soon as one `deny` check evaluates to `true`, the whole rule will evaluate
to `false`. This rule will always evaluate to `false`:
action :create do
allow true
deny true
end
If you pass a list of checks to either `allow/1` or `deny/1`, the checks
are combined with a logical `AND`.
# false
action :create do
allow [true, false]
end
# true
action :create do
allow [true, true]
end
# true
action :create do
allow [true, true]
deny [true, false]
end
# false
action :create do
allow [true, true]
deny [true, true]
end
On the other hand, if either the `allow/1` or the `deny/1` macro is used
multiple times, the checks are combined with a logical `OR`.
# true
action :create do
allow true
allow false
end
# false
action :create do
allow [true, false]
allow false
end
# true
action :create do
allow [true, false]
allow true
end
# false
action :create do
allow [true, true]
allow true
deny false
deny true
end
## Pre-hooks
You can use pre-hooks to process or gather additional data about the subject
and/or object before running the checks. This can be useful if you need to
preload associations or make external requests. Pre-hooks run once per
authorization request before running the checks. See the documentation for
`pre_hooks/1`.
"""
alias LetMe.Rule
@doc """
Returns all authorization rules as a list.
## Example
iex> MyApp.PolicyShort.list_rules()
[
%LetMe.Rule{
action: :create,
allow: [[role: :admin], [role: :writer]],
deny: [],
name: :article_create,
object: :article,
pre_hooks: []
},
%LetMe.Rule{
action: :update,
allow: [:own_resource],
deny: [],
name: :article_update,
object: :article,
pre_hooks: [:preload_groups]
}
]
"""
@callback list_rules :: [LetMe.Rule.t()]
@doc """
Same as `c:list_rules/0`, but takes a keyword list with filter options.
See `LetMe.filter_rules/2` for a list of available filter options.
"""
@callback list_rules(keyword) :: [LetMe.Rule.t()]
@doc """
Takes a list of rules and only returns the rules that would evaluate to `true`
for the given subject and object.
## Examples
The object can be passed as a tuple, where the first element is the
object name, and the second element is the actual object, e.g.
`{:article, %Article{}}`.
iex> rules = MyApp.Policy.list_rules()
iex> MyApp.Policy.filter_allowed_actions(
...> rules,
...> %{id: 2, role: nil},
...> {:article, %MyApp.Blog.Article{}}
...> )
[
%LetMe.Rule{
action: :view,
allow: [true],
deny: [],
description: "allows to view an article and the list of articles",
name: :article_view,
object: :article,
pre_hooks: []
}
]
If you registered the schema module with `LetMe.Policy.object/3`, you can also
pass the schema module or the struct instead of a tuple.
iex> rules = MyApp.Policy.list_rules()
iex> MyApp.Policy.filter_allowed_actions(
...> rules,
...> %{id: 2, role: nil},
...> %MyApp.Blog.Article{}
...> )
[
%LetMe.Rule{
action: :view,
allow: [true],
deny: [],
description: "allows to view an article and the list of articles",
name: :article_view,
object: :article,
pre_hooks: []
}
]
"""
@callback filter_allowed_actions([LetMe.Rule.t()], subject, object) ::
[LetMe.Rule.t()]
when subject: any, object: {atom, any} | struct
@doc """
Returns the rule for the given name. Returns an `:ok` tuple or `:error`.
The rule name is an atom with the format `{object}_{action}`.
## Example
iex> MyApp.Policy.fetch_rule(:article_create)
{:ok,
%LetMe.Rule{
action: :create,
allow: [[role: :admin], [role: :writer]],
deny: [],
name: :article_create,
object: :article,
pre_hooks: []
}}
iex> MyApp.Policy.fetch_rule(:cookie_eat)
:error
"""
@callback fetch_rule(atom) :: {:ok, LetMe.Rule.t()} | :error
@doc """
Returns the rule with the given name. Raises if the rule is not found.
The rule name is an atom with the format `{object}_{action}`.
## Example
iex> MyApp.Policy.fetch_rule!(:article_create)
%LetMe.Rule{
action: :create,
allow: [[role: :admin], [role: :writer]],
deny: [],
name: :article_create,
object: :article,
pre_hooks: []
}
"""
@callback fetch_rule!(atom) :: LetMe.Rule.t()
@doc """
Returns the schema module for the given object name, if it was registered
using `object/3`.
## Examples
iex> MyApp.Policy.get_schema(:article)
MyApp.Blog.Article
iex> MyApp.Policy.get_schema(:user)
nil
"""
@callback get_schema(atom) :: module | nil
@doc """
Returns the object name for the given schema module or struct, if it was
registered using `object/3`.
## Examples
iex> MyApp.Policy.get_object_name(MyApp.Blog.Article)
:article
iex> MyApp.Policy.get_object_name(%MyApp.Blog.Article{})
:article
iex> MyApp.Policy.get_object_name(MyApp.Blog.Tag)
nil
"""
@callback get_object_name(module) :: atom | nil
@doc """
Authorizes a request defined by the action, subject and object.
## Example
Assume we defined this authorization rule:
object :article do
action :update do
allow :own_resource
end
end
And the `:own_resource` check is defined as:
def own_resource(%{id: user_id}, %{user_id: user_id}), do: true
def own_resource(_, _), do: false
The rule name consists of the object and the action name, in this case
`:article_create`. To authorize the action, we need to pass the rule name, the
subject (current user) and the object (the article to be updated).
iex> article = %{id: 80, user_id: 1}
iex> user_1 = %{id: 1}
iex> user_2 = %{id: 2}
iex> MyApp.Policy.authorize(:article_update, user_1, article)
:ok
iex> MyApp.Policy.authorize(:article_update, user_2, article)
{:error, :unauthorized}
If the checks don't require the object, it can be omitted.
object :user do
action :list do
allow {:role, :admin}
allow {:role, :client}
end
end
iex> user = %{id: 1, role: :admin}
iex> MyApp.Policy.authorize(:user_list, user)
:ok
iex> user = %{id: 2, role: :user}
iex> MyApp.Policy.authorize(:user_list, user)
{:error, :unauthorized}
The error reason can be customized by setting the `:error_reason` option when
using the module.
"""
@callback authorize(atom, any, any) :: :ok | {:error, any}
@doc """
Same as `c:authorize/3`, but raises an error if unauthorized.
## Example
With the same authorization rules as defined in the `c:authorize/3`
documentation, we get this:
iex> article = %{id: 80, user_id: 1}
iex> user_1 = %{id: 1}
iex> user_2 = %{id: 2}
iex> MyApp.Policy.authorize!(:article_update, user_1, article)
:ok
iex> MyApp.Policy.authorize!(:article_update, user_2, article)
** (LetMe.UnauthorizedError) unauthorized
"""
@callback authorize!(atom, any, any) :: :ok
@doc """
Same as `c:authorize/3`, but returns a boolean.
## Example
With the same authorization rules as defined in the `c:authorize/3`
documentation, we get this:
iex> article = %{id: 80, user_id: 1}
iex> user_1 = %{id: 1}
iex> user_2 = %{id: 2}
iex> MyApp.Policy.authorize?(:article_update, user_1, article)
true
iex> MyApp.Policy.authorize?(:article_update, user_2, article)
false
"""
@callback authorize?(atom, any, any) :: boolean
@doc """
Returns the rule for the given rule name. Returns `nil` if the rule is
not found.
The rule name is an atom with the format `{object}_{action}`.
## Example
iex> MyApp.Policy.get_rule(:article_create)
%LetMe.Rule{
action: :create,
allow: [[role: :admin], [role: :writer]],
deny: [],
name: :article_create,
object: :article,
pre_hooks: []
}
iex> MyApp.Policy.get_rule(:cookie_eat)
nil
"""
@callback get_rule(atom) :: LetMe.Rule.t() | nil
defmacro __using__(opts \\ []) do
opts =
Keyword.validate!(opts,
check_module: Module.concat(__CALLER__.module, Checks),
error_reason: :unauthorized
)
quote do
Module.put_attribute(__MODULE__, :opts, unquote(opts))
Module.register_attribute(__MODULE__, :schemas, accumulate: true)
Module.register_attribute(__MODULE__, :rules, accumulate: true)
Module.register_attribute(__MODULE__, :actions, accumulate: true)
Module.register_attribute(__MODULE__, :allow_checks, accumulate: true)
Module.register_attribute(__MODULE__, :deny_checks, accumulate: true)
Module.register_attribute(__MODULE__, :pre_hooks, accumulate: true)
@behaviour LetMe.Policy
import LetMe.Policy
import LetMe.Builder
require Logger
@before_compile unquote(__MODULE__)
@after_compile unquote(__MODULE__)
end
end
defmacro __before_compile__(env) do
opts = Module.get_attribute(env.module, :opts)
schemas = Module.get_attribute(env.module, :schemas)
rules =
env.module
|> Module.get_attribute(:rules)
|> Enum.reverse()
|> Enum.into(%{}, &{:"#{&1.object}_#{&1.action}", &1})
introspection_functions = LetMe.Builder.introspection_functions(rules)
authorize_functions = LetMe.Builder.authorize_functions(rules, opts)
schema_functions = LetMe.Builder.schema_functions(schemas)
quote do
unquote(introspection_functions)
unquote(authorize_functions)
unquote(schema_functions)
end
end
defmacro __after_compile__(env, _) do
rules = Module.get_attribute(env.module, :rules)
validate_no_duplicate_rules!(rules, env.module)
validate_no_duplicate_checks!(rules, env.module)
end
defp validate_no_duplicate_rules!(rules, module) do
duplicate_rules =
rules
|> Enum.frequencies_by(&{&1.object, &1.action})
|> Enum.filter(fn {_, count} -> count > 1 end)
# coveralls-ignore-start
if duplicate_rules != [] do
rules_as_string =
Enum.map_join(duplicate_rules, "\n ", fn {{object, action}, _} ->
"object: #{inspect(object)}, action: #{inspect(action)}"
end)
raise """
duplicate authorization rules
The policy module #{module} has duplicate authorization rules.
#{rules_as_string}
Look out for actions that are defined twice for the same object.
"""
end
# coveralls-ignore-end
:ok
end
defp validate_no_duplicate_checks!(rules, module) do
Enum.each(rules, fn rule ->
do_validate_no_duplicate_checks!(rule, :allow, module)
do_validate_no_duplicate_checks!(rule, :deny, module)
end)
end
defp do_validate_no_duplicate_checks!(rule, field, module) do
rule
|> Map.fetch!(field)
|> Enum.each(fn
checks when is_list(checks) ->
duplicate_checks =
checks
|> Enum.frequencies()
|> Enum.filter(fn {_, count} -> count > 1 end)
|> Enum.map(&elem(&1, 0))
# coveralls-ignore-start
if duplicate_checks != [] do
raise """
duplicate authorization checks
The policy module #{module} has duplicate authorization checks.
object: #{rule.object}
action: #{rule.action}
macro: #{field}/1
#{inspect(duplicate_checks)}
"""
end
# coveralls-ignore-end
_ ->
:ok
end)
end
@doc """
Defines an action that needs to be authorized.
Within the do-block, you can use the `allow/1`, `deny/1` and `pre_hooks/1`
macros to define the checks to be run and the `desc/1` macro to add a
description.
This macro must be used within the do-block of `object/2`.
Each `action` block will be compiled to a rule. The rule name is an atom with
the format `{object}_{action}`.
## Example
object :article do
action :create do
allow role: :editor
allow role: :writer
end
action :update do
allow role: :editor
allow [:own_resource, role: :writer]
end
end
If you have multiple actions with the same allow and deny rules, you can also
pass a list of action names as the first argument.
object :article do
action [:create, :update, :delete] do
allow role: :editor
allow role: :writer
end
end
"""
@spec action(atom | [atom], Macro.t()) :: Macro.t()
defmacro action(names, do: block) do
names = List.wrap(names)
quote do
# reset attributes from previous `action/2` calls
Module.delete_attribute(__MODULE__, :allow_checks)
Module.delete_attribute(__MODULE__, :description)
Module.delete_attribute(__MODULE__, :deny_checks)
Module.delete_attribute(__MODULE__, :pre_hooks)
Module.delete_attribute(__MODULE__, :metadata)
# compile inner block
unquote(block)
for name <- unquote(names) do
Module.put_attribute(__MODULE__, :actions, %{
name: name,
allow: get_acc_attribute(__MODULE__, :allow_checks),
description: Module.get_attribute(__MODULE__, :description),
deny: get_acc_attribute(__MODULE__, :deny_checks),
pre_hooks:
__MODULE__ |> get_acc_attribute(:pre_hooks) |> List.flatten(),
metadata: get_acc_attribute(__MODULE__, :metadata)
})
end
end
end
@doc """
Defines the checks to be run to determine if an action is allowed.
The argument can be:
- a function name as an atom
- a tuple with the function name and an additional argument
- a list of function names or function/argument tuples
- `true` or `false` - Always allows or denies an action. Can be useful in
combination with the `deny/1` macro.
The function must be defined in the configured check module and take the
subject (current user), object as arguments, and if given, the additional
argument.
If a list is given as an argument, the checks are combined with a logical
`AND`.
If the `allow/1` macro is used multiple times within the same `action/2`
block, the checks of each macro call are combined with a logical `OR`.
## Examples
Let's assume you defined the following checks:
defmodule MyApp.Policy.Checks do
def role(%User{role: role}, _, role), do: true
def role(_, _, _), do: false
def own_resource(%User{id: id}, %{user_id: id}, _), do: true
def own_resource(_, _, _), do: false
end
This would allow the `:article_update` action only if the current user has
the role `:admin`:
object :article do
action :update do
allow role: :admin
end
end
This is equivalent to:
object :article do
action :update do
allow {:role, :admin}
end
end
This would allow the `:article_update` action if the user has the role
`:writer` _and_ the article belongs to the user:
object :article do
action :update do
allow [:own_resource, role: :writer]
end
end
This is equivalent to:
object :article do
action :update do
allow [:own_resource, {:role, :writer}]
end
end
This would allow the `:article_update` action if
(the user has the role `:admin` _or_ (the user has the role `:writer` _and_
the article belongs to the user)):
object :article do
action :update do
allow role: :admin
allow [:own_resource, role: :writer]
end
end
"""
@spec allow(LetMe.Rule.check() | [LetMe.Rule.check()]) :: Macro.t()
defmacro allow(checks) do
quote do
Module.put_attribute(__MODULE__, :allow_checks, unquote(checks))
end
end
@doc """
Allows you to add a description to a rule.
The description can be accessed from the `LetMe.Rule` struct. You can use it
to generate help texts or documentation.
## Example
object :article do
action :create do
desc "allows a user to create a new article"
allow role: :writer
end
end
"""
@spec desc(String.t()) :: Macro.t()
defmacro desc(text) do
quote do
Module.put_attribute(__MODULE__, :description, unquote(text))
end
end
@doc """
Defines the checks to be run to determine if an action is denied.
If any of the checks evaluates to `true`, the `allow` checks are overridden
and the authorization request is automatically denied.
If a list is given as an argument, the checks are combined with a logical
`AND`.
If the `allow/1` macro is used multiple times within the same `action/2`
block, the checks of each macro call are combined with a logical `OR`.
## Examples
Let's assume you defined the following checks:
defmodule MyApp.Policy.Checks do
def role(%User{role: role}, _, role), do: true
def role(_, _, _), do: false
def own_resource(%User{id: id}, %{user_id: id}, _), do: true
def own_resource(_, _, _), do: false
def same_user(%User{id: id}, %User{id: id}, _), do: true
def same_user(_, _, _), do: false
end
This would allow the `:user_delete` action by default, _unless_ the object is
the current user:
object :user do
action :delete do
allow true
deny :same_user
end
end
This would allow the `:article_update` action only if the current user has
the role `:admin`, _unless_ the object is the current user:
object :user do
action :delete do
allow role: :admin
deny :same_user
end
end
This would allow the `:user_delete` by default, _unless_ the object is the
current user _and_ the current user is an admin:
object :user do
action :delete do
allow true
deny [:same_user, role: :admin]
end
end
This would allow the `:user_delete` by default, _unless_ the object is the
current user _or_ the current user is a peasant:
object :user do
action :delete do
allow true
deny :same_user
deny role: :peasant
end
end
"""
@spec deny(LetMe.Rule.check() | [LetMe.Rule.check()]) :: Macro.t()
defmacro deny(checks) do
quote do
Module.put_attribute(__MODULE__, :deny_checks, unquote(checks))
end
end
@doc """
Assigns metadata to the action in the form of a key value pair.
The metadata can be accessed from the `LetMe.Rule` struct. You can use it
to extend the functionality of the library.
## Example
object :article do
action :create do
allow role: :writer
desc "Allows a user to create a new article."
metadata :desc_ja, "ユーザーが新しい記事を作成できるようにする"
end
end
The `LetMe.Rule` struct returned by the introspection functions would look
like this:
%LetMe.Rule{
action: :create,
allow: [[role: :writer]],
deny: [],
description: "Allows a user to create a new article.",
name: :article_create,
object: :article,
pre_hooks: [],
metadata: [
desc_ja: "ユーザーが新しい記事を作成できるようにする"
]
}
It is also possible to use the `metadata` macro multiple times.
object :article do
action :create do
allow role: :writer
desc "Allows a user to create a new article."
metadata :desc_ja, "ユーザーが新しい記事を作成できるようにする"
metadata :desc_es, "Permite al usuario crear un nuevo artículo."
end
end
This would result in:
%LetMe.Rule{
action: :create,
allow: [[role: :writer]],
deny: [],
description: "Allows a user to create a new article.",
name: :article_create,
object: :article,
pre_hooks: [],
metadata: [
desc_ja: "ユーザーが新しい記事を作成できるようにする",
desc_es: "Permite al usuario crear un nuevo artículo."
]
}
"""
@spec metadata(atom(), term()) :: Macro.t()
defmacro metadata(key, value) do
unless is_atom(key) do
raise """
Invalid metadata key.
Expected an atom, got: #{inspect(key)}
"""
end
quote do
current_meta = get_acc_attribute(__MODULE__, :metadata)
new_meta = {unquote(key), unquote(value)}
Module.put_attribute(__MODULE__, :metadata, [new_meta | current_meta])
end
end
@doc """
Defines an object on which actions can be performed.
Within the do-block, you can use the `action/2` macro to define the actions
and checks.
## Examples
object :article do
action :create do
allow role: :writer
end
action :delete do
allow role: :editor
end
end
You can optionally pass the schema module as the second argument. The schema
module should implement the `LetMe.Schema` behaviour.
object :article, MyApp.Blog.Article do
action :create do
allow role: :writer
end
end
At the moment, this doesn't do much, except that you can find the schema
module by passing the object name to `c:get_schema/1`, or find the object name
by passing the schema module or struct to `c:get_object_name/1` now. Also,
you can now only pass the struct to`c:MyApp.Policy.filter_allowed_actions/3`,
without explicitly passing the object name.
"""
@spec object(atom, module | nil, Macro.t()) :: Macro.t()
defmacro object(name, module \\ nil, do: block) do
quote do
if unquote(module) do
Module.put_attribute(
__MODULE__,
:schemas,
{unquote(name), unquote(module)}
)
end
# reset attributes from previous `object/2` calls
Module.delete_attribute(__MODULE__, :actions)
# compile inner block
unquote(block)
for action <- Module.get_attribute(__MODULE__, :actions, []) do
Module.put_attribute(__MODULE__, :rules, %Rule{
action: action.name,
allow: action.allow,
deny: action.deny,
description: action.description,
name: :"#{unquote(name)}_#{action.name}",
object: unquote(name),
pre_hooks: action.pre_hooks,
metadata: action.metadata
})
end
end
end
@doc """
Registers one or multiple functions to run in order to hydrate the subject
and/or object of the request.
This is useful if you need to enhance the data for multiple checks in the
same action by preloading associations, making external requests, or similar
things. The configured hook functions will be called once before running the
checks for an action.
The referenced functions must take the subject and object as arguments and
return a 2-tuple with the updated subject and object.
## Examples
Let's assume we defined these check and hook functions in our check module:
def MyApp.Policy.Checks do
# Checks
def min_age(%{age: age}, _, min_age), do: age >= min_age
# Hooks
def double_age(subject, object) do
new_subject = %{subject | age: subject.age * 2}
{new_subject, object}
end
def set_age(subject, object, age) do
new_subject = %{subject | age: age}
{new_subject, object}
end
end
If an atom is passed, LetMe will try to find the function in the check module.
object :article do
action :view do
pre_hooks :double_age
allow min_age: 50
end
end
With this in place, the following authorization request will evaluate to
`true`:
MyApp.Policy.authorize!(:article_view, %{age: 25})
# => true
If your hooks are defined in a different module, you can also pass a
module/function tuple. The pre-hook configuration above is equivalent to:
object :article do
action :view do
pre_hooks {MyApp.Policy.Checks, :double_age}
allow min_age: 50
end
end
You can also pass options to a hook by using an MFA tuple:
object :article do
action :view do
pre_hooks {MyApp.Policy.Checks, :set_age, 50}
allow min_age: 50
end
end
MyApp.Policy.authorize!(:article_view, %{age: 10})
# => true
And finally, you can also pass a list of hooks, which will be run in sequence:
alias MyApp.Policy.Checks
object :article do
action :view do
pre_hooks [{Checks, :set_age, 25}, :double_age]
allow min_age: 50
end
end
MyApp.Policy.authorize!(:article_view, %{age: 10})
# => true
"""
@spec pre_hooks(LetMe.Rule.hook() | [LetMe.Rule.hook()]) :: Macro.t()
defmacro pre_hooks(hooks) do
quote do
Module.put_attribute(__MODULE__, :pre_hooks, unquote(hooks))
end
end
end