Repository navigation
Jak dodać RLS do tabeli
Instrukcja dla tabel, których wiersze należą do konkretnego użytkownika. Opisuje, co robi się ręcznie, bo po dodaniu kolumny z właścicielem RLS nie włącza się sam — żaden element modelu EF nie generuje polityk Postgresa.
Przykłady w kodzie: migracja AddChildUserIdAndRls
(budżety i ich dzieci, jedna polityka) oraz EnableRulesRls
(reguły kategoryzacji, dane własne + wspólne, cztery polityki).
Dane użytkownika chronią trzy warstwy. Każda robi co innego i żadnej nie da się pominąć:
| Warstwa | Gdzie | Zakłada się |
|---|---|---|
Filtr EF HasQueryFilter(QueryFilterNames.Owner, …)
|
AppDbContext |
ręcznie, per encja |
| Polityka RLS | Postgres | ręcznie, SQL-em w migracji |
| Uprawnienia ról do tabeli | Postgres |
automatycznie dla nowych tabel (ALTER DEFAULT PRIVILEGES), raz na bazę |
Aplikacja łączy się jedną wspólną rolą dla wszystkich użytkowników, więc Postgres nie wie, kto pyta. Dlatego
RlsSessionInterceptor przy każdym otwarciu połączenia ustawia zmienną sesyjną app.current_user_id (identyfikator konta z
tokenu), a polityki porównują z nią kolumnę UserId.
| Rola | Po co | RLS |
|---|---|---|
budget |
superuser, właściciel tabel, konto administracyjne do psql i migracji |
zawsze omija |
budget_app |
rola aplikacji na co dzień | dotyczy jej naprawdę |
budget_jobs |
NOLOGIN, BYPASSRLS: zadania bez użytkownika (seedy, czyszczenie, administracja) |
omija |
FORCE ROW LEVEL SECURITY. Lokalny connection string z Username=budget
(domyślny w appsettings.json) oznacza więc, że lokalnie chroni wyłącznie filtr EF, a polityki działają dopiero na roli
budget_app. Dlatego testy RLS muszą łączyć się jako budget_app (sekcja 4).
Role zakłada raz na klaster skrypt api/db/setup-rls-roles.sql,
uprawnienia do tabel raz na bazę api/db/grant-app-privileges.sql.
Hasła ról nie trafiają do repozytorium — aplikacja bierze je z connection stringa.
- Pole właściciela
Guid UserIdw konstruktorze encji (EF wiąże parametry konstruktora z właściwościami po nazwie — parametr bez właściwości wywala się dopiero w runtime, na każdym teście używającymAppDbContext). - W
AppDbContext:IsRequired(),HasIndex(x => x.UserId)i filtr:
e.HasIndex(x => x.UserId);
e.HasQueryFilter(QueryFilterNames.Owner, x => CurrentUserId == null || x.UserId == CurrentUserId);- Handler tworzący wiersz bierze właściciela z
ICurrentUserAccessor.UserId— nigdy z ciała żądania. - Nazwa kolumny to
UserId, jak w pozostałych tabelach. Kod administracyjny (OwnerDataService) i polityki jej szukają.
$env:BaseOutputPath = "$env:TEMP\bt\bin\"; $env:IntermediateOutputPath = "$env:TEMP\bt\obj\"
dotnet ef migrations add <NazwaPoAngielsku> --project api/src/BudgetTracker.Api --output-dir Infrastructure/Migrations
Remove-Item Env:BaseOutputPath; Remove-Item Env:IntermediateOutputPathEF wygeneruje kolumnę i indeks. Politykę dopisujesz sam w Up (i jej zdjęcie w Down):
migrationBuilder.Sql(
"""
ALTER TABLE "MojaTabela" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "MojaTabela" FORCE ROW LEVEL SECURITY;
CREATE POLICY owner_isolation ON "MojaTabela"
USING ("UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid);
""");Down:
DROP POLICY owner_isolation ON "MojaTabela";
ALTER TABLE "MojaTabela" NO FORCE ROW LEVEL SECURITY;
ALTER TABLE "MojaTabela" DISABLE ROW LEVEL SECURITY;Uwagi:
-
NULLIF(current_setting(…, true), '')::uuid, nie gołe rzutowanie: poza żądaniem HTTP interceptor ustawia pusty string, a rzutowanie go nauuidrzuciłoby błędem SQL zamiast po prostu nie dopasować żadnego wiersza. - Bez
USING … WITH CHECKjedna polityka obejmuje odczyt i zapis (warunekUSINGjest wtedy używany także do sprawdzania nowych wierszy). - Istniejące wiersze potrzebują backfillu
UserId(np. z rodzica po kluczu obcym, jak wAddChildUserIdAndRls). Wiersze bez dopasowania dostają pusty Guid i stają się niewidoczne dla zwykłych kont. - Po wygenerowaniu przeczytaj migrację i dopisz XML
<summary>/<remarks>z tym, co robi z istniejącymi danymi.
Usunięcie konta z ekranu administratora idzie przez OwnerDataService (API budżetu). Dopisz tam nową tabelę do:
DeleteAsync (trwałe usunięcie, IgnoreQueryFilters()) i ReassignAsync (przepisanie na inne konto). Bez tego po usunięciu
konta wiersze zostają w bazie jako dane, których nikt nie zobaczy.
Jeśli tabela jest dzieckiem budżetu, dopisz ją też do BudgetChildren.cs i BudgetPurger.cs (reset zostawia zasady budżetu,
usunięcie zabiera wszystko).
Seedy, zadania w tle i administracja nie mają app.current_user_id, więc pod budget_app widziałyby pustą tabelę, a ich zapis
odrzuciłaby polityka. Wykonaj je w jednej transakcji na roli z BYPASSRLS:
await using var transaction = await db.Database.BeginTransactionAsync();
await db.Database.ExecuteSqlRawAsync("SET LOCAL ROLE budget_jobs");
// … praca …
await transaction.CommitAsync();To SET LOCAL, na czas jednej transakcji. Zwykłe SET ROLE zostałoby w połączeniu wracającym do puli.
Wzorce: Program.cs (seedy), BudgetPurger, OwnerDataService.AsSystemAsync.
Nic nie robisz dla nowych tabel — ALTER DEFAULT PRIVILEGES z grant-app-privileges.sql nadaje budget_app i budget_jobs
SELECT/INSERT/UPDATE/DELETE, gdy tabelę tworzy budget. Wyjątek: baza założona przed uruchomieniem tego skryptu —
uruchom go na niej ponownie (jest per baza).
Migracja i (w razie potrzeby) grant-app-privileges.sql na każdej bazie: deweloperska, demo (budgettracker_uiverify), testowe.
Na prawdziwej bazie budgettracker tylko po wyraźnej zgodzie właściciela i po pg_dump.
Gdy część wierszy ma być widoczna dla wszystkich (reguły bazowe), a użytkownik ma zmieniać tylko własne, jedna polityka nie
wystarczy. Wiersze wspólne mają pusty UserId, a polityki są cztery:
CREATE POLICY rules_read ON "CategoryRules" FOR SELECT
USING ("UserId" = '00000000-0000-0000-0000-000000000000'::uuid
OR "UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid);
CREATE POLICY rules_insert ON "CategoryRules" FOR INSERT
WITH CHECK ("UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid);
CREATE POLICY rules_update ON "CategoryRules" FOR UPDATE
USING ("UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid)
WITH CHECK ("UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid);
CREATE POLICY rules_delete ON "CategoryRules" FOR DELETE
USING ("UserId" = NULLIF(current_setting('app.current_user_id', true), '')::uuid);Skutki, o których trzeba pamiętać:
-
Filtr EF musi odpowiadać polityce:
CurrentUserId == null || x.UserId == CategoryRule.SharedUserId || x.UserId == CurrentUserId. -
Zapis wspólnego wiersza kończy się w bazie zmianą 0 wierszy (błąd 500), więc handler sprawdza to wcześniej i zwraca 409
(
CategoryRuleSharedReadOnlyException). -
Wiersze wspólne zakłada seed jako
budget_jobs—budget_appnie ma prawa wstawić wiersza z cudzym (pustym)UserId. ⚠️ PusteUserId= „właściciel bez konta" na ekranie „Dane bez właściciela". Podsumowanie i sierotki nie liczą takiej tabeli, aOwnerDataServicenie kasuje i nie przepisuje wierszy wspólnych (guardownerId == Guid.Empty) — inaczej usunięcie „pustego właściciela" zabrałoby reguły bazowe wszystkim kontom.
Testy handlerów łączą się jako budget (superuser), który omija RLS, więc nie mówią nic o politykach. Test RLS musi:
- zbudować bazę prawdziwymi migracjami (
MigrateAsync) —EnsureCreatednie zakłada polityk, - nadać uprawnienia (treść
grant-app-privileges.sql), - połączyć się jako
budget_app, zRlsSessionInterceptordla wybranego użytkownika, - czytać z
IgnoreQueryFilters(), żeby filtr EF niczego nie ukrył i sprawdzać wyłącznie bazę.
Wzorzec: CategoryRulesRlsTests.
Sprawdza m.in.: użytkownik widzi własne i wspólne wiersze, nigdy cudzych; bez użytkownika tylko wspólne; nie wstawi wiersza
cudzego ani wspólnego (SQLSTATE 42501); UPDATE/DELETE cudzego wiersza dotyka 0 wierszy; budget_jobs może zapisać wspólny.
Dobry nawyk: po napisaniu testu wyłącz na chwilę ENABLE ROW LEVEL SECURITY w migracji i zobacz, że testy robią się czerwone.
Test, który przechodzi bez RLS, niczego nie pilnuje.
SET ROLE budget_app;
SELECT set_config('app.current_user_id', '<guid użytkownika>', false);
SELECT count(*) FROM "MojaTabela"; -- tylko wiersze tego użytkownika
RESET ROLE;Stan tabeli: SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'MojaTabela';
oraz SELECT policyname, cmd FROM pg_policies WHERE tablename = 'MojaTabela';
- Kolumna z właścicielem nie włącza RLS. Polityka to osobny SQL w migracji.
- Superuser (
budget) iBYPASSRLS(budget_jobs) omijają RLS. Aplikacja na co dzień powinna łączyć się jakobudget_app. -
SET LOCAL ROLE, nieSET ROLE— inaczej rola zostaje w puli połączeń. -
ExecuteUpdate/ExecuteDeletena wierszu, którego RLS nie pokazuje, zwracają 0, nie błąd. Sprawdzaj wynik. - Nowa tabela musi trafić do
OwnerDataService, inaczej usunięcie konta zostawi jej wiersze. - Zmiana nazwy parametru konstruktora encji albo parametr bez właściwości psuje model dopiero w runtime.
- Polityka bez
NULLIF(…, '')rzuca błąd SQL, gdy zmienna sesyjna jest pusta (zapytanie bez użytkownika), zamiast zwrócić zero wierszy.