Tutorial

Multi-tenancy and Phoenix - Part 2

This post was updated 01 May - 2020

authentication multitenancy phoenix pow

In the previous tutorial, I wrote how to set up multi-tenancy with Phoenix and Pow. In this tutorial, I will show how I scope resources the current user's account. And also, how to load the current account in a plug and make the tests pass.

STEP 1 - Generate a new resource

The first step is to generate a new resource. Since it requires a sign in, I will call it Secrets. And since I want this to be scoped to a specific account, I need to make a relation to the accounts table. I will do so by adding a reference like account_id:references:accounts

mix tailwind.gen.html Secrets Secret secrets account_id:references:accounts

NOTE I use my own custom Tailwind html generator that I introduced in this tutorial

The first file to modify is the migration file. Change the line:

# priv/repo/migrations/20200316074930_create_secrets.exs

add :account_id, references(:accounts, on_delete: :nothing)

to this:

add :account_id, references(:accounts, on_delete: :delete_all), null: false

What did I actually change, and why?

  1. I want to make sure that every row I store in the database has an account.
  2. Also make sure that the account that I point to actually exists in the accounts table
  3. Lastly, if I delete an account row, I want to cascade and delete all secrets.

Next, I need to add resources "/secrets", SecretController to the routes before I run the migrations.

# lib/tutorial_web/router.ex

defmodule TutorialWeb.Router do

  ...

  scope "/", TutorialWeb do

    pipe_through [:protected, :browser]

    get "/private", PageController, :private
    resources "/secrets", SecretController
  end
  ...

end

With that in place, I can run the migration.

mix ecto.migrate

STEP 2 - Update context and tests

If I run the tests in test/tutorial/secrets_test.exs they would almost all fail.

To get that passing, I need to complete these 3 steps

  1. Update Secret and setup the correct relation in the schema to Account
  2. Go through Secrets and take account into account(!) for some of the function calls
  3. Update tests so I create an account and pass them in
defmodule Tutorial.Secrets.Secret do
  ...

  schema "secrets" do
    field :key, :string
    field :value, :string
    
    belongs_to :account, Tutorial.Accounts.Account

    timestamps()
  end
  ...

end

I also need to setup the relation in the Account module.

# lib/tutorial/accounts/account.ex

defmodule Tutorial.Accounts.Account do
  ...

  schema "accounts" do
    field :name, :string
    has_many :secrets, Tutorial.Secrets.Secret

    timestamps()
  end
  ...

end

In Secrets context module I basically need to change in three places. First is listing secrets list_secrets/0

# lib/tutorial/secrets.ex

def list_secrets do
  Repo.all(Secret)
end

Change that to list_secrets/1 and pass in the account

# lib/tutorial/secrets.ex

def list_secrets(account) do
  from(s in Secret, where: s.account_id == ^account.id, order_by: [asc: :id])
  |> Repo.all()
end

Further down is the get_secret!/1

# lib/tutorial/secrets.ex

def get_secret!(id), do: Repo.get!(Secret, id)

Change that to get_secret!/2 and again, pass in account

# lib/tutorial/secrets.ex

def get_secret!(account, id) do
  Repo.get_by!(Secret, account_id: account.id, id: id)
end

Finally, last function to change here is creating a secret. I need to change create_secret/1.

# lib/tutorial/secrets.ex

def create_secret(attrs \\ %{}) do
  %Secret{}
  |> Secret.changeset(attrs)
  |> Repo.insert()
end

Again, I need to pass in the account and build the relationship to secrets in create_secret/2

# lib/tutorial/secrets.ex

def create_secret(account, attrs \\ %{}) do
  Ecto.build_assoc(account, :secrets)
  |> Secret.changeset(attrs)
  |> Repo.insert()
end

Last part here is to update the tests. I need to add an account fixture so I can pass in an account to all the tests. I also setup all the data creation to a setup-function:

# test/tutorial/secrets_test.exs

def account_fixture() do
  {:ok, account} = Tutorial.Accounts.create_account(%{name: "Acme Corp"})
  account
end

def secret_fixture(account, attrs \\ %{}) do
  {:ok, secret} =
    Secrets.create_secret(
      account,
      Enum.into(attrs, @valid_attrs)
    )

  secret
end

setup do
  account = account_fixture()
  secret = secret_fixture(account)
  {:ok, %{account: account, secret: secret}}
end

Next I need to go through the test and change from

# test/tutorial/secrets_test.exs

test "list_secrets/0 returns all secrets" do
  secret = secret_fixture()
  assert Secrets.list_secrets() == [secret]
end

to

# test/tutorial/secrets_test.exs

test "list_secrets/0 returns all secrets", %{account: account, secret: secret} do
  assert Secrets.list_secrets(account) == [secret]
end

When I have updated all the tests, I can run in the console

mix test test/tutorial/secrets_test.exs

And they should all pass

STEP 3 - Create a plug for loading the account

When I have my context specs passing, I can now look at the controller tests. I already now know that I need the account in all the controller actions. I usually solve this by creating a plug that loads the account and assign it to the conn.

I will create the plug SetCurrentAccount and it works by if there is a current_user, that comes from the Pow Plug, then also get the current account and assign that.

# lib/tutorial_web/plugs/set_current_account.ex

defmodule TutorialWeb.Plugs.SetCurrentAccount do
  import Plug.Conn, only: [assign: 3]

  alias Tutorial.Repo
  alias Tutorial.Users.User

  def init(options), do: options

  def call(conn, _opts) do
    case conn.assigns[:current_user] do
      %User{} = user ->
        %User{account: account} = Repo.preload(user, :account)
        assign(conn, :current_account, account)
      _ ->
        assign(conn, :current_account, nil)
    end
  end
end

In the router, I will then add it after Pow.Plug.RequireAuthenticated

# lib/tutorial_web/router.ex

pipeline :protected do
  plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
  plug TutorialWeb.Plugs.SetCurrentAccount
end

STEP 4 - Updating Controller and tests

Last part here is to go through all the controller action functions and take the account from the conn and pass is it in when loading or creating the resource. So change from

# lib/tutorial_web/controllers/secret_controller.ex

defmodule TutorialWeb.SecretController do
  use TutorialWeb, :controller
  ...

  def index(conn, _params) do
    secrets = Secrets.list_secrets()
    render(conn, "index.html", secrets: secrets)
  end
  ...

end

to:

# lib/tutorial_web/controllers/secret_controller.ex

defmodule TutorialWeb.SecretController do
  use TutorialWeb, :controller
  ...

  def index(conn, _params) do
    current_account = conn.assigns.current_account
    secrets = Secrets.list_secrets(current_account)

    render(conn, "index.html", secrets: secrets)
  end
  ...

end

When it comes to the tests, I need to do two main things. First accommodate for creating an account and user fixtures. But I also need to remember that all routes are protected so I need to setup a login feature.

I deleted the setups that were generated in the tests and add:

# test/tutorial_web/controllers/secret_controller_test.exs

defmodule TutorialWeb.SecretControllerTest do
  use TutorialWeb.ConnCase

  alias Tutorial.Secrets
  alias Tutorial.Repo
  alias Tutorial.Users.User
  ...

  defp login(%{conn: conn}) do
    user = create_user()
    conn = Pow.Plug.assign_current_user(conn, user, otp_app: :mail_flow_admin)

    {:ok, conn: conn}
  end

  defp create_user do
    user_attrs = %{account_name: "Acme Corp", email: "john.doe@example.com", password: "SuperSecret123", password_confirmation: "SuperSecret123"}

    {:ok, user} = User.changeset(%User{}, user_attrs)
                  |> Repo.insert()

    user
    |> Tutorial.Repo.preload(:account)
  end

  defp create_secret(%{conn: conn}) do
    {:ok, secret} =
      case conn.assigns do
        %{current_user: %{account: account}} -> Secrets.create_secret(account, @create_attrs)
        _ ->
          another_account = create_user().account
          Secrets.create_secret(another_account, @create_attrs)
      end

    {:ok, secret: secret}
  end
  ...

end

Then I change so I have two versions for the tests. One when a user is signed in and the route is accessible and one without logging in and the user gets redirected.

# test/tutorial_web/controllers/secret_controller_test.exs

defmodule TutorialWeb.SecretControllerTest do

  ...

  describe "index as not logged in" do
    test "redirects to login", %{conn: conn} do
      conn = get(conn, Routes.secret_path(conn, :index))
      assert redirected_to(conn) =~ Routes.pow_session_path(conn, :new)
    end
  end

  describe "index" do
    setup [:login]

    test "lists all secrets", %{conn: conn} do
      conn = get(conn, Routes.secret_path(conn, :index))
      assert html_response(conn, 200) =~ "Secrets"
    end
  end

  ...
  # rest of the tests
end

So, after I have gone through the tests and created two versions of them, they should all pass.

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 15 Mar - 2020
Updated 01 May - 2020

Multi-tenancy and authentication with Pow

I basically model every app with multi-tenancy in mind. It is way easier to do it while building than to implement it as an after thought. Also, P..