Postgres FORCE RLS — 18 hónap multi-tenant tapasztalat, és a csendes bug, ami majdnem kinyírta a reset-password-ünket
Az RLS a multi-tenant biztonságunk fundamentuma. De a "csendes 0-row update" trap egy egész napra kivett egy reset-password flow-t. Itt a recept.
Az RLS nem dob hibát, ha 0 sort érintettél. Ezért kell mindig RETURNING — különben a "siker" hazudni fog.
2024 októberében döntöttünk úgy, hogy a multi-tenant adatszigetelést Postgres FORCE Row-Level Security-vel oldjuk meg, nem application-szintű `WHERE tenantId = ?` szűrőkkel. 18 hónappal később, 47 táblán futtatva, 23 tenant-tel és 11 millió sorral — még mindig nem bántuk meg. De voltak fájdalmas leckék. Az egyik egy 2026 júniusi reset-password incidens, amit ebben a posztban végigviszünk.
Miért FORCE RLS, nem sima RLS
A sima Postgres RLS-t az asztal-tulajdonos megkerüli. A migration-eink az adatbázis-tulajdonosi user-rel futnak (mert DDL-t kell írniuk), és ha valaki véletlen ezzel a user-rel csatlakozott az alkalmazásból, a szigetelés egy `if`-fel meghalt. A FORCE RLS megoldása: a policy a tulajdonosra is érvényes. A trade-off: a migration-eknek explicit `SET LOCAL row_security = off`-ot kell mondaniuk vagy egy bypass-role-t kell használniuk. Mi az utóbbit választottuk — minden migration egy `app_migration` role-lal fut, amelynek BYPASSRLS attribútuma van.
A csendes bug: amikor az anon user reset-elt jelszót próbál állítani
A reset-password flow lényege: a felhasználó kap egy egyszer használatos tokent emailben, beüti, és új jelszót állít be. Ez a kérés egy anonymous (nem-bejelentkezett) HTTP-kérés. A backend a tokent validálja, megtalálja a user-t, és UPDATE-eli a jelszót — egy connection-ön, ahol nincs `app.current_tenant_id` GUC beállítva, mert nincs munkamenet. A FORCE RLS policy a `users` táblán azt mondja: `tenantId = current_setting('app.current_tenant_id')::uuid`. Ha a GUC üres, a kifejezés `NULL`-t ad, az `=` `NULL`-t ad, a sor nem látszik, és az UPDATE 0 sort érint. Az ORM (Prisma) nem dob hibát, mert az UPDATE szintaktikailag sikeres volt. A felhasználó visszairányítva a login-ra "jelszó megváltoztatva" üzenettel — és nem tud belépni.
A megoldás: RLS-bypass tranzakció + RETURNING-check
- A `/auth/reset-password` endpoint egy dedikált `auth_bypass` Postgres role-on fut (BYPASSRLS), nem a standard `app_runtime`-on
- Minden anon-mutáció egy explicit `BEGIN; SET LOCAL ROLE auth_bypass; ... COMMIT;` blokkban fut, hogy a privilege-emelés csak a tranzakció erejéig tartson
- Minden UPDATE/DELETE-nek `RETURNING id`-vel kell visszaadnia legalább egy sort — ha nem, exception és audit-event
- A migration-ek a CI-ban egy "0-row check" linterrel ellenőrzik, hogy minden új `UPDATE` van-e RETURNING-gel
- A Pino logger minden mutáció-műveletre kötelezően jelez `affectedRows`-t — Grafana alert > 5 percig 0-érték esetén
Mit tanultunk 18 hónap alatt
Az RLS-t nem szabad rétegként hozzátenni egy meglévő app-hez. Vagy a kezdetektől tervezed, vagy nem éri meg. A teljesítményhatás 47 táblánkon ~3% (a policy minden indexen rajta van), ami elhanyagolható. A legdrágább óra: a connection-poolban a GUC-state-et megőrizni (PgBouncer transaction-mode pool-lal kell, statement-mode nem). A legjobb hozadék: 18 hónap alatt 0 cross-tenant data-leak incidens. Egy junior fejlesztő nem tudja "elfelejteni" a tenant-szűrőt — az adatbázis nem engedi.
Az RLS nem egy security-feature. Egy architektúra-döntés. Ha félve használod, csak kínlódni fogsz vele.