LetMe.Policy behaviour (LetMe v1.2.5)
View SourceThis 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
use LetMe.Policy
When you use LetMe.Policy
, the module will set @behaviour LetMe.Policy
and define all callback functions for that behaviour based on the macros
you use. It will also define an action
type based your rules.
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 theauthorize/4
callback. Defaults to:unauthorized
.error_message
- The error message used by theauthorize!/4
. 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
.
Summary
Callbacks
Authorizes a request defined by the action, subject and object.
Same as authorize/4
, but raises an error if unauthorized.
Same as authorize/4
, but returns a boolean.
Returns the rule for the given name. Returns an :ok
tuple or :error
.
Returns the rule with the given name. Raises if the rule is not found.
Takes a list of rules and only returns the rules that would evaluate to true
for the given subject and object.
Returns the object name for the given schema module or struct, if it was
registered using object/3
.
Returns the rule for the given rule name. Returns nil
if the rule is
not found.
Returns the schema module for the given object name, if it was registered
using object/3
.
Returns all authorization rules as a list.
Same as list_rules/0
, but takes a keyword list with filter options.
Functions
Defines an action that needs to be authorized.
Defines the checks to be run to determine if an action is allowed.
Defines the checks to be run to determine if an action is denied.
Allows you to add a description to a rule.
Assigns metadata to the action in the form of a key value pair.
Defines an object on which actions can be performed.
Registers one or multiple functions to run in order to hydrate the subject and/or object of the request.
Callbacks
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.
The last parameter is a set of arguments that can be defined dynamically
which will be passed into any pre_hook
s defined on the resource's policy.
Same as authorize/4
, but raises an error if unauthorized.
Example
With the same authorization rules as defined in the authorize/4
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
Same as authorize/4
, but returns a boolean.
Example
With the same authorization rules as defined in the authorize/4
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 fetch_rule(atom()) :: {:ok, LetMe.Rule.t()} | :error
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()) :: LetMe.Rule.t()
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 filter_allowed_actions([LetMe.Rule.t()], subject, object) :: [LetMe.Rule.t()] when subject: any(), object: {atom(), any()} | struct()
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
pass the struct without tagging it with the object name.
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: []
}
]
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_rule(atom()) :: LetMe.Rule.t() | nil
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
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 list_rules() :: [LetMe.Rule.t()]
Returns all authorization rules as a list.
Example
iex> MyApp.PolicyShort.list_rules() |> Enum.sort()
[
%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(keyword()) :: [LetMe.Rule.t()]
Same as list_rules/0
, but takes a keyword list with filter options.
See LetMe.filter_rules/2
for a list of available filter options.
Functions
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 allow(LetMe.Rule.check() | [LetMe.Rule.check()]) :: Macro.t()
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
orfalse
- Always allows or denies an action. Can be useful in combination with thedeny/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 deny(LetMe.Rule.check() | [LetMe.Rule.check()]) :: Macro.t()
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
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
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."
]
}
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 get_schema/1
, or find the object name
by passing the schema module or struct to get_object_name/1
now. Also,
you can now only pass the struct toc:MyApp.Policy.filter_allowed_actions/3
,
without explicitly passing the object name.
@spec pre_hooks(LetMe.Rule.hook() | [LetMe.Rule.hook()]) :: Macro.t()
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.
The referenced functions may also take an optional third argument that are
opts passed through the authorize
functions. These opts are merged into
any opts that are specified in the pre_hook
definition.
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: 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, age: 50}
allow min_age: 50
end
end
MyApp.Policy.authorize!(:article_view, %{age: 10})
# => true
You can achieve the same functionality dynamically using the opts on authorize!
:
object :article do
action :view do
pre_hooks {MyApp.Policy.Checks, :set_age}
allow min_age: 50
end
end
MyApp.Policy.authorize!(:article_view, %{age: 10}, age: 50)
# => 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