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.