Tutorial

Multi-tenancy and Phoenix - Part 2

This post was updated 27 Mar

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 to 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/my_app_web/router.ex
defmodule MyAppWeb.Router do
...
scope "/", MyAppWeb 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/my_app/secrets_test.exs they would almost all fail.

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

  1. Update Secret and set up 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 it in
defmodule MyApp.Secrets.Secret do
...
schema "secrets" do
field :key, :string
field :value, :string
belongs_to :account, MyApp.Accounts.Account
timestamps()
end
...
end

I also need to set up the relation in the Account module.

# lib/my_app/accounts/account.ex
defmodule MyApp.Accounts.Account do
...
schema "accounts" do
field :name, :string
has_many :secrets, MyApp.Secrets.Secret
timestamps()
end
...
end

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

# lib/my_app/secrets.ex
def list_secrets do
Repo.all(Secret)
end

Change that to list_secrets/1 and pass in the account

# lib/my_app/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/my_app/secrets.ex
def get_secret!(id), do: Repo.get!(Secret, id)

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

# lib/my_app/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/my_app/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/my_app/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 set up all the data creation in a setup function:

# test/my_app/secrets_test.exs
def account_fixture() do
{:ok, account} = MyApp.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 tests and change from

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

to

# test/my_app/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/my_app/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 know that I need the account in all the controller actions. I usually solve this by creating a plug that loads the account and assigns it to the conn.

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

# lib/my_app_web/plugs/set_current_account.ex
defmodule MyAppWeb.Plugs.SetCurrentAccount do
import Plug.Conn, only: [assign: 3]
alias MyApp.Repo
alias MyApp.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/my_app_web/router.ex
pipeline :protected do
plug Pow.Plug.RequireAuthenticated, error_handler: Pow.Phoenix.PlugErrorHandler
plug MyAppWeb.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 it in when loading or creating the resource. So change from

# lib/my_app_web/controllers/secret_controller.ex
defmodule MyAppWeb.SecretController do
use MyAppWeb, :controller
...
def index(conn, _params) do
secrets = Secrets.list_secrets()
render(conn, "index.html", secrets: secrets)
end
...
end

to:

# lib/my_app_web/controllers/secret_controller.ex
defmodule MyAppWeb.SecretController do
use MyAppWeb, :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 set up a login feature.

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

# test/my_app_web/controllers/secret_controller_test.exs
defmodule MyAppWeb.SecretControllerTest do
use MyAppWeb.ConnCase
alias MyApp.Secrets
alias MyApp.Repo
alias MyApp.Users.User
...
defp login(%{conn: conn}) do
user = create_user()
conn = Pow.Plug.assign_current_user(conn, user, otp_app: :my_app)
{: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
|> MyApp.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/my_app_web/controllers/secret_controller_test.exs
defmodule MyAppWeb.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

NEW
Published 27 Mar

Adding Modals to Phoenix 1.8 with DaisyUI

In Phoenix 1.8, the built-in modal component was removed. Instead, Phoenix now encourages developers to use separate LiveView pages for new and edit..

Published 15 Mar - 2020
Updated 27 Mar

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