Gå til indhold

Test Supabase RLS: 30 af 87 rettigheder gjorde intet

Vis som MarkdownÅbn i ClaudeÅbn i ChatGPTÅbn i Perplexity

Spørgsmålet kopieres også. Hvis chatten åbner tom, så indsæt det.

TL;DR

Test row level security (sikkerhed på rækkeniveau) i Supabase, som et medlem møder den: log ind som hver rolle, kør den rigtige handling med én rettighed slået fra og derefter til, og tjek, at resultatet ændrer sig. I en produktionsapp med fem redigerbare roller viste det, at 30 af de 87 rettighedsfelter, en administrator kunne gemme, ikke ændrede noget i databasen. En test pr. policy kan ikke se det, fordi den tester den policy, du skrev, ikke feltet på skærmen.

Jeg er Denys, fullstack- og AI-ingeniør. Tidligere i år byggede jeg et projekt- og tidsregistreringssystem til et revisionsfirma på Supabase, hvor hver adgangsregel ligger i databasen. Herunder beskriver jeg metoden, jeg bruger til at bevise de regler, hvad den fandt, og hvad den koster at køre.

Hvorfor er policytests ikke nok til at teste Supabase RLS?

En policytest svarer på “gør denne policy det, jeg skrev?”. Dine brugere stiller et andet spørgsmål: “hvis jeg fjerner fluebenet i dette felt for denne rolle, kan de så stadig gøre det?”. I en lille app har spørgsmålene samme svar. I en app med en indstillingsside, hvor administratorer redigerer en rettighedsmatrix, glider de fra hinanden.

Appen havde 41 tabeller med RLS slået til og 402 CREATE POLICY-sætninger fordelt på 255 migreringer. Administratorskærmen viste en matrix med områder (projekter, opgaver, timesedler, filer og så videre) mod handlinger (se, oprette, redigere, arkivere), én kolonne pr. rolle. Hvert gemt felt var et løfte til administratoren. Intet tjekkede, at databasen holdt det.

Supabase dokumenterer standardværktøjerne godt: pgTAP-tests og testhjælperne lader dig oprette brugere, skifte til dem og tjekke, hvad de kan læse. Brug dem. Metoden herunder ligger ovenpå: den styres af den matrix, administratoren ser, ikke af listen over policies.

Hvordan fungerer matrixbeviset?

Hvert felt i matrixen bliver en probe. En probe logger ind som medlem af rollen, udfører én rigtig handling og registrerer udfaldet. Det sker to gange: én gang med feltet slået fra og én gang med feltet slået til, alt andet lige. Ændrer udfaldet sig, håndhæves feltet. Gør det ikke, gør feltet ingenting, uanset hvor overbevisende det ser ud på skærmen.

  1. Ét felt i matrixenEksempel: Standardmedlem · Opgaver · Opret
  2. Feltet fra: optræd som medlem af rollenOpret en opgave via samme vej, som appen bruger. Registrér udfaldet.
  3. Feltet til: gentag samme handlingSamme bruger, samme data. Kun feltet er ændret.
  4. Sammenlign de to udfaldDommen afhænger kun af, hvad databasen gjorde.
  • Udfaldet ændrede sigHåndhævet. Afvist når det er fra, tilladt når det er til.
  • Ens begge gangeGør ingenting. Skærmen lover en kontrol, der ikke findes.
  • Skærm og database er uenigeEn fejl. Siden viser en knap, databasen afviser, eller omvendt.
Figur 1. Hver dom kommer fra et par rigtige udfald, aldrig fra at læse koden.

“Udfald” betyder her det, en forbruger af rettigheden gør: en policy returnerer rækker eller afviser en skrivning, en databasefunktion kaster en fejl, en trigger blokerer, eller en edge function svarer 403. Ét felt kan beskytte flere af disse, så de 87 felter krævede 279 tjek pr. rolle. Hvert tjek endte med én af disse domme:

Dom Tjek pr. rolle Hvad det betyder
Effekt 227 Udfaldet skiftede med feltet
Død 30 Udfaldet var det samme med feltet fra og til
Utilgængelig 19 Handlingen kan slet ikke nås for denne rolle, bevist separat
Edge function 1 Håndhæves af en edge function, tjekket over HTTP
Kun skærm 1 Ændrer kun det, siden viser, dækket af en frontend-test
Skærm og database er enige 1 Det, siden tilbyder, stemmer med det, databasen tillader
Fejlet 0 Et tjek, hvor udfaldet ikke svarede til forventningen

Hvad fandt beviset?

Kørslen den 15. september 2026 dækkede alle fem roller, en administrator kan redigere (Admin, Senior, Standard, Restricted og en klonet brugerdefineret rolle). Resultaterne var identiske på tværs af rollerne.

87 rettighedsfelter, en administrator kunne gemme. Efter hvad der faktisk håndhævede hvert felt. Samme for alle fem roller. Håndhævet af databasen: 55; Håndhævet andetsteds: 1 edge function, 1 skærm: 2; Ændrede ingenting: 30.
Figur 2. De 87 rettighedsfelter, en administrator kunne gemme, efter hvad der faktisk håndhævede dem. Samme resultat for hver af de fem roller.

Ingen af de 30 lukkede nogen ind. For 29 af dem læste ingen policy, funktion eller trigger nogensinde feltet; det sidste holdt op med at betyde noget, efter at en policy blev skrevet om. Det er stadig et reelt problem: en administrator, der fjerner fluebenet ved “Slet” for en rolle, tror, at rollen ikke kan slette. Vi skjulte alle 30 i stedet for at koble dem til, fordi hvert af dem ville have krævet en produktbeslutning, og et felt, der ikke gør noget, bør ikke stå på skærmen, mens du træffer den.

Det samme arbejde afslørede fire fejl mere:

Hvad der var galt Hvordan det viste sig Løsning
30 felter gjorde ingenting Dommen Død, identisk udfald fra og til Skjult fra matrixen
Tre databasefunktioner lukkede en handling igennem, når en opgave ikke havde nogen ansvarlig En probe, der burde være afvist, blev tilladt Funktionerne afviser nu, når værdien mangler
At sætte en timeseddel tilbage til kladde afhang af det forkerte felt Skærm og database var uenige for én rolle Styres af rettigheden til genåbning, som den hører til
Et medlem så først nye rettigheder efter en genindlæsning Databasen havde ændret sig, siden havde ikke Rettigheder opdateres hvert 60. sekund og ved vinduesfokus
En standardværdi for en kolonne brød sin egen check constraint, så inserts uden den kolonne fejlede Fundet af sikkerhedsgennemgangen af rettelsen, i samme arbejde Standardværdien ændret til en værdi, constrainten tillader

Fejlen med manglende ansvarlig var den, der betød noget. Spærrerne var skrevet sådan i PL/pgSQL:

if not (has_permission or assignee_id = auth.uid()) then
  raise exception 'forbidden';
end if;

Når assignee_id er null, er assignee_id = auth.uid() null, false or null er null, not null er stadig null, og if behandler null som falsk. Undtagelsen udløses aldrig. I produktion havde ingen opgave en ansvarlig sat, så det var ikke et grænsetilfælde: det gjaldt hver opgave i omfanget. Rettelsen pakker betingelsen ind i coalesce(…, false), så en manglende værdi afvises, og den blev leveret først, for sig selv, før resten af ændringerne.

Hvordan kører du det mod en kopi af produktion?

Et bevis mod et skema, der afviger fra produktion, beviser intet om produktion. Testopsætningen kører på den lokale Supabase-stack, i en separat database, der er en kopi af produktionsskemaet plus de migreringer, der gennemgås. Før hver kørsel sammenlignes et fingeraftryk af begge lokale databaser: 799 rækker, der beskriver tabeller, policies, funktioner og rettigheder. Enhver forskel stopper kørslen.

  1. ProduktionsskemaKopieret til én betroet maskine. Det havner aldrig på en hostet CI-runner.
  2. Lokal stack: appdatabaseBetjenes af lokalt API og auth, bruges af end-to-end-tjekkene.
  3. Lokal stack: isoleret bevisdatabaseSamme skema, bruges kun af matrixbeviset.
  4. Tjek af fingeraftryk799 rækker pr. database. Enhver forskel stopper kørslen før første probe.
  5. Prober køres som hver rolleSom rigtige indloggede medlemmer, via de samme veje, som appen bruger.
  6. Ingen spor tilbageAntal rækker til sidst skal være lig med antal rækker ved start.
Figur 3. Beviset tæller kun, når databasen, det kørte mod, stemmer med produktion.

To detaljer gjorde testopsætningen til at stole på:

  • Ødelæg det med vilje først. Før jeg stolede på en grøn kørsel, udskiftede jeg én insert-policy med en, der accepterer alt, og kørte beviset igen. Det skulle blive rødt på de felter, den policy beskytter. En testopsætning, der forbliver grøn med en ødelagt policy, er pynt.
  • Tjek også den gemte tilstand. Første gennemløb starter hver rolle fra et rent udgangspunkt. Et andet gennemløb starter fra hver rolles nuværende gemte matrix, fordi et felt kan opføre sig anderledes ved siden af de felter, en administrator faktisk har sat flueben i.

Hvordan holder du det grønt efter release?

Et bevis, du kører én gang, bliver forældet ved næste migrering. Beviset kører nu før hver push, der berører migreringer, auth-koden eller selve matrixen.

  1. git pushÆndringen berører migreringer, auth eller matrixen.
  2. Pre-push-hook: matrixbevisKører lokalt mod bevisdatabasen. Rødt blokerer pushet.
  3. Pushet når mainUdrulningen kører som normalt.
  4. Kørsel på egen CI-maskineSamme bevis igen, på maskinen, der har kopien af produktionsskemaet.
Figur 4. Den blokerende spærre er lokal, fordi kopien af produktionsskemaet bliver på én maskine.

Beviset er 135 tests i 11 filer, ved siden af cirka 3.100 almindelige enhedstests i samme projekt.

Hvad virkede ikke?

  • Hostet CI kunne ikke køre det. Beviset kræver kopien af produktionsskemaet, og den ville jeg ikke have på en hostet runner. Derfor er den blokerende spærre en pre-push-hook, og CI på en egen maskine er et ekstra tjek, som udrulningen ikke venter på.
  • Faste rækketal gik i stykker. Det første “ingen spor”-tjek sammenlignede med faste tal. Den næste legitime migrering tilføjede en rettighedsrække og gjorde det rødt. Nu sammenlignes tallene til sidst med tallene ved start.
  • Sprunget over er ikke bestået. End-to-end-tjekkene kræver den lokale edge-runtime. Når den er nede, springes de over, og et overspring er let at læse som grønt.
  • Det er langsomt på en travl laptop. Gennemløbene pr. rolle krævede længere timeouts på en belastet maskine.
  • Det beviser det, proberne beskriver. Et felt, som ingen har skrevet en probe til, rapporteres som et hul i dækningen, ikke som sikkert.

Sådan gentager du det i din Supabase-app

  1. Skriv hver rettighed op, som skærmen lader nogen gemme: rolle × område × handling. Den liste er specifikationen, ikke dine policies.
  2. Notér for hvert felt den rigtige handling, det skal styre, og hvordan et afslag viser sig (ingen rækker, en fejl, en 403).
  3. Byg en database, der stemmer med produktion, og tag et fingeraftryk af den, så afvigelser i skemaet stopper kørslen.
  4. Kør hver handling som et indlogget medlem, feltet fra og så til, og sammenlign.
  5. Ødelæg én policy med vilje og bekræft, at beviset bliver rødt.
  6. Skjul eller kobl hvert felt, der ikke gør noget, til, og gør så beviset til en spærre før push.

Har din app allerede RLS, men er du ikke sikker på, at den gør det, indstillingsskærmen siger, fungerer samme metode som en gennemgang af en eksisterende kodebase, første skridt i en projektredning. Jeg har skrevet om projektet bag tallene i casen om tidsregistrering for et revisionsfirma.

Usikker på, om din RLS stemmer med indstillingsskærmen?

Jeg udveksler gerne erfaringer om, hvordan du tester den. Ingen salgstale.

Kontakt mig