by clemens (03.03.2023)

Using the power of macros for consistent authorization

Recently we had to implement user authorization for one of our apps. In that app a user can belong to many different projects, and users should only ever be able to modify a project if they are also a member of that project.

In our use case the authorization needed to be implemented right in the context modules, i.e. each function that requires authorization needs to be passed the user that should perform the operation and the project the operation is performed on. So let’s try to build this, and maybe we’ll find an actual nice use case for a mini DSL for the first time 😊.

In this project we are using the bodyguard library for performing the authorization checks, since we have had a good experience with it in the past.

Our first try looked something like this. First we defined our Policy file:

defmodule App.Policy do
  @behaviour Bodyguard.Policy

  @actions ~w(list_objects edit_object create_objects delete_object)a

  def authorize(action, user, project) when action in @actions do
    user in project.members
  end
end

As you can see the policy is the bare minimum: First the action must be matched to be one of the known actions. And then we check if the user is a member of the project. Looking good so far 👍.

On a side note, we decided to implement the policy like this so that you must add new actions before you can authorize them, thus forcing us to think about the correct authorization rules right away, and not only after we disover a leak in our application. Also this fails fast if you forget to do it, which is always a nice to have for security measures.

Next let’s add authorization to our edit_object function in our list_objects() function in the business module:

defmodule App.Business do
  require Logger

  def list_objects(user, project) do
    with :ok <- Bodyguard.permit(App.Policy, :list_objects, user, project) do
      Logger.info("AUTHORIZED user for #{inspect(action)}")

      Repo.all(Object)
    else
      error -> 
        Logger.warn("DENIED user for action: #{inspect(action)}")

        error
    end
  end
end

This is straight from the bodyguard example, and works fine. However typing all that stuff everytime for every function that needs to be authorized will be a lot of work. Let’s see if a helper function will make things easier?

defmodule App.Business do
  require Logger

  def list_objects(user, project) do
    authorize(:list_objects, user, project, fn -> 
      Repo.all(Object)
    end)
  end

  defp authorize(action, user, project, func) do
    with :ok <- Bodyguard.permit(App.Policy, action, user, project) do
      Logger.info("AUTHORIZED user for #{inspect(action)}")

      func.()
    else
      error -> 
        Logger.warn("DENIED user for action: #{inspect(action)}")

        error
    end
  end
end

Ok, this is better already. However we are going to need this in a lot of modules because there are many kinds of objects that users can manipulate. Let’s try to move this into a macro for a change:

defmodule App.Authorize do
  defmacro __using__(opts) do
    policy = Keyword.fetch!(opts, :policy)

    quote do
      require App.Authorize
      import App.Authorize

      require Logger

      defp authorize(action, user, project, func) do
        with :ok <- Bodyguard.permit(unquote(policy), action, user, project) do
          Logger.info("AUTHORIZED user for #{inspect(action)}")

          func.()
        else
          unauthorized ->
            Logger.warn("DENIED user for action: #{inspect(action)}")

            unauthorized
        end
      end
    end
  end

  defmacro auth(action, user project, func) do
    quote do
      authorize(unquote(action), unquote(user), unquote(project), unquote(func))
    end
  end
end

Now we can simplify our code in the business modules:

defmodule App.Business do
  use App.Authorize, policy: App.Policy

  def list_objects(user, project) do
    auth(:list_objects, user, project, fn -> 
      Repo.all(Object)
    end)
  end
end

Ok, that is looking much better already. But wait, the action argument is basically only the function name everytime, and the user and project arguments must be passed to the function at all times anyway. Can we use some magic ✨ here and automatically extract those values in the macro?

defmodule App.Authorize do
  defmacro __using__(opts) do
    ...
  end

  defmacro auth_user_project(func) do
    quote do
      user = var!(user)
      project = var!(project)

      authorize(unquote(elem(__CALLER__.function, 0)), user, project, fn ->
        unquote(block)
      end)
    end
  end
end

Note that we changed the name of the macro to make the magic that is happening a bit clearer. Also note that the macro is no longer hygienic. For this use case we recon that this is ok, because all we are doing is reading the value that is being passed to the function without modifying it. Now our business modules can look like this:

defmodule App.Business do
  use App.Authorize, policy: App.Policy

  def list_objects(user, project) do
    auth_user_project (fn -> 
      Repo.all(Object)
    end)
  end
end

This doesn’t look like to much code that we need to add for getting consistent authorization in our critical business code. But can we do even better?

defmodule App.Authorize do
  defmacro __using__(opts) do
    ...
  end
 
  defmacro auth_user_project(opts, do: block) do
    action = Keyword.get(opts, :action, elem(__CALLER__.function, 0))

    user =
      case Keyword.get(opts, :user) do
        nil ->
          quote do
            var!(user)
          end

        var_name ->
          quote do
            var!(unquote(var_name))
          end
      end

    project =
      case Keyword.get(opts, :project) do
        nil ->
          quote do
            var!(project)
          end

        var_name ->
          quote do
            var!(unquote(var_name))
          end
      end

    quote bind_quoted: [action: action, user: user, project: project, block: block] do
      authorize(action, user, project, fn ->
        block
      end)
    end
  end

  defmacro auth_user_project(do: block) do
    quote do
      auth_user_project([], do: unquote(block))
    end
  end
end

So this gives us automatic extraction of the action, user and project, but this can be overriden if necessary. The trick for achieving this was extracting the logic to either use the default name of the variable (i.e. user or project) or the passed variable in the options to outside of the main quote block and to then only inject the AST there. And moreover we’ve changed the macro so that we can now simply write a do block in the calling code, which looks more idiomatic then writing fn -> everywhere.

And with all those changes using our authorization macro looks like this:

defmodule App.Business do
  use App.Authorize, policy: App.Policy

  def list_objects(user, project) do
    auth_user_project do
      Repo.all(Object)
    end
  end

  def list_objects(user) do
    project = user.current_project

    auth_user_project project: project do
      Repo.all(Object)
    end
  end
end

What is left todo?

We could try to inject the user and project variables into the function parameters using a macro, however we find this a bit too much magic 🎩 for our liking. Also in cases like in the last example of the business logic where we pass only some of the parameters to the function because the other one is computed inside the function would make this rather complicated. And you could no longer at the function itself to see what parameters it takes, which would also hinder readability a lot.

So we’ve decided that this solution is good enough for our purpose and we’ll leave it be for now.