---
title: "Test Supabase RLS: 30 av 87 tilganger gjorde ingenting"
description: "Policytester for Supabase RLS overser det brukerne ser. Et bevis rolle for rolle mot en kopi av produksjon viste at 30 av 87 tilgangsbokser ikke gjorde noe."
url: "https://madsalt.tech/no/blog/how-to-test-supabase-rls/"
author: "Denys Zaichenko"
language: "no"
published: 2026-10-14
---

# Test Supabase RLS: 30 av 87 tilganger gjorde ingenting

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](https://supabase.com/docs/guides/database/extensions/pgtap) og [testhjelperne](https://supabase.com/docs/guides/local-development/testing/pgtap-extended) 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 matrisen**. Eksempel: Standardmedlem · Oppgaver · Opprette
2. **Boksen av: opptre som medlem av rollen**. Opprett en oppgave via samme vei som appen bruker. Registrer utfallet.
3. **Boksen på: gjenta samme handling**. Samme bruker, samme data. Bare boksen er endret.
4. **Sammenlign de to utfallene**. Dommen avhenger bare av hva databasen gjorde.
- **Utfallet endret seg**. Håndheves. Avvist når den er av, tillatt når den er på.
- **Likt begge ganger**. Gjør ingenting. Skjermen lover en kontroll som ikke finnes.
- **Skjerm og database er uenige**. En 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.](https://madsalt.tech/charts/no/how-to-test-supabase-rls.svg)

*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:

```sql
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. **Produksjonsskjema**. Kopiert til én betrodd maskin. Det havner aldri på en hostet CI-runner.
2. **Lokal stack: appdatabase**. Betjenes av lokalt API og auth, brukes av ende-til-ende-kontrollene.
3. **Lokal stack: isolert bevisdatabase**. Samme skjema, brukes bare av matrisebeviset.
4. **Kontroll av fingeravtrykk**. 799 rader per database. Enhver forskjell stopper kjøringen før første probe.
5. **Prober kjøres som hver rolle**. Som faktiske innloggede medlemmer, via de samme veiene appen bruker.
6. **Ingen spor igjen**. Antall 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 push**. Endringen berører migreringer, auth eller matrisen.
2. **Pre-push-hook: matrisebevis**. Kjører lokalt mot bevisdatabasen. Rødt blokkerer pushen.
3. **Pushen når main**. Utrullingen kjører som vanlig.
4. **Kjøring på egen CI-maskin**. Samme 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](https://madsalt.tech/no/services/project-rescue/). Jeg har skrevet om prosjektet bak disse tallene i [caset om timeregistrering for et revisjonsselskap](https://madsalt.tech/no/work/audit-firm-time-tracking/).

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

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

[Ta kontakt](https://madsalt.tech/no/#contact)
