Row-level security în Supabase: testează, nu presupune
În Supabase baza de date e accesibilă printr-un API, deci row-level security este controlul de acces. Iată ce face, greșeala care expune un tabel și cum testezi o politică pe anon și pe un utilizator autentificat.

În Supabase baza de date este accesibilă printr-un API, așa că row-level security nu este un detaliu de pus la punct după lansare. Este controlul de acces. Un tabel dintr-o schemă expusă, fără RLS, poate fi citit și scris de orice rol care are un grant pe el, iar cheia publishable este făcută să ajungă în codul din browser. Politicile de pe tabel sunt singurul lucru care stă între acea cheie și rânduri.
Ce face row-level security
Row-level security din Postgres atașează o regulă fiecărui rând atins de o interogare. Supabase descrie fiecare politică drept un WHERE suplimentar adăugat la interogare. Un select întoarce doar rândurile care trec de politică, un update ajunge doar la rândurile care trec, iar un insert este verificat după ce va conține rândul nou.
La fiecare cerere rulează două verificări și ambele trebuie să treacă. Granturile decid dacă un rol poate executa operația pe tabel. Politicile decid căror rânduri li se aplică operația. Dacă lipsește grantul, Postgres ridică 42501 înainte să evalueze vreo politică, deci o cerere legitimă poate eșua chiar când politica este corectă. Dacă grantul există și RLS este oprit, nu există nicio politică să ajute.
Activezi RLS cu o singură instrucțiune, în editorul SQL, prin psql sau într-o migration: alter table public.reports enable row level security;. Migration-ul este locul mai bun, pentru că grantul și politica merg atunci împreună cu schema. Ghidul Supabase recomandă secvența completă în același migration: activezi RLS, revoci granturile existente, acorzi înapoi doar ce are nevoie rolul, apoi testezi.
Cum se leagă rolurile anon și authenticated de cheile API
Supabase leagă fiecare cheie API de un rol Postgres intern, iar acel rol este cel pe care se potrivesc politicile tale. Cheia publishable (sb_publishable_...) este cheia cu privilegii mici pentru browsere, aplicații mobile și cod public. Fără un utilizator autentificat rulează ca anon. Când un utilizator se autentifică prin Supabase Auth, aceeași cheie rulează ca authenticated. O cheie publishable nu face utilizatorul anonim; cheia identifică aplicația, iar tokenul utilizatorului identifică persoana.
Cheia secret (sb_secret_...) corespunde rolului service_role, care are atributul BYPASSRLS. Politicile nu se aplică niciodată la ea. Are ce căuta doar pe server, iar Supabase respinge cu HTTP 401 cererile din browser făcute cu o asemenea cheie, pe baza antetului User-Agent. Cheile JWT vechi, cu durată lungă de viață, anon și service_role, încă funcționează și sunt versiunile legacy ale acelorași două chei; Supabase spune că le scoate din uz până la finalul anului 2026.
Consecința practică este că browserul poate ține o cheie care ajunge la baza de date, iar RLS este singurul lucru care decide la ce ajunge. Pagina despre chei o spune direct: activează RLS pe fiecare tabel înainte de lansare.
Greșeala pe care o vedem cel mai des
Este un tabel în schema public, expus prin API, cu RLS oprit. Linterul din Supabase îl semnalează ca 0013_rls_disabled_in_public, la nivel de eroare, cu rezumatul „Table publicly accessible” și cu nota că oricine are URL-ul proiectului poate citi, modifica și șterge toate datele din el. Nicio parte a cheii nu protejează acel tabel, pentru că cheia publishable este menită să fie publică.
A doua variantă e mai tăcută: RLS este pornit, dar politica e prea largă. O politică cu using (true) sau with check (true) lasă să treacă fiecare rând. Linterul o raportează ca 0024_permissive_rls_policy, la nivel de avertisment, și prinde doar formele literale mereu-adevărate. O politică ce compară coloana greșită sau uită clauza to și rulează astfel și pentru anon se citește corect și tot lasă să treacă rânduri.
Merită știute și două linturi vecine. 0007_policy_exists_rls_disabled înseamnă că politicile există, dar RLS e oprit, deci nu fac nimic. 0008_rls_enabled_no_policy înseamnă că RLS e pornit fără nicio politică, deci API-ul nu întoarce niciun rând. Verificările se fac în Security Advisor din dashboard sau cu supabase db advisors.
Mai există o problemă de granturi pe care politicile nu o repară. Adăugarea politicilor nu scoate granturile existente. Supabase notează că tabelele noi din public, la unele proiecte, acordă în continuare toate privilegiile rolurilor anon, authenticated și service_role. Revocă întâi, apoi acordă înapoi ce are nevoie tabelul.
O politică pe care o putem apăra
Luăm un tabel profiles în care fiecare rând aparține unui utilizator prin user_id. Scopul e simplu: un utilizator autentificat își citește și își modifică doar propriul rând, iar nimeni altcineva nu citește nimic. Exemplul din ghidul Supabase folosește auth.uid(), id-ul utilizatorului autentificat, iar forma pe care o folosim noi este aceasta:
alter table public.profiles enable row level security;revoke all on table public.profiles from anon, authenticated;grant select, insert, update, delete on table public.profiles to authenticated;create policy "read own profile" on public.profiles for select to authenticated using ((select auth.uid()) is not null and (select auth.uid()) = user_id);create policy "insert own profile" on public.profiles for insert to authenticated with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);create policy "update own profile" on public.profiles for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
Patru detalii din blocul acela sunt intenționate. to authenticated apare mereu, pentru că o politică fără el rulează și pentru anon. Există o politică pentru fiecare operație, nu for all, ca fiecare regulă să rămână vizibilă. using controlează ce rânduri sunt vizibile sau afectate, iar with check controlează cum poate arăta un rând nou sau modificat. Iar cazul null este explicit, pentru că auth.uid() întoarce null când nu e nimeni autentificat, iar null = user_id nu este niciodată adevărat.
Mai contează două reguli din aceeași pagină. Un update are nevoie și de o politică select, altfel nu se comportă cum te aștepți. Iar o politică menită să fie publică trebuie să spună asta cu to anon using (true), pe un tabel în care chiar toate rândurile sunt publice.
Testează pe fiecare rol
O politică ce se citește bine este o ipoteză. Modul de a o verifica este să rulezi interogarea pe rolul pe care îl va folosi API-ul. În editorul SQL sau în psql, schimbi rolul într-o tranzacție și faci rollback:
begin; set local role anon; select count(*) from public.profiles; rollback;begin; set local role authenticated; set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; select count(*) from public.profiles; rollback;
Rulează aceeași probă cu un al doilea id de utilizator și cu o sesiune în care claim-ul lipsește. Rezultatele trebuie să spună o poveste: zero rânduri pentru anon, un rând pentru proprietar, zero rânduri pentru celălalt utilizator. Testează și o scriere, de exemplu un insert care pretinde user_id-ul altui utilizator, și așteaptă-te să eșueze cu 42501. Ca să verifici grantul separat de politică, întreabă direct Postgres cu select has_table_privilege('anon', 'public.profiles', 'insert,update,delete');.
Pentru o suită pe care o păstrezi, Supabase documentează teste pgTAP în supabase/tests/. Creezi unul cu supabase test new <name> și rulezi tot setul cu supabase test db. Aserțiunile recomandate în ghid sunt precise: throws_ok cu 42501 pentru un grant lipsă sau o încălcare a lui with check, is_empty cu returning pentru un rând filtrat de using și results_eq pentru o scriere care trebuie să reușească. Ghidul avertizează împotriva lui lives_ok, care trece și când zero rânduri se potrivesc.
De ce testul bate cititul politicii
Citind o politică, compari propoziția cu intenția ta. Rulând-o, compari propoziția cu Postgres, iar cele două verificări nu sunt același lucru. Diferența apare în locuri obișnuite.
- Granturile se verifică primele. Un grant lipsă produce
42501înainte ca politica să fie consultată, iar un grant rămas peanoneste cealaltă jumătate a aceleiași greșeli. usingșiwith checkfac treburi diferite. O politicăupdatecuusingcorect și fărăwith checkpoate lăsa un rând să treacă la alt utilizator.- O politică fără
tose aplică și luianon. Pare limitată la utilizatorii autentificați și nu este. - Un
updateare nevoie de o politicăselectca să își găsească rândurile. Fără ea, interogarea nu întoarce nimic și tabelul pare stricat. - View-urile rulează de obicei ca autorul lor și ocolesc RLS. Pe Postgres 15 sau mai nou,
security_invoker = trueschimbă asta; înainte, revoci accesul sau muți view-ul din schema expusă. - Două tabele ale căror politici se citesc reciproc eșuează cu
42P17, recursivitate infinită, iar eroarea apare doar când rulezi interogarea. - Claim-urile din JWT rămân vechi până la reîmprospătarea tokenului, iar
user_metadatapoate fi modificat de utilizator. Autorizarea stă înapp_metadata.
Linterul ajută, dar semnalează șabloane, nu intenții. Prinde using (true). Nu prinde o politică ce compară user_id cu id-ul greșit sau un tabel ale cărui granturi nu au fost revocate, dar ale cărui politici par stricte. Rularea interogării pe fiecare rol este singura verificare care parcurge exact drumul API-ului: rezolvarea rolului, grantul, politica, rândul.
Un sandbox pentru același test
Verificăm destul de des acest lucru manual, încât a influențat un instrument. Supabase Local Manager este un proiect independent, neafiliat cu Supabase, Inc., aflat în early access. Oferă și administrează mai multe stack-uri Supabase locale dintr-un singur serviciu Node și Express, iar panoul lui include row-level security cu un sandbox de testare a politicilor, unde alegi un rol și rulezi o interogare de probă pe un proiect. Este aceeași buclă descrisă mai sus, cu schimbarea de rol și rollback-ul gestionate pentru tine. Accesul se cere prin GitHub, iar codul sursă rămâne privat.
Unde se leagă asta de munca noastră
Danube Labs este un studio de software condus de fondator, în București. Multe dintre site-urile pe care le livrăm nu au deloc bază de date, cum arătăm în articolul despre CMS-ul bazat pe Git, iar când un proiect are nevoie de Postgres, politicile fac parte din schemă încă de la prima migration, nu sunt un pas de securizare la final. Dacă construiești pe Supabase și vrei o a doua trecere peste modelul de acces, vezi proiectele noastre de software la comandă sau programează un apel introductiv gratuit.
Întrebări frecvente
- Ce este row-level security în Supabase?
- Row-level security este o funcție Postgres care limitează ce rânduri poate citi sau modifica o interogare. În Supabase fiecare politică acționează ca un WHERE suplimentar pe tabel, iar politicile se potrivesc pe rolul Postgres sub care rulează cererea API. Granturile decid dacă un rol poate executa operația, iar politicile decid căror rânduri li se aplică.
- Care este diferența dintre anon și authenticated în Supabase?
- Sunt roluri Postgres, nu chei. O cerere cu cheia publishable rulează ca anon când nu e nimeni autentificat și ca authenticated după ce un utilizator se autentifică prin Supabase Auth. Cheia identifică aplicația, iar tokenul utilizatorului identifică persoana. Scrie politicile pentru ambele roluri.
- Este sigură expunerea cheii publishable sau anon din Supabase?
- Este menită să fie publică, pentru că ajunge în codul din browser. Ajunge doar la ce permite row-level security, deci protecția vine din politicile de pe tabele. Cheia secret este opusul: corespunde rolului service_role, ocolește RLS și trebuie ținută pe server.
- Ce se întâmplă dacă un tabel din Supabase are RLS oprit?
- Dacă tabelul e într-o schemă expusă, precum public, și un rol are grant pe el, oricine are URL-ul proiectului poate citi, modifica și șterge rândurile. Linterul Supabase raportează asta ca 0013_rls_disabled_in_public, la nivel de eroare. Activează RLS, revocă granturile existente și acordă înapoi doar ce are nevoie rolul.
- Cum testez o politică RLS pe un anumit rol?
- Pornește o tranzacție, rulează set local role anon sau set local role authenticated și setează id-ul utilizatorului cu set local request.jwt.claim.sub. Rulează select-ul, insert-ul sau update-ul în acea tranzacție, apoi fă rollback. Pentru o suită păstrată, scrie teste pgTAP în supabase/tests/ și rulează-le cu supabase test db.
- De ce politica mea RLS nu întoarce niciun rând sau lasă totul să treacă?
- Cauzele obișnuite sunt lipsa unei politici select pentru update, un grant lipsă sau revocat ori o politică fără clauza to, care se aplică și lui anon. O politică ce folosește true e complet deschisă, iar una care compară un auth.uid() null nu se potrivește niciodată. Rularea interogării pe fiecare rol arată care dintre ele este.


