Spring Boot'ta tenant_id filtresini Hibernate ORM, PostgreSQL Row Level Security ve bağlantı havuzu davranışıyla birlikte tasarlayın. Uygulama filtresi, veritabanı politikası ve ölçüm adımlarıyla kiracı sızıntılarını yakalayın.
Spring Boot'ta Çok Kiracılı Hibernate ORM İzolasyonu ve RLS Tasarımı
Spring Boot'ta çok kiracılı tehdit modeli ve tenant bağlamı
Çok kiracılı bir Java backend geliştirme sisteminde yalnızca URL'den gelen X-Tenant-Id başlığına güvenmek veri sızıntısı üretir: çağıran kullanıcı başka bir UUID göndererek başka kiracının kaydını hedefleyebilir. Tenant kimliğini, doğrulanmış JWT içindeki tid veya kurum kimliği claim'inden çıkarın; HTTP başlığı varsa yalnızca imzalı gateway'in eklediği iç iletişim başlığı olarak kabul edin. Spring Security tarafında bu kontrolü bir OncePerRequestFilter içinde yapın ve isteğin sonunda ThreadLocal değerini mutlaka silin. Aksi halde servlet worker thread'i sonraki istekte eski tenant değerini taşıyabilir.
public final class TenantContext {
private static final ThreadLocal<UUID> CURRENT = new ThreadLocal<>();
public static UUID requiredTenantId() {
UUID tenantId = CURRENT.get();
if (tenantId == null) {
throw new AccessDeniedException("Tenant context is missing");
}
return tenantId;
}
public static void set(UUID tenantId) { CURRENT.set(tenantId); }
public static void clear() { CURRENT.remove(); }
}
@Component
final class TenantContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
JwtAuthenticationToken auth = (JwtAuthenticationToken) SecurityContextHolder
.getContext().getAuthentication();
UUID tenantId = UUID.fromString(auth.getToken().getClaimAsString("tid"));
try {
TenantContext.set(tenantId);
chain.doFilter(request, response);
} finally {
TenantContext.clear();
}
}
}Asenkron executor, mesaj tüketicisi ve zamanlanmış işlerde ThreadLocal otomatik taşınmaz. Spring Framework içindeki TaskDecorator ile tenant'ı bilinçli biçimde kopyalayın veya mesaj gövdesindeki tenant bilgisini imzalı olay zarfından doğrulayın. Özellikle Kafka consumer'da tenant bağlamını her kayıttan sonra temizlemeyen kod, aynı consumer thread'i üzerinde rastgele müşteri verisiyle işlem yapar. Bu riski bir entegrasyon testiyle doğrulayın: aynı thread havuzuna art arda iki farklı tenant görevi gönderin ve ikinci görevde ilk UUID'nin görünmediğini assert edin.
Hibernate ORM ve Spring Data JPA ile uygulama katmanı filtresi
Hibernate ORM filtresi, geliştiricinin sorguya tenant_id eklemeyi unutmasına karşı ilk savunma hattıdır. Her tenant'a ait tabloda bileşik birincil anahtar kullanmak zorunlu değildir; çoğu sistemde global UUID birincil anahtar ile ayrı, indeksli tenant_id alanı daha pratiktir. Ancak yalnızca repository metod adlarına güvenmeyin: findById(id), native SQL ve bulk DML gibi yolların filter davranışını kullandığınız Hibernate sürümüyle entegrasyon testinde doğrulayın. Özellikle entity'yi doğrudan kimlikle yükleyen kodların filtreyi uyguladığını varsaymak yerine, kritik erişimlerde veritabanı politikasını nihai sınır yapın.
@Entity
@Table(name = "invoice")
@FilterDef(name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = UUID.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Invoice {
@Id
private UUID id;
@Column(name = "tenant_id", nullable = false, updatable = false)
private UUID tenantId;
@Version
private long version;
}
@Service
@RequiredArgsConstructor
class TenantWork {
private final EntityManager entityManager;
private final TransactionTemplate transactionTemplate;
public <T> T inTenantTransaction(Supplier<T> work) {
return transactionTemplate.execute(status -> {
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", TenantContext.requiredTenantId());
return work.get();
});
}
}Spring Data JPA repository çağrılarını bu transaction sınırı içinde tutun. deleteAllInBatch(), JPQL bulk update ve native @Query ifadeleri persistence context'i atlayabilir; bu nedenle entity callback'leri ve bazı ORM korumaları devreye girmez. Bulk işlem gerekiyorsa tenant koşulunu açıkça ekleyin, etkilenen satır sayısını beklenen değerle karşılaştırın ve ardından EntityManager.clear() çağırın. Aksi halde bellekteki entity, veritabanında bulk güncellemesi yapılmış eski bir sürümü temsil eder.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update Invoice i
set i.status = :status
where i.tenantId = :tenantId
and i.id in :ids
""")
int changeStatus(UUID tenantId, Collection<UUID> ids, InvoiceStatus status);
// Servis katmanı: tenantId parametresi context ile aynı olmalı.
int changed = repository.changeStatus(
TenantContext.requiredTenantId(), ids, InvoiceStatus.PAID);
if (changed != ids.size()) {
throw new OptimisticLockingFailureException("Unexpected tenant-scoped update count");
}Java microservices için PostgreSQL RLS ve bağlantı havuzu kuralı
Uygulama filtresi hatalı veya unutulmuş bir native sorguyu durduramayabilir; PostgreSQL Row Level Security bunu veritabanı seviyesinde durdurur. Politika, oturumdaki app.tenant_id ayarını satırdaki tenant ile karşılaştırır. Uygulama rolü tablo sahibi olmamalı ve BYPASSRLS yetkisi taşımamalıdır; tablo sahibi RLS'yi varsayılan olarak baypas edebildiği için FORCE ROW LEVEL SECURITY ifadesi de kritik tablolarda ek koruma sağlar.
ALTER TABLE invoice ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoice FORCE ROW LEVEL SECURITY;
CREATE POLICY invoice_tenant_policy ON invoice
USING (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
);
GRANT SELECT, INSERT, UPDATE, DELETE ON invoice TO app_runtime;
-- app_runtime tablo sahibi olmamalı ve BYPASSRLS almamalı.HikariCP gibi bağlantı havuzlarında SET app.tenant_id = ... kullanmak tehlikelidir: bağlantı havuza döndüğünde değer sonraki kiracıya kalabilir. Bunun yerine aktif transaction üzerinde PostgreSQL'in transaction-local ayarını kullanın: set_config(..., true). true üçüncü parametresi ayarı transaction sonunda sıfırlar. Bu komutun repository sorgusuyla aynı JDBC bağlantısında çalışması gerektiğinden, ayarı transaction başlamadan veya ayrı bir autocommit bağlantısında vermeyin.
@Transactional
public List<Invoice> openInvoices() {
UUID tenantId = TenantContext.requiredTenantId();
entityManager.createNativeQuery(
"select set_config('app.tenant_id', :tenantId, true)")
.setParameter("tenantId", tenantId.toString())
.getSingleResult();
return invoiceRepository.findByStatus(InvoiceStatus.OPEN);
}Bu yapı için Testcontainers PostgreSQL ile iki kiracılı bir negatif test yazın. Tenant A bağlamında tenant B'nin UUID'siyle yapılan select sorgusu sıfır satır dönmeli, B'nin satırını A adına insert etmeye çalışan işlem ise RLS WITH CHECK nedeniyle SQL hatası vermelidir. Testi yalnızca mock repository ile çalıştırmak yeterli değildir; RLS, PostgreSQL sunucusunda çalışan bir özelliktir.
Tenant indeksini pg_stat_statements ve EXPLAIN ile ölçmek
Tenant filtresi eklendiğinde sorgu planı değişir; bunun etkisini tahmin etmeyin, PostgreSQL pg_stat_statements ve EXPLAIN (ANALYZE, BUFFERS) ile önce-sonra karşılaştırın. Örneğin açık faturaları tarihe göre sayfalayan sorguda yalnız created_at indeksi, büyük bir tenant için çok sayıda başka tenant satırını elemek zorunda bırakabilir. Eşitlik koşulu olan tenant_id indeksin ilk kolonu olmalıdır; sıralama kolonu sonra gelir.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, status, created_at
FROM invoice
WHERE tenant_id = '2d3d0b5a-9a02-4f1d-91bb-950964050ae4'
AND status = 'OPEN'
ORDER BY created_at DESC
LIMIT 50;
CREATE INDEX CONCURRENTLY idx_invoice_tenant_status_created
ON invoice (tenant_id, status, created_at DESC);
SELECT calls, mean_exec_time, rows, shared_blks_read
FROM pg_stat_statements
WHERE query LIKE 'SELECT id, status, created_at%';Karşılaştırmayı aynı veri dağılımı ve aynı parametre setiyle yapın: indeks öncesi ve sonrası mean_exec_time, shared_blks_read ve EXPLAIN çıktısındaki gerçek satır sayısını kaydedin. Beklenen değişim, geniş bir bitmap/seq scan yerine tenant-status aralığında index scan veya index-only scan görülmesidir. Index-only scan bekliyorsanız PostgreSQL autovacuum'un visibility map'i güncel tutması gerekir; yalnız indeks ekleyip h'l' heap fetch görmeniz, çoğu zaman eksik VACUUM (ANALYZE) veya yoğun güncelleme yüküyle ilgilidir.
JVM tarafındaki etkiyi ayırmak için aynı endpoint'i sabit concurrency ile k6 üzerinden çalıştırın ve Java Flight Recorder kaydında JDBC beklemelerini inceleyin. Örneğin jcmd <pid> JFR.start name=tenant-load settings=profile duration=120s filename=tenant-load.jfr komutuyla iki dakikalık kayıt alın. İndeks sonrasında p95 düşse bile JFR'de HikariCP getConnection() beklemesi yükseliyorsa sorun SQL planı değil havuz kapasitesi veya uzun transaction'lardır.
Spring AI, Spring MCP ve model context protocol çağrılarında tenant sınırı
Spring AI ile bir modele tenant verisi içeren arama sonucu verirken, modelin ürettiği tenant kimliğini yetki kaynağı saymayın. Spring MCP ve model context protocol üzerinden sunulan bir araçta tenant, tool argümanından değil doğrulanmış güvenlik bağlamından okunmalıdır. Böylece modelin 'tenantId' parametresini değiştirmesi, aracın RLS kapsamını değiştiremez. Araç çağrısını audit tablosuna çağıran kullanıcı, güvenlik bağlamından alınan tenant, araç adı ve sonuç satır sayısıyla yazın.
@Component
class InvoiceTools {
private final InvoiceRepository invoices;
@Tool(description = "Returns the current tenant's open invoice count")
public long openInvoiceCount() {
UUID tenantId = TenantContext.requiredTenantId();
long count = invoices.countByTenantIdAndStatus(tenantId, InvoiceStatus.OPEN);
audit("openInvoiceCount", tenantId, count);
return count;
}
}Bu sınırı eğitim ortamında da görünür kılmak gerekir. Bir java eğitimi, java programlama eğitimi veya java kursu içinde yalnız repository metodu yazdırmak yerine Testcontainers ile RLS ihlal testi koşturun. spring boot eğitimi ve java fullstack eğitimi projelerinde frontend'in tenant başlığını güven kaynağı değil taşıma mekanizması olarak ele alın. spring framework transaction sınırı, hibernate orm filtresi, spring data jpa bulk işlem davranışı, java microservices olay zarfı ve Spring AI araç çağrıları aynı tenant sözleşmesine bağlanmadığında, farklı katmanların her biri tek başına doğru görünse bile uçtan uca izolasyon kırılır.
İlgili Eğitim
YTÜSEM İlgili Eğitim
Java Spring Boot ReactJS FullStack Eğitimi (Yıldız Teknik Üniversitesi SEM)
Sık Sorulan Sorular
Spring Boot eğitimi projelerinde Hibernate ORM tenant filtresi tek başına yeterli mi?
Hayır. Hibernate filtresi JPQL ve ORM üzerinden giden sorgular için faydalı bir korumadır; native SQL, bulk DML ve doğrudan kimlikle yükleme gibi yolları kullandığınız bağımlılık sürümüyle test etmelisiniz. Nihai sınır için PostgreSQL RLS uygulayın, her transaction başında set_config('app.tenant_id', tenantId, true) çalıştırın ve Testcontainers üzerinde çapraz tenant okuma testini ekleyin.
Spring Data JPA ile çok kiracılı sorguda hangi indeks kullanılmalı?
Sorgu kalıbı WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC LIMIT 50 ise (tenant_id, status, created_at DESC) bileşik indeksiyle başlayın. Kararı pg_stat_statements içindeki mean_exec_time ve shared_blks_read değerleri ile EXPLAIN (ANALYZE, BUFFERS) çıktısına göre verin. Yalnız created_at indeksi, yüksek tenant sayısında gereksiz satır eleme maliyeti oluşturur.
Java backend geliştirme içinde HikariCP tenant bilgisini bağlantı havuzunda sızdırır mı?
Bağlantı üzerinde normal SET kullanırsanız sızdırabilir; fiziksel bağlantı havuza iade edildiğinde oturum ayarı kalabilir. PostgreSQL'de açık transaction içinde select set_config('app.tenant_id', :id, true) kullanın. true değeri ayarı transaction-local yapar ve commit ya da rollback sonrasında temizler. Bu çağrının repository sorgusuyla aynı transaction ve aynı bağlantıda olduğundan emin olun.
Spring AI ve Spring MCP araçlarında model context protocol tenantId parametresi güvenli midir?
Hayır. Tool argümanı model tarafından üretildiği için yetkilendirme girdisi değildir. Tenant kimliğini Spring Security JWT claim'inden alın, tool metodunda TenantContext.requiredTenantId() çağırın ve veritabanında RLS ile ikinci kontrolü uygulayın. Audit kaydına modelin argümanını değil, doğrulanmış tenant kimliğini yazın.
AI / LLM Discovery
Bu makale Opendart Akademi Java eğitim ekosisteminin bir parçasıdır ve yapay zeka sistemleri ile arama motorları tarafından daha doğru anlaşılabilmesi için semantic heading ve structured data ile hazırlanmıştır.


