İçeriğe atla

2026-03-15

Yetkilendirme Temelleri ve İzin Sistemleri Neden Bozulur

Authentication ve authorization farkı, yaygın izin sistemi tuzakları, fail-closed prensibi ve her izin sisteminin karşılaması gereken hedefler.

Özet

Broken access control, OWASP Top 10’da (2021, 2025 güncellemesinde de doğrulandı) bir numaralı güvenlik açığı. İzin sistemi hataları genellikle tek bir eksik kontrolden kaynaklanmıyor — kontrolleri unutmayı kolay, tutarlı tutmayı zor kılan bir mimariden kaynaklanıyor. Bu yazıda izin sistemlerinin neden bozulduğunu inceliyor, fail-closed prensibini tanıtıyor ve iyi tasarlanmış bir izin sisteminin karşılaması gereken hedefleri belirliyoruz.

Kimlik Doğrulama ve Yetkilendirme

Bu iki kavram genellikle tek bir “auth” kelimesi altında birleştiriliyor ve bu da karışıklığa yol açıyor. Temelde farklı konulardır.

Kimlik doğrulama (authentication) sorusunu yanıtlar: “Sen kimsin?” Kimliği doğrular — geçerli oturum token’ları, JWT’ler, OAuth akışları. İkilidir. Kullanıcının geçerli bir oturumu ya vardır ya yoktur.

Yetkilendirme (authorization) sorusunu yanıtlar: “Ne yapabilirsin?” Kullanıcıya, kaynağa ve eyleme göre izinleri değerlendirir. Bağlamsaldır. Bir kullanıcı kendi belgelerini düzenleyebilir ama başkasının belgelerini düzenleyemeyebilir — tamamen kimliği doğrulanmış olmasına rağmen.

Bu ikisinin karıştırılması yaygın bir hata kalıbı oluşturur: kullanıcının giriş yapıp yapmadığını kontrol eden ama belirli bir eylemi gerçekleştirmeye yetkili olup olmadığını asla kontrol etmeyen kod tabanları.

Hayır

Evet

Hayır

Evet

Gelen İstek

Kimlik Doğrulandı mı?

401 Unauthorized

Yetkili mi?

403 Forbidden

Erişim Verildi

HTTP durum kodları bu ayrımı yansıtır. 401 Unauthorized yanıtı, sunucunun kullanıcının kim olduğunu bilmediği anlamına gelir (kafa karıştırıcı ismine rağmen, “kimlik doğrulanmamış” demektir). 403 Forbidden yanıtı ise sunucunun kullanıcının kim olduğunu bildiği ama izni olmadığı anlamına gelir.

Tip: Uygulamanız izin hatalarında 401, giriş sorunlarında 403 döndürüyorsa, bu kimlik doğrulama ve yetkilendirmenin kod tabanında birbirine karıştırıldığının bir işaretidir.

Örnek Uygulama

Bu seri boyunca, sürekli örneğimiz olarak bir proje ve belge yönetimi uygulaması kullanacağız. Domain tipleri şöyle:

interface User {
  id: string;
  email: string;
  role: 'admin' | 'editor' | 'author' | 'viewer';
}

interface Project {
  id: string;
  name: string;
  ownerId: string;
  visibility: 'public' | 'private';
  members: ProjectMember[];
}

interface ProjectMember {
  userId: string;
  role: 'editor' | 'author' | 'viewer';
}

interface Document {
  id: string;
  projectId: string;
  title: string;
  content: string;
  authorId: string;
  status: 'draft' | 'review' | 'published';
}

Hedeflenen izinler:

  • Admin: Her şeye tam erişim
  • Editor: Üyesi olduğu projelerdeki belgeleri düzenleyebilir ve yayınlayabilir
  • Author: Kendi belgelerini oluşturabilir, düzenleyebilir ve incelemeye gönderebilir
  • Viewer: Erişimi olan projelerdeki yayınlanmış belgeleri okuyabilir

Teoride yeterince basit. Pratikte, işlerin bozulmaya başladığı yer tam burası.

Naif İzin Kontrolü Tuzakları

Dağınık İzin Kontrolleri

En yaygın anti-pattern, izin mantığının sayfalara, sunucu eylemlerine ve API route’larına kopyalanması — her birinin biraz farklı mantığa sahip olmasıdır.

Sayfa bileşeni (app/projects/[id]/page.tsx):

// İzin kontrolü #1 -- sayfa bileşeninde
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const session = await getSession();
  const project = await db.project.findUnique({
    where: { id },
  });

  if (project.visibility === 'private') {
    const isMember = project.members.some(
      (m) => m.userId === session.userId
    );
    if (!isMember && session.user.role !== 'admin') {
      redirect('/unauthorized');
    }
  }

  return <ProjectView project={project} />;
}

Sunucu eylemi (app/actions/documents.ts):

// İzin kontrolü #2 -- benzer ama ince farklarla
'use server';

export async function updateDocument(
  documentId: string,
  content: string
) {
  const session = await getSession();
  const document = await db.document.findUnique({
    where: { id: documentId },
  });

  // Hata: proje üyeliğini kontrol etmeyi unutmuş!
  // Sadece kullanıcının yazar olup olmadığını kontrol ediyor
  if (document.authorId !== session.userId) {
    throw new Error('Unauthorized');
  }

  // Hata: editörler de düzenleyebilmeli,
  // ama bu kontrol onları dışlıyor
  await db.document.update({
    where: { id: documentId },
    data: { content },
  });
}

API route (app/api/projects/[id]/documents/route.ts):

// İzin kontrolü #3 -- yine farklı bir varyasyon
export async function GET(
  req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const session = await getSession();

  // Hata: admin VEYA üye kontrolü yapıyor ama proje
  // görünürlüğünü kontrol etmiyor. Public proje belgeleri
  // herkese açık olmalı.
  if (session.user.role !== 'admin') {
    const project = await db.project.findUnique({
      where: { id },
    });
    const isMember = project.members.some(
      (m) => m.userId === session.userId
    );
    if (!isMember) {
      return new Response('Forbidden', { status: 403 });
    }
  }

  const documents = await db.document.findMany({
    where: { projectId: id },
  });

  // Hata: taslaklar dahil TÜM belgeleri viewer'lara döndürüyor
  return Response.json(documents);
}

Üç konum. Üç farklı izin kontrolü. Her birinde farklı bir hata var. “Editörler artık yayınlayabilir” gibi bir kural değişikliği, her konumun bulunup güncellenmesini gerektiriyor. “Kullanıcı X, kaynak Z üzerinde eylem Y’yi yapabilir mi?” sorusunun tek bir doğru kaynağı yok.

Aşırı İzin: Gereğinden Fazla Veri Döndürme

Bir diğer yaygın tuzak, kullanıcının görmesi gerekenden fazla veri döndürmektir:

export async function GET(req: Request) {
  const session = await getSession();
  const documents = await db.document.findMany({
    where: { projectId: req.params.projectId },
    include: { author: true, reviews: true },
  });

  // Viewer, bilmemesi gereken taslak belgeleri görüyor
  // Author, sadece editörlere yönelik inceleme geri
  // bildirimlerini görüyor
  return Response.json(documents);
}

Sorgu her şeyi döndürüyor. Kullanıcının rolüne göre filtreleme yok. Viewer taslak belgeleri alıyor. Author, editörlere yönelik inceleme yorumlarını alıyor. API “çalışıyor” ama veri sızdırıyor.

Katmanlar Arası Tutarsız Kontroller

Aynı “belge düzenleyebilir mi” izni, üç farklı yerde farklı şekilde kontrol ediliyor:

// UI bileşeni -- rol string'ine göre kontrol ediyor
function EditButton({ user, document }: Props) {
  const canEdit =
    user.role === 'admin' ||
    user.role === 'editor' ||
    document.authorId === user.id;
  // Eksik: proje üyeliğini kontrol etmiyor

  if (!canEdit) return null;
  return <button>Edit</button>;
}

// Sunucu eylemi -- farklı şekilde kontrol ediyor
async function editDocument(docId: string, content: string) {
  const session = await getSession();
  const doc = await getDocument(docId);

  // Farklı mantık: yazar kontrolü yapıyor ama editör rolünü unutuyor
  if (
    doc.authorId !== session.userId &&
    session.user.role !== 'admin'
  ) {
    throw new Error('Cannot edit');
  }
  // ...
}

// Middleware -- başka bir şekilde kontrol ediyor
export function middleware(req: NextRequest) {
  const session = getSessionFromCookie(req);
  if (!session) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  // Sadece kimlik doğrulama kontrolü, yetkilendirme yok
  return NextResponse.next();
}

Sonuç: Edit butonu doğru kullanıcılara gösteriliyor ama tıklandığında başarısız olabilir — ya da daha kötüsü, olmaması gerektiğinde başarılı olabilir — çünkü sunucu tarafı kontrolü istemci tarafındakinden farklı mantık kullanıyor.

Next.js’e Özgü Tuzaklar

Layout tabanlı yetkilendirme, Next.js uygulamalarında yaygın bir hatadır:

// app/dashboard/layout.tsx
export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();
  if (!session) {
    redirect('/login');
  }

  // Sorun 1: Layout'lar istemci tarafı navigasyonda yeniden
  // render edilmez. Oturum kontrolü bir kez çalışır ama
  // sonraki route değişikliklerinde çalışmaz.

  // Sorun 2: İç route'lardaki Server Action'lar bu kontrolü
  // atlar. Bir server action her yerden çağrılabilir.

  // Sorun 3: /dashboard altındaki API route'lar da bu
  // layout tarafından korunmaz.

  return <>{children}</>;
}

Sadece middleware tabanlı yetkilendirme bir diğer tuzaktır — ve iyi belgelenmiş bir tuzaktır. CVE-2025-29927 (CVSS 9.1), Next.js’teki x-middleware-subrequest header’ının tüm middleware tabanlı erişim kontrolünü atlamak için kullanılabileceğini gösterdi. Sadece middleware’de yaşayan herhangi bir koruma, tek bir HTTP header’ı ile aşılabiliyordu.

// middleware.ts -- güvenlik açığı bulunan pattern
export function middleware(request: NextRequest) {
  const session = getSessionFromCookie(request);

  if (request.nextUrl.pathname.startsWith('/admin')) {
    if (!session || session.role !== 'admin') {
      return NextResponse.redirect(
        new URL('/login', request.url)
      );
    }
  }

  return NextResponse.next();
}
// CVE-2025-29927: Saldırgan x-middleware-subrequest
// header'ı ekleyerek TÜM middleware'i atlayabiliyordu.
// Yetkilendirme, veri erişim katmanında yapılmalıdır.

Bu güvenlik açığı temel bir prensibi gösteriyor: yetkilendirme, sadece middleware veya routing seviyesinde değil, veri erişim katmanında yapılmalıdır. Derinlemesine savunma — birden fazla katmanda kontroller — zorunludur.

”Fail Closed” Prensibi

Bir izin kontrolü hatayla karşılaştığında — veritabanı zaman aşımı, beklenmeyen bir istisna, eksik bir alan — iki olası varsayılan davranış vardır:

Fail open (tehlikeli): Kontrol başarısız olduğunda erişim ver.

async function checkPermission(
  userId: string,
  resourceId: string
): Promise<boolean> {
  try {
    const permission = await db.permission.findFirst({
      where: { userId, resourceId },
    });
    return permission !== null;
  } catch (error) {
    console.error('Permission check failed:', error);
    return true; // FAIL OPEN: hata durumunda erişim veriyor
  }
}

Fail closed (güvenli): Kontrol başarısız olduğunda erişimi reddet.

async function checkPermission(
  userId: string,
  resourceId: string
): Promise<boolean> {
  try {
    const permission = await db.permission.findFirst({
      where: { userId, resourceId },
    });
    return permission !== null;
  } catch (error) {
    console.error('Permission check failed:', error);
    return false; // FAIL CLOSED: hata durumunda erişimi reddediyor
  }
}

Bu tuzağın daha ince bir versiyonu isAdmin boolean varsayılanıdır:

// TEHLİKELİ: varsayılan değer true
let isAdmin = true;
try {
  const user = await getUser(session.userId);
  isAdmin = user.role === 'admin';
} catch (error) {
  logger.error('Failed to fetch user role', error);
  // isAdmin true kalıyor -- herhangi bir hata admin erişimi veriyor
}

// GÜVENLİ: varsayılan değer false
let isAdmin = false;
try {
  const user = await getUser(session.userId);
  isAdmin = user.role === 'admin';
} catch (error) {
  logger.error('Failed to fetch user role', error);
  // isAdmin false kalıyor -- hata, admin erişimi yok demek
}

Kural basit: isAuthorized(), isAuthenticated() ve validate() gibi güvenlik metotları herhangi bir istisna durumunda false döndürmelidir. Şüphe varsa, erişimi reddet.

Warning: Bu durum allowlist ve blocklist karşılaştırması için de geçerlidir. Allowlist (“sadece bu roller erişebilir”) fail-closed çalışır — yeni bir rolün varsayılan olarak erişimi yoktur. Blocklist (“bu rolleri engelle”) fail-open çalışır — yeni bir rol, biri onu engelleme listesine eklemeyi hatırlayana kadar erişime sahiptir.

İyi Bir İzin Sisteminin Hedefleri

Bir çözüm oluşturmadan önce, iyi bir izin sisteminin neyi başarması gerektiğini tanımlamak faydalıdır. Bu hedefler, serinin geri kalanına rehberlik edecek:

1. Yetkisiz Erişimi Önleme

Hem yatay yetki yükseltme (kullanıcı A’nın kullanıcı B’nin verilerine erişmesi) hem de dikey yetki yükseltme (viewer’ın admin eylemleri gerçekleştirmesi) önlenmelidir. Her endpoint ve her veri sorgusu, belirli kullanıcıyı, kaynağı ve eylemi dikkate alan bir yetkilendirme kontrolüne ihtiyaç duyar.

2. Tutarlı Olma

Aynı izin mantığı her yerde geçerli olmalıdır. Bir editör UI’da belge düzenleyebiliyorsa, aynı kural sunucu eylemlerinde, API route’larında ve veritabanı sorgularında da geçerli olmalıdır. İzin kuralları için tek bir doğruluk kaynağı, katmanlar arasındaki tutarsızlığı ortadan kaldırır.

3. Varsayılan Olarak Otomatik Uygulama

Geliştiricilerin izin kontrolü eklemeyi hatırlaması gerekmemelidir. Sistem mimarisi, yetkisiz erişimi varsayılan olarak imkansız kılmalıdır. Verilere erişmek izin-farkında bir service layer üzerinden geçmeyi gerektiriyorsa, eksik bir kontrol güvenlik açığı yerine derleme hatası olur.

4. Kolay Güncellenebilir Olma

“Editörler artık yayınlayabilir” gibi bir izin kuralı değişikliği, tek bir yerde tek bir değişiklik gerektirmelidir. Bir izin kuralını güncellemek için tüm kod tabanında dağınık kontrolleri aramak gerekiyorsa, hatalar kaçınılmazdır.

5. Denetlenebilir Olma

Kimin neye, ne zaman eriştiğini bilmek gerekir. Fintech, sağlık ve diğer düzenlenmiş sektörlerdeki uyumluluk gereksinimleri, erişim denetim izleri talep eder. Düzenlenmiş sektörlerin dışında bile, denetim günlükleri izin sorunlarını hata ayıklamak için çok değerlidir.

6. Performanslı Olma

İzin kontrolleri her istekte, genellikle istek başına birden fazla kez gerçekleşir. Önemli gecikme ekleyemezler. Önbellekleme stratejileri, verimli veri yapıları ve minimum veritabanı sorguları hep dikkate alınması gereken konulardır.

7. Type-Safe Olma

TypeScript’te, tip sistemi izin hatalarını derleme zamanında yakalamalıdır. Bir rol yeniden adlandırılırsa, derleyici her referansı işaretlemelidir. Bir izin kontrolü kaynak nesnesi gerektiriyorsa ama undefined alıyorsa, bu çalışma zamanı hatası değil tip hatası olmalıdır.

Gerçek Dünya Senaryoları: Pratikte İzin Hataları

Anti-pattern’lerin nasıl ortaya çıktığını görmek için örnek uygulamadaki belirli hata senaryolarını inceleyelim.

Senaryo 1: Yatay Yetki Yükseltme

Kullanıcı A, Proje X’te bir yazar. URL’deki belge ID’sini /projects/x/documents/123’ten /projects/y/documents/456’ya değiştiriyor. Sayfa bileşeni “kullanıcı kimliği doğrulanmış mı?” diye kontrol ediyor ama “bu kullanıcının Proje Y’ye erişimi var mı?” diye asla kontrol etmiyor.

Kullanıcı A artık hiçbir ilişkisi olmayan bir projeden belgeleri okuyor.

Senaryo 2: Dikey Yetki Yükseltme

Bir viewer, “belge yayınla” sunucu eyleminin tahmin edilebilir bir endpoint’te bulunduğunu keşfeder. UI, viewer’lar için “Yayınla” butonunu gizler ama sunucu eylemi yalnızca kullanıcının kimliğinin doğrulanıp doğrulanmadığını kontrol eder — rolünü değil.

Viewer, sunucu eylemini doğrudan çağırarak belgeleri yayınlar.

Senaryo 3: IDOR (Güvensiz Doğrudan Nesne Referansı)

API endpoint’i /api/documents/123, belge veritabanında varsa döndürür. İstekte bulunan kullanıcının belgeyle veya projesiyle herhangi bir ilişkisi olup olmadığına dair kontrol yoktur. Ardışık ID’ler numaralandırmayı kolay kılar — saldırgan ID’ler üzerinde ilerleyerek sistemdeki tüm belgeleri indirir.

Senaryo 4: Tutarsız Rol Uygulaması

Admin paneli, UI üzerinden yalnızca adminlere erişilebilirdir (istemci tarafı kontrolü navigasyon bağlantısını gizler). Ancak admin API route’ları (/api/admin/users, /api/admin/settings) rol doğrulamasından yoksundur. URL’yi bilen herhangi bir kimliği doğrulanmış kullanıcı, admin API’lerini doğrudan çağırabilir.

Merkezi Kontroller (Hedef)

Sayfa Bileşeni

İzin Servisi

Sunucu Eylemi

API Route

Middleware

Veritabanı

Dağınık Kontroller (Anti-Pattern)

if role === admin

if userId === authorId

if isMember

if session exists

Sayfa Bileşeni

Veritabanı

Sunucu Eylemi

API Route

Middleware

Bu senaryoların her biri ortak bir kök nedeni paylaşır: dağınık, çoğaltılmış veya tamamen eksik yetkilendirme mantığı.

Yaygın Tuzaklar Özeti

YaklaşımAvantajProblem
Dağınık ad-hoc kontrollerBaşlangıçta hızlı uygulanabilirTutarsız, bakımı zor, güvenlik açıkları oluşturur
Sadece middleware kontrolleriHer istekte çalışırAtlanabilir (CVE-2025-29927), kaynak düzeyinde bağlam yok
Layout tabanlı kontrollerMerkezi hissedilirNavigasyonda yeniden render edilmez, sunucu eylemlerini korumaz
Sadece istemci tarafı kontrollerİyi UX (erişilemeyen UI’ı gizler)Sıfır güvenlik değeri — sunucu her zaman doğrulamalı

Sıradaki

Bu yazıdaki anti-pattern’ler ortak bir kök nedeni paylaşıyor: yetkilendirme mantığı dağınık, çoğaltılmış ve tutarsız. “Bu kullanıcı bu kaynakta bu eylemi yapabilir mi?” sorusunun tek bir doğruluk kaynağı yok.

Çözüm merkezileştirmedir. Sonraki yazıda, service layer pattern’ini tanıtacağız — tüm veri erişiminin geçtiği, yetkilendirmenin bir kez ve doğru şekilde kontrol edildiği tek bir katman.

// Ön izleme -- tam uygulama Post 102'de
// Her yerde dağınık kontroller yerine,
// tüm erişim izin-farkında bir servis üzerinden geçer:
const document = await documentService.getById(
  session.userId,
  documentId
);
// Servis şunları yönetir: kimlik doğrulama, yetkilendirme
// ve veri filtreleme.
// Kullanıcı bu belgeye erişemezse, servis hata fırlatır
// -- sayfa değil.

İzinleri sayfalarda, eylemlerde ve route’larda kontrol etmek yerine, uygulama kodunuz ile veritabanınız arasında oturan bir service layer’da bir kez kontrol edersiniz. Artık dağınık kontroller yok. Tutarsızlıklar yok. Unutulan izin mantığı yok.

Kaynaklar

Ölçeklenebilir İzin Sistemleri

TypeScript ve Next.js ile ölçeklenebilir izin sistemleri oluşturma rehberi. Basit kontrol mekanizmalarından RBAC ve ABAC'a, oradan multi-tenant yetkilendirme sistemlerine kadar kapsamlı bir seri.

İlerleme 1 / 6 yazı

İlgili yazılar