Should user permissions / data correctness logic live in my Ecto validations or Context logic?

I’m trying to do it like you describe in approach B. I define “policies” for protected resources and call them from the context.

@spec create_transaction(map, by: %User{}) :: {:ok, %Transaction{}} | {:error, Ecto.Changeset.t} | no_return
def create_transaction(attrs, by: %User{id: uid} = user) do
  Policy.authorize!(:create_transaction, user)

  %Transaction{user_id: uid}
  |> Transaction.changeset(attrs)
  |> Repo.insert()
end

and then in ledger/policy.ex I have something like this

defmodule Ledger.Policy do
  alias MyApp.Authorization.UnauthorizedError
  
  @typep action :: :create_transaction
  @typep actor :: %User{}

  @spec authorized?(action, actor) :: boolean
  defp authorized?(:create_transaction, %User{role: :admin}), do: true
  defp authorized?(:create_transaction, _user}), do: false

  # usually I have authorize!(action, target, actor) though
  @spec authorize!(action, actor) :: nil | no_return
  def authorize!(action, actor) do
    unless authorized?(action, actor) do
      raise(UnauthorizedError, message: "nah ah")
    end
  end
end

And the exception has a plug_status to be rendered correctly on raise

defmodule MyApp.Authorization.UnauthorizedError do
  defexception message: "you are not authorized to access this resource :(",
               plug_status: 403
end

A lot of boilerplate though … It also causes a stacktrace to be generated which might hurt performance.

Can you show how Approach A would work? I don’t quite get it.

I’m also thinking about how I could move some of the above to the database. Because I don’t know how to handle get queries like

def get(resource_id, by: _user) do
  # authorize somehow
  Repo.get(Resource, resource_id)
end

since the result of my authorization depends on the resource being fetched.

btw, the controllers end up looking the same as if there was no authorization

def create(conn, %{"transaction_params"= > params}, current_user) do
  case Ledger.create_transaction(params, by: current_user) do
    {:ok, %Ledger.Transaction{} = transaction} ->
      render(conn, "show.html", transaction: transaction)
    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end
4 Likes