Giter VIP home page Giter VIP logo

let_me's Introduction

LetMe

CI Hex Hex Docs Coverage Status

LetMe is an authorization library for Elixir.

It aims to give you an easy, readable and flexible way of defining authorization rules by defining a simple DSL, while also giving you introspection functions that allow you to answer questions like:

  • Which actions are defined in my application?
  • What are the conditions for a certain action?
  • Which actions are allowed for a user with a certain role?

Installation

Add LetMe to mix.exs :

def deps do
  [
    {:let_me, "~> 1.0.2"}
  ]
end

Add LetMe to .formatter.exs:

[
  import_deps: [:let_me]
]

Policy module

The heart of LetMe is the Policy module. It provides a set of macros for defining the authorization rules of your application. These rules are compiled to authorization and introspection functions.

A policy module for a simple article CRUD interface might look like this:

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

Whether you want to have a single policy module for your whole application, or one per context, or whether you want to break it up in any other way is up to you.

Check module

In general, authorization rules are based on the subject (usually the current user), the object on which the action is performed, and the action (verb). LetMe does not make any assumptions on the authorization model or check implementation.

The checks passed to LetMe.Policy.allow/1 reference functions in the check module (by default __MODULE__.Checks, so in the example MyApp.Policy.Checks). Each check function must take the subject, the object, and optionally an additional argument, and must return a boolean value.

The check module for the policy module above might look like this:

defmodule MyApp.Policy.Checks do
  alias MyApp.Accounts.User

  @doc """
  Returns `true` if the `banned` flag is set on the user.
  """
  def banned(%User{banned: banned}, _, _), do: banned

  @doc """
  Checks whether the user ID of the object matches the ID of the current user.

  Assumes that the object has a `:user_id` field.
  """
  def own_resource(%User{id: id}, %{user_id: id}, _opts) when is_binary(id), do: true
  def own_resource(_, _, _), do: false

  @doc """
  Checks whether the user role matches the role passed as an option.

  ## Usage

      allow role: :editor

  or

      allow {:role, :editor}
  """
  def role(%User{role: role}, _object, role), do: true
  def role(_, _, _), do: false
end

Callbacks

With use LetMe.Policy at the top of your policy module, LetMe will generate several functions for you.

  • Authorization functions: See c:LetMe.Policy.authorize/3, c:LetMe.Policy.authorize!/3 and c:LetMe.Policy.authorize?/3.
  • Introspection functions: See c:LetMe.Policy.list_rules/0, c:LetMe.Policy.list_rules/1, c:LetMe.Policy.get_rule/1 and others.

Authorization

You can use the authorization functions wherever you need to make authorization decisions, for example in your context module:

defmodule MyApp.Blog do
  alias MyApp.Accounts.User
  alias MyApp.Blog.Article
  alias MyApp.Policy

  def list_articles(%User{} = current_user) do
    with :ok <- Policy.authorize(:article_read, current_user) do
      {:ok, Repo.all(Article)}
    end
  end

  def fetch_article(id, %User{} = current_user) do
    with :ok <- Policy.authorize(:article_read, current_user, id) do
      case Repo.get(Article, id) do
        nil -> {:error, :not_found}
        article -> {:ok, article}
      end
    end
  end

  def create_article(params, %User{} = current_user) do
    with :ok <- Policy.authorize(:article_create, current_user) do
      %Article{}
      |> Article.changeset(params)
      |> Repo.insert()
    end
  end

  def update_article(%Article{} = article, params, %User{} = current_user) do
    with :ok <- Policy.authorize(:article_update, current_user, article) do
      article
      |> Article.changeset(params)
      |> Repo.update()
    end
  end

  def delete_article(%Article{} = article, %User{} = current_user) do
    with :ok <- Policy.authorize(:article_delete, current_user, article) do
      Repo.delete(article)
    end
  end
end

Introspection

With the introspection functions, you can get the complete list of authorization rules, e.g. to display them in a documentation page:

iex> MyApp.Policy.list_rules()
[
  %LetMe.Rule{
    action: :create,
    allow: [
      [role: :admin],
      [role: :writer]
    ],
    deny: [],
    description: nil,
    name: :article_create,
    object: :article,
    pre_hooks: []
  },
  # ...
]

You can also find a rule by its name:

iex> MyApp.Policy.get_rule(:article_create)
%LetMe.Rule{
  action: :create,
  allow: [
    [role: :admin],
    [role: :writer]
  ],
  name: :article_create,
  object: :article,
  # ...
}

Or you can list all actions tied to a certain role (or any other check):

iex> MyApp.Policy.list_rules(allow: {:role, :writer})
[
  %LetMe.Rule{
    action: :create,
    object: :article,
    # ...
  },
  %LetMe.Rule{
    action: :update,
    object: :article,
    # ...
  }
]

Scoped queries

Even if a user is generally allowed to see a certain resource type, they may only be allowed to see a subset of the data. For example, in a blog system, a user might only be allowed to see published articles, unless they are a writer. Or in a system where users belong to companies, a company user might only be allowed to see users who belong to the same company.

In order to scope your queries depending on the user type, you can implement the c:LetMe.Schema.scope/3 callback of the LetMe.Schema behaviour, usually in your Ecto schema module.

defmodule MyApp.Blog.Article do
  use Ecto.Schema
  use LetMe.Schema

  import Ecto.Query
  alias MyApp.Accounts.User

  # Ecto schema and changeset

  @impl LetMe.Schema
  def scope(q, user, opts \\ nil)
  def scope(q, %User{role: :editor}, _), do: q
  def scope(q, %User{role: :writer}, _), do: q
  def scope(q, %User{}, _), do: where(q, published: true)
end

Here, the Ecto query is modified to only return published articles, unless the user is an editor or writer. You can use the third argument for additional options.

With this, you can then update your list and fetch functions:

def list_articles(%User{} = current_user) do
  with :ok <- Policy.authorize(:article_read, current_user) do
    articles =
      Article
      |> Article.scope(current_user)
      |> Repo.all()

    {:ok, articles}
  end
end

def fetch_article(id, %User{} = current_user) do
  with :ok <- Policy.authorize(:article_read, current_user, id) do
    result =
      Article
      |> where(id: ^id)
      |> Article.scope(current_user)
      |> Repo.one()

    case result do
      nil -> {:error, :not_found}
      article -> {:ok, article}
    end
  end
end

Looks familiar? Yes, this is nearly the same as in Bodyguard. However, Bodyguard exposes a Bodyguard.scope/2 function, which does nothing more than to derive the Ecto schema module from the Ecto.Queryable and dispatch the call to that module. But since you usually know which schema module you are dealing with, I believe this is of limited use, and hence you need to call the scope/2 function of your Ecto schema directly. The behaviour then only serves to enforce the pattern.

Field redactions

Sometimes a user may be allowed to retrieve a resource, but some fields should be hidden. For example, a user might be allowed to see some general information about another user, such as the name and avatar, but should not be able to see contact information like the email or the phone number. You could handle situations like this by conditionally showing or hiding certain information in the frontend, but it would be cleaner if the context functions would not return those fields in the first place.

To assist in these kinds of situations, the LetMe.Schema behaviour has another callback: c:LetMe.Schema.redacted_fields/3.

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  use LetMe.Schema

  alias MyApp.Accounts.User

  # Ecto schema and changeset

  @impl LetMe.Schema
  def redacted_fields(%User{}, %User{role: :admin}, _), do: []
  def redacted_fields(%User{id: id}, %User{id: id}, _), do: []
  def redacted_fields(%User{}, %User{}, _), do: [:email, :phone_number]
end

The redacted_fields/2 function takes the object as the first argument and the subject as the second argument. The third argument can be used to pass any additional options. The function returns a list of fields that need to be redacted.

In the example above, all fields are visible if the user is an admin or if the user that is viewed (the object) is the same as the current user (the subject). In all other cases, the email and phone number are hidden.

You have two options to handle field redactions:

  1. Modify the query to not select the redacted fields.
  2. Redact the fields after retrieving the resource(s) from the database.

Modifying the query

You can get the non-virtual schema fields with the __schema__/1 function that Ecto defines in your Ecto module, reject the redacted fields from the list and then add a select clause with only the unredacted fields.

def list_users(%User{} = current_user) do
  fields = User.__schema__(:fields)
  filtered_fields = LetMe.reject_redacted_fields(fields, %User{}, current_user)

  Article
  |> select(^filtered_fields)
  |> Repo.all()
end

The advantage of this method is that the fields won't even be transferred from the DB.

The drawback is that you cannot make decisions on which fields to select based on data in the struct. Used with the redacted_fields/2 function above, we can make sure that admins can see all fields, but we can not make sure that users can see all fields of their own user account.

Another drawback is that all redacted fields will be returned as nil, and you won't be able to distinguish which fields were redacted and which fields are just empty, which might be information you would want to display in the frontend.

Also, you might have more complex select clauses that are not compatible with this syntax.

Redacting the query result

To mitigate these shortcomings, you can do the redactions after retrieving the data from the database using LetMe.redact/2.

def list_articles(%User{} = current_user) do
  Article
  |> Repo.all()
  |> LetMe.redact(current_user)
end

The redact function can handle structs, lists of structs, and nil values.

Why would I want to use this library?

  • You want an easy-to-read DSL for authorization rules that still allows you to implement your authorization checks in any way you want.
  • You prefer to put your authorization rules into your business layer and decouple them from your interfaces.
  • You prefer to keep your authorization rules in one place (or one place per context, or similar).
  • You want to generate a list of authorization rules.
  • You want to filter your authorization rules, e.g. to find out which actions are available to a certain user role.
  • You want a library that can also help you with query scopes and field redactions.
  • You want a library with zero dependencies.

Why wouldn't I want to use this library?

  • You prefer to couple authorization checks to your interfaces.
  • You prefer to use plugs or middlewares for authorization checks and want the necessary plumbing ready for use (you can of course build your own plugs and middlewares around the functions of this library).
  • You don't like DSLs and prefer to write functions (note that the DSL only describes which checks to run and how to apply them, though; you still write the actual checks as regular functions).
  • You don't care about introspection.
  • You want to return details on why an authorization request fails. Checks in LetMe must currently return a boolean value, which means you'll only be able to give your users a generic error, without telling them which exact check failed.

Status

This library is actively maintained, but since it has no dependencies and the feature set is narrowly scoped, you may not see a whole lot of updates. Should you miss something, though, don't hesitate to open an issue.

Alternatives

For comparison, please have a look at these Elixir libraries:

You might also find the article Authorization for Phoenix Contexts helpful.

let_me's People

Contributors

woylie avatar dependabot[bot] avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.