PostgreSQL radnivå‑säkerhet för SaaS hjälper till att upprätthålla tenant‑isolering i databasen. Lär dig när du ska använda det, hur du skriver policys och vad du bör undvika.

I en SaaS‑app är den farligaste säkerhetsbuggen den som dyker upp efter att du har skalat. Du börjar med en enkel regel som "användare kan bara se sin tenants data", sedan levererar du snabbt en ny endpoint, lägger till en rapportfråga eller introducerar en join som tyst hoppar över kontrollen.
Endast applikationsbaserad auktorisering brister under tryck eftersom reglerna sprids. En controller kollar tenant_id, en annan kontrollerar medlemskap, ett bakgrundsjobb glömmer, och en "admin export"‑väg förblir "tillfällig" i månader. Även försiktiga team missar en plats.
PostgreSQL row‑level security (RLS) löser ett specifikt problem: den får databasen att bestämma vilka rader som är synliga för en given förfrågan. Mentaliteten är enkel: varje SELECT, UPDATE och DELETE filtreras automatiskt av policys, på samma sätt som varje förfrågan filtreras av autentiserings‑middleware.
Att det handlar om "rader" spelar roll. RLS skyddar inte allt:
Ett konkret exempel: du lägger till en endpoint som listar projekt med en join till invoices för en instrumentpanel. Med enbart applikationsauth är det lätt att filtrera projects men glömma att filtrera invoices, eller att joina på en nyckel som korsar tenants. Med RLS kan båda tabellerna upprätthålla tenant‑isolering, så frågan failar säkert istället för att läcka data.
Avvägningen är verklig. Du skriver mindre upprepad auktoriseringskod och minskar antalet ställen som kan läcka. Men du tar också på dig nytt arbete: du måste utforma policys noggrant, testa tidigt och acceptera att en policy kan blockera en fråga du trodde skulle fungera.
RLS kan kännas som extra arbete tills din app växer förbi ett fåtal endpoints. Om du har strikta tenant‑gränser och många frågevägar (listaskärmar, sök, export, adminverktyg) betyder det att regeln i databasen gör att du inte behöver komma ihåg att lägga samma filter överallt.
RLS passar bra när regeln är tråkig och universell: "en användare kan bara se rader för sin tenant" eller "en användare kan bara se projekt där hen är medlem". I sådana upplägg minskar policys misstag eftersom varje SELECT, UPDATE och DELETE går igenom samma grind, även när en fråga läggs till senare.
Det hjälper också i lästunga appar där filtreringslogiken är konsekvent. Om din API har 15 olika sätt att ladda fakturor (efter status, datum, kund, sökning) låter RLS dig sluta återimplementera tenant‑filtrering för varje fråga och istället fokusera på funktionaliteten.
RLS skapar problem när reglerna inte är radsbaserade. Per‑fält‑regler som "du kan se lön men inte bonus" eller "maskera denna kolumn om du inte är HR" blir ofta till klumpig SQL och svårunderhållna undantag.
Det passar heller inte bra för tung rapportering som verkligen behöver bred åtkomst. Team skapar ofta bypass‑roller för "bara detta jobb", och där samlas misstagen.
Innan du bestämmer dig, avgör om du vill att databasen ska vara den slutgiltiga grindvakten. Om ja, planera för disciplin: testa databatsbeteende (inte bara API‑svar), behandla migrationer som säkerhetsändringar, undvik snabba bypasser, bestäm hur bakgrundsjobb autentiserar och håll policys små och återanvändbara.
Om du använder verktyg som genererar backend kan det snabba upp leverans, men det tar inte bort behovet av tydliga roller, tester och en enkel tenant‑modell. (Till exempel använder Koder.ai Go och PostgreSQL för genererade backends, och du vill fortfarande designa RLS medvetet istället för att "strö det i senare".)
RLS är enklast när ditt schema redan tydligt säger vem som äger vad. Om du börjar med en otydlig modell och försöker "fixa det i policys" får du ofta långsamma queries och förvirrande buggar.
Välj en tenant‑nyckel (som org_id) och använd den konsekvent. De flesta tenant‑ägda tabeller bör ha den, även om de också refererar en annan tabell som har den. Det undviker joins i policys och håller USING‑kontrollerna enkla.
En praktisk regel: om en rad ska försvinna när en kund avbokar, behöver den sannolikt org_id.
RLS‑policys svarar oftast på en fråga: "Är denna användare medlem i denna org, och vad får hen göra?" Det är svårt att härleda från ad hoc‑kolumner.
Håll kärntabellerna små och tråkiga:
users (en rad per person)orgs (en rad per tenant)org_memberships (user_id, org_id, role, status)project_memberships för per‑projekt‑åtkomstMed det på plats kan dina policys kontrollera medlemskap med en enda indexerad uppslagning.
Inte allt behöver org_id. Referenstabeller som länder, produktkategorier eller plan‑typer delas ofta mellan tenants. Gör dem read‑only för de flesta roller och knyt dem inte till en org.
Tenant‑ägd data (projekt, fakturor, tickets) bör undvika att dra in tenant‑specifika detaljer via delade tabeller. Håll delade tabeller minimala och stabila.
Foreign keys fungerar fortfarande med RLS, men deletes kan överraska om den roll som raderar inte "ser" beroende rader. Planera kaskader noga och testa verkliga delete‑flöden.
Indexera kolumner som dina policys filtrerar på, särskilt org_id och medlemskapsnycklar. En policy som ser ut som WHERE org_id = ... ska inte bli en fulltable scan när tabellen når miljoner rader.
RLS är en per‑tabell‑brytare. När den är på börjar PostgreSQL inte lita på att din app kommer ihåg tenant‑filtret. Varje SELECT, UPDATE och DELETE filtreras av policys, och varje INSERT och UPDATE valideras av policys.
Den största mentala omställningen: med RLS på kan queries som tidigare returnerade data börja returnera noll rader utan fel. Det är PostgreSQL som utför åtkomstkontrollen.
Policys är små regler fästa vid en tabell. De använder två kontroller:
USING är läsfiltern. Om en rad inte matchar USING är den osynlig för SELECT, och den kan inte riktas av UPDATE eller DELETE.WITH CHECK är skrivporten. Den bestämmer vilka nya eller ändrade rader som är tillåtna för INSERT eller UPDATE.Ett vanligt SaaS‑mönster: USING säkerställer att du bara ser rader från din tenant, och WITH CHECK säkerställer att du inte kan skapa en rad i någon annans tenant genom att gissa tenant‑ID.
När du lägger till fler policys senare spelar detta roll:
PERMISSIVE (standard): en rad är tillåten om någon policy tillåter den.RESTRICTIVE: en rad är tillåten endast om alla restrictive policys tillåter den (utöver permissive‑beteendet).Om du planerar att lägga lager som tenant‑match plus rollkontroller plus projektmedlemskap kan restrictive policys göra intenten tydligare, men de gör det också enklare att låsa ute dig själv om du glömmer ett villkor.
RLS behöver ett tillförlitligt "vem ringer"‑värde. Vanliga alternativ:
app.user_id och app.tenant_id).SET ROLE ... per förfrågan), vilket kan fungera men lägger till driftöverhead.Välj en metod och använd den överallt. Att blanda identitetskällor över tjänster är en snabb väg till förvirrande buggar.
Använd en förutsägbar konvention så schema‑dumpningar och loggar blir läsbara. Till exempel: {table}__{action}__{rule}, som projects__select__tenant_match.
Om du är ny med RLS, börja med en tabell och ett litet bevis. Målet är inte perfekt täckning. Målet är att få databasen att vägra cross‑tenant‑åtkomst även när en app‑bugg händer.
Anta en enkel projects‑tabell. Lägg först till tenant_id på ett sätt som inte bryter skrivningar.
ALTER TABLE projects ADD COLUMN tenant_id uuid;
-- Backfill existing rows (example: everyone belongs to a default tenant)
UPDATE projects SET tenant_id = '11111111-1111-1111-1111-111111111111'::uuid
WHERE tenant_id IS NULL;
ALTER TABLE projects ALTER COLUMN tenant_id SET NOT NULL;
Separera sedan ägarskap från åtkomst. Ett vanligt mönster är: en roll äger tabeller (app_owner), en annan roll används av API:t (app_user). API‑rollen bör inte vara tabellägare, annars kan den kringgå policys.
ALTER TABLE projects OWNER TO app_owner;
REVOKE ALL ON projects FROM PUBLIC;
GRANT SELECT, INSERT, UPDATE, DELETE ON projects TO app_user;
Bestäm nu hur förfrågan talar om för Postgres vilken tenant den tjänar. Ett enkelt tillvägagångssätt är en request‑scoped setting. Din app sätter den direkt efter att ha öppnat en transaktion.
-- inside the same transaction as the request
SELECT set_config('app.current_tenant', '22222222-2222-2222-2222-222222222222', true);
Aktivera RLS och börja med läsåtkomst.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY projects_tenant_select
ON projects
FOR SELECT
TO app_user
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Bevisa att det fungerar genom att testa två olika tenants och kontrollera att radantalet ändras.
Läs‑policys skyddar inte skrivningar. Lägg WITH CHECK så inserts och updates inte kan smuggla rader till fel tenant.
CREATE POLICY projects_tenant_write
ON projects
FOR INSERT, UPDATE
TO app_user
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Ett snabbt sätt att verifiera beteende (inklusive fel) är att behålla ett litet SQL‑skript du kan köra om efter varje migration:
BEGIN; SET LOCAL ROLE app_user;SELECT set_config('app.current_tenant', '\u003ctenant A\u003e', true); SELECT count(*) FROM projects;INSERT INTO projects(id, tenant_id, name) VALUES (gen_random_uuid(), '\u003ctenant B\u003e', 'bad'); (bör misslyckas)UPDATE projects SET tenant_id = '\u003ctenant B\u003e' WHERE ...; (bör misslyckas)ROLLBACK;Om du kan köra det skriptet och få samma resultat varje gång har du en pålitlig baslinje innan du expanderar RLS till andra tabeller.
De flesta team tar till RLS efter att ha tröttnat på att upprepa samma auktoriseringskontroller i varje query. Goda nyheter: de policyformer du behöver är ofta konsekventa.
Vissa tabeller ägs naturligt av en användare (notes, API‑tokens). Andra tillhör en tenant där åtkomst beror på medlemskap. Behandla dessa som olika mönster.
För ägar‑data kontrollerar policys ofta created_by = app_user_id(). För tenant‑data kontrollerar policys ofta om användaren har en medlemskapsrad för orgen.
Ett praktiskt sätt att hålla policys läsbara är att centralisera identitet i små SQL‑helpers och återanvända dem:
-- Example helpers
create function app_user_id() returns uuid
language sql stable as $$
select current_setting('app.user_id', true)::uuid
$$;
create function app_is_admin() returns boolean
language sql stable as $$
select current_setting('app.is_admin', true) = 'true'
$$;
Läsningar är ofta bredare än skrivningar. Till exempel kan vilken org‑medlem som helst SELECT projects, men bara editors kan UPDATE, och bara owners kan DELETE.
Håll det explicit: en policy för SELECT (medlemskap), en policy för INSERT/UPDATE med WITH CHECK (roll), och en för DELETE (ofta striktare än update).
Undvik att "stänga av RLS för admins". Lägg istället in en nödlösning i policys, som app_is_admin(), så du inte av misstag ger full åtkomst till en delad service‑roll.
Om du använder deleted_at eller status, baka in det i SELECT‑policyn (deleted_at is null). Annars kan någon "återuppväcka" rader genom att vända flaggor appen antog var definitiva.
WITH CHECK vänligtINSERT ... ON CONFLICT DO UPDATE måste uppfylla WITH CHECK för raden efter skrivningen. Om din policy kräver created_by = app_user_id(), se till att din upsert sätter created_by vid insert och inte skriver över det vid update.
Om du genererar backend‑kod är dessa mönster värda att göra till interna mallar så nya tabeller startar med säkra standarder istället för ett blankt blad.
RLS är fantastiskt tills en liten detalj får det att se ut som PostgreSQL "slumpmässigt" döljer eller visar data. Följande misstag slösar mest tid.
Den första fällan är att glömma WITH CHECK på insert och update. USING styr vad du kan se, inte vad du får skapa eller ändra. Utan WITH CHECK kan en app‑bugg skriva en rad till fel tenant, och du märker det inte eftersom samma användare inte kan läsa tillbaka den.
En annan vanlig läcka är den "läckande joinen". Du filtrerar korrekt projects, men joinear till invoices, notes eller files som inte skyddas på samma sätt. Fixen är strikt men enkel: varje tabell som kan avslöja tenant‑data behöver sin egen policy, och vyer ska inte bero på att bara en tabell är säker.
Vanliga felmönster dyker upp tidigt:
WITH CHECK.tenant_id, och ett bakgrundsjobb glömmer.Policys som refererar samma tabell (direkt eller via en vy) kan skapa rekursiva överraskningar. En policy kan kontrollera medlemskap genom att fråga en vy som läser den skyddade tabellen igen, vilket leder till fel, långsamma queries eller en policy som aldrig matchar.
Rollsetup är en annan källa till förvirring. Tabellägare och upphöjda roller kan kringgå RLS, så dina tester passar medan riktiga användare misslyckas (eller tvärtom). Testa alltid med samma lågprivilegierade roll som din app använder.
Var försiktig med SECURITY DEFINER‑funktioner. De körs med funktionsägarens privilegier, så en helper som current_tenant_id() kan vara okej, men en "bekvämlighets"‑funktion som läser data kan av misstag läsa över tenants om du inte designar den att respektera RLS.
Sätt också en säker search_path inuti security definer‑funktioner. Annars kan funktionen plocka upp ett annat objekt med samma namn, och din policylogik kan tyst peka på fel sak beroende på sessionstillstånd.
RLS‑buggar handlar oftast om saknad kontext, inte "dålig SQL". En policy kan vara korrekt på pappret och ändå misslyckas eftersom sessionrollen är annan än du tror, eller för att förfrågan aldrig satte de tenant‑ och användarvärden policyn förlitar sig på.
Ett pålitligt sätt att reproducera ett produktionsproblem är att spegla samma sessionuppsättning lokalt och köra exakt samma query. Det innebär vanligtvis:
SET ROLE app_user; (eller den riktiga API‑rollen)SELECT set_config('app.tenant_id', 't_123', true); och SELECT set_config('app.user_id', 'u_456', true);SELECT current_user, current_setting('app.tenant_id', true), current_setting('app.user_id', true);När du är osäker på vilken policy som tillämpas, kolla katalogen istället för att gissa. pg_policies visar varje policy, kommandot och USING och WITH CHECK‑uttrycken. Kombinera det med pg_class för att bekräfta att RLS är aktiverat på tabellen och inte kringgås.
Prestandaproblem kan se ut som auth‑problem. En policy som joinear medlemskapstabellen eller kallar en funktion kan vara korrekt men långsam när tabellen växer. Använd EXPLAIN (ANALYZE, BUFFERS) på den reproducerade frågan och leta efter sekventiella skann, oväntade nested loops eller filter som appliceras sent. Saknade index på (tenant_id, user_id) och medlemskapstabeller är vanliga orsaker.
Det hjälper också att logga tre värden per förfrågan i applikationslagret: tenant‑ID, user‑ID och den databasroll som användes. När de inte matchar vad du tror du satte kommer RLS bete sig "fel" eftersom input är fel.
För tester, behåll några seed‑tenants och gör fel tydliga. Ett litet testsuite brukar inkludera: "Tenant A kan inte läsa Tenant B", "användare utan medlemskap kan inte se projektet", "ägare kan uppdatera, viewer kan inte", "insert blockeras om inte tenant_id matchar kontexten" och "admin override gäller endast där avsett."
Behandla RLS som en säkerhetsbälte, inte en feature‑toggle. Små missar blir till "alla kan se allas data" eller "allt returnerar noll rader".
Säkerställ att din tabell‑design och policyregler matchar din tenant‑modell.
tenant_id). Om den inte har det, skriv ner varför (t.ex. globala referenstabeller).FORCE ROW LEVEL SECURITY på de tabellerna.USING. Skriv måste ha WITH CHECK så inserts och updates inte kan flytta en rad till en annan tenant.tenant_id eller joinar via medlemskapstabeller, lägg till matchande index.Ett enkelt sanitetstest: en användare i tenant A kan läsa sina fakturor, kan endast skapa faktura för tenant A, och kan inte uppdatera en faktura för att byta tenant_id.
RLS är bara så starkt som de roller din app använder.
bypassrls.Föreställ dig en B2B‑app där företag (orgs) har projekt, och projekt har tasks. Användare kan tillhöra flera orgs, och en användare kan vara medlem i vissa projekt men inte i andra. Det här är en bra match för RLS eftersom databasen kan upprätthålla tenant‑isolering även om en API‑endpoint glömmer ett filter.
En enkel modell är: orgs, users, org_memberships (org_id, user_id, role), projects (id, org_id), project_memberships (project_id, user_id), tasks (id, project_id, org_id, ...). Det org_id på tasks är avsiktligt. Det håller policys enkla och minskar överraskningar vid joins.
En klassisk läcka uppstår när tasks bara har project_id, och din policy kontrollerar åtkomst via en join till projects. Ett misstag (en permissive policy på projects, en join som tappar ett villkor, eller en vy som ändrar kontext) kan exponera tasks från en annan org.
En säkrare migreringsväg undviker att bryta produktionstrafik:
org_id på tasks, lägg till medlemskapstabeller).tasks.org_id från projects.org_id, och lägg sedan till NOT NULL.Supportåtkomst hanteras oftast bäst med en smal break‑glass‑roll, inte genom att stänga av RLS. Håll den separat från normala supportkonton och gör det explicit när den används.
Dokumentera reglerna så policys inte glider: vilka sessionvariabler som måste sättas (user_id, org_id), vilka tabeller som måste bära org_id, vad "medlem" betyder, och några SQL‑exempel som bör returnera 0 rader när de körs som fel org.
RLS är enklast att leva med när du behandlar det som en produktförändring. Rulla ut i små bitar, bevisa beteende med tester och håll en klar redogörelse för varför varje policy finns.
En rollout‑plan som ofta fungerar:
projects) och lås ner den.Efter att den första tabellen är stabil, gör policyändringar avsiktliga. Lägg en policy‑granskning i migrationsprocessen, och inkludera en kort notering om intent (vem ska få åtkomst och varför) plus en matchande testuppdatering. Det förhindrar att folk "bara lägger till en OR"‑policys som sakta blir ett hål.
Om du rör dig snabbt kan verktyg som Koder.ai (koder.ai) hjälpa dig att generera en Go + PostgreSQL startpunkt via chatt, och sedan kan du lägga RLS‑policys och tester ovanpå med samma disciplin som ett handbyggt backend.
Till sist, behåll säkerhetsstöd under rollout. Ta snapshots före policy‑migrationer, öva rollback tills det är tråkigt, och håll en liten break‑glass‑väg för support som inte inaktiverar RLS över hela systemet.
RLS får PostgreSQL att bestämma vilka rader som är synliga eller skrivbara för en förfrågan, så tenant‑isolering inte hänger på att varje endpoint minns rätt WHERE tenant_id = ...‑filter. Huvudvinsten är att minska ”en missad kontroll”‑buggar när appen växer och queries blir fler.
Det är värt när åtkomstreglerna är konsekventa och radsbaserade, som tenant‑isolering eller medlemskapsbaserad åtkomst, och när du har många sätt att läsa data (sök, export, adminskärmar, bakgrundsjobb). Det är oftast inte värt det om reglerna främst är per‑fält, fulla av undantag eller om du domineras av breda rapporter som behöver tvär‑tenant‑läsningar.
RLS hanterar synlighet på radnivå och grundläggande skrivbegränsningar. Kollektiv integritet och fältspecifik sekretess behöver ofta vyer och kolumnprivilegier, och komplexa affärsregler (som fakturaägarskap eller godkännandeprocesser) hör ofta hemma i applikationslogiken eller i väl utformade databaskonstraint.
Skapa en lågprivilegierad roll för API:t (inte tabellägaren), aktivera RLS, lägg till en SELECT‑policy och en INSERT/UPDATE‑policy med WITH CHECK. Sätt en request‑scoped session‑variabel (t.ex. app.current_tenant) och verifiera att att byta den ändrar vilka rader du kan se och skriva.
Ett vanligt val är en sessionvariabel per förfrågan, satt i början av transaktionen, till exempel app.tenant_id och app.user_id. Nyckeln är konsekvens: varje kodväg (webbförfrågningar, jobb, skript) måste sätta samma värden som policys förväntar sig, annars får du förvirrande ”nollrader”‑beteende.
USING styr vilka befintliga rader som är synliga och målbara för SELECT, UPDATE och DELETE. WITH CHECK styr vilka nya eller ändrade rader som är tillåtna under INSERT och UPDATE, så det förhindrar att man skriver in rader i en annan tenant även om appen skickar fel .
Om du bara lägger till USING kan en bugg fortfarande göra INSERT eller UPDATE med fel tenant_id, och du märker det inte eftersom samma användare inte kan läsa tillbaka den felaktiga raden. Para alltid ihop läsregler med en matchande WITH CHECK‑regel för skrivningar så att felaktiga data inte kan skapas från början.
Undvik joins inuti policys genom att placera tenant‑nyckeln (t.ex. org_id) direkt på tenant‑ägda tabeller, även om de också refererar en annan tabell som har den. Lägg till explicita medlemskapstabeller (org_memberships, eventuellt project_memberships) så policys kan göra en enda indexerad uppslagning istället för komplicerad härledning.
Återskapa först samma sessionkontext som din app använder genom att sätta samma roll och sessioninställningar, och kör sedan exakt samma SQL‑query. Bekräfta också att RLS är aktiverat och inspektera pg_policies för att se vilka USING och WITH CHECK‑uttryck som tillämpas — RLS felsöks ofta bättre genom att kontrollera identitetskontext än genom att gissa fel i SQL.
Ja, men behandla genererad kod som en utgångspunkt, inte ett säkerhetssystem. Om du använder Koder.ai för att generera en Go + PostgreSQL‑backend måste du fortfarande definiera din tenant‑modell, sätta sessionidentitet konsekvent och lägga till policys och tester med omsorg så nya tabeller inte släpps utan rätt skydd.
tenant_id