Appearance
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
| Tabela | tenant_id | Uwagi |
|---|---|---|
tenants | — | sama encja Tenant; nie scopowana |
users | NOT NULL (FK) | tenant scope, ale bez TenantScoped w PHP — login musi znaleźć usera po email zanim tenant będzie znany |
products | NOT NULL (FK) | domain table, TenantScoped |
roles | nullable (FK) | NULL = globalna rola wbudowana (super_admin / catalog_manager / integration_manager / viewer) |
permissions | — | globalny katalog (resource, action), intencjonalnie unscopowany |
role_permissions, user_roles | — | M2M junction; tenant scope dziedziczony po wierszach rodziców |
refresh_tokens | NOT NULL (denormalised UUID) | lookup po token_hash UNIQUE; tenant_id dla integralności + hipotetycznej RLS w fazie 2 |
messenger_messages | — | infra (Symfony Messenger) |
doctrine_migration_versions | — | bookkeeping 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". ImplementujeUser. Używany przezCurrentTenantProviderżeby resolver wyciągnął tenant z autoryzowanego principal'a (JWT → User → tenant).TenantScoped— "ta encja niesietenant_idna poziomie schematu, listener auto-stempluje + filter scopuje". ImplementujeProduct. WymagagetTenant(): ?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:
- encja nie jest
TenantScoped→ ignore; getTenant()już ustawiony → respect (caller stamped manually);TenantContext::get()jestnull→ throwLogicException(clearer niż NOT NULL constraint violation z DB);- inaczej →
$entity->assignTenant($tenant).
Konsekwencje:
- nowe encje opt-in przez
implements TenantScoped— bez modyfikacji listener'a; - bulk path (
COPY, raw INSERT,RefreshTokenService) ustawiatenant_idrę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::nativeSqlBypassesTheDoctrineFilterByDesignudowadnia że granica leży na warstwie aplikacji); COPYi rawINSERT … 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łaproducts+refresh_tokensze starą nazwąpim.current_tenant_id, której kod nigdy nie ustawia. Politykiproductszniknęły przy migracjiproducts → objects(Version20260428222056);refresh_tokenspozostał 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łarefresh_tokensnaapp.current_tenant— warunek wstępny FORCE RLS (W1-1): pod FORCE niedopasowany GUC dawałbytenant_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 lazytenant_locales) ZANIMRlsContextListener(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_logsjest tu bo wiersz jest pisany nakernel.responsez tenantem, którego GUC zkernel.requestmógł nie mieć (login autentykuje się W TRAKCIE requestu); listener i tak ustawiatenant_idz principal'a, więc RLS to defence-in-depth, nie główna granica. Wiersze NULL-tenant (platform/super-admin) pozostają widoczne tylko przezsuper_admin_bypass. - roles, smart_filter_presets — nullable
tenant_id(built-iny/system-shipped mają NULL i są widoczne dla wszystkich tenantów); polityka doklejaOR tenant_id IS NULL(lustrzane doTenantFilterSystemShipped). - 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:
COPYignoruje RLS i Doctrine filter. Zawsze ustawtenant_idw SELECT klauzuli źródłowej (COPY products FROM stdin … WHERE tenant_id = '…') lub jako stałą kolumnę przy imporcie.INSERT … SELECTrespektuje RLS (gdy aktywne), bo to normalny INSERT — używaj go zamiastCOPYdla cross-tenant exportów.- bulk worker (
AbstractBatchHandlerz #13) musi po każdymclear()zrobićfind()naTenantiTenantContext::set()przed kontynuacją batch'a (lekcja z #13 — bez tegoTenantAssignmentListenerrzuca na detached referencję). - gdy aktywujemy RLS w fazie 2, runbook backup/restore (
docs/runbook/restore.md) musi explicit:- przed
pgBackRest restore→ALTER TABLE … DISABLE ROW LEVEL SECURITY(tylko dla supera); - po restore →
ALTER TABLE … ENABLE ROW LEVEL SECURITY. InaczejpgBackRestCOPYna pusty target z aktywnym RLS = wszystkie wiersze deny.
- przed
CLI — pim:tenant:audit
bash
docker compose exec api bin/console pim:tenant:auditRead-only audit każdej tabeli z public schema:
- FAIL — domena bez
tenant_id(najczęściej: zapomniana migracja przy nowej encji); - FAIL — domena z
tenant_idnullable (pozaroles); - 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/productszwraca tylko produkty A (5 z 10);GET /api/products/{id-z-tenant-B}zwraca 404 (filter ukrywa istnienie wiersza, nie 403);PATCHcross-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.
