Bego Solutions

Setting up auditing for a phoenix_live_view application

Introduction

As I’m sure it is with many other Elixir companies, the product I work on uses PhoenixLiveView to build an admin dashboard.

For a security compliance reason, I got asked to figure out if it’s possible to somehow record every action the admin user performs in such an application, for auditing purposes.

Turns out, it’s very possible, and as it is with many things Elixir, in relatively few lines of code.

What is an action?

Thinking about how a live view works, it has lifecycle hooks

  • mount - called when the page is opened up and then once more when the socket connects
  • handle_params - called when params are received or changed from the url
  • handle_event - called when an even is triggered, usually a user interaction
  • handle_info - called when a message is sent to the live view process, maybe a pub-sub message, or an old-school async result
  • handle_async - called when a more modern async result is received

Anything that happens in the live view, happens through one of these lifecycle hooks, so it would make sense to somehow boost them to do more.

How do these hooks work?

These functions, you define in your live view and you actually only need to define those that are valid for your page. If there are no events, you don’t need a handle_event, etc.

This is done via macros in the Phoenix.LiveView module. You’re supposed to use Phoenix.LiveView which then calls the __using__ macro, which sets everything up for you.

Usually, this is done indirectly by calling use MyAppWeb, :live_view.

Could we do the same?

We sure could. There are several tools in the shed we can use.

  • using macro as an entry point to set everything up
  • @behavior to ensure your live views define the necessary callbacks
  • @before_compile as a way to predefine some functions in your live view process module
  • defoverridable to grant the ability to override these functions
  • the Module module, specifically Module.defines? to see which functions actually need to be setup

How do we do it?

Let’s give an example of a simplified LiveAudit module. We plug it into our app like this:

# my_app_web.ex
def live_view do
  quote do
    use Phoenix.LiveView, layout: {MyAppWeb.Layouts, :app}
    use MyApp.LiveAudit

    unquote(html_helpers())
  end
end

And then a cut down version looks like this:

defmodule LiveAudit do
  @moduledoc """
  ...
  """

  require Logger

  @typep hook :: 
    :mount | :handle_async | :handle_params 
    | :handle_event | :handle_info | :update,

  @callback audit(hook, hook_data :: %{}) :: 
    {:audit, data_to_record :: %{}} | :ignore

  def handle_audit(:ignore, _view_module), do: :ok

  def handle_audit({:audit, %{} = data_to_audit}, view_module) do
    view_module = 
        view_module |> Atom.to_string() |> String.replace("Elixir.", "")

    params = %{
      params
      | data: Map.put(params.data, :module, view_module)
    }

    AuditModule.record!(params)

    Logger.info("Audit log entry created",
      user_id: params.user_id,
      data: %{
        action: params.action, 
        event: params.data[:event], 
        module: view_module
      }
    )
  end

  defmacro __using__(_opts) do
    quote do
      @behaviour LiveAudit

      def audit(:mount, %{}) do
        raise "You did not define an audit callback for :mount"
      end

      def audit(:update, %{}) do
        raise "You did not define an audit callback for :update"
      end

      # ... same for :handle_event, :handle_async, :handle_info

      defoverridable LiveAudit

      @before_compile {LiveAudit, :mount_audit}
      @before_compile {LiveAudit, :handle_async_audit}
      @before_compile {LiveAudit, :handle_params_audit}
      @before_compile {LiveAudit, :handle_event_audit}
      @before_compile {LiveAudit, :handle_info_audit}
    end
  end

  defmacro mount_audit(_env) do
    quote do
      if Module.defines?(__MODULE__, {:mount, 3}) do
        defoverridable mount: 3

        def mount(params, session, socket) do
          result = super(params, session, socket)

          :mount
          |> __MODULE__.audit(%{
            params: params, 
            session: session, 
            result: result
          })
          |> LiveAudit.handle_audit(socket.view)

          result
        end
      end
    end
  end


  defmacro handle_async_audit(_env) do
    # ...
  end

  defmacro handle_params_audit(_env) do
    # ...
  end

  defmacro handle_event_audit(_env) do
    # ...
  end

  defmacro handle_info_audit(_env) do
    # ...
  end
end

So what do we have? Using the LiveAudit module inside a live view module does the following

Adds the LiveAudit behavior to the module

This ensures the live view module must define an audit function that accepts a hook name in atom form as the first argument and a map as the second.

It returns either an {:audit, data_to_audit} tuple or an :ignore

That way, every live view module must, no exceptions, explicitly decide whether to audit or not, the outcome of every lifecycle hook. If the function is not defined, a compiler warning will be shown.

Defines a handle_audit/2 function

This is the single function we use to record data into an audit log. Everything else will be calling this. How we implement this one depends on the app we use this in.

Wraps the already defined lifecycle callbacks, using @before_compile

Lets look once more at the example of wrapping mount/3

defmacro mount_audit(_env) do
  quote do
    if Module.defines?(__MODULE__, {:mount, 3}) do
      defoverridable mount: 3

      def mount(params, session, socket) do
        result = super(params, session, socket)

        :mount
        |> __MODULE__.audit(%{params: params, session: session, result: result})
        |> LiveAudit.handle_audit(socket.view)
 
        result
      end
    end
  end
end

Note that this code is executing within the live view module using LiveAudit.

If the module defines a mount/3 function already, we first make it overridable using defoverridable, then define a new such function.

This new function calls the original using super, then takes the result, passes it into __MODULE__.audit/2, which is the function you must define in your live view module. The result of that is either :ignore or {:audit, data_to_audit} and this is then piped into LiveAudit.handle_audit/2 to record the data.

The mount/3 callback will always be defined in a livew view module, but we use Module.defines? for other callbacks, which aren’t always there.

Defines default audit callbacks for every lifecycle hook,

By that, I mean this part:

def audit(:mount, %{}) do
  raise "You did not define an audit callback for :mount"
end

def audit(:update, %{}) do
  raise "You did not define an audit callback for :update"
end

# ... same for :handle_event, :handle_async, :handle_info

We don’t really, absolutely need these, but many elixir apps have really spammy logs, so unless you do mix compile --warnings-as-errors in your CI, that “your behavior is missing an implementation” warning might get lost. This way, as you’re developing your app, an error will be raised after a lifecycle hook, if your audit/2 function is missing.

How well does it work?

It’s a bit boilerplatey, sure, and a lot of the handle_event events, or handle_params, don’t really need to be audited, but it’s very effective catch all.

Every new live view we create will be erroring out unless we immediately define an audit/2 function in it.

This works great as a reminder, but of course, is still easily broken if you just do

def audit(_, _), do: :ignore

Se we could certainly improve it or approach it differently.

There are a couple other options that might be a better way to achieve this

socket.assigns / socket.private

defmacro mount_audit(_env) do
  quote do
    if Module.defines?(__MODULE__, {:mount, 3}) do
      defoverridable mount: 3

      def mount(params, session, socket) do
        {:ok, socket} = super(params, session, socket)

        socket.assigns 
        |> Map.fetch!(:audit) 
        |> LiveAudit.handl_audit(socket.view)

        {:ok, socket}
      end
    end
  end
end

With this approach, instead of requiring the behavior that defines an audit/2 callback, we require every lifecylce hook to assign an audit key to the socket.

It will still error out, but we now have to be explicit for every clause of mount/3, handle_event/3, etc, rather than just once in a single spot in the livew view module.

There’s also potentially less boilerplate, but the audit code we do have to write this way is more scattered and less uniform.

Instead of the socket assigns, in this case, we could also use socket.private, using Phoenix.LiveView.put_private. This is no changed-tracked and is in fact a better place to put this data.

Result tuple

We could also have make the wrappers expect a different kind of response from the original lifecylce hook

For example

defmacro mount_audit(_env) do
  quote do
    if Module.defines?(__MODULE__, {:mount, 3}) do
      defoverridable mount: 3

      def mount(params, session, socket) do
        case super(params, session, socket) do
          {:ignore, result} -> 
            result
          {:audit, audit_data, result} -> 
            LiveAudit.handle_audit(audit_data, socket.view)
            result
        end
      end
    end
  end
end

This provides similar benefits to socket.assigns, or socket.private but might be a bit more uniform in how we use it.

Effectively, each lifecylce hook now needs to return a tuple that wraps what would’ve been the original result or in other words, what was before

def mount(_, _, socket) do
  # do stuff
  {:ok, socket}
end

is now

def mount(_, _, socket) do
  # do stuff
  {:audit, %{foo: "bar"}, {:ok, socket}}
end

That would work, but it feels weird to me.

Conclusion

The original implementation works just fine for us, but I might make the stretch and actually implement the less boilerplatey one, using socket.private. It feels like it could be more powerful.

Working on this task had me learn quite a bit about use modules in Elixir. The concept of super is something I’ve glossed over in the past, but it’s the first time I’m actually looking at it and I’m surprised it’s even there. It feels very OOP :).