View Source Multitenancy with Pow
You can pass repo options to the functions used in Pow.Ecto.Context
by using the :repo_opts
configuration option. This makes it possible to pass on the prefix option used in multitenancy apps, so you can do the following:
config :my_app, :pow,
# ...
repo_opts: [prefix: "tenant_a"]
You can also pass the prefix option to Pow.Plug.Session
in your WEB_PATH/endpoint.ex
:
plug Pow.Plug.Session, otp_app: :my_app, repo_opts: [prefix: "tenant_a"]
And you can add it as a custom plug to use a dynamic prefix value (in this case the conn.private[:tenant_prefix]
has been set beforehand):
defmodule MyAppWeb.Pow.TenantPlug do
def init(config), do: config
def call(conn, config) do
prefix = conn.private[:tenant_prefix]
config = Keyword.put(config, :repo_opts, [prefix: prefix])
Pow.Plug.Session.call(conn, config)
end
end
Process dictionary
Another common approach is to use the process dictionary. In that case you won't have to pass any repo opts. You just use your modified MyApp.Repo
module to set and fetch the tenant id in a controller or plug. It could look like:
defmodule MyAppWeb.SetTenantPlug do
def init(opts), do: opts
def call(conn, _opts_) do
MyApp.Repo.put_org_id(conn.private[:tenant_org_id])
conn
end
end
Triplex
Triplex is a database multitenancy package. Pow can be easily configured to work with Triplex.
Update your WEB_PATH/endpoint.ex
using a custom plug rather than the default Pow.Plug.Session
:
# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# ...
# You should load the tenant with Triplex before calling the
# `TriplexSessionPlug`. If you use the `ParamPlug` you could add it here:
# plug Triplex.ParamPlug, param: :subdomain
# ...
plug Plug.Session, @session_options
plug MyAppWeb.Pow.TriplexSessionPlug, otp_app: :my_app
# ...
end
Then set up WEB_PATH/pow/triplex_session_plug.ex
:
# lib/my_app_web/pow/triplex_session_plug.ex
defmodule MyAppWeb.Pow.TriplexSessionPlug do
def init(config), do: config
def call(conn, config) do
tenant = conn.assigns[:current_tenant] || conn.assigns[:raw_current_tenant]
prefix = Triplex.to_prefix(tenant)
config = Keyword.put(config, :repo_opts, [prefix: prefix])
Pow.Plug.Session.call(conn, config)
end
end
Triplex create tenant migration with user
You may want to create an new tenant with a Pow user as part of account registration.
First we'll add the account context module that will create an account with a user using an Ecto.Multi
transaction:
# test/my_app/accounts.exs
defmodule MyApp.Accounts do
alias Ecto.Changeset
alias MyApp.{Repo, Users.User}
@otp_app :my_app
@tenant_param :tenant
def create_account(tenant_id, params) do
tenant_id
|> Triplex.create_schema(Repo, fn (tenant, repo) ->
do_create_account(tenant, repo, params)
end)
|> case do
{:ok, tenant} -> {:ok, Repo.one!(User, prefix: tenant)}
{:error, %Changeset{} = changeset} -> {:error, changeset}
{:error, reason} -> invalid_tenant_changeset_error(params, "couldn't be created", reason)
end
end
defp do_create_account(tenant, repo, params) do
pow_config =
[
otp_app: @otp_app,
repo: repo,
user: User,
repo_opts: [prefix: tenant]
]
Ecto.Multi.new()
|> Ecto.Multi.run(:triplex_migration, fn repo, %{} ->
Triplex.migrate(tenant, repo)
end)
|> Ecto.Multi.run(:user, fn _, _ ->
Pow.Ecto.Context.create(params, pow_config)
end)
|> repo.transaction()
|> case do
{:ok, any} ->
{:ok, any}
{:error, :triplex_migration, reason, _} ->
invalid_tenant_changeset_error(params, "couldn't be created", [reason: reason])
{:error, :user, changeset, _} ->
{:error, changeset}
end
end
defp invalid_tenant_changeset_error(params, error, keys) do
changeset =
%User{}
|> User.changeset(params)
|> Changeset.add_error(@tenant_param, error, keys)
{:error, changeset}
end
end
Next we'll add the controller:
# lib/my_app_web/controllers/account_controller.ex
defmodule MyAppWeb.AccountController do
use MyAppWeb, :controller
alias MyApp.Accounts
def new(conn, _params) do
render(conn, "new.html")
end
def create(conn, %{"account" => tenant_id, "user" => user_params}) do
case Accounts.create_account(tenant_id, user_params) do
{:ok, user} ->
conn
|> Pow.Plug.create(user)
|> put_flash(:info, "Welcome!")
|> redirect(to: ~p"/")
{:error, changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
end
Now all you need is to set up the template, and update your router module.
Triplex test modules
# lib/my_app_web/pow/triplex_session_plug_test.exs
defmodule MyAppWeb.Pow.TriplexSessionPlugTest do
use MyAppWeb.ConnCase
alias MyAppWeb.Pow.TriplexSessionPlug
@pow_config [otp_app: :my_app]
@tenant_a "tenant_a"
@tenant_b "tenant_b"
@valid_user_params %{
"email" => "test@example.com",
"password" => "password",
"password_confirmation" => "password"
}
setup do
{:ok, conn: init_conn()}
end
test "handles Triplex tenants", %{conn: conn} do
opts = TriplexSessionPlug.init(@pow_config)
{:ok, _user, _conn} =
conn
|> set_triplex_tenant(@tenant_a)
|> TriplexSessionPlug.call(opts)
|> Pow.Plug.create_user(@valid_user_params)
{:error, _conn} =
conn
|> set_triplex_tenant(@tenant_b)
|> TriplexSessionPlug.call(opts)
|> Pow.Plug.authenticate_user(@valid_user_params)
{:ok, _conn} =
conn
|> set_triplex_tenant(@tenant_a)
|> TriplexSessionPlug.call(opts)
|> Pow.Plug.authenticate_user(@valid_user_params)
end
defp init_conn() do
:get
|> Plug.Test.conn("/")
|> Plug.Test.init_test_session(%{})
|> fetch_flash()
end
defp set_triplex_tenant(conn, tenant) do
conn = %{conn | params: %{"subdomain" => tenant}}
opts = Triplex.ParamPlug.init(param: :subdomain)
Triplex.ParamPlug.call(conn, opts)
end
end
# test/my_app/accounts_test.exs
defmodule MyApp.AccountsTest do
use MyApp.DataCase
alias MyApp.{Repo, Accounts}
@tenant_id "test_tenant"
@tenant_param :tenant
@valid_params %{email: "test@example.com", password: "secret1234", password_confirmation: "secret1234"}
setup do
Ecto.Adapters.SQL.Sandbox.mode(Repo, :auto)
Triplex.drop(@tenant_id, Repo)
on_exit fn ->
Triplex.drop(@tenant_id, Repo)
end
:ok
end
test "create_account/2" do
assert {:ok, user} = Accounts.create_account(@tenant_id, @valid_params)
assert user.__meta__.prefix == @tenant_id
assert {:error, changeset} = Accounts.create_account(@tenant_id, @valid_params)
assert changeset.errors[@tenant_param] == {"couldn't be created", "ERROR 42P06 (duplicate_schema) schema \"#{@tenant_id}\" already exists"}
end
end
# test/my_app_web/controllers/account_controller_test.exs
defmodule MyAppWeb.AccountControllerTest do
use MyAppWeb.ConnCase
alias MyApp.Repo
@tenant_id "test-tenant"
setup do
Ecto.Adapters.SQL.Sandbox.mode(Repo, :auto)
Triplex.drop(@tenant_id, Repo)
on_exit fn ->
Triplex.drop(@tenant_id, Repo)
end
:ok
end
describe "create/2" do
@valid_params %{"account" => @tenant_id, "user" => %{"email" => "test@example.com", "password" => "secret1234", "password_confirmation" => "secret1234"}}
@invalid_params %{"account" => @tenant_id, "user" => %{"email" => "test@example.com", "password" => "secret1234"}}
test "with invalid params", %{conn: conn} do
conn = post(conn, ~p"/account", @invalid_params)
assert html_response(conn, 500)
refute Pow.Plug.current_user(conn)
end
test "with valid params", %{conn: conn} do
conn = post(conn, ~p"/account", @valid_params)
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Welcome!"
assert redirected_to(conn) == ~p"/"
assert Pow.Plug.current_user(conn)
end
end
end