Psst. It would be super cool if you could try the new Phoenix Boilerplate!

Try now

Tutorial

View on Github

Multi-tenancy and Phoenix - Part 2

This post was updated 01 May

authenticationmultitenancyphoenixpow

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 users 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 key value

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. Firsts 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

Furter 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 trough 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
# test/tutorial/secrets_test.exs
test "list_secrets/0 returns all secrets" do
  secret = secret_fixture()
  assert Secrets.list_secrets() == [secret]
end
# 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 accomodate 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 where 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.


What are you working on?

If you want, you can send me a link to your Phoenix or Phoenix LiveView project so. Lets connect on Twitter or Linkedin.

- Andreas Eriksson, web developer since 2005

Related Tutorials

Published 15 Mar

Multi-tenancy and authentication with Pow

authenticationphoenixpow

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..

Published 30 Jul

Getting started with GraphQL and Absinthe in Phoenix

phoenixgraphqlapiabsinthe

In the last tutorial, there I had an app with a simple rest api that was authenticated with Guardian and Json Web Token. In this tutorial, I will go..