Hopp til innhold

Test Supabase RLS: 30 av 87 tilganger gjorde ingenting

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

Spørsmålet kopieres også. Hvis chatten åpnes tom, lim det inn.

TL;DR

Test row level security (radnivåsikkerhet) i Supabase slik et medlem møter den: logg inn som hver rolle, kjør den faktiske handlingen med én tilgang slått av og deretter på, og sjekk at resultatet endrer seg. I en produksjonsapp med fem redigerbare roller viste dette at 30 av de 87 tilgangsboksene en administrator kunne lagre, ikke endret noe i databasen. En test per policy kan ikke se det, fordi den tester policyen du skrev, ikke boksen på skjermen.

Jeg er Denys, fullstack- og AI-ingeniør. Tidligere i år bygde jeg et prosjekt- og timeregistreringssystem for et revisjonsselskap på Supabase, der hver tilgangsregel ligger i databasen. Under beskriver jeg metoden jeg bruker for å bevise de reglene, hva den fant og hva den koster å kjøre.

Hvorfor holder ikke policytester for å teste Supabase RLS?

En policytest svarer på “gjør denne policyen det jeg skrev?”. Brukerne dine stiller et annet spørsmål: “hvis jeg fjerner haken i denne boksen for denne rollen, kan de fortsatt gjøre det?”. I en liten app har spørsmålene samme svar. I en app med en innstillingsside der administratorer redigerer en tilgangsmatrise, glir de fra hverandre.

Appen hadde 41 tabeller med RLS slått på og 402 CREATE POLICY-setninger fordelt på 255 migreringer. Administratorskjermen viste en matrise med områder (prosjekter, oppgaver, timelister, filer og så videre) mot handlinger (vise, opprette, redigere, arkivere), én kolonne per rolle. Hver lagrede boks var et løfte til administratoren. Ingenting sjekket at databasen holdt det.

Supabase dokumenterer standardverktøyene godt: pgTAP-tester og testhjelperne lar deg opprette brukere, bytte til dem og sjekke hva de kan lese. Bruk dem. Metoden under ligger oppå: den styres av matrisen administratoren ser, ikke av listen over policyer.

Hvordan fungerer matrisebeviset?

Hver boks i matrisen blir en probe. En probe logger inn som medlem av rollen, utfører én faktisk handling og registrerer utfallet. Det skjer to ganger: én gang med boksen av og én gang med boksen på, alt annet likt. Endrer utfallet seg, håndheves boksen. Gjør det ikke det, gjør boksen ingenting, uansett hvor overbevisende den ser ut på skjermen.

  1. Én boks i matrisenEksempel: Standardmedlem · Oppgaver · Opprette
  2. Boksen av: opptre som medlem av rollenOpprett en oppgave via samme vei som appen bruker. Registrer utfallet.
  3. Boksen på: gjenta samme handlingSamme bruker, samme data. Bare boksen er endret.
  4. Sammenlign de to utfalleneDommen avhenger bare av hva databasen gjorde.
  • Utfallet endret segHåndheves. Avvist når den er av, tillatt når den er på.
  • Likt begge gangerGjør ingenting. Skjermen lover en kontroll som ikke finnes.
  • Skjerm og database er uenigeEn feil. Siden viser en knapp databasen avviser, eller omvendt.
Figur 1. Hver dom kommer fra et par faktiske utfall, aldri fra å lese koden.

“Utfall” betyr her det en konsument av tilgangen gjør: en policy returnerer rader eller avviser en skriving, en databasefunksjon kaster en feil, en trigger blokkerer, eller en edge function svarer 403. Én boks kan beskytte flere av disse, så de 87 boksene krevde 279 kontroller per rolle. Hver kontroll endte med én av disse dommene:

Dom Kontroller per rolle Hva det betyr
Effekt 227 Utfallet snudde med boksen
Død 30 Utfallet var det samme med boksen av og på
Utilgjengelig 19 Handlingen kan ikke nås for denne rollen i det hele tatt, bevist separat
Edge function 1 Håndheves av en edge function, sjekket over HTTP
Bare skjerm 1 Endrer bare det siden viser, dekket av en frontend-test
Skjerm og database er enige 1 Det siden tilbyr, stemmer med det databasen tillater
Feilet 0 En kontroll der utfallet ikke stemte med forventningen

Hva fant beviset?

Kjøringen 15. september 2026 dekket alle fem rollene en administrator kan redigere (Admin, Senior, Standard, Restricted og en klonet egendefinert rolle). Resultatene var identiske for alle rollene.

87 tilgangsbokser en administrator kunne lagre. Etter hva som faktisk håndhevet hver boks. Likt for alle fem rollene. Håndheves av databasen: 55; Håndheves annet sted: 1 edge function, 1 skjerm: 2; Endret ingenting: 30.
Figur 2. De 87 tilgangsboksene en administrator kunne lagre, etter hva som faktisk håndhevet dem. Samme resultat for hver av de fem rollene.

Ingen av de 30 slapp noen inn. For 29 av dem leste ingen policy, funksjon eller trigger noen gang boksen; den siste sluttet å bety noe etter at en policy ble skrevet om. Det er likevel et reelt problem: en administrator som fjerner haken for “Slette” for en rolle, tror at rollen ikke kan slette. Vi skjulte alle 30 i stedet for å koble dem til, fordi hver av dem ville ha krevd en produktbeslutning, og en boks som ikke gjør noe, bør ikke stå på skjermen mens du tar den.

Det samme arbeidet avdekket fire feil til:

Hva som var feil Hvordan det viste seg Løsning
30 bokser gjorde ingenting Dommen Død, identisk utfall av og på Skjult fra matrisen
Tre databasefunksjoner slapp gjennom en handling når en oppgave ikke hadde noen ansvarlig En probe som skulle vært avvist, ble tillatt Funksjonene avviser nå når verdien mangler
Å sette en timeliste tilbake til utkast var avhengig av feil boks Skjerm og database var uenige for én rolle Styres av tilgangen for gjenåpning som den hører til
Et medlem så nye tilganger først etter en ny innlasting Databasen hadde endret seg, siden ikke Tilganger oppdateres hvert 60. sekund og ved vindusfokus
En standardverdi for en kolonne brøt sin egen check constraint, så inserts uten den kolonnen feilet Funnet av sikkerhetsgjennomgangen av løsningen, i samme arbeid Standardverdien endret til en verdi constrainten tillater

Feilen med manglende ansvarlig var den som betydde noe. Sperrene var skrevet slik 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 fortsatt null, og if behandler null som usant. Unntaket utløses aldri. I produksjon hadde ingen oppgave en ansvarlig satt, så dette var ikke et grensetilfelle: det gjaldt hver oppgave som var omfattet. Løsningen pakker betingelsen inn i coalesce(…, false) slik at en manglende verdi avvises, og den ble levert først, for seg selv, før resten av endringene.

Hvordan kjører du det mot en kopi av produksjon?

Et bevis mot et skjema som avviker fra produksjon, beviser ingenting om produksjon. Testoppsettet kjører på den lokale Supabase-stacken, i en egen database som er en kopi av produksjonsskjemaet pluss migreringene som gjennomgås. Før hver kjøring sammenlignes et fingeravtrykk av begge de lokale databasene: 799 rader som beskriver tabeller, policyer, funksjoner og rettigheter. Enhver forskjell stopper kjøringen.

  1. ProduksjonsskjemaKopiert til én betrodd maskin. Det havner aldri på en hostet CI-runner.
  2. Lokal stack: appdatabaseBetjenes av lokalt API og auth, brukes av ende-til-ende-kontrollene.
  3. Lokal stack: isolert bevisdatabaseSamme skjema, brukes bare av matrisebeviset.
  4. Kontroll av fingeravtrykk799 rader per database. Enhver forskjell stopper kjøringen før første probe.
  5. Prober kjøres som hver rolleSom faktiske innloggede medlemmer, via de samme veiene appen bruker.
  6. Ingen spor igjenAntall rader på slutten må være lik antall rader ved start.
Figur 3. Beviset teller bare når databasen det kjørte mot, stemmer med produksjon.

To detaljer gjorde testoppsettet til å stole på:

  • Ødelegg det med vilje først. Før jeg stolte på en grønn kjøring, byttet jeg ut én insert-policy med en som godtar alt, og kjørte beviset igjen. Det måtte bli rødt på boksene den policyen beskytter. Et testoppsett som forblir grønt med en ødelagt policy, er pynt.
  • Sjekk den lagrede tilstanden også. Første runde starter hver rolle fra et rent utgangspunkt. En andre runde starter fra hver rolles nåværende lagrede matrise, fordi en boks kan oppføre seg annerledes ved siden av boksene en administrator faktisk har krysset av.

Hvordan holder du det grønt etter lansering?

Et bevis du kjører én gang, blir utdatert ved neste migrering. Beviset kjører nå før hver push som berører migreringer, auth-koden eller selve matrisen.

  1. git pushEndringen berører migreringer, auth eller matrisen.
  2. Pre-push-hook: matrisebevisKjører lokalt mot bevisdatabasen. Rødt blokkerer pushen.
  3. Pushen når mainUtrullingen kjører som vanlig.
  4. Kjøring på egen CI-maskinSamme bevis igjen, på maskinen som har kopien av produksjonsskjemaet.
Figur 4. Den blokkerende sperren er lokal, fordi kopien av produksjonsskjemaet blir på én maskin.

Beviset er 135 tester i 11 filer, ved siden av rundt 3 100 vanlige enhetstester i samme prosjekt.

Hva fungerte ikke?

  • Hostet CI kunne ikke kjøre det. Beviset trenger kopien av produksjonsskjemaet, og den ville jeg ikke ha på en hostet runner. Derfor er den blokkerende sperren en pre-push-hook, og CI på en egen maskin er en ekstra kontroll som utrullingen ikke venter på.
  • Faste radantall brøt sammen. Den første “ingen spor”-kontrollen sammenlignet mot faste tall. Neste legitime migrering la til en tilgangsrad og gjorde den rød. Nå sammenlignes antallet på slutten med antallet ved start.
  • Hoppet over er ikke bestått. Ende-til-ende-kontrollene trenger den lokale edge-runtimen. Når den er nede, hoppes de over, og en overhoppet test er lett å lese som grønn.
  • Det er tregt på en travel laptop. Rundene per rolle trengte lengre timeouts på en belastet maskin.
  • Det beviser det probene beskriver. En boks ingen har skrevet en probe for, rapporteres som et hull i dekningen, ikke som trygg.

Slik gjentar du dette i Supabase-appen din

  1. List opp hver tilgang skjermen lar noen lagre: rolle × område × handling. Den listen er spesifikasjonen, ikke policyene dine.
  2. Skriv for hver boks ned den faktiske handlingen den skal styre, og hvordan et avslag viser seg (ingen rader, en feil, en 403).
  3. Bygg en database som stemmer med produksjon, og ta et fingeravtrykk av den, slik at avvik i skjemaet stopper kjøringen.
  4. Kjør hver handling som et innlogget medlem, boksen av og så på, og sammenlign.
  5. Ødelegg én policy med vilje og bekreft at beviset blir rødt.
  6. Skjul eller koble til hver boks som ikke gjør noe, og gjør så beviset til en sperre før push.

Har appen din allerede RLS, men du er usikker på om den gjør det innstillingsskjermen sier, fungerer samme metode som en gjennomgang av en eksisterende kodebase, første steg i en prosjektredning. Jeg har skrevet om prosjektet bak disse tallene i caset om timeregistrering for et revisjonsselskap.

Usikker på om RLS-en din stemmer med innstillingsskjermen?

Utveksler gjerne erfaringer om hvordan du tester den. Ingen salgspitch.

Ta kontakt