Server Actions'ta Yetkilendirme: Butonu Gizlemek Koruma Değildir
Next.js Server Action'lar doğrudan çağrılabilen endpoint'lerdir. Yetki kontrolünü action'ın içine taşıyan küçük bir policy deseni ve test yaklaşımı.
Server Actions'ta Yetkilendirme: Butonu Gizlemek Koruma Değildir#
Middleware ile sayfa koruma yazısında rota seviyesindeki ayrımı işlemiştik: linki bilenlerin gördüğü sayfa ile gerçekten özel alan farklı şeydir. Server Actions bu ayrımın delindiği yerdir. Butonu frontend'de gizlemek, action'ı korumaz; action doğrudan çağrılabilir bir endpoint'tir ve middleware rota eşleşmesi dışında onu görmez.
Middleware neden yetmez#
Middleware, isteğin hangi rotaya gittiğine ve çağıran kişinin kim olduğuna bakar. Hangi faturanın, hangi işlem için, hangi rolle açılmak istendiğine bakmaz. Bu ayrım bilinçli bir görev paylaşımıdır: kimlik ve rota kontrolü middleware tarafında, nesne ve işlem kontrolü action tarafında durur.
| Soru | Nerede cevaplanır | Örnek |
|---|---|---|
| Kullanıcı giriş yapmış mı | Middleware | Oturumsuz isteği login sayfasına gönderir |
| Bu rota özel mi | Middleware | /invoices eşleşmesi varsa kimlik ister |
| Bu fatura hangi tenant'a ait | Action içindeki policy | tenantId filtresi ile sorgulanır |
| Çağıran bu faturada hangi role sahip | Action içindeki policy | memberships.some ile rol kontrolü |
| Okuma mı yazma mı isteniyor | Action içindeki policy | read ve write ayrımı fonksiyonda |
| Yönetici işlemi mi isteniyor | Action içindeki policy | admin ayrımı fonksiyonda |
Tabloyu tersten okumak daha öğreticidir. Middleware ilk iki satırı cevaplar, son dört satırı cevaplayamaz. Son dört satırın tamamı istek gövdesindeki parametreye bağlıdır. Middleware istek gövdesindeki invoiceId değerini açıp veritabanında sahiplik sorgulamaz. Böyle bir sorgu middleware içine konursa her istekte veritabanı okuması yapılır, rol mantığı iki yere dağılır ve tek bir doğruluk noktası kalmaz.
Middleware'in hiç görmediği çağrı tipleri vardır. Bunlar istisna değil, Server Actions modelinin normal sonucudur:
- Sayfa hiç açılmadan action endpoint'ine doğrudan POST göndermek. Tarayıcıda sayfayı açmak gerekmez, action adı ve parametre yeterlidir.
- Görünen arayüzde butonu olmayan bir action'ı, geliştirici araçlarından kopyalanan parametreyle çağırmak. Butonun DOM içinde olmaması çağrıyı engellemez.
- Başka bir tenant'a ait
invoiceIddeğerini kendi oturumuyla göndermek. Rota aynı kalır, parametre değişir, middleware farkı göremez. - Okuma yetkisi olan bir hesabın yazma action'ını çağırması. Rota seviyesinde ikisi de aynı kullanıcıdır, ayrım ancak rol kontrolünde ortaya çıkar.
- Formun dışarıdan kopyalanıp ayrı bir istemciden gönderilmesi.
multipartya da JSON gövde, sayfa bağlamı olmadan da işlenir.
Bu liste şunu anlatır: middleware rota ve kimlik katmanıdır, nesne ve işlem katmanı değildir. Güvenli uygulama yapısı notlarındaki yan yol fikri burada tekrar eder. Ana sayfa korumalı görünür, yan yol olan action açık kalır. Çözüm middleware'i büyütmek değil, action içine küçük ve tek bir kontrol koymaktır.
Policy tek fonksiyonda toplanır#
Her action'a serpiştirilmiş if (ownerId !== user.id) kontrolleri, controller'a serpilen yetki kontrolüyle aynı hatadır. Tek bir policy fonksiyonu olur, action'lar onu çağırır:
// lib/actions/policy.ts export async function requireInvoiceAccess( invoiceId: string, action: 'read' | 'write', ) { const session = await auth() if (!session) throw new Error('Unauthorized') const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, memberships: { some: { userId: session.user.id, role: action === 'write' ? { in: ['owner', 'finance'] } : { in: ['owner', 'finance', 'viewer'] }, }, }, }, }) if (!invoice) throw new Error('Not found') return { invoice, session } }
Yetkisiz kaynak ile var olmayan kaynak yine aynı hatayı verir. Rol ayrımı read/write seviyesinde fonksiyonun içindedir, her action'da yeniden yazılmaz.
Yukarıdaki parça fikri verir, üretimde kullanılacak hali biraz daha açıktır. Rol listesi tabloda durur, oturum tek noktadan okunur, tenant filtresi her sorguda zorunludur:
// lib/actions/policy.ts import { cache } from 'react' import { auth } from '@/lib/auth' import { db } from '@/lib/db' export type InvoiceAction = 'read' | 'write' | 'admin' const ROLE_BY_ACTION: Record<InvoiceAction, string[]> = { read: ['owner', 'finance', 'viewer'], write: ['owner', 'finance'], admin: ['owner'], } export const getSession = cache(async () => auth()) export async function requireInvoiceAccess( invoiceId: string, action: InvoiceAction, ) { const session = await getSession() if (!session) throw new Error('Unauthorized') const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, memberships: { some: { userId: session.user.id, role: { in: ROLE_BY_ACTION[action] }, }, }, }, }) if (!invoice) throw new Error('Not found') return { invoice, session } }
Bu blokta dört karar tek yerdedir. Rol hiyerarşisi ROLE_BY_ACTION içinde okunur: okuma üç role açıktır, yazma iki role iner, yönetici işlemi tek role iner. Tenant kapsamı tenantId filtresiyle sorgunun parçasıdır, sonradan eklenen bir kontrol değildir. Okuma yazma yönetici ayrımı string karşılaştırmayla her action içine dağılmaz. Hata sözleşmesi iki satırdır: oturum yoksa Unauthorized, fatura bulunamazsa ya da üyelik tutmazsa Not found.
Hata sözleşmesi özellikle önemlidir. Yetkisiz erişim ile var olmayan kayıt aynı hatayı vermelidir. Aksi halde saldırgan farklı hata mesajlarını karşılaştırarak hangi invoiceId değerlerinin gerçek olduğunu öğrenir. Bu sızıntı küçük görünür, fakat ID taraması yapan biri için kayıt varlığını doğrulayan bir sinyale dönüşür. API yetkilendirme hataları yazısındaki nesne seviyesi kontrollerle aynı ilkedir: yok ile yasak aynı cevap verir.
Tek giriş noktası ilkesi de buradan gelir. Bütün action'lar requireInvoiceAccess çağırır, hiçbiri kendi rol sorgusunu yazmaz. Yeni bir rol eklendiğinde ya da bir işlemin seviyesi değiştiğinde tek tablo güncellenir. Denetim sırasında bakılacak yer bellidir: policy dosyası ve onu çağırmayan action var mı sorusu.
Action ince kalır#
// app/invoices/actions.ts 'use server' export async function exportInvoicePdf(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'read') return renderPdf(invoice) }
PDF export özellikle seçildi: güvenli uygulama yapısı notlarında da geçen yan yol probleminin tipik örneğidir. Ana detay sayfası korumalı görünürken export action'ı korumasız kalır.
Desen oturduktan sonra her action aynı iskeleti kullanır. Okuma, yazma ve yönetici işlemi arasındaki fark yalnızca ikinci parametredir:
// app/invoices/actions.ts 'use server' import { requireInvoiceAccess } from '@/lib/actions/policy' export async function exportInvoicePdf(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'read') return renderPdf(invoice) }
// app/invoices/actions.ts 'use server' import { z } from 'zod' import { db } from '@/lib/db' import { requireInvoiceAccess } from '@/lib/actions/policy' const UpdateInput = z.object({ note: z.string().max(500), }) export async function updateInvoice(invoiceId: string, raw: unknown) { const { invoice } = await requireInvoiceAccess(invoiceId, 'write') const input = UpdateInput.parse(raw) return db.invoice.update({ where: { id: invoice.id }, data: { note: input.note }, }) }
// app/invoices/actions.ts 'use server' import { requireInvoiceAccess } from '@/lib/actions/policy' import { queueWebhookRetry } from '@/lib/billing/queue' export async function retryWebhook(invoiceId: string) { const { invoice } = await requireInvoiceAccess(invoiceId, 'admin') return queueWebhookRetry(invoice.id) }
Üç örnekte de ortak nokta aynıdır: policy satırı en üstte, iş mantığı altta. updateInvoice girdiyi zod ile doğrular, fakat doğrulama yetkilendirme yerine geçmez. Sıralama sabittir: önce erişim, sonra ayrıştırma, sonra yazma. retryWebhook yalnızca owner rolüne açıktır, arayüzde buton herkese gizlense bile sunucu tarafı bu ayrımı kendi uygular.
Her fonksiyon 5 ile 10 satır arasında kalır. Satır sayısı estetik değil, denetim kolaylığı içindir. Kısa action baştan sona tek bakışta okunur. Policy çağrısı olmayan action dosyada hemen göze batar.
Butonu gizlemek neden koruma değildir#
Aşağıdaki parça gerçek projelerde sık görülür. Rolü yetmeyen kullanıcı butonu görmez, ekip de action korundu sanır:
// BAD: yalnızca arayüzde gizler, sunucuda kontrol yoktur export function InvoiceToolbar({ role, id }: { role: string; id: string }) { if (role !== 'owner') return null return <button onClick={() => retryWebhook(id)}>Tekrar dene</button> }
Bu kodun sorunu eksik koşul değil, yanlış katmandır. Butonun render edilmemesi DOM ile ilgilidir, endpoint ile ilgili değildir. Action POST ile çağrılabilir durumda kaldığı sürece viewer rolündeki biri istek gövdesine geçerli bir invoiceId yazarak aynı fonksiyonu çalıştırabilir. Tarayıcıda butonun görünmemesi, istek göndermeye engel değildir.
Buradaki ders serttir: arayüzdeki koşullu render bir kullanılabilirlik kararıdır, güvenlik kararı değildir. Güvenlik kararı action fonksiyonunun ilk satırında verilir. Koşullu render kalabilir, fakat tek başına sayılmaz. Policy çağrısı olmayan her action, gizli butonun arkasında açık duran bir kapıdır.
Test: butona değil action'a vur#
Test, arayüzdeki butonun görünürlüğünü değil, action'ın doğrudan çağrısını hedefler. İki farklı üyelikle aynı action çağrılır; ikinci kullanıcının çağrısı veri değil hata dönmelidir. Dönen hatanın "Not found" ile aynı olması, obje varlığının dışarı sızmadığını gösterir.
Aşağıdaki test iki üyelik kurar: biri faturaya erişebilen finance üyesi, diğeri farklı tenant'ta olan ya da faturayla bağı olmayan kullanıcı. Arayüz hiç açılmaz, action fonksiyonu doğrudan çağrılır:
// app/invoices/actions.test.ts import { describe, it, expect, vi } from 'vitest' import { exportInvoicePdf } from './actions' vi.mock('@/lib/actions/policy', async (importOriginal) => { const mod = await importOriginal<typeof import('@/lib/actions/policy')>() return { ...mod } }) describe('exportInvoicePdf', () => { it('erişimi olan üyeye PDF döner', async () => { const pdf = await exportInvoicePdfAs('finance-user', 'inv_123') expect(pdf).toBeDefined() }) it('bağı olmayan kullanıcı veri değil hata alır', async () => { await expect( exportInvoicePdfAs('outsider-user', 'inv_123'), ).rejects.toThrow('Not found') }) it('varlık bilgisi sızmaz', async () => { const missing = await captureError(() => exportInvoicePdfAs('outsider-user', 'inv_missing'), ) const forbidden = await captureError(() => exportInvoicePdfAs('outsider-user', 'inv_123'), ) expect(forbidden.message).toBe(missing.message) }) })
Test yardımcısı oturumu taklit eder, gerçek veritabanı ya da yalıtılmış test veritabanı kullanılır. Önemli olan çağrı yoludur: bileşen render edilmez, buton tıklanmaz, exportInvoicePdf doğrudan çağrılır.
Doğrulama listesi kısadır ve her madde ayrı bir açığı kapatır:
- Yetkili çağrı veri döner, yetkisiz çağrı veri dönmez.
- Yetkisiz çağrının hatası
Not foundmetniyle eşleşir,Unauthorizedya da özel bir yasak mesajı dönmez. - Var olmayan kayıt ile erişilemeyen kayıt aynı hatayı verir, iki durum ayırt edilemez.
- Yazma ve yönetici action'ları için aynı matris tekrarlanır:
viewerokuyabilir ama yazamaz,financeyazabilir ama webhook tekrarı başlatamaz. - Tenant dışı
invoiceIdile yapılan çağrı da aynı hatayı verir.
Bu liste geçmeden arayüz testine bakılmaz. Arayüz testi butonun gizlendiğini kanıtlar, buradaki test endpoint'in kapalı olduğunu kanıtlar. İkisi farklı sorulara cevap verir.
Her çağrıda ne loglanır#
Yetki kontrolü sessizce geçmemelidir. Log satırı hem saldırı incelemesine hem de yanlış rol atamasını bulmaya yarar. Her action çağrısında üç bilgi tek satırda toplanır: kim çağırdı, hangi nesne istendi, policy ne karar verdi.
// lib/actions/audit.ts export function logInvoiceAccess(args: { userId: string tenantId: string invoiceId: string decision: 'allow-read' | 'allow-write' | 'allow-admin' | 'deny' }) { console.log( JSON.stringify({ event: 'invoice.access', ...args, }), ) }
decision alanı yoksa log yalnızca kullanım istatistiğidir, denetim kaydı değildir. Başarısız denemelerde deny yazılır, hangi invoiceId değerinin kimin tarafından denendiği görülür. Kısa sürede aynı kullanıcıdan gelen çok sayıda deny kaydı, ID taraması şüphesi doğurur.
Log içine fatura tutarı ya da müşteri e-postası gibi hassas alanlar yazılmaz. Nesne kimliği ve karar yeterlidir. Amaç veriyi kopyalamak değil, erişim izini sürülebilir kılmaktır.
Sık yapılan dört hata#
1. İstemciden gelen ID'ye güvenmek#
Action parametresi kullanıcı girdisidir. invoiceId değeri adres çubuğundan, form alanından ya da istek gövdesinden gelir. Policy bu değeri ham kabul edip sahiplik süzgecinden geçirir. Süzgeçsiz sorgu yazmak, kapıyı açık bırakmakla aynıdır:
// BAD: tenant ve üyelik filtresi yok const invoice = await db.invoice.findFirst({ where: { id: invoiceId }, })
Doğru sorgu her zaman üç koşulu birlikte taşır: id, tenantId ve memberships.some. Üçünden biri eksikse sorgu düzeltilmeden action yayına alınmaz.
2. İlişkili kayıtlarda tenant filtresini unutmak#
Ana fatura sorgusu doğru olsa bile include ile çekilen ilişkiler kapsam dışına kaçabilir. Fatura satırları, ödeme denemeleri ya da webhook kayıtları ayrı tablolardaysa her biri tenant bağlamında okunmalıdır:
// BAD: ana kayıt süzgeçli, ilişki süzgeçsiz const invoice = await db.invoice.findFirst({ where: { id: invoiceId, tenantId: session.tenantId, }, include: { webhookAttempts: true }, })
İlişki tek başına zararsız görünür, fakat action bu kayıtları döndürürse kapsam genişler. İlişki ya ayrı ve süzgeçli bir sorguyla okunur ya da üst sorgunun üyelik koşulu ilişkiye de taşınır. Kural basittir: kullanıcıya dönen her satır aynı policy süzgecinden geçmiş olmalıdır.
3. Farklı hata mesajlarıyla varlık sızdırmak#
Yok ile yasak için ayrı mesaj üretmek yaygın bir dikkatsizliktir:
// BAD: iki durum ayırt edilebiliyor if (!invoice) throw new Error('Invoice does not exist') if (!canAccess) throw new Error('Access denied for this invoice')
İlk mesaj kaydın olmadığını, ikinci mesaj kaydın var olduğunu fakat erişim verilmediğini söyler. Saldırgan bu farkı ID taramasında kullanır. Tek mesaj kuralı bu yüzden vardır: bulamama ve erişememe aynı Not found cevabını verir. Hata metinleri kod incelemesinde tek tek okunur, ayrıksı mesaj bırakılmaz.
4. Her action içinde auth() çağırıp önbelleğe almamak#
Her action kendi auth() çağrısını yaparsa aynı istek içinde oturum tekrar tekrar çözülür. React cache() ile sarmalanmış tek bir okuma bu tekrarı kaldırır:
// lib/actions/policy.ts import { cache } from 'react' import { auth } from '@/lib/auth' export const getSession = cache(async () => auth())
Policy ve action'lar auth() yerine getSession() kullanır. Davranış değişmez, yalnızca tekrar eden iş kalkar. Daha önemlisi oturum kaynağı tekleşir: yarın oturum okuma şekli değişirse tek fonksiyon güncellenir, her action tek tek elden geçmez.
Bu desen neyi kapsamaz#
Bu yazıdaki policy, nesne ve işlem yetkisini kapatır. Başka katmanların işini üstlenmez. Sınırı bilmek, deseni olduğundan güçlü sanmaktan korur.
Hız sınırlama bu desenin dışındadır. Yetkili bir kullanıcı endpoint'i saniyede onlarca kez çağırabilir, policy her seferinde geçiş verir. Kötüye kullanım sayıyla ilgilidir, kimlikle değil. Gerekli yerde ayrı bir katman istek sayısını kısıtlar.
İş mantığı suistimali de ayrıdır. Yetkili bir finance üyesi geçerli faturaları arka arkaya güncelleyebilir, tutarları kural dışı şekilde değiştirebilir. Policy rolün işlem yapabileceğini söyler, işlemin ticari anlamda doğru olduğunu söylemez. Tutar aralığı, onay akışı ve çift kontrol gibi kurallar iş mantığı katmanında durur.
İstemci tarafına güven bu desenle çözülmez. Gizli alan, devre dışı buton ya da salt okunur form, sunucuda yeniden doğrulanmadıkça yalnızca arayüz tercihidir. Sunucu, istemciden gelen her değeri doğrulanmamış girdi sayar. Bu yüzden zod ayrıştırması policy sonrasında çalışır, öncesinde değil.
Son olarak bu desen veri sızıntısının tamamını kapatmaz. Loglara yazılan hassas alan, hata mesajına eklenen iç detay ya da PDF içine gömülen fazla bilgi ayrı incelemedir. Policy kapıyı tutar, odanın içindeki düzen ayrı iştir.
Kısa sonuç#
Server Action yazarken sorulacak tek soru vardır: bu fonksiyon, butonu gören kullanıcı için mi yazıldı, yoksa çağıran kullanıcı için mi? Güvenli cevap her zaman ikincisidir. Policy fonksiyonda, action ince, test doğrudan çağrıda.
Yeni bir action eklerken sıra değişmez: policy tablosuna işlem seviyesini yaz, action içinde tek satırla çağır, iki üyelikle testi çalıştır, denetim logunu ekle. Buton koşulu en son gelir ve hiçbir zaman tek koruma sayılmaz.
Ne düşünüyorsun?
Tepki bırakarak geri bildirim ver