Skip to content

Multi-tenancy — strategia, kontrakty, RLS

PIM jest multi-tenant ready, single-tenant deployed (ADR-003). Każda tabela domenowa niesie tenant_id UUID od dnia 1, izolacja na poziomie aplikacji (Doctrine TenantFilter) działa od Sprintu 0. Od W1-1 / AUD-002 druga linia obrony jest realna: aplikacja łączy się jako nie-uprzywilejowana rola pim_app (NOSUPERUSER, NOBYPASSRLS, nie-owner), a wszystkie tabele z tenant_id mają FORCE ROW LEVEL SECURITY + polityki izolacji. Bug w TenantFilter (native SQL, zapomniany TenantScoped, disable('tenant') bez re-enable) nie jest już cross-tenant leakiem — RLS odrzuca obce wiersze na poziomie bazy.

Tabele i ich tenant scope

Tabelatenant_idUwagi
tenantssama encja Tenant; nie scopowana
usersNOT NULL (FK)tenant scope, ale bez TenantScoped w PHP — login musi znaleźć usera po email zanim tenant będzie znany
productsNOT NULL (FK)domain table, TenantScoped
rolesnullable (FK)NULL = globalna rola wbudowana (super_admin / catalog_manager / integration_manager / viewer)
permissionsglobalny katalog (resource, action), intencjonalnie unscopowany
role_permissions, user_rolesM2M junction; tenant scope dziedziczony po wierszach rodziców
refresh_tokensNOT NULL (denormalised UUID)lookup po token_hash UNIQUE; tenant_id dla integralności + hipotetycznej RLS w fazie 2
messenger_messagesinfra (Symfony Messenger)
doctrine_migration_versionsbookkeeping Doctrine

pim:tenant:audit (poniżej) sprawdza ten zestaw automatycznie — gdy w epiku 0.3 dochodzą Object/Channel/Asset, audit alarmuje jeśli któraś tabela zapomni tenant_id.

Interfejsy

Dwa marker interface'y w App\Identity\Application\:

  • TenantAware"ten obiekt umie zwrócić aktywny tenant". Implementuje User. Używany przez CurrentTenantProvider żeby resolver wyciągnął tenant z autoryzowanego principal'a (JWT → User → tenant).
  • TenantScoped"ta encja niesie tenant_id na poziomie schematu, listener auto-stempluje + filter scopuje". Implementuje Product. Wymaga getTenant(): ?Tenant + assignTenant(Tenant): void.

Można implementować oba (User by mógł — w MVP nie potrzebuje, lookup po email globalny), ale 95% domain entities chce tylko TenantScoped.

Listener — auto-stempling przy prePersist

TenantAssignmentListener dispatchuje przez instanceof TenantScoped:

  1. encja nie jest TenantScoped → ignore;
  2. getTenant() już ustawiony → respect (caller stamped manually);
  3. TenantContext::get() jest null → throw LogicException (clearer niż NOT NULL constraint violation z DB);
  4. inaczej → $entity->assignTenant($tenant).

Konsekwencje:

  • nowe encje opt-in przez implements TenantScoped — bez modyfikacji listener'a;
  • bulk path (COPY, raw INSERT, RefreshTokenService) ustawia tenant_id ręcznie w kodzie — bypass listener'a jest świadomy.

Filter — auto-scope w SELECT/UPDATE/DELETE

TenantFilter (Doctrine SQLFilter) dokleja WHERE <alias>.tenant_id = :current_tenant do każdego query którego targetEntity implements TenantScoped. Parametr current_tenant ustawia TenantFilterConfigurator po resolve'owaniu tenant'a z requestu.

Co filter NIE łapie:

  • native SQL przez DBAL (smoke #12 / TenantIsolationTest::nativeSqlBypassesTheDoctrineFilterByDesign udowadnia że granica leży na warstwie aplikacji);
  • COPY i raw INSERT … SELECT (patrz "Bulk operations" niżej);
  • query po encjach NIE-TenantScoped (User, Role, Permission, RefreshToken).

RLS — kontrakt GUC i stan aktywacji

Kanon nazwy GUC: app.current_tenant (plus app.is_super_admin dla break-glass bypass). To jedyna zmienna sesyjna jaką ustawia aplikacja — patrz RlsContextListener (HTTP, kernel.request) i TenantRlsGucMiddleware (worker async). Każda polityka RLS w bazie MUSI czytać tę nazwę. Przykład polityki:

sql
CREATE POLICY tenant_isolation_select ON refresh_tokens FOR SELECT
    USING (tenant_id = current_setting('app.current_tenant', true)::uuid);

Historia driftu (AUD-027 / W1-2, naprawione Version20260617000000): pierwsza fala RLS (Version20260428195217) seedowała products + refresh_tokens ze starą nazwą pim.current_tenant_id, której kod nigdy nie ustawia. Polityki products zniknęły przy migracji products → objects (Version20260428222056); refresh_tokens pozostał osamotniony na starym GUC. Era IMP2-2.x (api_tokens, audit_logs, import_logs, import_staged_files, import_undo_log, invitations, user_tenant_memberships) używa już app.current_tenant. Migracja W1-2 ujednoliciła refresh_tokens na app.current_tenantwarunek wstępny FORCE RLS (W1-1): pod FORCE niedopasowany GUC dawałby tenant_id = NULL (three-valued logic) → deny-all → zepsuty refresh-login. Migracja W1-2 sama nie włącza ani nie wymusza RLS — zmienia tylko nazwę GUC w politykach.

Skala GUC — set_config(..., false) (session), NIE SET LOCAL (transaction). Ścieżka HTTP NIE owija requestu w jedną transakcję — Doctrine/PDO działa w trybie autocommit libpq, więc każde zapytanie to osobna domyślna transakcja commitowana natychmiast. GUC transaction-local (is_local=true) znikałby w momencie auto-commitu statementu set_config i każde kolejne zapytanie (oraz INSERT do audit_logs na kernel.response) widziałoby pusty tenant → pod FORCE RLS deny-all / failed write. Dlatego RlsContextListener ustawia GUC na poziomie sesji (is_local=false) na kernel.request i zeruje go na kernel.terminate (priorytet -256) — FrankenPHP worker mode reużywa połączenie DBAL między requestami, więc bez resetu poprzedni tenant wyciekałby do okna pre-auth kolejnego requestu. To ten sam wybór co TenantRlsGucMiddleware dla workerów.

Polityki castują przez NULLIF(current_setting('app.current_tenant', true), '')::uuid, nie gołe ::uuid. GUC jest zerowany do pustego stringa (nie NULL), a SQL nie gwarantuje że OR short-circuituje przed castem — gołe ''::uuid rzuca invalid input syntax for type uuid: "" i zamienia każde zapytanie w 500 zamiast pustego wyniku. NULLIF zamienia '' na NULL, cast jest bezpieczny, a tenant_id = NULL → unknown → wiersz odrzucony (fail-closed dla tabel domenowych).

Stan dziś (po W1-1 / AUD-002): RLS ENABLED + FORCED na wszystkich 43 tabelach z tenant_id. Każda tabela ma tenant_isolation_<t> + super_admin_bypass_<t>. Connection user runtime to pim_app (NOSUPERUSER + NOBYPASSRLS + nie-owner) → RLS realnie egzekwowane. Migracje działają pod pim (owner, osobne połączenie DBAL owner / DATABASE_URL_OWNER, doctrine_migrations.connection: owner). Dwa kształty polityki:

  • tabele DOMENOWE (objects, object_values, attributes, channels, assets, imports/exports, …) — polityka ścisła tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid. Pusty GUC = 0 wierszy (fail-closed; endpointy domenowe zawsze mają tenant w kontekście).
  • tabele AUTH (users, refresh_tokens, password_reset_tokens, api_keys, api_tokens, roles, sso_providers, tenant_locales, user_tenant_memberships, invitations, audit_logs) — polityka pre-context-safe: current_setting(...) IS NULL OR current_setting(...) = '' OR tenant_id = NULLIF(...)::uuid. Te wiersze są czytane PODCZAS autentykacji (firewall priorytet 8 ładuje usera po email, waliduje hash refresh-tokena, resolwuje SSO providera, hydratuje lazy tenant_locales) ZANIM RlsContextListener (priorytet 0) ustawi GUC. Ścisła polityka odrzuciłaby te odczyty (GUC jeszcze pusty) → zepsuty login/refresh/SSO. Relaksacja działa TYLKO gdy GUC pusty (okno pre-auth); gdy tenant jest w kontekście ta sama polityka egzekwuje ścisłą izolację. Lookupy w tym oknie idą po unikalnych nieenumerowanych kluczach (email, 256-bit hash, UUID), a logika auth waliduje własność. audit_logs jest tu bo wiersz jest pisany na kernel.response z tenantem, którego GUC z kernel.request mógł nie mieć (login autentykuje się W TRAKCIE requestu); listener i tak ustawia tenant_id z principal'a, więc RLS to defence-in-depth, nie główna granica. Wiersze NULL-tenant (platform/super-admin) pozostają widoczne tylko przez super_admin_bypass.
  • roles, smart_filter_presets — nullable tenant_id (built-iny/system-shipped mają NULL i są widoczne dla wszystkich tenantów); polityka dokleja OR tenant_id IS NULL (lustrzane do TenantFilter SystemShipped).
  • junction bez tenant_id (user_role_assignments) — bez RLS; granicę daje FK do scopowanego rodzica + RLS rodzica.
  • infra bez tenant_id (tenants, permissions, messenger_messages, doctrine_migration_versions) — poza RLS.

Migracja: Version20260617050000 (idempotentny guard CREATE ROLE pim_app + GRANT-y DML + default privileges + ENABLE/FORCE + polityki). Round-trip up/down/up czysty. Rola pim_app (LOGIN + hasło) jest tworzona/synchronizowana idempotentnie przez docker/postgres/pim-init-app-role.sh na każdym starcie bazy (przed connectem api, na świeżym i istniejącym wolumenie).

Test izolacji pod non-superuserem

ForceRlsTenantIsolationTest odtwarza oba stany na tabeli domenowej (channels) na jednym połączeniu: RED (bez polityki, NOBYPASSRLS reader scopowany do A widzi wiersz B) → GREEN (po FORCE + polityce z migracji, B = 0, A = 1). Związany z faktyczną migracją (regresja drop'ująca tabelę z listy → fail). Pełen pen-test zewnętrznego audytora pozostaje przed SaaS go-live (Phase 7).

Bulk operations — runbook

COPY, raw INSERT … SELECT, oraz każdy worker bulk-import używający EntityManager::clear() wymagają osobnej dyscypliny:

  • COPY ignoruje RLS i Doctrine filter. Zawsze ustaw tenant_id w SELECT klauzuli źródłowej (COPY products FROM stdin … WHERE tenant_id = '…') lub jako stałą kolumnę przy imporcie.
  • INSERT … SELECT respektuje RLS (gdy aktywne), bo to normalny INSERT — używaj go zamiast COPY dla cross-tenant exportów.
  • bulk worker (AbstractBatchHandler z #13) musi po każdym clear() zrobić find() na Tenant i TenantContext::set() przed kontynuacją batch'a (lekcja z #13 — bez tego TenantAssignmentListener rzuca na detached referencję).
  • gdy aktywujemy RLS w fazie 2, runbook backup/restore (docs/runbook/restore.md) musi explicit:
    • przed pgBackRest restoreALTER TABLE … DISABLE ROW LEVEL SECURITY (tylko dla supera);
    • po restore → ALTER TABLE … ENABLE ROW LEVEL SECURITY. Inaczej pgBackRest COPY na pusty target z aktywnym RLS = wszystkie wiersze deny.

CLI — pim:tenant:audit

bash
docker compose exec api bin/console pim:tenant:audit

Read-only audit każdej tabeli z public schema:

  • FAIL — domena bez tenant_id (najczęściej: zapomniana migracja przy nowej encji);
  • FAIL — domena z tenant_id nullable (poza roles);
  • WARN — domena bez indeksu na tenant_id (perf concern);
  • OK — wszystko gra.

Exit code 0/1 (success/failure) — nadaje się do CI gate'u w przyszłości. Idempotent, nieinwazyjny.

Smoke test izolacji

TenantIsolationTest (#12 / 0.0.12, rozszerzony przez voters w #26) tworzy dwa tenanty, dwie pary userów + produktów, autentykuje admin'em tenant'u A i sprawdza że:

  • GET /api/products zwraca tylko produkty A (5 z 10);
  • GET /api/products/{id-z-tenant-B} zwraca 404 (filter ukrywa istnienie wiersza, nie 403);
  • PATCH cross-tenant zwraca 404;
  • raw SQL przez DBAL widzi obie strony — explicit, dokumentuje gdzie kończy się boundary.

W epiku 0.3 ten test rozszerzy się o Object/Channel/Asset. Po fazie 2 dochodzi pen-test RLS — patrz wyżej.