Skip to content

Latest commit

 

History

History
321 lines (244 loc) · 8.22 KB

multitenancy.md

File metadata and controls

321 lines (244 loc) · 8.22 KB

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