View Source LetMe.Policy behaviour (LetMe v1.2.4)

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

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 the authorize/4 callback. Defaults to :unauthorized.
  • error_message - The error message used by the authorize!/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

Link to this callback

authorize(atom, any, any, keyword)

View Source
@callback authorize(atom(), any(), any(), keyword()) :: :ok | {:error, any()}

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_hooks defined on the resource's policy.

Link to this callback

authorize!(atom, any, any, keyword)

View Source
@callback authorize!(atom(), any(), any(), keyword()) :: :ok

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
Link to this callback

authorize?(atom, any, any, keyword)

View Source
@callback authorize?(atom(), any(), any(), keyword()) :: boolean()

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: []
}
Link to this callback

filter_allowed_actions(list, subject, object)

View Source
@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: []
  }
]
@callback get_object_name(module()) :: atom() | nil

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
@callback get_schema(atom()) :: module() | 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

Link to this macro

action(names, list)

View Source (macro)
@spec action(atom() | [atom()], Macro.t()) :: Macro.t()

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 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 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
@spec desc(String.t()) :: Macro.t()

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
Link to this macro

metadata(key, value)

View Source (macro)
@spec metadata(atom(), term()) :: Macro.t()

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."
  ]
}
Link to this macro

object(name, module \\ nil, list)

View Source (macro)
@spec object(atom(), module() | nil, Macro.t()) :: Macro.t()

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.

Link to this macro

pre_hooks(hooks)

View Source (macro)
@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