I have a few LiveView pages used to inspect games in progress which are currently available to all users. I want to protect these routes so only users designated as admin have access. If an unauthenticated or unauthorized user visits an admin route, I want to return a 404 to not acknowledge the route even exists.

I first write a test that I know will fail since I haven’t written the code for the desired behavior.

defmodule MinotaurWeb.Admin.GameListLiveTest do
  @moduledoc false

  use MinotaurWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  import Minotaur.AccountsFixtures

  describe "when user is unauthenticated" do
    test "responds with 404", %{conn: conn} do
      assert_error_sent :not_found, fn ->
        live(conn, ~p"/admin/games")
      end
    end
  end
end

To make the test pass, I create a custom exception module that I can raise from the router pipeline for unauthorized requests to admin routes.

defmodule MinotaurWeb.UnauthorizedAdminAccessError do
  defexception [:message, plug_status: 404]
end

I create a new plug in the UserAuth module which is already imported in the application router module.

  def require_admin_user(conn, _opts) do
    user = conn.assigns[:current_user]

    if user && user.is_admin do
      conn
    else
      raise MinotaurWeb.UnauthorizedAdminAccessError, "User is not admin"
    end
  end

There is no is_admin key on a User struct so I add a virtual field to the User schema to allow the test to compile. I set the default value for is_admin to false which is enough to get my initial test to pass.

To test for users where is_admin is true, I will need to add the column to the database table. I create a new migration file for this:

defmodule Minotaur.Repo.Migrations.AddIsAdminToUsersTable do
  use Ecto.Migration

  def change do
    alter(table("users")) do
      add :is_admin, :boolean, default: false, null: false
    end
  end
end

I remove the :virtual key from the User schema, but keep the default value since calls to Repo.insert will only return the values that are sent to the insert query. If I set the default value only at the database level, this field would always be nil anytime the key was omitted on insert.

I also add a function to the Accounts module which promotes a user to an admin. I create additional test cases for a logged in non-admin user and a happy path for an admin user. Once these tests are passing, I deploy the changes to production. The admin routes are now protected!