Elixir is one of my favorite languages! Dealing in functions, pipes, Process-Oriented
programming, OTP and a great community make working in Elixir a great experience.
However, there isn’t a better workflow/means to limit access control across
modules (for example: protected
in ruby
). Because of this deficiency, I often
find myself working in a project where apps try to make calls to modules that
they shouldn’t.
One classic example that comes to mind is a phoenix app in an umbrella. Often
times people make calls to the Repo
in the ecto app from Phoenix
controllers.
In phoenix
1.3, Contexts
were introduced which add an implicit layer of abstraction around a set of
database operations. The idea was to use these context
s as an API to make
changes to the database. This was a great step forward and a clean solution to
the above problem, besides the fact that you could still make calls to the Repo
.
So, Using context
s as means of communication with the database wasn’t enforced
and I didn’t like that.
Recently, I came across mix xref
task which can be used to do cross reference
checks between the modules of a mix project. So, I did the obvious, use it to
enforce better design for my applications.
Let’s take an umbrella of apps, my_app_umbrella
with apps: my_app
and
my_app_web
.
Here my_app
is the ecto
app which has MyApp.Repo
defined and my_app_web
is the phoenix app.
We can create a new mix task which checks for cross reference and raises an error
if apps other than my_app/
try to reference MyApp.Repo
.
# in mix.exs of the umbrella project.
defmodule MyApp.Umbrella.Mixfile do
use Mix.Project
@allowed_xrefs [{MyApp.Repo, [~r{apps/my_app/}]}]
def project do
[
apps_path: "apps",
start_permanent: Mix.env == :prod,
deps: deps(),
aliases: aliases()
]
end
defp deps do
[]
end
defp aliases do
[
"test.xref": &test_xref/1,
]
end
defp test_xref(_) do
Enum.each(@allowed_xrefs, fn {callee, callers} ->
case System.cmd("sh", ["-c", "mix xref callers #{callee}"]) do
{result, 0} ->
check_allowed(callers, callee, result)
_ -> raise "Unexpected Error"
end
end)
end
defp check_allowed(callers, callee, string) do
string
|> String.split("==>")
|> Enum.map(&String.trim/1)
|> Enum.filter(& &1 != "")
|> Enum.each(&check_allowed_for_app(&1, callee, callers))
require Logger
Logger.info("All ok")
end
def check_allowed_for_app(app_string, callee, callers) do
[app | files] = String.split(app_string, "\n")
case (files
|> Enum.map(& "apps/#{app}/" <> &1)
|> Enum.filter(& !match_callers?(callers, &1))) do
[] -> :ok
files -> raise "Tried to access #{callee} from #{Enum.join(files, ", ")}.. Not allowed!"
end
end
def match_callers?(callers, file) do
Enum.any?(callers, &String.match?(file, &1))
end
end
Here the module attribute, @allowed_xrefs
, is a list of tuples. First element
of the tuple is a callee
(the module whose access we want to control) and the
second element of the tuple is a list of caller
s (list of regexps of files
which are allowed to access the callee
). In this example, callee
is MyApp.Repo
and callers
include ~r{apps/my_app/}
. This means only files in the app my_app
can make calls to the callee, MyApp.Repo
.
This allows us to run $ mix test.xref
to check if there are any files calling
modules that we have limited access to.
This task can further be extended to functions of a callee
, to limit calls
to a particular function in a module.
Check out mix xref
for
more information on the task and to extend the usage of $ mix test.xref
.