W moim schemacie profili była taka polityka, napisana helperem Drizzle dla Neona:
crudPolicy({
role: authenticatedRole,
read: authUid(table.userId),
modify: false,
})
Trzy linie. Mówią, że uwierzytelniony użytkownik widzi tylko swoje wiersze i nie może ich modyfikować. Trudno z tym dyskutować. Półtora roku później skasowałem te trzy linie, i te z tabeli feedbacku, w tym samym commicie. Miesiące później powtórzyłem to kasowanie w innym projekcie, który odziedziczył ten sam schemat.
To nie był kaprys. Chodziło o to, że polityka i kod mówiły to samo, a kiedy dwa miejsca mówią to samo, jedno z nich w końcu zaczyna kłamać.
Obietnica RLS i dlaczego ją kupiłem
Obietnica jest dobra: uprawnienie mieszka w bazie danych, więc nie ma znaczenia, kto pyta. Jeśli jutro napiszę skrypt utrzymaniowy albo otworzę nowy endpoint, albo ktoś zapomni wstawić where, wiersz nie wyjdzie. To prawdziwa obrona w głąb, nie ozdóbka.
Zbudowałem wszystko wokół tego. Dwa połączenia:
export const getRlsDb = async (user: StackUser): Promise<DrizzleDb> => {
const authToken = (await user?.getAuthJson())?.accessToken;
const url = process.env.NEXT_PUBLIC_DATABASE_AUTHENTICATED_URL;
const sql = neon(url, { authToken });
return drizzle(sql);
};
Połączenie "uwierzytelnione", niosące JWT użytkownika i respektujące polityki, oraz administracyjne ze zwykłym DATABASE_URL, które je omija. Doszedłem nawet do cache'owania mapy połączeń RLS po id użytkownika, aż zorientowałem się, że trzymam połączenia przywiązane do wygasającego tokenu, i zostałem przy tworzeniu nowego za każdym razem.
Gdzie zaczęło zgrzytać z Drizzle i server actions
Problemem nie jest to, że RLS zawodzi. Problemem jest to, że w aplikacji na Nekscie z server actions prawie nigdy nie jest to warstwa, która decyduje.
Każda akcja serwerowa i tak zaczyna od rozwiązania sesji. Masz już użytkownika, zanim tkniesz bazę. I wtedy piszesz to:
const db = await getRlsDb(user);
return await db.select().from(feedback).where(eq(feedback.userId, user.id));
Ten where jest nadmiarowy wobec polityki. Ale nie możesz go usunąć, bo jak go usuniesz, kod zaczyna kłamać: wygląda, jakby ciągnął cały feedback, i zależy od niewidzialnej polityki siedzącej w innym pliku. Kończysz więc z filtrem w kodzie i w bazie, oraz z wątpliwością, który z dwóch faktycznie działa.
Druga sprawa to codzienne tarcie:
- Dwa połączenia. Każda nowa funkcja zaczyna się od pytania, czy idzie przez RLS, czy przez admina. A wszystko, co jest administracją, webhookami płatności, cronami czy migracjami, idzie przez admina. Czyli: drogą, która niczego nie sprawdza.
- Błędy niczego nie tłumaczą. Kiedy polityka cię blokuje, nie mówi "brakuje ci claimu"; zwraca zero wierszy. Debugowanie
select, który zwraca pustą tablicę, choć wiersz istnieje, kosztuje więcej, niż się wydaje. - Token wędruje tam, gdzie byś nie chciał. Uwierzytelniony URL ląduje w zmiennej z prefiksem
NEXT_PUBLIC_, a to już zmusza do dwukrotnego zastanowienia się, co wystawiasz. - Polityki mieszkają w snapshocie. Zmiana polityki to wygenerowanie migracji i jej zastosowanie. Zmiana sprawdzenia w kodzie to zmiana jednej linii.
Co więcej, w pewnym momencie ominąłem Drizzle w całości i przestawiłem feedback oraz profile na rozmowę przez PostgREST, właśnie po to, żeby naprawdę oprzeć się na RLS. Tam cena wyszła na wierzch: straciłem typowanie, musiałem pisać nazwy kolumn ręcznie w snake_case (user_id, created_at), a każde wywołanie kończyło się tym samym rytuałem.
const { data, error } = await pg.from('feedback').select('*').eq('user_id', user.id);
if (error) throw new Error(error.message);
return data as Feedback[];
as Feedback[] na końcu. Czyli zaufanie, że baza zwraca to, co ja mówię. To wróciło do Drizzle dwa tygodnie później.
Ten sam finał w drugim projekcie
Kiedy odpalałem kolejny projekt z tego samego boilerplate'u, polityki przyszły w środku. Wytrzymały tyle, ile zajęło mi ruszenie tabeli feedbacku na poważnie. Commit, w którym je wyrzuciłem, robił przy okazji coś jeszcze: zmieniał nazwę feedback.description na feedback.content. To głupi szczegół, ale opowiada całą historię: nadawałem kształt modelowi danych, a polityki były tym, co przeszkadzało mi go ruszać. W tym commicie zniknął import z drizzle-orm/neon i cztery wywołania crudPolicy. Tabelom zostało to jedyne, z czego naprawdę korzysta się codziennie:
(table) => [index("idx_feedback_user_id").on(table.userId)]
Indeks po user_id. Z tego Postgres korzysta przy każdym żądaniu.
Gdzie dziś mieszka moja logika uprawnień
W middleware i w warstwie akcji serwerowych. Kiedy odszedłem od zarządzanego auth i napisałem sobie własne middleware sesji, miejsce, w którym decyduje się, kto co widzi, zostało jednym punktem: rozwiązujesz sesję, sprawdzasz rolę, a od tego momentu wszystkie zapytania pisze się z userId już w ręku. Jedno połączenie, administracyjne, i żadnej równoległej uprzywilejowanej ścieżki.
Na papierze jest to mniej bezpieczne. Zapytanie, w którym zapomnisz where, zwróci cudze wiersze. Rekompensuję to tym, że te zapytania mieszkają w jednym katalogu akcji, a nie są rozrzucone po aplikacji, i tym, że sygnatura funkcji zmusza cię do przekazania użytkownika.
Kiedy jednak wróciłbym do RLS
Kiedy baza danych ma więcej niż jednego klienta. Jeśli poza moją aplikacją jest publiczny PostgREST, klient łączący się bezpośrednio albo analitycy z dostępem tylko do odczytu, polityka jest jedynym miejscem, w którym reguła obowiązuje wszystkich. Tam koszt utrzymywania dwóch prawd zwraca się sam.
Także w twardym multi-tenant, gdzie błędem nie jest "widzisz cudzy feedback", tylko "widzisz dane innej firmy". Tam chcę mieć siatkę pod spodem, nawet jeśli kod już filtruje.
Dla aplikacji z jednym klientem, z server actions i użytkownikiem rozwiązanym przed pierwszym zapytaniem, RLS jest drugą kopią reguły, którą już masz zapisaną. A reguła zapisana dwa razy nie jest dwa razy bezpieczniejsza: jest dwa razy łatwiejsza do zdezaktualizowania.
