Tutorial
Multi-tenancy and Phoenix - Part 2
This post was updated 01 May - 2020
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?
- I want to make sure that every row I store in the database has an account.
- Also make sure that the account that I point to actually exists in the accounts table
- 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
- Update
Secret
and setup the correct relation in the schema toAccount
- Go through
Secrets
and takeaccount
into account(!) for some of the function calls - 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.