Web razvoj
Next.jsSupabasePostgreSQLRow Level SecurityMulti-tenantSaaSSigurnostApp Router

Next.js + Supabase RLS za multi‑tenant SaaS: pravila, uloge i siguran pristup podacima

AO
Adrijan Omićević
·15 min čitanja

# Što ćete izgraditi#

Ovaj vodič prikazuje praktičnu, production‑grade strategiju za Next.js Supabase row level security multitenant aplikacije uz Next.js App Router. Dizajnirat ćete tablice, implementirati RLS pravila s ulogama i koristiti sigurne server-side obrasce pristupa koji drže podatke tenanata izoliranima čak i kad upiti postanu kompleksni.

Također ćete naučiti kako timovi slučajno naruše izolaciju zbog pogrešne upotrebe service role i leaky joinova, te dobivate kontrolnu listu za deployment koju možete kopirati u svoj runbook.

Za širi kontekst dizajna multi-tenant sustava pogledajte naš arhitekturni deep dive: Vodič za Next.js multi-tenant SaaS arhitekturu. Ako još birate autentikaciju, ovaj vodič dobro ide uz: Vodič za autentikaciju u Next.js za NextAuth, Clerk i Supabase. Za end-to-end osnovu pogledajte: Next.js Supabase SaaS starter arhitektura.

# Multi-Tenant RLS strategija na jednoj stranici#

Postoje dva uobičajena modela tenanata:

ModelOpisPrednostiNedostaciNajbolje za
Jedna baza, zajedničke tabliceSvaki red ima tenant_id (ili organization_id) i RLS izolira pristupJednostavniji ops, niži trošak, laka analitikaPravila moraju biti ispravna svugdjeVećina B2B SaaS
Baza po tenantu (ili schema po tenantu)Svaki tenant ima zasebnu bazu ili schemuSnažna granica izolacijeKompliciran ops, migracije, trošakRegulirano okruženje ili vrlo veliki tenanti

Ovaj vodič se fokusira na zajedničke tablice + strogi RLS, jer je to tipično dobar fit za Supabase i dobro skalira dok ne dođete do enterprise zahtjeva za izolacijom.

Robustan pristup ima tri stupa:

  1. 1
    Članstvo u tenantu se sprema u bazu, a ne hardkodira u JWT claimove.
  2. 2
    Svaka tablica vezana uz tenanta ima tenant_id i uključen RLS.
  3. 3
    Aplikacijski kod nikad ne “filtrira po tenantu” kao primarnu sigurnosnu granicu. Baza je granica.

🎯 Ključna poruka: WHERE tenant_id = ... u aplikaciji služi za performanse i jasnoću, ne za sigurnost. RLS je sigurnosna kontrola.

# Preduvjeti#

ZahtjevVerzijaNapomena
Next.js14+ ili 15+Koriste se obrasci App Routera
SupabasePostgres 15+ (managed)RLS, auth, policyji
supabase-js2.xServer-side obrasci klijenta
AuthSupabase Auth ili vanjski providerMora isporučiti valjani korisnički JWT Supabaseu

Ako koristite Clerk ili NextAuth, integracija i dalje mora završiti tako da Supabase dobije JWT koji može validirati kao korisnika iz zahtjeva. Ako to nije istina, vaš “RLS” postaje “vjeruj app serveru”, što poništava smisao.

# Dizajn tablica za multi-tenant SaaS#

Jezgreni entiteti#

Čest B2B SaaS model podataka koristi organizacije, članstva i resurse vezane uz tenanta.

TablicaSvrhaVezano uz tenantaNapomena
organizationsZapis tenantaDaJedan red po tenantu
organization_membersKorisnik pripada organizaciji, s ulogomDaVaš primarni autorizacijski primitiv
projectsPrimjer resursaDaIma organization_id
ticketsPrimjer resursaDaIma organization_id i možda project_id
profilesPodaci profila korisnikaNeObično 1 red po korisniku, nije tenant-specifično

Primjer SQL sheme#

Shema neka bude eksplicitna: organization_id svugdje, stroga FK ograničenja i stabilni ID-evi.

SQL
create table public.organizations (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  created_at timestamptz not null default now()
);
 
create table public.organization_members (
  organization_id uuid not null references public.organizations(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role text not null check (role in ('owner', 'admin', 'member')),
  created_at timestamptz not null default now(),
  primary key (organization_id, user_id)
);
 
create table public.projects (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references public.organizations(id) on delete cascade,
  name text not null,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
create index projects_org_idx on public.projects (organization_id);
 
create table public.tickets (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references public.organizations(id) on delete cascade,
  project_id uuid references public.projects(id) on delete set null,
  title text not null,
  body text,
  created_by uuid not null references auth.users(id),
  created_at timestamptz not null default now()
);
 
create index tickets_org_idx on public.tickets (organization_id);
create index tickets_project_idx on public.tickets (project_id);

Zašto je ovo bitno:

  • Kompozitni primarni ključ na članstvu sprječava duplikate.
  • Svaka tablica vezana uz tenanta ima direktan stupac organization_id, čak i ako referencira drugu tenant-scope tablicu.
  • Indeksiranje organization_id nije opcija. Bez toga RLS filtriranje postaje skupi seq scan pod opterećenjem.

💡 Savjet: Denormalizirajte organization_id na svaku tablicu vezanu uz tenanta, čak i kad ga možete izvesti kroz joinove. Poboljšava performanse upita i čini RLS pravila jednostavnijima i manje sklona greškama.

# Uloge, claimovi i kako Supabase evaluira RLS#

Supabase koristi Postgres uloge i JWT claimove. U praksi:

  • anon i authenticated su uloge koje ćete najčešće koristiti.
  • auth.uid() vraća ID trenutnog korisnika iz JWT-a.
  • Pravila se evaluiraju po tablici, po naredbi (SELECT, INSERT, UPDATE, DELETE).
  • Ako je RLS uključen i ne postoji odgovarajuće pravilo, pristup je odbijen.

Ključna dizajnerska odluka je: gdje žive uloge?

Preporuka: uloge žive u organization_members#

Tenant uloge spremite u tablicu i provjeravajte ih u pravilima. Ovo podržava:

  • Korisnike u više organizacija
  • Promjene uloga bez problema s osvježavanjem tokena
  • Auditabilnu povijest članstva (ako kasnije dodate logove)

Izbjegavajte oslanjanje na custom JWT claimove za tenant uloge osim ako u potpunosti kontrolirate izdavanje tokena i strategiju refreshanja. Drift claimova je stvaran problem, a drift u kontroli pristupa je sigurnosni problem.

# Implementacija RLS-a: pravila koja skaliraju s kompleksnošću#

Korak 1: Uključite RLS na tenant tablicama#

SQL
alter table public.organizations enable row level security;
alter table public.organization_members enable row level security;
alter table public.projects enable row level security;
alter table public.tickets enable row level security;

Razmotrite i prisilu:

SQL
alter table public.organizations force row level security;
alter table public.projects force row level security;
alter table public.tickets force row level security;

Force RLS je defenzivna opcija koja sprječava vlasnike tablica da u nekim kontekstima zaobiđu pravila. Koristite je ako ste disciplinirani oko admin pristupa i migracija.

Korak 2: Pomoćni predikat kroz SQL funkciju#

Ponovno upotrebljiv helper smanjuje duplikacije pravila. Neka bude jednostavan, stabilan i siguran.

SQL
create or replace function public.is_org_member(org_id uuid)
returns boolean
language sql
stable
as $$
  select exists (
    select 1
    from public.organization_members m
    where m.organization_id = org_id
      and m.user_id = auth.uid()
  );
$$;

Možete dodati i provjeru uloga:

SQL
create or replace function public.org_role(org_id uuid)
returns text
language sql
stable
as $$
  select m.role
  from public.organization_members m
  where m.organization_id = org_id
    and m.user_id = auth.uid()
  limit 1;
$$;

Korak 3: Pravilo vidljivosti organizacije#

Organizacije bi trebale biti vidljive samo ako je korisnik član.

SQL
create policy org_select_for_members
on public.organizations
for select
to authenticated
using (public.is_org_member(id));

Korak 4: Pravila za članstva#

Tablice članstva su osjetljive jer mogu otkriti strukturu tenanta i popise korisnika. Odlučite što je dopušteno.

Uobičajeno pravilo:

  • Svaki član može vidjeti popis članova svoje organizacije (za UI poput “Team members”).
  • Samo owneri i admini mogu dodavati ili uklanjati članove.
SQL
create policy members_select_in_org
on public.organization_members
for select
to authenticated
using (public.is_org_member(organization_id));

Za inserte, ograničite po ulozi. Također zaključajte dodjelu user_id kako biste izbjegli napade tipa “pozovem sam sebe kao owner”.

SQL
create policy members_insert_admin_only
on public.organization_members
for insert
to authenticated
with check (
  public.org_role(organization_id) in ('owner', 'admin')
);

Za brisanja:

SQL
create policy members_delete_admin_only
on public.organization_members
for delete
to authenticated
using (
  public.org_role(organization_id) in ('owner', 'admin')
);

Ovo možete dodatno rafinirati, npr. spriječiti uklanjanje zadnjeg ownera. To je najbolje raditi u transakciji ili triggeru, ne samo u RLS-u.

⚠️ Upozorenje: RLS ne može pouzdano provoditi složene invarijante preko više redaka poput “mora ostati barem jedan owner” bez pažljivog lockanja. Implementirajte tu logiku u jednoj SQL funkciji ili transakciji i izložite je kroz RPC.

Korak 5: Pravila za resurse vezane uz tenanta#

Za projects:

SQL
create policy projects_select_in_org
on public.projects
for select
to authenticated
using (public.is_org_member(organization_id));
 
create policy projects_insert_in_org
on public.projects
for insert
to authenticated
with check (public.is_org_member(organization_id));
 
create policy projects_update_in_org
on public.projects
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));
 
create policy projects_delete_admin_only
on public.projects
for delete
to authenticated
using (public.org_role(organization_id) in ('owner', 'admin'));

Za tickets, sličan obrazac:

SQL
create policy tickets_select_in_org
on public.tickets
for select
to authenticated
using (public.is_org_member(organization_id));
 
create policy tickets_insert_in_org
on public.tickets
for insert
to authenticated
with check (public.is_org_member(organization_id));
 
create policy tickets_update_in_org
on public.tickets
for update
to authenticated
using (public.is_org_member(organization_id))
with check (public.is_org_member(organization_id));

Zašto i using i with check za update:

  • using kontrolira koje postojeće retke možete ciljati.
  • with check kontrolira u što se red može pretvoriti nakon updatea.
  • Bez with check, korisnik bi mogao ažurirati organization_id i “premjestiti” red u drugi tenant ako vaša shema to dopušta.

Ako se organization_id nikad ne bi smio mijenjati, provedite to:

  • Učinite ga nepromjenjivim na razini aplikacije
  • Razmotrite trigger koji baca exception na promjenu
  • Ili uopće uklonite update dozvolu za taj stupac preko viewova ili RPC-a

# Next.js App Router: sigurni server-side obrasci pristupa#

Obrazac 1: Server Components i Server Actions koriste session korisnika#

Default bi trebao biti: koristite korisnikov JWT, pozovite Supabase i pustite RLS da filtrira.

To zahtijeva server-side Supabase klijent koji može čitati cookies i automatski priložiti session. Točna implementacija varira ovisno o setupu, ali princip je uvijek isti.

Minimalni factory za “server client” može izgledati ovako:

TypeScript
// lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
 
export function createSupabaseServerClient(accessToken: string) {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    { global: { headers: { Authorization: `Bearer ${accessToken}` } } }
  );
}

Zatim u server action proslijedite korisnički token iz svojeg auth sloja.

TypeScript
'use server';
 
import { createSupabaseServerClient } from '@/lib/supabase/server';
 
export async function listProjects(accessToken: string, organizationId: string) {
  const supabase = createSupabaseServerClient(accessToken);
 
  const { data, error } = await supabase
    .from('projects')
    .select('id, name, created_at')
    .eq('organization_id', organizationId)
    .order('created_at', { ascending: false });
 
  if (error) throw new Error(error.message);
  return data;
}

.eq('organization_id', organizationId) nije sigurnosna granica. Smanjuje broj redaka i poboljšava performanse. Ako korisnik pošalje ID drugog tenanta, RLS i dalje blokira pristup.

Obrazac 2: Koristite RPC za privilegirane višekoračne operacije#

Za radnje poput “invite member” često trebate:

  • Validirati ulogu
  • Insertati članstvo
  • Možda zapisati audit red
  • Možda poslati email job

Raditi to kroz više round-tripova iz Next.js-a povećava race conditione. Radije koristite jednu transakciju u bazi.

SQL
create or replace function public.invite_member(org_id uuid, new_user_id uuid, new_role text)
returns void
language plpgsql
security invoker
as $$
begin
  if public.org_role(org_id) not in ('owner', 'admin') then
    raise exception 'not allowed';
  end if;
 
  insert into public.organization_members(organization_id, user_id, role)
  values (org_id, new_user_id, new_role)
  on conflict (organization_id, user_id) do update set role = excluded.role;
end;
$$;

Pozovite iz Next.js-a koristeći session korisnika:

TypeScript
'use server';
 
import { createSupabaseServerClient } from '@/lib/supabase/server';
 
export async function inviteMember(accessToken: string, orgId: string, userId: string) {
  const supabase = createSupabaseServerClient(accessToken);
  const { error } = await supabase.rpc('invite_member', {
    org_id: orgId,
    new_user_id: userId,
    new_role: 'member',
  });
  if (error) throw new Error(error.message);
}

Držite security invoker kako bi se RLS i provjere uloga primijenile na pozivatelja.

ℹ️ Napomena: Izbjegavajte security definer za tenant podatke osim ako dubinski razumijete kako može zaobići RLS. Ako ga baš morate koristiti, eksplicitno provodite tenant provjere unutar funkcije i ograničite vraćene stupce.

Obrazac 3: Service role samo za background jobove#

Neki workloadovi legitimno trebaju service role:

  • Stripe webhookovi koji sinkaju status pretplate
  • Scheduled jobovi (dnevni računi)
  • Admin dashboard koji obuhvaća sve tenantove

U Next.js-u to bi trebalo živjeti u server-only routama uz strogu autentikaciju zahtjeva, i nikad za čitanje u kontekstu korisnika.

Sigurna osnovna postavka:

  • Service role se koristi samo u /api/webhooks/* ili internim cron endpointima.
  • Endpointi su zaštićeni provjerom potpisa ili internom autentikacijom.
  • Sve korisničke stranice i akcije koriste anon key plus korisnički JWT.

# Česte zamke koje ruše izolaciju tenanata#

# Zamka 1: Korištenje service role “iz praktičnosti”#

Developeri posegnu za service role jer “sve radi” kad RLS blokira upit. Upravo zato je opasno.

Ako koristite service role u bilo kojem request pathu koji pokreće korisnik, morate ponovno izgraditi autorizaciju u aplikacijskom kodu savršeno. To je teško održavati i lako se regresira.

Konkretan rizičan obrazac:

  • List endpoint koristi service role.
  • “Filtrira po organization_id” iz query parametra.
  • Napadač promijeni parametar i čita drugog tenanta.

Rješenje:

  • Koristite korisnički JWT za tenant-scope čitanja.
  • Ograničite service role na server-to-server tokove.
  • Dodajte automatizirane testove koji potvrđuju da cross-tenant čitanja vraćaju nula redaka.

# Zamka 2: Leaky joinovi i nedostajući RLS na povezanim tablicama#

RLS je na razini tablice. Ako tenant tablicu joinate s tablicom koja nije zaštićena, možete slučajno procuriti podatke.

Primjer scenarija:

  • tickets ima RLS.
  • Joinate na ticket_comments, ali ste zaboravili uključiti RLS tamo.
  • Korisnik selekta tickete i dobije komentare iz drugih orgova jer tablica komentara nije filtrirana.

Rješenje:

  1. 1
    Svaka tablica koja sadrži tenant podatke mora imati:
    • organization_id
    • uključen RLS
    • pravila koja provode članstvo
  2. 2
    Izbjegavajte tablice koje “implicitno pripadaju” tenantu samo kroz joinove. Vlasništvo tenanta učinite eksplicitnim.

Ako baš morate izvoditi tenanta kroz joinove, provedite to viewom ili RPC-om koji primjenjuje join ograničenje server-side, a zatim zaključajte direktan pristup tablici.

# Zamka 3: Pravila koja referenciraju neindeksirane stupce#

RLS dodaje predikate u upite. Ako vaše pravilo provjerava članstvo kroz subquery, taj subquery mora biti brz.

Osigurajte indekse:

  • organization_members (organization_id, user_id) je već primarni ključ u našem modelu
  • Ako često upitujete po user_id, dodajte organization_members_user_idx (user_id)

Loše performanse postaju sigurnosni problem kada timovi “privremeno” isključe RLS da bi isporučili funkcionalnost.

# Zamka 4: Dopuštanje updatea koji mijenjaju tenant vlasništvo#

Ako je organization_id moguće mijenjati, a update pravilo previše permisivno, napadači mogu pokušati “premjestiti” retke između tenanata.

Mitigacije:

  • Učinite organization_id neupdatable preko RPC-a ili viewova.
  • Dodajte trigger koji blokira promjene.
  • Koristite with check u UPDATE pravilima.
  • Razmotrite i project_id i slična FK polja, ako premještanje između projekata implicira promjene pristupa.

# Zamka 5: Ne testirati RLS stvarnim upitima#

RLS bugovi se ne vide u unit testovima koji mockaju pristup podacima.

Što biste trebali testirati:

  • Korisnik A u orgu A ne može čitati ni ažurirati retke orga B.
  • Korisnik A ne može insertati red za org B.
  • Admin-only operacije padaju za member ulogu.
  • Joinovi ne cure cross-tenant podatke.

Čak i mali set integracijskih testova uhvati većinu regresija.

# Kontrolna lista za deployment za Next.js i Supabase RLS#

Koristite ovo prije deploya na staging i produkciju.

PodručjeProvjeraZašto je važno
RLS pokrivenostRLS uključen na svakoj tablici vezanoj uz tenantaJedna propuštena tablica može procuriti podatke
PravilaSELECT, INSERT, UPDATE, DELETE pravila definirana namjernoNedostajuće pravilo znači denied ili nedosljedno ponašanje
Tenant stupacSvaka tenant tablica uključuje organization_id i indekseJednostavnija pravila i predvidljive performanse
Model članstvaTablica članstva ima PK i ograničenje ulogeSprječava duplikate članstva i nevažeće uloge
Service roleNe koristi se u user-driven request pathovimaIzbjegava slučajan pristup svim podacima
RPC funkcijePreferirati security invoker, validirati ulogu unutar funkcijeSprječava zaobilaženje RLS-a
TriggeriBlokirati promjene tenant ID-a gdje je potrebnoSprječava napade prebacivanja vlasništva
LogiranjeLogirati admin akcije i promjene članstvaAudit i incident response
OkruženjeService role ključ nikad nije izložen klijentuTretirajte ga kao database root
TestoviTestovi cross-tenant pristupa u CISprječava regresije
ObservabilityPratiti spore upite na provjerama članstvaPerformanse guraju timove prema nesigurnim prečacima

💡 Savjet: U CI-u pokrenite “tenant isolation smoke test” koji kreira dva orga i dva korisnika, ubaci podatke i potvrdi da svaki cross-tenant upit vraća prazan skup. Ovo traje par minuta i sprječava skupe incidente.

# Ključne poruke#

  • Dizajnirajte multi-tenant tablice s eksplicitnim organization_id na svakom tenant-scope retku, uz indekse za predvidljive RLS performanse.
  • Uloge spremite u organization_members i provjeravajte članstvo u RLS pravilima koristeći helpere poput public.is_org_member(org_id).
  • U Next.js App Routeru koristite korisnički JWT za sva tenant-scope čitanja i pisanja kako bi RLS uvijek bio sigurnosna granica.
  • Koristite RPC za višekoračne operacije i držite funkcije security invoker osim ako imate jak razlog i dodatne zaštite.
  • Izbjegavajte service role u user-triggered code pathovima i auditirajte shemu zbog leaky joinova tako da svugdje uključite RLS gdje postoje tenant podaci.
  • Isporučujte uz kontrolnu listu za deployment i CI testove koji eksplicitno potvrđuju da je cross-tenant pristup nemoguć.

# Zaključak#

Next.js i Supabase mogu biti snažan stack za multi-tenant SaaS, ali samo ako bazu tretirate kao enforcement sloj. Kada su tablice dizajnirane za tenancy, RLS pravila dosljedna, a Next.js server kod koristi korisničke sesije umjesto service role prečaca, dobivate izolaciju koju je teško slučajno pokvariti.

Ako želite da pregledamo vašu shemu i pravila ili implementiramo sigurnu multi-tenant osnovu s Next.js App Routerom i Supabaseom, kontaktirajte Samioda i pomoći ćemo vam da brže isporučite production-ready arhitekturu.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.