Kimlik Doğrulama ve RBAC Yerinde, Kullanıcılar Neden Birbirinin Verisini Görüyor?
RBAC geçerken kullanıcılar neden hâlâ birbirinin kaydını okuyor: OWASP API1 BOLA, gediği kapatan dört yöntem, önerilen varsayılan ve CI için çok kiracılı test.
Bir API her JWT’yi doğrulayabilir, her rotada rol kontrolü çalıştırabilir ve buna rağmen B kullanıcısının tek bir path segmentini değiştirerek A kullanıcısının faturasını açmasına izin verebilir. Sebep yapısaldır: RBAC, bir rolün Invoice kaynak türü üzerinde invoice:read eylemini yapıp yapamayacağını yanıtlar; bu çağrıyı yapanın 8812 numaralı faturayı okuyup okuyamayacağını hiçbir zaman yanıtlamaz. İkinci soru nesne düzeyinde yetkilendirmedir ve yokluğu OWASP API Security Top 10 listesinin ilk sırasında durur (API1:2023, Broken Object Level Authorization, kısaca BOLA; çoğu kişi hâlâ IDOR der). RBAC’ı çoktan üretime almış ve altında ilişkisel bir veritabanı olan bir ekip için önerdiğim varsayılan şudur: Principal zorunlu kılan bir repository arkasında kapsam kısıtlı sorgular, artı CI’da rota tablosundan üretilen iki kullanıcılı bir çapraz kiracı testi. Paylaşımlı şemalı çok kiracılı PostgreSQL üzerinde buna, veritabanı katmanında son savunma hattı olarak Row-Level Security eklenir. Alışılmış sertleştirme adımları bu gediği açık bırakır; dört yöntem kapatır, rota tablosundan üretilen bir test de kapalı tutar.
Üç Soru, Üç OWASP Kategorisi#
RBAC, JWT scope’ları ve gateway rota politikaları hep aynı soruyu yanıtlar: bu çağrıyı yapan bu fonksiyonu kullanabilir mi? OWASP bu sorunun hatalarını API5:2023, Broken Function Level Authorization (BFLA) başlığına koyar. Veri sızdıran soru başka bir sorudur; alan düzeyindeki soru ise üçüncüsüdür.
| Soru | OWASP kategorisi | Tipik kontrol | Örnek hata |
|---|---|---|---|
| Bu çağrıyı yapan bu fonksiyonu kullanabilir mi? | API5:2023 BFLA | RBAC, JWT scope’ları, rota koruyucuları | Bir viewer DELETE /admin/users/:id çağırır |
| Bu çağrıyı yapan bu nesneye dokunabilir mi? | API1:2023 BOLA | Sahiplik, tenant veya ilişki kontrolü | B kullanıcısı A’ya ait GET /invoices/8812 çağırır |
| Bu çağrıyı yapan bu alanı okuyabilir veya yazabilir mi? | API3:2023 BOPLA | Çıktı DTO’ları, yazma izin listeleri | Bir kullanıcı PATCH ile role: "admin" atar |
OWASP, BOLA’yı istismarı kolay, yaygın ve tespiti kolay olarak derecelendirir. Saldırgan path’teki, query string’deki, bir header’daki ya da gövdedeki nesne kimliğini değiştirir ve API başkasının kaydını döndürür. Rol kontrolü BFLA satırında oturduğu için her seferinde geçer: B kullanıcısı gerçekten invoice:read iznine sahiptir. Başarısız olması gereken kontrol hiç yazılmamıştır.
Zafiyetli şekil ilk bakışta tanınacak kadar kısadır. Aşağıdaki örnek Prisma ile bir Hono handler’ı; aynı kalıp Express’te, Next.js route handler’larında ve GraphQL resolver’larında da görülür:
// RBAC passes: user B has the "accountant" role, which grants invoice:read.
app.get('/invoices/:id', requirePermission('invoice:read'), async (c) => {
const invoice = await prisma.invoice.findUnique({
where: { id: c.req.param('id') }, // any invoice in the database
});
if (!invoice) return c.json({ error: 'Not found' }, 404);
return c.json(invoice);
});
İstek üç kapıdan geçer ve veritabanına çağrıyı yapandan söz eden hiçbir koşul olmadan ulaşır.
Çalıştığım bir projede liste endpoint’i doğru kapsamlanmıştı: GET /invoices ilk commit’ten itibaren çağrıyı yapanın tenant’ına göre filtreleniyordu. Detay endpoint’i ile PDF dışa aktarma ise daha sonra farklı kişilerce eklendi ve ikisi de faturayı yalnızca kimliğe göre arıyordu. RBAC middleware’i üç rotada da vardı. Kod incelemesinde fark göze çarpmadı, çünkü rol kontrolü gözle görülür biçimde oradaydı, eksik koşul ise görünmezdi.
Bilinen Sızıntılarda Aynı Gedik#
Kalıp o kadar yaygın ki olay raporları tek bir şablonun çeşitlemeleri gibi okunuyor. OWASP Top 10:2025, test ettiği veri setindeki her uygulamada bir biçimde broken access control bulunduğunu bildiriyor. Bu oran yalnızca BOLA’yı değil, kategorinin tamamını kapsıyor. Eşlenen zayıflıklar arasında CWE-639 (kullanıcı kontrollü anahtarla yetki atlatma) da var.
| Olay | Yerinde olan | Eksik olan |
|---|---|---|
| USPS Informed Visibility (2018) | API’yi kullanmak için giriş gerekiyordu | Kimliği doğrulanmış herhangi bir kullanıcı başkalarının hesap bilgilerini sorgulayabiliyordu; KrebsOnSecurity’ye göre yaklaşık 60 milyon hesap açığa çıktı |
| Peloton (2021) | İlk düzeltmeden sonra endpoint’ler kimlik doğrulama istiyordu | Pen Test Partners, aynı verinin sonraki bir düzeltmeye kadar giriş yapan her hesap tarafından okunabildiğini ve herkesin kendi kendine kayıt olabildiğini belgeledi |
| First American Financial (2019) | Belge bağlantıları geçerli bir kayıt kimliği taşıyordu | Bağlantıdaki kimliği değiştirmek başka müşterilerin belgelerini döndürüyordu; yüz milyonlarca dosya erişilebilir durumdaydı |
| Optus (2022) | Erişim kontrolü ana portalda düzeltilmişti | Düzenleyici kurum, aynı kodlama hatasının API alan adında düzeltilmediğini ve saldırının basit bir deneme yanılma süreci olduğunu iddia ediyor |
Bu olaylardan ikisi asıl dersi veriyor. Peloton, bir endpoint’e kimlik doğrulama eklemenin BOLA’yı kapatmadığını gösteriyor; her saldırgan kimliği doğrulanmış bir kullanıcı olabilir. Optus (ACMA’nın mahkeme dosyasındaki iddia; henüz bir karar değil), endpoint başına kontrolün hata modunu gösteriyor: düzeltme bir kod yoluna uygulandı, ikizinde atlandı.
Gediği Açık Bırakan Sertleştirme Adımları#
Yaygın birkaç tepki, eksik kontrolü eklemeden istismarı zorlaştırır. Hepsi yapmaya değer, ama hiçbiri kontrolün kendisi değildir.
| Önlem | Ne yapar | BOLA neden açık kalır |
|---|---|---|
| Sıralı kimlik yerine UUID | Numaralandırmayı yavaşlatır | Kimlikler URL’ler, loglar, paylaşılan bağlantılar, referrer’lar ve diğer API yanıtları üzerinden sızar. OWASP IDOR cheat sheet’i karmaşık kimlikleri derinlemesine savunma olarak tanımlar ve erişim kontrollerinin yine de şart olduğunu söyler |
| Butonu arayüzde gizlemek | UX’i iyileştirir | API doğrudan çağrılabilir durumdadır |
| API gateway kimlik doğrulaması ve rota politikaları | Fonksiyon düzeyi: rota artı token | Gateway, alan servisine sormadıkça 8812 numaralı faturanın kime ait olduğunu bilmez; sorduğunda ise bu, başka bir yerdeki handler kontrolüdür |
JWT scope’ları veya claim’leri (invoice:read) | Fonksiyon düzeyi (BFLA satırı) | Scope hangi fatura olduğu hakkında hiçbir şey söylemez |
if (params.userId === jwt.sub) | /users/:userId/* rotalarını korur | OWASP’ın API1 sayfası bunun yalnızca küçük bir alt kümeyi kapsadığını ve yeterli bir çözüm olmadığını söyler |
| Hız sınırlama | Toplu çekmeyi yavaşlatır | Sızan tek bir kayıt yine sızıntıdır |
UUID satırı dikkatli okunmayı hak ediyor. OWASP’ın API1 sayfası rastgele kimlikleri önleme maddeleri arasında sayar; IDOR cheat sheet’i ise bunların rolünü açıklar: tahmin etmeyi zorlaştırırlar ve bir kimlik bir kez görüldükten sonra hiçbir işe yaramazlar. Onları nesne kontrolünü tamamlayan sertleştirme olarak ele alın.
Repository Arkasında Kapsam Kısıtlı Sorgular#
İlke şudur: çağrıyı yapanın kapsamı içinde getir; getirip sonra kontrol etme. OWASP IDOR cheat sheet’i, tüm projelerde arama yapan güvensiz bir sorgu ile yalnızca geçerli kullanıcının projelerinde arayan kapsamlı bir sorguyu karşılaştırır ve findByIdAndOwnerId(id, currentUserId) gibi metot biçimleri önerir. SQL’de kapsam hem okumada hem yazmada arama anahtarının parçasıdır:
-- Read: tenant is part of the key, not a post-check
SELECT id, amount, status
FROM invoices
WHERE id = $1 AND tenant_id = $2;
-- Write: the scope is repeated in the write itself
UPDATE invoices SET status = $3
WHERE id = $1 AND tenant_id = $2
RETURNING id; -- zero rows => 404
Bunu bir gelenek olmaktan çıkarıp tek mümkün yol hâline getiren şey repository’dir. Principal olmadan findById(id) yoktur ve Principal doğrulanmış token’dan bir kez üretilir:
import type { PrismaClient } from '@prisma/client';
interface Principal {
tenantId: string;
userId: string;
}
// Same error for "does not exist" and "exists but is not yours".
export class NotFoundError extends Error {}
// The only way handlers reach invoice data.
export function invoiceRepo(db: PrismaClient, p: Principal) {
const scope = { tenantId: p.tenantId };
return {
findById: (id: string) => db.invoice.findFirst({ where: { id, ...scope } }),
list: (opts: { take: number; cursor?: string }) =>
db.invoice.findMany({
where: scope,
take: opts.take,
...(opts.cursor ? { skip: 1, cursor: { id: opts.cursor } } : {}),
orderBy: { id: 'asc' },
}),
async markPaid(id: string) {
const { count } = await db.invoice.updateMany({
where: { id, ...scope },
data: { status: 'PAID' },
});
if (count === 0) throw new NotFoundError();
},
};
}
// Tenant context comes from the verified token, never from a client header.
app.use('*', async (c, next) => {
const claims = c.get('jwtPayload');
c.set('principal', { tenantId: claims.tenant_id, userId: claims.sub });
await next();
});
Değerin çoğunu iki ayrıntı taşıyor. Birincisi, tenant_id zorunlu bir koşuldur ve tenant token’dan ya da oturumdan gelir. İstemcinin gönderdiği bir X-Tenant-Id header’ı, ancak repository’ye ulaşmadan önce kullanıcının üyelikleriyle doğrulanırsa kabul edilebilir. İkincisi, yazma yolu kapsamla birlikte updateMany kullanır ve sayıyı kontrol eder; böylece bir kontrol ile kapsamsız bir update({ where: { id } }) arasında boşluk kalmaz. İzin sistemleri serisindeki 102 numaralı yazı, bu repository’nin dayandığı genel not-found hatasını açıklıyor; numaralandırma argümanını burada tekrarlamayacağım.
Repository’nin bir çite ihtiyacı var, çünkü ham istemci hâlâ import edilebilir. src/repositories/ dışında Prisma istemcisinin import edilmesini yasaklayan bir ESLint no-restricted-imports kuralı, kapsamsız yolun incelemede lint hatası olarak görünmesi için yeterlidir. Buradaki parçalar, eski @prisma/client paketiyle Prisma ORM 7’yi hedefliyor; prisma-client generator kullanılıyorsa kuralın bunun yerine üretilen çıktı yolunu adlandırması gerekir. Arka plan işleri ve yönetim araçları ise kendi denetim izi olan açık bir sistem principal’ına ihtiyaç duyar. Bu bir kez verilen bir tasarım kararıdır ve kapsamsız arka kapıyı adı konmuş, denetlenen bir yola dönüştürür.
Bu yöntemin ödünü şudur: sahipliği ve tenant’ı iyi modeller, başka hiçbir şeyi modellemez. Paylaşım, ekipler ve devredilmiş erişim ya daha zengin kapsam nesneleri ya da aynı repository’yi besleyen bir ilişki motoru ister; varsayılandan sapmayı gerektiren durum budur ve aşağıda ele alınıyor. 104 numaralı yazı, öznitelik tabanlı kurallar için can() ve policy builder yaklaşımını anlatıyor; yeniden kurmak yerine bağlantı veriyorum. Repository’nin karşılığında kazandırdığı ise kapsamlı aramanın sunduğu tek biçim olmasıdır; hiçbir handler’ın hatırlaması gerekmez. Lint kuralı bir inceleme sinyalidir, derleyici sınırı değildir. Ham SQL, ikinci bir istemci ve ayrı servisler onu yine atlar; bir sonraki bölüm bu boşluğu ele alıyor.
Son Savunma Hattı Olarak Row-Level Security#
Repository kendi içinden geçen kodu kapsar. Bir migration script’indeki ham SQL, bir raporlama işi ya da lint kuralını hiç görmemiş birinin yazdığı yeni bir servis kapsam dışında kalır. Paylaşımlı şemalı çok kiracılı SaaS için PostgreSQL üzerinde Row-Level Security, uygulama koşulu unuttuğunda bile veritabanının tenant dışındaki satırları reddetmesini sağlar. 106 numaralı yazı RLS’yi satır düzeyi izolasyon için bir güvenlik ağı olarak tek cümleyle anıyor; bu bölüm o cümlenin arkasındaki operasyonel ayrıntıdır.
-- The app connects as a role that does not own the tables and cannot bypass RLS.
CREATE ROLE app_user LOGIN NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON invoices TO app_user;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- the table owner is subject to policies too
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);
Uygulama tenant’ı transaction başına ayarlar; böylece ayar COMMIT ya da ROLLBACK ile ölür ve havuzlanmış bir bağlantıda sonraki isteğe sızamaz:
import type { Pool, PoolClient } from 'pg';
export async function withTenant<T>(
pool: Pool,
tenantId: string,
fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// is_local = true: the value applies only to the current transaction
await client.query(`SELECT set_config('app.tenant_id', $1, true)`, [tenantId]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
PostgreSQL dokümantasyonundaki dört nokta, bu kurulumda karşılaşılan sürprizlerin çoğunu açıklar. Politikası olmayan bir tabloda RLS etkinleştirildiğinde varsayılan reddetme politikası uygulanır ve hiçbir satır görünmez. Superuser’lar ve BYPASSRLS özniteliğine sahip roller satır güvenliğini her zaman atlar. Tablo sahipleri de FORCE ROW LEVEL SECURITY ayarlanmadıkça politikaları atlar. RLS’nin psql’de çalışıp uygulamada çalışmamasının, ya da tersinin, olağan sebebi budur: migration’lar genellikle tablo sahibi olarak çalışır. Son olarak referans bütünlüğü kontrolleri (birincil anahtar, unique, yabancı anahtar) satır güvenliğini her zaman atlar ve dokümantasyon bunun bir gizli kanal üzerinden varlık sızdırabileceği konusunda uyarır.
Havuzlama uyarısı pratikte önemlidir. PgBouncer’ın özellik matrisi, transaction havuzlama modunda oturum düzeyindeki SET ve RESET komutlarını desteklenmiyor olarak işaretler; dolayısıyla transaction dışında verilen bir SET app.tenant_id, havuzun sonraki isteğe verdiği herhangi bir bağlantıya düşebilir. Bir transaction içindeki transaction’a özel set_config(..., true) güvenli biçimdir; Prisma’nın resmi RLS client extension örneği de bu biçimi kullanır. O örnek açıkça yalnızca gösterim amaçlıdır ve README’si, açık $transaction() çağrılarının extension ile çakışabileceğini belirtir. Yukarıdaki parçanın düz pg kullanmasının nedeni budur.
nullif(..., '') sarmalayıcısı belgelenmemiş bir tuhaflığı ele alır. pgsql-general listesindeki bir konu şunu bildiriyor: özel bir ayara SET LOCAL ile değer atandıktan sonra transaction bittiğinde current_setting boş string döndürür. Aynı adımlar PostgreSQL 17.10 üzerinde çalıştırıldığında hem commit hem rollback sonrasında aynı boş string görülüyor. Tom Lane’in listedeki açıklamasına göre özel bir ayar bir kez değer aldığında var olur ve sıfırlama değeri boş string’dir. Lane bu kalıbı, extension ayarları için tasarlanmış bir mekanizmanın desteklenmeyen bir kullanımı olarak da nitelendiriyor; bu yüzden nullif isteğe bağlı değil, zorunlu kabul edilmelidir. Bu durumda çıplak bir ''::uuid dönüşümü hata fırlatır; erişim yine kapalı kalır ama hata gürültülüdür. nullif ile karşılaştırma NULL üzerinden yapılır ve hiçbir satırla eşleşmez; erişim yine kapalı kalır, bu kez sessizce.
Performans konusunda Supabase’in RLS rehberi her PostgreSQL kurulumu için geçerlidir. Politikanın filtrelediği sütunları indeksleyin. Politika içindeki fonksiyon çağrılarını (select ...) içine alın; böylece satır başına değil, ifade başına bir kez değerlendirilirler. Bu yalnızca fonksiyonun sonucu satıra bağlı değilse geçerlidir. Politikalarda join kullanmayın; önceden hesaplanmış bir küme üzerinde IN ya da ANY tercih edin. Sizin iş yükünüz için doğru sayı, politika etkinleştirilmeden önce ve sonra kendi tablolarınızda ölçülen EXPLAIN ANALYZE çıktısından gelir.
Ödün operasyoneldir: iki veritabanı rolü (tablolara sahip bir migration rolü ve sahip olmayan bir çalışma zamanı rolü), istek başına bir transaction ve “satır yok” ile “veri yok” arasındaki farkın seçilemediği hata ayıklama oturumları. Buradaki kurulum veritabanının önünde bir uygulama sunucusu varsayar; orada RLS son savunma hattı, repository ise birincil kontroldür. İstemcilerin veritabanını doğrudan sorguladığı Supabase benzeri mimarilerde RLS birincil sınırdır ve politikalar yetkilendirme modelinin tamamını taşır.
Bu kurulum unutulan bir koşula karşı korur. SQL injection’a karşı korumaz. Her rol set_config çağırabilir; bu yüzden aynı bağlantıda çalışan enjekte edilmiş SQL, app.tenant_id değerini başka bir kiracıya çevirebilir. Parametreli sorgular ayrı ve zorunlu bir kontrol olarak kalır.
Dört Yöntem Arasında Seçim#
Dört yöntem en çok tek bir eksende ayrışır: biri unuttuğunda ne olur?
| Yöntem | Unutulan endpoint’e dayanır mı? | Liste endpoint’leri | Paylaşım ve ilişkiler | Operasyonel maliyet | Ana hata modu |
|---|---|---|---|---|---|
| Handler kontrolü (getir, sonra karşılaştır) | Hayır | Liste başına elle | Gelişigüzel | Yok | İsteğe bağlı; atlanan tek rota sızdırır |
| Kapsam kısıtlı repository | Evet, ham istemci çitlenmişse | Doğal (WHERE kapsamı) | Sahiplik ve tenant ile sınırlı | Düşük | Kaçış yolları: ham SQL, yönetim script’leri, işler |
| PostgreSQL RLS | Evet, veritabanında | Doğal | Tenant veya sahip sütunu dışında zahmetli | Orta (roller, havuzlama, migration’lar) | Sahip veya superuser atlaması; havuzlamayla oturum SET; yavaş politikalar |
| ReBAC veya policy motoru | Yalnızca bağlandığı yerde | List-objects veya filtre API’si gerekir | Güçlü | Yüksek (ek servis, veri senkronu) | Çift yazma kayması; öğe başına kontrol döngüleri |
Varsayılandan Ne Zaman Sapmalı#
Handler düzeyindeki kontroller, çoğu ekibin birkaç yerde zaten sahip olduğu yöntemdir ve hatırladığınız endpoint için işe yarar:
app.get('/invoices/:id', requirePermission('invoice:read'), async (c) => {
const { tenantId } = c.get('principal');
const invoice = await prisma.invoice.findUnique({ where: { id: c.req.param('id') } });
if (!invoice || invoice.tenantId !== tenantId) {
return c.json({ error: 'Not found' }, 404);
}
return c.json(invoice);
});
Rotalar repository’ye taşınırken geçici bir adım olarak kabul edilebilirler; daha uzun süre için değil. Her yeni rota (dışa aktarma, toplu işlem, kopyalama, webhook tekrarı, GraphQL resolver) karşılaştırmayı hatırlayacak bir insana ihtiyaç duyar ve okumadaki kontrol, başka bir fonksiyonda gelen yazma hakkında hiçbir şey söylemez. OWASP’ın API1 için verdiği üçüncü örnek senaryo, izin kontrolü olmadan belge kimliği alan bir GraphQL silme mutation’ıdır; şekil aynıdır.
İlişki ve policy motorları, varsayılandan sapmayı haklı çıkaran başka bir sorunun çözümüdür: tek bir owner_id ya da tenant_id sütunu yerine paylaşımdan, iç içe klasörlerden veya organizasyon-ekip-proje kalıtımından gelen erişim. Bu sitedeki SpiceDB ve Auth0 FGA karşılaştırması ile Cedar, Rego ve OpenFGA karşılaştırması motorları, çift yazma sorununu ve policy dillerini derinlemesine ele alıyor. O yazıların açık bıraktığı açı liste endpoint’leridir. Nesne başına bir check() çağrısı GET /invoices/:id rotasını korur; GET /invoices ise bir filtre ister ve her aday satır üzerinde kontrol döngüsü yanlış araçtır. OpenFGA’nın ListObjects çağrısı bir kullanıcının ilişkisi olduğu belirli türdeki nesneleri döndürür ve dokümantasyonu, performansın model karmaşıklığına ve tuple sayısına göre ciddi biçimde değiştiği konusunda uyarır. OPA’nın kısmi değerlendirmesi (partial evaluation) bir politikayı veri filtresine dönüşebilen artık koşullara çevirir. Her iki durumda da kimlik kümesinin ya da filtrenin sorguya itilmesi gerekir; dolayısıyla pratikte motor aynı repository’yi besler:
findById: async (id: string) => {
const { allowed } = await fga.check({
user: `user:${p.userId}`,
relation: 'viewer',
object: `invoice:${id}`,
});
return allowed ? db.invoice.findFirst({ where: { id, ...scope } }) : null;
},
Ödün, kontrol başına bir ağ gidiş dönüşü, ilişkiler için ikinci bir doğruluk kaynağı ve SpiceDB yazısının zaten işlediği tutarlılık sorularıdır. İlişki grafı ürünün kendisi olduğunda motora yönelin ve her iki durumda da repository darboğazını koruyun.
CI’da İki Kullanıcılı Test#
OWASP’ın API1 önleme listesi, yetkilendirme mekanizmasını değerlendiren testler yazma ve bu testleri bozan değişiklikleri dağıtmama talimatıyla biter. IDOR cheat sheet’i yöntemi ekler: farklı yetki kapsamlarına sahip birden fazla hesap oluşturun ve kimlik tahmin edilebilir olsun ya da olmasın erişimin reddedildiğini doğrulayın. Aşağıdaki düzenek ikisini de yapar. A kiracısını (bir faturayla) ve B kiracısını (bir token’la) hazırlar, ardından B’ye kimlik taşıyan her rotada A’nın nesnesini istetir. PATCH ve DELETE için durum kodu tek başına az şey kanıtlar, çünkü bir handler satırı değiştirip yine de 404 dönebilir. Bu yüzden her durum, yanıtın A’nın verisini içermediğini ve A’nın satırının hazırlandığı gibi kaldığını da kontrol eder. Rota tablosu tek doğruluk kaynağıdır. İkinci bir test, router’da tablonun kapsamadığı parametreli bir rota olduğunda başarısız olur; Optus hata moduna karşı koruma budur. Bu test kapsamı kanıtlar, tablodaki her beklentinin doğru olduğunu kanıtlamaz; o kısım inceleme işi olarak kalır.
import { describe, expect, it } from 'vitest';
import { app } from '../src/app';
import { fixtures } from './fixtures'; // seeds tenant A invoice, tenant B token; reads A's row directly
const idRoutes = [
{ method: 'GET', pattern: '/invoices/:id' },
{ method: 'PATCH', pattern: '/invoices/:id', body: { status: 'PAID' } },
{ method: 'DELETE', pattern: '/invoices/:id' },
{ method: 'GET', pattern: '/invoices/:id/pdf' },
] as const;
describe.each(idRoutes)('cross-tenant $method $pattern', (route) => {
it('user B cannot reach tenant A object', async () => {
const res = await app.request(route.pattern.replace(':id', fixtures.tenantA.invoiceId), {
method: route.method,
headers: {
Authorization: `Bearer ${fixtures.tenantB.token}`,
'Content-Type': 'application/json',
},
body: 'body' in route ? JSON.stringify(route.body) : undefined,
});
expect(res.status).toBe(404);
// A 404 alone proves little on writes: the response must not echo A's data,
// and A's row must be exactly as seeded.
expect(await res.text()).not.toContain(fixtures.tenantA.invoiceId);
expect(await fixtures.readTenantAInvoice()).toEqual(fixtures.tenantA.invoice);
});
});
it('every parameterised route has a cross-tenant case', () => {
const registered = app.routes
.filter((r) => r.method !== 'ALL' && r.path.includes(':'))
.map((r) => `${r.method} ${r.path}`);
const covered = idRoutes.map((r) => `${r.method} ${r.pattern}`);
expect(new Set(registered)).toEqual(new Set(covered));
});
Hono app.routes dizisini doğrudan sunar; app.use('*', ...) ile kaydedilen her middleware orada ALL metoduyla görünür ve filtre bunları eler. Set karşılaştırması, rota başına middleware olduğunda Hono’nun kaydettiği yinelenen girdileri de yutar. Express’te aynı envanter için router yığınında gezinmek gerekir; düzeneğin geri kalanı app.request yerine Supertest konulması dışında aynıdır.
RLS devredeyken aynı pipeline’a üç kontrol daha girer. Birincisi app_user olarak bağlanıp app.tenant_id değerini B kiracısına ayarlar ve SELECT count(*) FROM invoices WHERE tenant_id = '<A>' sorgusunun sıfır döndürdüğünü doğrular. İkincisi aynı sayımı app.tenant_id hiç ayarlanmamışken ve commit edilmiş bir transaction’dan sonra tekrar çalıştırır; kullandığınız PostgreSQL sürümünde iki durumda da sıfır bekler. Üçüncüsü, tenant_id sütunu olan herhangi bir tabloda bayraklardan biri eksikse başarısız olan bir katalog sorgusudur:
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid
AND a.attname = 'tenant_id' AND NOT a.attisdropped
WHERE n.nspname = 'public'
AND c.relkind = 'r'
AND NOT (c.relrowsecurity AND c.relforcerowsecurity);
-- expected: zero rows
Elle ve dinamik test için Autorize açık kaynak bir Burp Suite eklentisidir. Yüksek yetkili bir kullanıcının trafiğini düşük yetkili ya da kimliği doğrulanmamış bir oturumla yeniden oynatır ve eşleşen yanıtları işaretler; işaretlediklerini yine bir insanın doğrulaması gerekir. Kimlik fuzzing’i aynı fikri elle uygular: A kullanıcısının oturumundan kimlikleri toplayın ve B kullanıcısının isteklerinde path’e, query string’e, gövdeye ve header’lara yerleştirin; OWASP dördünü de BOLA vektörü olarak sayar.
Statik analiz burada yapısal bir nedenle zayıftır. Geleneksel SAST, kirlenmiş veriyi kaynaktan hedefe izler; eksik bir AND tenant_id = $2 koşulunun ne kaynağı ne hedefi vardır. Tarayıcının B kullanıcısının 8812 numaralı faturayı görmemesi gerektiğini söyleyecek iş bağlamı yoktur. Birkaç satıcı artık yapay zekâ destekli analizin bu tür mantık hatalarını bulduğunu iddia ediyor; SonarSource’un Hunter Agent’ı bunlardan biri. Bu iddialar satıcı kaynaklı ve bağımsız olarak kıyaslanmamış; bu araçları ek bir sinyal olarak kullanın ve asıl kontrol olarak iki kullanıcılı testi tutun.
Gediği Yeniden Açan Hatalar#
- Okumayı kapsamlayıp yazmayı kapsamlamamak. Sahiplik karşılaştırması
findUniqueüzerinde koşar, üç satır sonrakiupdate({ where: { id } })ise kapsamsızdır. Kapsamı herUPDATEveDELETEiçinde tekrarlayın ya da yukarıdaki repository’deki gibi sayı kontrolüyleupdateManykullanın. - Tenant kimliğini istemciden almak. Bir
X-Tenant-Idheader’ı ya da gövdedekitenantIdalanı olduğu gibi kabul edilir. Doğrulanmış token’dan ya da oturumdan türetin veya önce kullanıcının üyelikleriyle doğrulayın. - RLS etkin, ama uygulama tablo sahibi olarak bağlanıyor. Politikalar sessizce uygulanmaz.
FORCE ROW LEVEL SECURITYayarlayın ve uygulamayı sahip olmayan ayrı birNOBYPASSRLSrolü altında çalıştırın. - Transaction modlu havuzun arkasında oturum düzeyinde
SET app.tenant_id. Ayar yanlış bağlantıya düşer. Açık bir transaction içindeset_config(..., true)kullanın. current_setting(...)değerininullif(..., '')olmadan dönüştürmek. Boş string durumu, ilk transaction bittikten sonra dönüşüm hatasına dönüşür.- “Senin değil” için 403, “yok” için 404. Fark, saldırgana varlığı doğrular. İkisi için aynı yanıtı döndürün.
- Tek kiracıyla ya da admin hesabıyla test etmek. 106 numaralı yazı test edilmemiş çapraz kiracı senaryolarını tuzak olarak listeliyor; yukarıdaki düzenek çözümüdür.
- Liste endpoint’lerinde öğe başına
check()döngüleri. Bunun yerine sorguya bir filtre itin; bu filtre bir kapsam, bir list-objects sonucu ya da kısmen değerlendirilmiş bir politika olabilir. - GraphQL iç içe resolver’lar. Üst sorgu kapsamlıdır,
node(id:)ya da iç içeinvoice(id:)resolver’ı değildir. Resolver’lar aynı repository’yi çağırır. - BOLA’dan sonra zafer ilan etmek. Kullanıcı nesnenin sahibi olabilir ve yine de
roleatamamalı ya dainternalNotesokumamalıdır. Bu API3 BOPLA’dır ve 105 numaralı yazı bunun için alan düzeyinde izinleri anlatıyor.
Çapraz kiracı CI testiyle birlikte kapsam kısıtlı repository, RBAC’ı çoktan üretime almış, ilişkisel bir veritabanı kullanan ve erişim kuralları sahiplik ya da tenant’a indirgenen her ekip için varsayılan olarak geçerlidir. Paylaşımlı şemalı çok kiracılı PostgreSQL üzerinde FORCE, sahip olmayan rol ve transaction’a özel set_config ile RLS onun altında yer alır. Erişimi paylaşım ve iç içe ilişkiler tanımlıyorsa bir ilişki ya da policy motoruna yönelin; o zaman bile çıktısını aynı repository üzerinden geçirin. Kod tabanı şu anda handler kontrollerine dayanıyorsa ilk adım rota envanteri testidir, çünkü herhangi bir yeniden yapılandırma başlamadan hangi endpoint’lerin kapsamsız olduğunu size söyler.
Kaynaklar#
- OWASP API1:2023 Broken Object Level Authorization (yeni sekmede açılır) - BOLA’nın tanımı, saldırı vektörleri, örnek senaryoları ve önleme listesi; oturum kullanıcı kimliğini path parametresiyle karşılaştırmanın neden yeterli olmadığı dahil
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet (yeni sekmede açılır) - Kapsam kısıtlı arama kalıbı, derinlemesine savunma olarak karmaşık kimlikler ve çok hesaplı test rehberi
- OWASP Top 10:2025 A01 Broken Access Control (yeni sekmede açılır) - Web uygulamalarında broken access control yaygınlık verileri ve CWE-639 eşlemesi
- PostgreSQL Documentation: Row Security Policies (yeni sekmede açılır) - RLS semantiği için resmi kaynak: varsayılan reddetme, BYPASSRLS, tablo sahibi atlaması, FORCE ROW LEVEL SECURITY ve referans bütünlüğü gizli kanalları
- PostgreSQL Documentation: System Administration Functions (yeni sekmede açılır) - Transaction’a özel kapsamlı
set_configvemissing_okparametrelicurrent_settingiçin referans - pgsql-general: SET LOCAL variable doesn’t become undefined after commit (yeni sekmede açılır) - Özel ayarların transaction bittikten sonra boş string’e döndüğünü gösteren posta listesi konusu
- pgsql-general: Tom Lane on RESET, NULL and empty-string settings (yeni sekmede açılır) - Özel bir ayarın bir kez değer aldıktan sonra sıfırlama değerinin neden boş string olduğunu ve bu kalıbın neden desteklenmediğini açıklayan yanıt
- PgBouncer Features (yeni sekmede açılır) - Transaction havuzlama modunda oturum düzeyindeki SET ve RESET komutlarının desteklenmediğini gösteren özellik matrisi
- Prisma Client Extensions: Row-Level Security example (yeni sekmede açılır) - Prisma’dan transaction’a özel bir PostgreSQL değişkeni ayarlamanın resmi örneği ve belgelenmiş uyarıları
- Supabase: RLS Performance and Best Practices (yeni sekmede açılır) - Politika sütunlarını indeksleme ve RLS politikalarında satır başına fonksiyon değerlendirmesinden kaçınma rehberi
- OpenFGA: Perform a List Objects Request (yeni sekmede açılır) - İlişki tabanlı bir motorun bir kullanıcının hangi nesneleri görebileceğini nasıl yanıtladığı ve liste endpoint’leri için performans uyarıları
- Open Policy Agent: Evaluating a Data Filter Policy (yeni sekmede açılır) - Yetkilendirme politikalarını kısmi değerlendirmeyle sorgu filtrelerine dönüştürme
- PortSwigger/autorize on GitHub (yeni sekmede açılır) - İstekleri düşük yetkili bir oturumla yeniden oynatarak yetkilendirme açıklarını tespit eden Burp Suite eklentisi
- KrebsOnSecurity: USPS Site Exposed Data on 60 Million Users (yeni sekmede açılır) - Giriş yapan her kullanıcının başkalarının hesap verilerini sorgulamasına izin veren kimlik doğrulamalı bir API
- Pen Test Partners: Tour de Peloton, Exposed User Data (yeni sekmede açılır) - Kimlik doğrulama eklemenin kullanıcı verisini giriş yapan her hesaba açık bıraktığını gösteren birincil açıklama
- SecurityWeek: First American Financial Exposed Millions of Sensitive Documents (yeni sekmede açılır) - Bir bağlantı parametresi değişikliğinin başka müşterilerin belgelerini açığa çıkarması
- CSO Online: Optus breach occurred due to a coding error, alleges ACMA (yeni sekmede açılır) - Düzenleyici kurumun, bir alan adında düzeltilip API alan adında atlanan erişim kontrolü hatasına dair anlatımı
- SonarSource: SonarQube Hunter Agent detects logical flaws (yeni sekmede açılır) - Geleneksel SAST kör noktasına karşı sav olarak, bir satıcının yapay zekâ destekli broken access control tespiti iddiası
İlgili yazılar
AWS Cognito ve Verified Permissions ile SaaS yetkilendirmeyi Cedar politikaları, çok kiracılı desenler, JWT akışı ve maliyet analiziyle TypeScript'te kurun.
authorization · aws · authentication +4
AWS Verified Permissions, SpiceDB, OpenFGA, Cerbos ve OPA gibi harici yetkilendirme platformlarını mimari, maliyet ve karar çerçevesi açısından tarafsızca inceliyoruz.
authorization · security · architecture +4
Cedar, Rego, OpenFGA DSL ve Cerbos YAML/CEL politika dillerini söz dizimi, performans, biçimsel doğrulama, araçlar ve TypeScript entegrasyonu açısından karşılaştırıyoruz.
authorization · security · architecture +2
SpiceDB ve Auth0 FGA (OpenFGA) karşılaştırması: şema, tutarlılık modelleri, dağıtım ve ölçeklenebilirlik açısından farklı tercihler yapan iki Zanzibar tabanlı sistem.
authorization · security · architecture +3
Authentication ve authorization farkı, yaygın izin sistemi tuzakları, fail-closed prensibi ve her izin sisteminin karşılaması gereken hedefler.
typescript · nextjs · authorization +2