Create table migration

It’s time to implement database-backed persistence for Minotaur user records. I generate a migration file with mix ecto.gen.migration add_users_table and update the change callback with the logic generated by phx.gen.auth in my demo auth project.

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

  def change do
    execute "CREATE EXTENSION IF NOT EXISTS citext", ""

    create table(:users) do
      add :email, :citext, null: false
      add :hashed_password, :string, null: false
      add :username, :string, null: false

      timestamps(type: :utc_datetime)
    end

    create unique_index(:users, [:email])
    create unique_index(:users, [:username])
  end
end

I run the migration locally for dev and test environments which complete successfully. I commit the change and build a new Docker image for production.

Deploying the new image runs into an error on startup:

19:01:31.828 [info] == Running 20240711180107 Minotaur.Repo.Migrations.AddUsersTable.change/0 forward
19:01:31.836 [info] execute "CREATE EXTENSION IF NOT EXISTS citext"
** (Postgrex.Error) ERROR 42501 (insufficient_privilege) permission denied to create extension "citext"

    hint: Must have CREATE privilege on current database to create this extension.
    (ecto_sql 3.11.3) lib/ecto/adapters/sql.ex:1054: Ecto.Adapters.SQL.raise_sql_call_error/1
    (elixir 1.16.2) lib/enum.ex:1700: Enum."-map/2-lists^map/1-1-"/2
    (ecto_sql 3.11.3) lib/ecto/adapters/sql.ex:1161: Ecto.Adapters.SQL.execute_ddl/4
    (ecto_sql 3.11.3) lib/ecto/migration/runner.ex:348: Ecto.Migration.Runner.log_and_execute_ddl/3
    (elixir 1.16.2) lib/enum.ex:1700: Enum."-map/2-lists^map/1-1-"/2
    (ecto_sql 3.11.3) lib/ecto/migration/runner.ex:311: Ecto.Migration.Runner.perform_operation/3
    (stdlib 5.2.3) timer.erl:270: :timer.tc/2

When I created the application user for PostgreSQL, I only granted the least privileges needed to run basic migration operations. This did not include the permission to run CREATE EXTENSION. I grant the application user CREATE permission for the production database. The migration runs succesfully after deploying again.

Adding user schema

User records for the application are currently stored within the in-memory state of an Elixir Agent. The UserStore module encapsulates all logic for reading and writing to the agent state. Swapping out the agent logic for a database interface should only require modifying the UserStore module.

I apply the changes to the module which cleans up the code to a few simple Ecto calls.

defmodule Minotaur.Accounts.UserStore do
  @moduledoc false

  alias Minotaur.Accounts.User
  alias Minotaur.Repo

  def get_user(id) do
    Repo.get(User, id)
  end

  def insert(changeset) do
    Repo.insert(changeset)
  end

  def get_by_username(username) do
    Repo.get_by(User, username: username)
  end

  def get_by_email(email) do
    Repo.get_by(User, email: email)
  end
end

The test suite is still green with these changes which makes me happy about my previous decisions to separate the storage implementation details from the business logic and only write tests around behavior.

I commit the changes and deploy to production. User registrations now persist between deployments!

Storing session tokens

The current implementation for session tokens in the application does not have a way to invalidate a session. To add this behavior, session tokens will need to be peristed and only considered valid if they exist in storage. I create a new migration file and use the phx.gen.auth generated files as a baseline for buildng the table. I am not adding email or password reset tokens yet, but I’ll still add the context field to the table.

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

  def change do
    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false
      add :context, :string, null: false

      timestamps(type: :utc_datetime, updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end

I follow the same pattern of running migrations locally and deploying to production if everything looks good.

I swap out the existing session token module for the UserToken implementation from the generated files. I don’t bring over any functions related to email tokens or password reset tokens. I also copy over the generated test files for invalidating session tokens and update any application code required to make the tests pass.

Once tests are all green again, I commit the changes and deploy to production. The feature for invalidating session tokens is now live.