Multi-tenancy w PostgreSQL i Phoenix - jeden system, 50 klientów, zero wycieków danych

Piątek, 17:00. Dzwoni telefon. Klient Twojego systemu SaaS - firma logistyczna z Gdańska - właśnie zobaczył na dashboardzie zamówienia firmy kurierskiej z Wrocławia. Nie swoje zamówienia. Cudze. Z cenami, adresami, danymi kontaktowymi.

Jak to możliwe? Programista zapomniał dodać WHERE tenant_id = ? w jednym zapytaniu. Jednym z dwustu. W piątkowym deployu, o 16:30, żeby zdążyć przed weekendem.

Wyciek danych między tenantami to nie bug. To katastrofa. RODO, utrata zaufania, potencjalne pozwy. I to katastrofa, której nie powinno być w ogóle możliwe wywołać jednym brakującym WHERE.

Istnieją trzy podejścia do izolacji danych w systemach multi-tenant. Każde eliminuje ryzyko wycieku na innym poziomie. Każde ma inne koszty. W Elixirze i PostgreSQL wszystkie trzy działają natywnie.

Trzy podejścia do multi-tenancy

1. Shared tables + Row-Level Security (RLS)

Wszyscy tenanci w tych samych tabelach. Kolumna tenant_id w każdej tabeli. PostgreSQL sam filtruje dane na poziomie bazy danych - nawet jeśli aplikacja zapomni o WHERE.

2. Schema-per-tenant (Ecto prefix)

Każdy tenant ma własny zestaw tabel w osobnym schemacie PostgreSQL. Tenant "acme" ma tenant_acme.orders, tenant_acme.users. Schemat "beta" ma tenant_beta.orders, tenant_beta.users. Fizycznie oddzielone, logicznie w jednej bazie.

3. Database-per-tenant

Każdy tenant ma własną bazę danych. Najsilniejsza izolacja. Niezależne backupy, niezależna wydajność, niezależne skalowanie.

WymiarRLS (shared tables)Schema-per-tenantDatabase-per-tenant
IzolacjaLogiczna (policy PostgreSQL)Logiczna (schemat)Fizyczna (osobna baza)
Skala tenantów10 000+10-50010-100
Overhead per tenantZero (jedna tabela)Metadane schematuPula połączeń + pamięć
MigracjeJedna migracja dla wszystkichN migracji (jedna per schemat)N migracji (jedna per bazę)
Backup/restoreCała baza (wszyscy tenanci)Per schemat (pg_dump -n)Per baza (niezależny)
Customizacja per tenantMinimalnaOsobne indeksy/rozszerzeniaPełna swoboda
Ryzyko wycieku danychNiskie (policy chroni)Bardzo niskie (oddzielne tabele)Zerowe (oddzielna baza)
Złożoność wdrożeniaNiskaŚredniaWysoka

Podejście 1: Row-Level Security

RLS to mechanizm PostgreSQL, który dodaje niewidoczny filtr do każdego zapytania. Programista pisze SELECT * FROM orders - PostgreSQL wykonuje SELECT * FROM orders WHERE tenant_id = 42. Automatycznie. Bez możliwości pominięcia.

Migracja - włączenie RLS

defmodule MyApp.Repo.Migrations.EnableRLS do
  use Ecto.Migration

  def up do
    create table(:orders) do
      add :title, :string, null: false
      add :total, :decimal
      add :tenant_id, references(:tenants, on_delete: :delete_all), null: false
      timestamps()
    end

    create index(:orders, [:tenant_id])

    # Włącz RLS
    execute "ALTER TABLE orders ENABLE ROW LEVEL SECURITY"
    # Wymuszaj RLS nawet dla właściciela tabeli
    execute "ALTER TABLE orders FORCE ROW LEVEL SECURITY"

    # Policy: wiersz widoczny tylko gdy tenant_id = bieżący tenant z sesji
    execute """
    CREATE POLICY tenant_isolation ON orders
      USING (tenant_id = current_setting('app.current_tenant')::bigint)
      WITH CHECK (tenant_id = current_setting('app.current_tenant')::bigint)
    """
  end

  def down do
    execute "DROP POLICY IF EXISTS tenant_isolation ON orders"
    execute "ALTER TABLE orders DISABLE ROW LEVEL SECURITY"
    drop table(:orders)
  end
end

Ustawianie kontekstu tenanta w Ecto

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  alias Ecto.Adapters.SQL

  def with_tenant(tenant_id, fun) when is_function(fun, 0) do
    transaction(fn ->
      # set_config z trzecim argumentem true = ustawienie lokalne dla transakcji
      # Automatycznie czyszczone po zakończeniu transakcji
      SQL.query!(__MODULE__, "SELECT set_config('app.current_tenant', $1, true)", [
        to_string(tenant_id)
      ])
      fun.()
    end)
  end
end

Użycie w kontekście biznesowym

defmodule MyApp.Orders do
  alias MyApp.{Repo, Order}

  def list_orders(tenant_id) do
    Repo.with_tenant(tenant_id, fn ->
      # RLS automatycznie filtruje - zwraca TYLKO zamówienia tego tenanta
      Repo.all(Order)
    end)
  end

  def create_order(tenant_id, attrs) do
    Repo.with_tenant(tenant_id, fn ->
      %Order{}
      |> Order.changeset(attrs)
      |> Ecto.Changeset.put_change(:tenant_id, tenant_id)
      |> Repo.insert()
    end)
  end
end

Programista pisze Repo.all(Order). Nie dodaje WHERE tenant_id = ?. I nie musi - PostgreSQL doda to sam. Nawet jeśli ktoś w przyszłości napisze surowe SQL w Repo.query!/2, RLS ochroni dane. To jest obrona na poziomie bazy danych, nie na poziomie kodu aplikacji.

Kluczowe: indeksy kompozytowe

RLS dołącza WHERE tenant_id = X do każdego zapytania. Bez odpowiednich indeksów to sequential scan:

# W migracji - indeksy MUSZĄ zaczynać się od tenant_id
create index(:orders, [:tenant_id, :inserted_at])
create index(:orders, [:tenant_id, :status, :inserted_at])

# Indeks TYLKO na :inserted_at NIE pomoże w zapytaniach z RLS
# PostgreSQL nie użyje go, bo filtr tenant_id jest pierwszy

Kiedy wybrać RLS

  • SaaS z tysiącami małych tenantów (darmowe plany, trial, self-service)
  • Jedna migracja dla wszystkich - zero fan-out
  • Tenant isolation jako "safety net" obok aplikacyjnego filtrowania
  • Gdy potrzebujesz cross-tenant raportów (admin dashboard z agregatami)

Podejście 2: Schema-per-tenant (Ecto prefix)

Ecto ma wbudowaną obsługę schematów PostgreSQL przez opcję :prefix. Każde zapytanie, insert, update czy delete może celować w konkretny schemat.

Automatyczne wstrzykiwanie prefiksu

Zamiast przekazywać prefix: do każdego wywołania Repo, użyj default_options/1:

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  @tenant_key {__MODULE__, :tenant_prefix}

  def put_tenant(prefix) do
    Process.put(@tenant_key, prefix)
  end

  def get_tenant do
    Process.get(@tenant_key)
  end

  @impl true
  def default_options(_operation) do
    case get_tenant() do
      nil -> []
      prefix -> [prefix: prefix]
    end
  end
end

Od tego momentu cały kod biznesowy jest identyczny jak w aplikacji single-tenant:

# Ustaw tenanta raz - na początku requesta
MyApp.Repo.put_tenant("tenant_acme")

# Wszystkie zapytania automatycznie celują w schemat tenant_acme
Repo.all(User)         # SELECT * FROM "tenant_acme"."users"
Repo.all(Order)        # SELECT * FROM "tenant_acme"."orders"
Repo.insert(%User{})   # INSERT INTO "tenant_acme"."users" ...

Plug do ekstrakcji tenanta z subdomeny

defmodule MyAppWeb.Plugs.TenantFromSubdomain do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    case extract_subdomain(conn.host) do
      nil ->
        conn |> send_resp(404, "Tenant not found") |> halt()

      subdomain ->
        case MyApp.Tenants.get_by_subdomain(subdomain) do
          nil ->
            conn |> send_resp(404, "Unknown tenant") |> halt()

          tenant ->
            MyApp.Repo.put_tenant(tenant.schema_prefix)

            conn
            |> assign(:current_tenant, tenant)
        end
    end
  end

  defp extract_subdomain(host) do
    case String.split(host, ".") do
      [subdomain, _, _] -> subdomain
      _ -> nil
    end
  end
end

LiveView - hook on_mount

defmodule MyAppWeb.TenantHook do
  import Phoenix.LiveView

  def on_mount(:default, _params, %{"tenant_id" => tenant_id}, socket) do
    tenant = MyApp.Tenants.get!(tenant_id)
    MyApp.Repo.put_tenant(tenant.schema_prefix)
    {:cont, assign(socket, :current_tenant, tenant)}
  end

  def on_mount(:default, _params, _session, socket) do
    {:halt, redirect(socket, to: "/")}
  end
end

Tworzenie nowego tenanta + migracje

defmodule MyApp.TenantManager do
  alias Ecto.Adapters.SQL
  alias MyApp.Repo

  @tenant_migrations_path "priv/repo/tenant_migrations"

  def create_tenant(slug) do
    prefix = "tenant_#{slug}"

    # 1. Utwórz schemat PostgreSQL
    SQL.query!(Repo, ~s(CREATE SCHEMA "#{prefix}"), [])

    # 2. Uruchom migracje tenant-specific w tym schemacie
    migrations_path = Application.app_dir(:my_app, @tenant_migrations_path)

    Ecto.Migrator.run(
      Repo,
      migrations_path,
      :up,
      all: true,
      prefix: prefix
    )

    # 3. Zarejestruj tenanta w schemacie public
    Repo.insert(%MyApp.Tenant{slug: slug, schema_prefix: prefix})
  end

  def migrate_all_tenants do
    migrations_path = Application.app_dir(:my_app, @tenant_migrations_path)

    Repo.all(MyApp.Tenant)
    |> Enum.each(fn tenant ->
      Ecto.Migrator.run(Repo, migrations_path, :up, all: true, prefix: tenant.schema_prefix)
    end)
  end
end

Dwa katalogi migracji:

  • priv/repo/migrations/ - migracje globalne (tabela tenants, konfiguracja)
  • priv/repo/tenant_migrations/ - migracje per tenant (tabele biznesowe)

Nowy tenant = CREATE SCHEMA + uruchomienie wszystkich migracji z tenant_migrations. Istniejący tenanci = migrate_all_tenants przy każdym deployu.

Kiedy wybrać schema-per-tenant

  • 10-500 tenantów (SaaS B2B z płatnymi planami)
  • Potrzeba izolacji silniejszej niż RLS, ale bez złożoności osobnych baz
  • Niezależne indeksy per tenant (duży tenant może mieć dodatkowe indeksy)
  • Niezależny backup per tenant (pg_dump -n tenant_acme)
  • Planer PostgreSQL z osobnymi statystykami per schemat (lepsze plany zapytań)

Ograniczenia

Przy 10 000 tenantów z 50 tabelami = 500 000 plików tabel w katalogu danych PostgreSQL. Skutki: wyczerpanie inode'ów, wolny VACUUM, powolny pg_dump, rozdęty pg_catalog. Dlatego limit to ~500 tenantów.

Podejście 3: Database-per-tenant

Najsilniejsza izolacja. Osobna baza, osobna pula połączeń, osobny backup.

Dynamiczny Repo

defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  def with_dynamic_repo(credentials, callback) do
    default = get_dynamic_repo()
    {:ok, repo} = start_link([name: nil, pool_size: 2] ++ credentials)

    try do
      put_dynamic_repo(repo)
      callback.()
    after
      put_dynamic_repo(default)
      Supervisor.stop(repo)
    end
  end
end

Cache puli połączeń

Start/stop puli per request jest drogi. Produkcyjny pattern: cache puli w GenServer:

defmodule MyApp.TenantRepoPool do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def get_repo(tenant_id) do
    GenServer.call(__MODULE__, {:get_repo, tenant_id})
  end

  @impl true
  def init(_) do
    {:ok, %{repos: %{}}}
  end

  @impl true
  def handle_call({:get_repo, tenant_id}, _from, state) do
    case Map.get(state.repos, tenant_id) do
      nil ->
        credentials = fetch_credentials(tenant_id)
        {:ok, pid} = MyApp.Repo.start_link([name: nil, pool_size: 5] ++ credentials)
        {:reply, {:ok, pid}, put_in(state, [:repos, tenant_id], pid)}

      pid ->
        if Process.alive?(pid) do
          {:reply, {:ok, pid}, state}
        else
          credentials = fetch_credentials(tenant_id)
          {:ok, new_pid} = MyApp.Repo.start_link([name: nil, pool_size: 5] ++ credentials)
          {:reply, {:ok, new_pid}, put_in(state, [:repos, tenant_id], new_pid)}
        end
    end
  end

  defp fetch_credentials(tenant_id) do
    [
      hostname: "db-#{tenant_id}.internal",
      database: "tenant_#{tenant_id}",
      username: "app",
      password: System.get_env("DB_PASSWORD")
    ]
  end
end

Plug integrujący dynamiczny Repo

defmodule MyAppWeb.Plugs.DynamicTenantRepo do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    tenant = conn.assigns.current_tenant

    case MyApp.TenantRepoPool.get_repo(tenant.id) do
      {:ok, repo_pid} ->
        MyApp.Repo.put_dynamic_repo(repo_pid)
        conn

      {:error, reason} ->
        conn |> send_resp(503, "Database unavailable") |> halt()
    end
  end
end

Kiedy wybrać database-per-tenant

  • Enterprise klienci z wymaganiami compliance (dane fizycznie oddzielone)
  • Regulowane branże (finanse, medycyna) - audytor chce widzieć osobną bazę
  • Klient płaci wystarczająco, żeby uzasadnić osobną infrastrukturę
  • Potrzeba niezależnego skalowania (jeden tenant generuje 90% ruchu)
  • Niezależne schedule backupów i retencji

Ograniczenia

PostgreSQL domyślnie: max_connections = 100. Przy 50 tenantach × pool_size: 5 = 250 połączeń. Wymaga PgBouncer lub podobnego poolera. Każda pula to pamięć i overhead zarządzania.

Podejście hybrydowe - najczęstsze w praktyce

W rzeczywistości rzadko wybiera się jedno podejście dla wszystkich tenantów:

Tier Free/Trial (1000+ tenantów)
  → RLS na shared tables
  → Jedna migracja, jeden backup, minimalne koszty

Tier Business (50-200 tenantów)
  → Schema-per-tenant
  → Osobne statystyki planera, osobny pg_dump

Tier Enterprise (5-10 tenantów)
  → Database-per-tenant
  → Osobna infrastruktura, dedykowany SLA
defmodule MyApp.TenantRouter do
  def setup_tenant_context(tenant) do
    case tenant.tier do
      :free ->
        # RLS - ustaw sesję PostgreSQL
        MyApp.Repo.set_rls_tenant(tenant.id)

      :business ->
        # Schema - ustaw prefix Ecto
        MyApp.Repo.put_tenant("tenant_#{tenant.slug}")

      :enterprise ->
        # Osobna baza - przełącz dynamic repo
        {:ok, repo} = MyApp.TenantRepoPool.get_repo(tenant.id)
        MyApp.Repo.put_dynamic_repo(repo)
    end
  end
end

Tenant zaczyna na darmowym planie z RLS. Przechodzi na Business - migrujemy do osobnego schematu. Podpisuje kontrakt Enterprise - przenosimy do osobnej bazy. Każdy krok to większa izolacja, większe koszty, większe możliwości customizacji. I każdy krok jest odwracalny.

Bezpieczeństwo - defense in depth

Nie polegaj na jednej warstwie. Najlepsze podejście to kombinacja:

Warstwa 1: Plug/Hook
  → Ekstrakcja tenanta z subdomeny/headera
  → Ustawienie kontekstu w conn/session

Warstwa 2: Ecto (prepare_query/default_options)
  → Automatyczne wstrzykiwanie WHERE tenant_id = ?
  → Programista NIE MOŻE zapomnieć

Warstwa 3: PostgreSQL RLS
  → Nawet jeśli warstwy 1 i 2 zawiodą
  → Baza danych nie zwróci cudzych danych

Warstwa 4: Testy
  → Test, który próbuje odczytać dane innego tenanta
  → Musi zwrócić pustą listę
defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres

  require Ecto.Query

  @impl true
  def prepare_query(_operation, query, opts) do
    cond do
      opts[:skip_tenant_id] || opts[:schema_migration] ->
        {query, opts}

      tenant_id = opts[:tenant_id] || get_tenant_id() ->
        {Ecto.Query.where(query, tenant_id: ^tenant_id), opts}

      true ->
        raise "tenant_id is required - use skip_tenant_id: true for global queries"
    end
  end
end

prepare_query/3 jest wywoływany przed każdym zapytaniem. Jeśli tenant_id nie jest ustawiony i nie ma jawnego skip_tenant_id: true, aplikacja crashuje. Nie zwraca danych wszystkich tenantów - crashuje. Fail-fast zamiast fail-unsafe.

Wpływ na wydajność

OperacjaRLSSchemaDatabase
Proste SELECT+0.1ms (policy evaluation)+0ms (natywna izolacja)+0ms
JOIN między tabelami tenantaIdentycznyIdentycznyIdentyczny
Cross-tenant raportProsty (brak filtra)N zapytań (per schemat)N połączeń (per baza)
Migracja DDL1 operacjaN operacjiN operacji
VACUUMJedna tabela (duża)N tabel (małych)N baz (niezależnych)
Planer zapytańGlobalne statystyki*Per-schemat (dokładne)Per-baza (dokładne)

*Globalne statystyki RLS mogą powodować suboptymalne plany, gdy jeden tenant ma 10x więcej danych niż reszta. Rozwiązanie: partycjonowanie po tenant_id.

Migracja między podejściami

Jedną z największych zalet Ecto jest to, że przejście między podejściami wymaga minimalnych zmian w kodzie biznesowym:

  • RLS → Schema: Utwórz schematy, skopiuj dane z filtrowane po tenant_id, zmień Repo.set_rls_tenant na Repo.put_tenant
  • Schema → Database: Utwórz bazy, pg_dump -n tenant_X | psql target_db, zmień na put_dynamic_repo
  • Każde → każde: Kod biznesowy (Repo.all(Order)) nie zmienia się. Zmienia się tylko warstwa konfiguracji tenanta

To jest kluczowa decyzja architektoniczna, którą warto podjąć wcześnie - ale nie jest to decyzja nieodwracalna.

Budujesz system SaaS i nie wiesz, które podejście do multi-tenancy wybrać? Porozmawiajmy - pomożemy dobrać architekturę do skali i wymagań Twoich klientów.