mocne.aiPorozmawiajmy

ARCHITEKTURA · 9 MIN CZYTANIA

Multi-tenant w Postgresie: jak zbudowaliśmy system dla sieci warsztatów, w którym dane oddziałów nigdy się nie mieszają

Jeden system, jedenaście oddziałów, jedna baza. Najtrudniejsze nie było zbudowanie funkcji — tylko udowodnienie, że oddział z Gdańska nigdy nie zobaczy zlecenia z Krakowa.

KRÓTKA ODPOWIEDŹ

System multi-tenant to jedna aplikacja i jedna baza obsługująca wiele niezależnych organizacji lub oddziałów. W PostgreSQL izolację realizuje się kolumną tenant_id w każdej tabeli, politykami Row Level Security porównującymi ją z identyfikatorem z tokenu użytkownika oraz funkcją SECURITY DEFINER sprawdzającą role. Bezpieczne wdrożenie wymaga trzech dodatkowych elementów: indeksów złożonych zaczynających się od tenant_id, idempotentnej kolejki zadań w tle (zadanie zna swojego tenanta) oraz automatycznych testów izolacji w CI, które przy każdej zmianie próbują odczytać dane cudzej organizacji i muszą dostać zero wierszy.

  • Izolacja danych opiera się na kolumnie tenant_id i politykach Row Level Security, nie na filtrach w kodzie aplikacji.
  • Identyfikator organizacji pochodzi z tokenu (claims), nigdy z parametru wysłanego przez przeglądarkę.
  • Role trzymamy w osobnej tabeli i sprawdzamy funkcją SECURITY DEFINER — rola w profilu użytkownika to podatność.
  • Każdy indeks zaczyna się od tenant_id, inaczej zapytania skanują dane wszystkich oddziałów.
  • Zadania w tle działają z uprawnieniami serwisowymi, więc muszą jawnie filtrować po tenancie i być idempotentne.
  • Testy izolacji w CI symulują użytkownika obcej organizacji i wymagają zera wierszy w odpowiedzi.
  • Pierwsza działająca wersja systemu dla sieci warsztatów powstała w 9 dni roboczych.

Telefon, od którego się zaczęło

„Mamy jedenaście warsztatów i jedenaście różnych sposobów pracy. Chcemy jeden system, ale każdy oddział ma widzieć tylko swoje. Poza centralą, która ma widzieć wszystko.” Tyle briefu.

Brzmi jak zwykła aplikacja z listą zleceń. Nie jest. W momencie, w którym w jednej bazie leżą dane jedenastu firm, każdy pojedynczy błąd w zapytaniu przestaje być literówką, a staje się incydentem RODO. To zmienia kolejność pracy: najpierw izolacja, potem funkcje.

Zbudowaliśmy pierwszą działającą wersję w 9 dni roboczych. Osiem z nich poszło na funkcje. Jeden — ten pierwszy — poszedł wyłącznie na model danych i granice.

Trzy modele izolacji i dlaczego wybraliśmy środkowy

Osobna baza dla każdego oddziału daje najtwardszą granicę, ale każda migracja to jedenaście wdrożeń, a raport dla centrali wymaga sklejania danych z jedenastu źródeł. Przy małej firmie to koszt utrzymania, którego nikt nie chce płacić co miesiąc.

Osobny schemat na oddział wygląda elegancko w prezentacji, a boli w praktyce: narzędzia migracyjne, typy generowane dla frontendu i planer zapytań zaczynają się gubić przy kilkudziesięciu schematach.

Została jedna baza, jeden schemat i kolumna tenant_id w każdej tabeli, pilnowana przez Row Level Security. To model, który skaluje się do setek oddziałów, ma jedną migrację i jedno miejsce prawdy — pod warunkiem, że polityki są napisane bez wyjątków.

Granica jest w bazie, nie w kodzie aplikacji

Najczęstszy błąd, jaki widzimy w przejmowanych projektach: filtr po organizacji dopisany w kodzie. Działa, dopóki ktoś nie doda nowego endpointu w piątek o 17:00 i nie zapomni o jednym `where`. Kod ma dziesiątki miejsc, w których można się pomylić — baza ma jedno.

Dlatego identyfikator organizacji czytamy z tokenu użytkownika, a nie z parametru zapytania. Parametr da się podmienić w przeglądarce w dwie sekundy; claim podpisany po stronie serwera — nie.

Role są w osobnej tabeli i sprawdza je funkcja SECURITY DEFINER. Rola zapisana w profilu użytkownika, który ten użytkownik może edytować, to gotowy scenariusz eskalacji uprawnień — i widzieliśmy to w produkcyjnym systemie więcej niż raz.

  • Każda tabela: kolumna tenant_id NOT NULL z kluczem obcym do organizacji.
  • Każda tabela: RLS włączony i polityki osobno dla odczytu, zapisu, aktualizacji i usuwania.
  • Uprawnienia GRANT nadane jawnie — sama polityka bez GRANT-a nie wystarczy.
  • Centrala to nie „użytkownik bez filtra”, tylko rola z własną, wyraźnie opisaną polityką.

Wydajność: gdzie multi-tenant potrafi zaboleć

Po tygodniu produkcji lista zleceń w największym oddziale zaczęła się ładować sekundę dłużej niż w pozostałych. Powód był prozaiczny: indeks założony na samej dacie. Planer musiał przejrzeć zlecenia wszystkich oddziałów, żeby dopiero potem odrzucić cudze wiersze przez politykę RLS.

Zasada, którą stosujemy od tamtej pory bez wyjątku: każdy indeks zaczyna się od tenant_id, a dopiero potem ma kolumnę sortowania czy filtrowania. Po zmianie zapytanie zeszło z 940 ms do 24 ms na tych samych danych.

Druga pułapka to funkcje w politykach. Jeśli polityka wywołuje funkcję niezadeklarowaną jako STABLE, Postgres wykona ją dla każdego wiersza. Przy dziesiątkach tysięcy wierszy to różnica między „szybko” a „klient dzwoni”.

Zadania w tle, czyli miejsce, gdzie izolacja najczęściej pęka

Przypomnienia o przeglądach, nocne raporty dla centrali, synchronizacja z hurtownią części — to wszystko działa bez zalogowanego użytkownika, a więc z uprawnieniami serwisowymi, które omijają RLS. To najbardziej wrażliwy fragment całego systemu.

Nasza reguła: zadanie w kolejce zawsze niesie ze sobą tenant_id i nigdy nie pracuje na „wszystkim, co znajdzie”. Pętla po oddziałach jest w kodzie kolejki, a nie w zapytaniu — dzięki temu awaria jednego oddziału nie zatrzymuje pozostałych dziesięciu.

Każde zadanie ma też klucz idempotencji złożony z tenanta, typu zadania i okna czasowego. Ponowne uruchomienie po błędzie sieci nie wyśle klientowi drugiego SMS-a. Nudne, ale to właśnie te szczegóły decydują, czy system po miesiącu jest zaufany, czy „ten, co czasem dubluje powiadomienia”.

Test, który uruchamiamy przy każdej zmianie

Izolacji nie da się „sprawdzić raz”. Wystarczy nowa tabela dodana w pośpiechu bez włączonego RLS i granica znika w miejscu, o którym nikt nie pamięta. Dlatego napisaliśmy zestaw testów, który podszywa się pod użytkownika z oddziału A i próbuje wykonać dwadzieścia operacji na danych oddziału B.

Test przechodzi tylko wtedy, gdy odczyty zwracają zero wierszy, a zapisy kończą się odmową. Do tego automatyczny linter przechodzi po schemacie i wywala build, jeśli w bazie jest tabela publiczna bez włączonego RLS albo bez polityki. To jedna z tych rzeczy, które kosztują pół dnia raz, a oszczędzają nieprzespaną noc.

Efekt po stronie klienta wygląda zwyczajnie: kierownik loguje się i widzi swój warsztat, właściciel widzi jedenaście. Cała ta architektura ma być niewidoczna — to jest dokładnie jej zadanie.

Co byśmy zrobili inaczej

Wcześniej dodalibyśmy „tryb centrali” do testów. Przez dwa dni raporty zbiorcze pisaliśmy z pominięciem polityk, bo tak było szybciej — i musieliśmy potem wrócić i przepisać je porządnie na dedykowaną rolę.

I jedna rzecz produktowa: warto od pierwszego dnia pokazywać w interfejsie, w kontekście którego oddziału pracujesz. Nie z powodów technicznych, tylko dlatego, że kierownik obsługujący dwa warsztaty zapyta o to w pierwszej godzinie korzystania.

Wróć do notatek