Supabase ile statik bir siteye üyelik eklemek: RLS, anahtarlar ve rıza

Sunucusuz bir sitede e-posta ve parolayla üyelik: satır düzeyi güvenlik politikaları, hangi anahtarın istemcide durabileceği ve rızaya bağlı eşitleme.

Yazar: TS SystemLabs mühendislik ekibi · Yayın: · 8 dk okuma

Statik bir site hızlıdır, ucuzdur ve bakımı kolaydır: sunucu tarafında çalışan kod yoktur, sayfalar bir içerik dağıtım ağından doğrudan gelir. Ama bir gün kullanıcılar "ilerlememi başka cihazda da görmek istiyorum" dediğinde, hesap, oturum ve kişiye ait veri ihtiyacı ortaya çıkar. Bunun için bütün siteyi bir uygulama sunucusuna taşımak gerekmiyor. Supabase gibi bir arka uç hizmeti, kimlik doğrulama ile PostgreSQL veritabanını tarayıcıdan güvenle kullanılabilir hâle getirir.

Bu yazıda, SendenSana uygulamasının statik web sitesine isteğe bağlı üyelik eklerken izlediğimiz yolu anlatıyoruz: e-posta ve parolayla giriş, satır düzeyi güvenlik (RLS) politikaları, hangi anahtarın tarayıcıda durabileceği, rızaya bağlı veri eşitleme ve hesap silme. Üyelik bu sitede zorunlu değil; pratikler herkese açık, hesap yalnız cihazlar arası eşitleme isteyenler için. Bu tercih, aşağıdaki birçok tasarım kararını da belirledi.

İki anahtar, iki dünya

Supabase projesinde iki tür anahtar vardır. Yayımlanabilir anahtar (eski adıyla anon key) tarayıcıda durmak için tasarlanmıştır; herkes görebilir ve görmesinde sakınca yoktur, çünkü tek başına hiçbir veriye erişim vermez. Erişimi belirleyen şey, veritabanındaki RLS politikalarıdır. Gizli anahtar (service role ya da secret key) ise RLS'yi tamamen atlar; veritabanında her şeyi okuyup yazabilir.

Buradan çıkan kural nettir: gizli anahtar hiçbir zaman istemci koduna, statik dosyaya, depoya ya da tarayıcıya giden bir yapılandırmaya girmez. Biz onu yalnız sunucu tarafında çalışan yönetim aracında, ortam değişkeni olarak tutuyoruz. Statik sitenin derleme betiğinde bile yer almıyor. Tarayıcı tarafı şöyle başlıyor:

import { createClient } from "./vendor/supabase.js";

const SUPABASE_URL = "https://PROJE.supabase.co";
const YAYIN_ANAHTARI = "sb_publishable_...";   // tarayıcıda durabilir; gizli anahtar ASLA buraya yazılmaz

export const supabase = createClient(SUPABASE_URL, YAYIN_ANAHTARI);

Kütüphaneyi bir CDN'den çekmek yerine sürümü sabit, yerel bir kopya olarak sunmayı tercih ettik. Böylece Content Security Policy'de dış bir betik kaynağına izin vermek gerekmiyor ve kütüphanenin habersiz değişmesi riski ortadan kalkıyor.

Kayıt, giriş ve e-posta doğrulama

supabase-js v2 ile kayıt ve giriş birkaç satırdır. Kayıtta emailRedirectTo değeri, doğrulama e-postasındaki bağlantının kullanıcıyı nereye döndüreceğini belirler; bu adresin Supabase panelindeki izinli yönlendirme listesinde olması gerekir.

export async function uyeOl(email, password) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
    options: { emailRedirectTo: "https://ornek.com/hesabim/" },
  });
  if (error) throw error;
  return data.user;   // e-posta doğrulanana kadar oturum açılmaz (doğrulama açıksa)
}

export async function girisYap(email, password) {
  const { data, error } = await supabase.auth.signInWithPassword({ email, password });
  if (error) throw error;
  return data.session;
}

E-posta doğrulamasının pratikte en çok sorun çıkaran parçası, e-postanın kendisidir. Supabase'in yerleşik e-posta gönderimi saatlik sınırı düşük ve deneme amaçlıdır; gerçek kullanıcıya açmadan önce kendi alan adınızdan gönderen bir SMTP sağlayıcısı tanımlamanız gerekir. Bunun için alan adının DNS kayıtlarında SPF, DKIM ve DMARC ayarlarının yapılmış olması, e-postaların istenmeyen klasörüne düşmemesi açısından önemli. Doğrulama ve parola sıfırlama şablonlarını da sitenin dillerinde ayrı ayrı hazırlamak gerekiyor.

Tablolar ve satır düzeyi güvenlik

Asıl güvenlik veritabanında kurulur. Her üyenin bir profil satırı ve bir de cihazlar arasında eşitlenen durum verisi var. İkisi de auth.users tablosuna bağlı, hesap silinince kendiliğinden siliniyor. RLS açıldığında, politikası olmayan hiçbir satır kimseye görünmez; politikalar ise yalnız oturum sahibinin kendi satırını görmesine izin veriyor.

create table public.profil (
  id uuid primary key references auth.users (id) on delete cascade,
  olusturma timestamptz not null default now(),
  riza_surum text,
  esitleme_riza boolean not null default false
);

create table public.uye_durum (
  kullanici_id uuid primary key references auth.users (id) on delete cascade,
  veri jsonb not null default '{}'::jsonb,
  guncelleme timestamptz not null default now(),
  constraint veri_boyutu check (octet_length(veri::text) <= 262144)
);

alter table public.profil enable row level security;
alter table public.uye_durum enable row level security;

create policy "profil: kendi satırını okur" on public.profil
  for select to authenticated using ((select auth.uid()) = id);
create policy "profil: kendi satırını günceller" on public.profil
  for update to authenticated
  using ((select auth.uid()) = id) with check ((select auth.uid()) = id);

create policy "durum: kendi satırını okur" on public.uye_durum
  for select to authenticated using ((select auth.uid()) = kullanici_id);
create policy "durum: kendi satırını ekler" on public.uye_durum
  for insert to authenticated with check ((select auth.uid()) = kullanici_id);
create policy "durum: kendi satırını günceller" on public.uye_durum
  for update to authenticated
  using ((select auth.uid()) = kullanici_id) with check ((select auth.uid()) = kullanici_id);

Birkaç ayrıntıya dikkat çekelim. update politikasında hem using hem with check var: ilki hangi satırın güncellenebileceğini, ikincisi güncelleme sonrası satırın hâlâ kullanıcıya ait olmasını denetler. with check yazılmazsa PostgreSQL using ifadesini yeni satıra da uygular; yine de ikisini açıkça yazmak niyeti belgeler ve iki koşulun farklı olması gereken durumlara hazırlar. auth.uid() çağrısını select içine almak, PostgreSQL'in değeri her satırda yeniden hesaplamak yerine bir kez hesaplamasını sağlar. Boyut sınırı check kısıtıyla veritabanında konuyor; istemcideki sınır kolayca atlanabileceği için tek başına yeterli değil.

Profil satırını istemcinin oluşturmasına güvenmek yerine, auth.users tablosuna yeni kullanıcı eklendiğinde çalışan bir tetikleyiciyle oluşturuyoruz:

create function public.yeni_uye()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
  insert into public.profil (id) values (new.id);
  return new;
end;
$$;

create trigger uye_olusunca
  after insert on auth.users
  for each row execute function public.yeni_uye();

Rıza sürümü ve rızaya bağlı eşitleme

Uygulamada kullanıcının tarayıcısında tuttuğu veriler iki gruba ayrılıyor. Tamamlanan pratikler ya da favoriler gibi genel kayıtlar, üye olan herkes için eşitleniyor. Kişisel notlar gibi daha özel kayıtlar ise yalnız kullanıcı bunun için ayrıca açık rıza verdiyse sunucuya gidiyor. Rıza metni değişebileceği için profil satırında kullanıcının hangi sürüme onay verdiğini tutuyoruz; metin özünden değiştiğinde sürümü yükseltip kullanıcıya yeniden soruyoruz.

const GENEL = ["tamamlanan_pratikler", "favoriler"];
const OZEL = ["notlar"];

function sunucuyaGidecek(yerel, ozelRiza) {
  const anahtarlar = ozelRiza ? [...GENEL, ...OZEL] : GENEL;
  const cikti = {};
  for (const k of anahtarlar) if (k in yerel) cikti[k] = yerel[k];
  return cikti;
}

export async function esitle(yerel, ozelRiza) {
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return null;
  const { error } = await supabase
    .from("uye_durum")
    .upsert({ kullanici_id: user.id, veri: sunucuyaGidecek(yerel, ozelRiza) },
            { onConflict: "kullanici_id" });
  if (error) throw error;
  const { data, error: okumaHatasi } = await supabase
    .from("uye_durum").select("veri, guncelleme").eq("kullanici_id", user.id).maybeSingle();
  if (okumaHatasi) throw okumaHatasi;
  return data;
}

upsert sütunun tamamını yeniden yazdığı için, kullanıcı özel rızasını geri aldığında bir sonraki eşitleme özel kayıtları sunucudaki kopyadan kendiliğinden çıkarır. upsert işleminin çalışması için tabloda hem insert hem update politikası bulunmalı; birini unutmak, ilk kayıtta ya da sonraki güncellemede sessiz bir hata olarak karşınıza çıkar. Gerçek uygulamada çakışma kuralı da gerekiyor: listeler birleştiriliyor, tek değerli alanlarda cihazdaki değer kazanıyor. Güncelleme zamanını istemcinin saatine bırakmamak için de sunucu saatini yazan bir tetikleyici kullanmak iyi bir alışkanlık.

Hesap silme ve security definer riski

Kullanıcının hesabını kendisinin silebilmesi, kişisel verilerin korunması açısından beklenen bir haktır. Ancak auth.users tablosundan silme yetkisi tarayıcıdaki rolde yoktur. Bu yüzden silmeyi, tanımlayan rolün yetkisiyle çalışan bir fonksiyona bırakıyoruz:

create function public.hesabimi_sil()
returns void
language plpgsql
security definer
set search_path = ''
as $$
begin
  if auth.uid() is null then
    raise exception 'oturum yok';
  end if;
  delete from auth.users where id = auth.uid();
end;
$$;

revoke execute on function public.hesabimi_sil() from public, anon;
grant execute on function public.hesabimi_sil() to authenticated;

security definer fonksiyonlar güçlüdür ve bu yüzden dikkat ister. Üç kuralımız var. Birincisi, search_path boş bir değere sabitlenir ve fonksiyon içindeki her nesne şema adıyla yazılır; aksi hâlde biri arama yoluna kendi tablosunu ya da fonksiyonunu sokarak fonksiyonu kendi koduna yönlendirebilir. İkincisi, fonksiyon yalnız oturum sahibinin kimliğiyle çalışır ve dışarıdan kimlik parametresi almaz. Üçüncüsü, çalıştırma yetkisi anonim rolden açıkça geri alınır. Supabase'in güvenlik danışmanı bu tür fonksiyonlar için uyarı verir; biz bu uyarıyı gerekçesini bilerek kabul ettik. Tablolar on delete cascade ile bağlı olduğu için, kullanıcı silindiğinde profil ve durum satırları da gider. İstemci tarafında çağrı tek satırdır: await supabase.rpc("hesabimi_sil"). Supabase'in belgelerde önerdiği diğer yol, silmeyi sunucu tarafında, gizli anahtarla çalışan bir Edge Function içinde auth.admin.deleteUser ile yapmaktır; bu yol auth şemasına doğrudan dokunmaz. Kullanıcının Storage'da dosyaları varsa, bu nesneler kullanıcıya bağlı olduğu için silme işlemi engellenebilir; önce dosyaları silmek gerekir.

Politikaları test etmek

RLS'nin çalıştığını varsaymak yerine ölçmek gerekiyor. İlk test, oturum açmadan yalnız yayımlanabilir anahtarla tabloyu okumayı denemek. RLS açık ve anonim role politika yoksa sonuç boş bir liste olur; tablo yetkilerini anonim rolden ayrıca geri aldıysanız bir yetki hatası alırsınız. İki durumda da tek bir satır bile dönmemeli.

curl -s "$SUPABASE_URL/rest/v1/uye_durum?select=*" \
  -H "apikey: $YAYIN_ANAHTARI"
# beklenen: [] ya da yetki hatası; asla veri satırı değil

Bu testi her yeni tablo eklediğinizde tekrarlamak gerekir; çünkü yeni bir tabloda RLS'yi açmayı unutmak, o tablonun yayımlanabilir anahtarla herkese açık kalması anlamına gelebilir. Supabase panelindeki güvenlik danışmanı RLS'si kapalı tabloları ayrıca listeler, ama bu listeye bakmayı bir alışkanlığa dönüştürmek, kendi ölçümünüzün yerini tutmaz.

İkinci test, iki ayrı deneme hesabıyla yapılır: A hesabıyla oturum açıp B hesabının satırını okumayı ve güncellemeyi denemek. Okuma boş dönmeli, güncelleme hiçbir satırı etkilememeli. Üçüncü test, hesap silme sonrasında ilgili satırların gerçekten gittiğini kontrol etmek. Bu testleri gerçek kullanıcı verisiyle değil, yalnız bu iş için açılmış deneme hesaplarıyla yapıyoruz.

İşletmeye dair notlar

Supabase'in kendi ücretsiz planıyla başlamak mümkün, ama bazı sınırlarını bilmek gerekiyor: bir süre hiç kullanılmayan ücretsiz projeler duraklatılabiliyor ve yeniden başlatılana kadar giriş çalışmıyor. Düzenli trafiği olmayan bir sitede bunu izlemek gerekir. Veritabanı bölgesini kullanıcılarınıza yakın ve veri koruma yükümlülüklerinize uygun seçmek de önemli; Türkiye'deki kullanıcılar için KVKK kapsamında yurt dışına veri aktarımı ayrı bir değerlendirme gerektirir ve aydınlatma metnine yansımalıdır. Bu konuda hukuki görüş almanızı öneririz. Kullanıcıya gösterilen aydınlatma metninde de önce kısa ve sade bir özet, ardından ayrıntılar sunmak, okunabilirliği belirgin biçimde artırıyor. Sitemizin kendi yaklaşımını gizlilik sayfamızda görebilirsiniz.

Sonuç

Statik bir siteye üyelik eklemek, bütün mimariyi değiştirmeyi gerektirmiyor. Tarayıcıda yalnız yayımlanabilir anahtar, veritabanında her tabloda açık RLS ve kullanıcıyı yalnız kendi satırına bağlayan politikalar, tetikleyiciyle oluşan profil, sürümlü rıza ve dikkatle yazılmış bir silme fonksiyonu; bunların hepsi birlikte güvenli ve sade bir düzen kuruyor. Kalan iş, her politikayı gerçek isteklerle test etmek ve e-posta teslimatını gerçek kullanıcıdan önce denemek. Bu yaklaşımı kullandığımız uygulamayı SendenSana sayfasında inceleyebilirsiniz.

Benzer bir sistem mi kuruyorsunuz?

İhtiyacınızı birkaç cümleyle yazın; uygun bir yol varsa birlikte netleştirelim.

Bize yazın