SQL Server'da Güvenlik Modeli: Login, User, Schema, Role, Least Privilege ve Row-Level Security
Uygulama kullanıcısını db_owner yapmak, bir injection açığında tablo silme yetkisi vermektir; doğrusu runtime ve migration kimliklerini ayırmak, yetkiyi schema ve rol üzerinden vermek ve kritik satırları Row-Level Security ile korumaktır. SQL Server 2025'in PBKDF2 parola hash'i, DENY önceliği, SESSION_CONTEXT'li RLS ve predicate şeklinin performansa etkisi, DDM'nin neden güvenlik sınırı olmadığı ve TrustServerCertificate ile TDS 8.0 strict bağlantı denemesi.
Sürüm notu: Bu yazıdaki SQL (Structured Query Language) örnekleri SQL Server 2025 (17.0.5035.5, Cumulative Update 10) üzerinde, compatibility level 170 ile çalıştırılmıştır; bağlantı denemesi .NET 10 ve Microsoft.Data.SqlClient 7.1.1 ile yapılmıştır.
Önce Tahmin Et: Uygulama Kullanıcısı Neler Yapabilir?
Bir ekip, "yetki sorunlarıyla uğraşmayalım" diye Bookstore API'sinin (Application Programming Interface) veritabanı kullanıcısını db_owner rolüne koymuş. Uygulama yalnız sipariş okuyup yazıyor; zararı ne olabilir? O kullanıcının kimliğine bürünüp bir transaction içinde deniyoruz:
CREATE USER lazy_app WITHOUT LOGIN;
ALTER ROLE db_owner ADD MEMBER lazy_app;
GO
EXECUTE AS USER = 'lazy_app';
BEGIN TRANSACTION;
DROP TABLE dbo.Shipments;
SELECT OBJECT_ID('dbo.Shipments') AS shipments_object_id;
ROLLBACK;
REVERT;
SELECT COUNT(*) AS shipments_after_rollback FROM dbo.Shipments;
shipments_object_id
-------------------
NULL
shipments_after_rollback
------------------------
954755
Tablo silindi; 954.755 kargo kaydını yalnız rollback geri getirdi. Uygulamada bir SQL injection açığı ya da sızan bir connection string, saldırgana tam olarak bu yetkiyi verir. OWASP (Open Worldwide Application Security Project) serisinin injection yazısında açığın kendisini görmüştük; bu yazı, açık bulunduğunda zararı sınırlayan katmanı anlatıyor: login, user, schema, role, least privilege, Row-Level Security ve bağlantı güvenliği.
Login, User ve Schema
SQL Server'da kimlik iki katmanlıdır. Login server seviyesindedir ve "kim bağlanıyor" sorusunu cevaplar: SQL authentication'da kullanıcı adı ve parola, Windows ya da Microsoft Entra ID authentication'da dış bir kimlik. User veritabanı seviyesindedir ve "bu veritabanında ne yapabilir" sorusunu cevaplar. Bir login'in her veritabanında ayrı bir user'a eşlenmesi gerekir. Contained database user ise login'siz, doğrudan veritabanında kimlik doğrulayan bir kullanıcıdır; veritabanını başka sunucuya taşımayı kolaylaştırır, ama server seviyesinde contained database authentication ayarının açılmasını ister. Azure SQL Database'de Microsoft Entra ID ile contained user önerilen yoldur.
Laboratuvarda iki login açıyoruz: biri uygulamanın çalışma zamanı için, biri migration'lar için. Parolaları script'e yazmıyor, sqlcmd değişkeniyle çalıştırma anında veriyoruz:
CREATE LOGIN sqls_17_api WITH PASSWORD = '$(ApiPassword)', CHECK_POLICY = ON;
CREATE LOGIN sqls_17_migrator WITH PASSWORD = '$(MigratorPassword)', CHECK_POLICY = ON;
GO
SELECT name, CONVERT(varchar(6), password_hash, 1) AS hash_prefix, DATALENGTH(password_hash) AS hash_bytes
FROM sys.sql_logins
WHERE name LIKE 'sqls[_]17[_]%';
name|hash_prefix|hash_bytes
----|-----------|----------
sqls_17_api|0x0300|70
sqls_17_migrator|0x0300|70
Hash'in ilk byte'ı algoritma versiyonunu gösteriyor: 0x03. SQL Server 2025, SQL login parolalarını RFC (Request for Comments) 2898'deki PBKDF2 (Password-Based Key Derivation Function 2) ile, Microsoft'un açıklamasına göre Secure Hash Algorithm 512 (SHA-512) üzerinde 100.000 iterasyonla saklar. SQL Server 2022 ve öncesinde versiyon 0x02'dir: tuzlanmış parolanın tek bir SHA-512 hash'i. Fark, parola hash'leri ele geçirildiğinde kaba kuvvet denemesinin maliyetinde. Yükseltilmiş bir sunucuda hangi login'in hangi formatta saklandığını, aynı sorguyla ilk byte'a bakarak görebilirsin.
Schema, yetki vermenin en kullanışlı birimidir. Tabloları tek tek değil, schema'ları yetkilendirirsin: dbo iş tabloları, 14. yazıdaki api uygulamaya açılan procedure'ler, birazdan oluşturacağımız security güvenlik fonksiyonları. Yeni bir tablo eklendiğinde yetkisi schema'dan gelir.
Least Privilege: Runtime ve Migration Kullanıcısını Ayırmak
Uygulamanın çalışırken ihtiyacı okumak ve yazmaktır; tablo oluşturmak ya da silmek değil. Şema değişikliği yalnız deploy sırasında, ayrı bir kimlikle yapılır:
CREATE USER sqls_17_api FOR LOGIN sqls_17_api;
CREATE USER sqls_17_migrator FOR LOGIN sqls_17_migrator;
GO
CREATE ROLE app_runtime;
GRANT SELECT, INSERT, UPDATE ON SCHEMA::dbo TO app_runtime;
DENY SELECT ON dbo.Customers (Email) TO app_runtime;
ALTER ROLE app_runtime ADD MEMBER sqls_17_api;
ALTER ROLE db_ddladmin ADD MEMBER sqls_17_migrator;
ALTER ROLE db_datareader ADD MEMBER sqls_17_migrator;
ALTER ROLE db_datawriter ADD MEMBER sqls_17_migrator;
GO
EXECUTE AS LOGIN = 'sqls_17_api';
SELECT TOP (1) CustomerId, FirstName, City FROM dbo.Customers ORDER BY CustomerId;
SELECT TOP (1) * FROM dbo.Customers;
DROP TABLE dbo.Shipments;
REVERT;
GO
EXECUTE AS LOGIN = 'sqls_17_migrator';
ALTER TABLE dbo.Customers ADD Phone varchar(20) NULL;
SELECT TOP (1) CustomerId, Email FROM dbo.Customers ORDER BY CustomerId;
REVERT;
CustomerId|FirstName|City
----------|---------|----
1|Grace|Istanbul
Msg 230, Level 14, State 1, Server b8be943222fa, Line 3
The SELECT permission was denied on the column 'Email' of the object 'Customers', database 'sqls_17', schema 'dbo'.
Msg 3701, Level 14, State 20, Server b8be943222fa, Line 4
Cannot drop the table 'Shipments', because it does not exist or you do not have permission.
CustomerId|Email
----------|-----
1|customer1@example.com
Runtime kullanıcısı izin verilen kolonları okuyabiliyor, ama SELECT * e-posta kolonuna takıldı ve DROP TABLE reddedildi. Hata 3701'in mesajı bilerek belirsiz: "yok ya da yetkin yok". Yetkisiz birine bir nesnenin var olup olmadığını söylemez. Migration kullanıcısı şemayı değiştirebiliyor ve veriyi okuyabiliyor.
Yetki üç komutla yönetilir. GRANT izin verir, DENY açıkça yasaklar, REVOKE daha önce verilmiş bir GRANT ya da DENY'ı kaldırır. Kural: DENY her zaman kazanır. Kullanıcı bir rolden schema seviyesinde SELECT alsa bile, başka bir rolden ya da kendisine verilmiş kolon seviyesindeki DENY geçerlidir. İstisna: db_owner üyeleri ve sysadmin'ler veritabanında DENY'a takılmaz; açılıştaki örneğin gerçek tehlikesi bu. Ve yetkiyi kişilere değil rollere ver: app_runtime, reporting, support. Yeni bir servis eklendiğinde yalnız rol üyeliği değişir.
Entity Framework Core (EF Core) kullanıyorsan iki connection string tut: uygulama sqls_17_api ile çalışır, Continuous Integration/Continuous Delivery (CI/CD) hattındaki migration bundle'ı ya da script'i sqls_17_migrator ile. Uygulamanın başlangıcında Database.Migrate() çağırmak, runtime kullanıcısına DDL (Data Definition Language) yetkisi vermeyi gerektirdiği için bu ayrımı bozar.
Row-Level Security ile Müşteri İzolasyonu
Müşteri portalında her müşteri yalnız kendi siparişlerini görmeli. Bunu uygulamada her sorguya WHERE CustomerId = @currentCustomer ekleyerek ya da EF Core serisinin global query filter yazısındaki gibi modelde bir filtreyle yaparsın. Unutulan tek bir sorgu IDOR'a (Insecure Direct Object Reference) dönüşür. RLS (Row-Level Security), aynı kuralı veritabanında, ikinci bir savunma hattı olarak uygular:
CREATE SCHEMA security AUTHORIZATION dbo;
GO
CREATE FUNCTION security.fn_CustomerFilter (@CustomerId int)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
SELECT 1 AS allowed
WHERE @CustomerId = CAST(SESSION_CONTEXT(N'CustomerId') AS int)
OR IS_ROLEMEMBER(N'reporting') = 1;
GO
CREATE SECURITY POLICY security.CustomerIsolation
ADD FILTER PREDICATE security.fn_CustomerFilter(CustomerId) ON dbo.Orders,
ADD BLOCK PREDICATE security.fn_CustomerFilter(CustomerId) ON dbo.Orders AFTER INSERT
WITH (STATE = ON);
Predicate bir inline TVF (Table-Valued Function); SQL Server onu tabloya erişen her sorguya otomatik ekler. Hangi müşterinin bağlı olduğunu uygulama, connection açıldıktan hemen sonra SESSION_CONTEXT'e yazar; örneğin kimlik doğrulanmış kullanıcının JWT'sinden (JSON Web Token) okunan müşteri kimliğini:
EXECUTE AS LOGIN = 'sqls_17_api';
SELECT COUNT(*) AS visible_orders FROM dbo.Orders;
EXEC sys.sp_set_session_context @key = N'CustomerId', @value = 2, @read_only = 1;
SELECT COUNT(*) AS visible_orders FROM dbo.Orders;
SELECT COUNT(*) AS customer_1_orders FROM dbo.Orders WHERE CustomerId = 1;
INSERT dbo.Orders (OrderNumber, CustomerId, Status, CreatedAt, TotalAmount, Currency)
VALUES ('BK-X1', 1, 'Pending', SYSUTCDATETIME(), 0, 'TRY');
REVERT;
visible_orders
--------------
0
visible_orders
--------------
17359
customer_1_orders
-----------------
0
Msg 33504, Level 16, State 1, Server b8be943222fa, Line 6
The attempted operation failed because the target object 'sqls_17.dbo.Orders' has a block predicate that conflicts with this operation. If the operation is performed on a view, the block predicate might be enforced on the underlying table. Modify the operation to target only the rows that are allowed by the block predicate.
Context yokken sıfır satır: yanlış yapılandırma veri sızdırmaz, boş sonuç döndürür (fail closed). Context 2 iken yalnız 2 numaralı müşterinin 17.359 siparişi görünüyor; WHERE CustomerId = 1 açıkça istense bile. Block predicate başka bir müşteri adına sipariş eklemeyi engelliyor. @read_only = 1, aynı connection üzerinde değerin sonradan değiştirilmesini önler. Connection pool kullanıyorsan context'in her istekte, connection açıldığı anda yazıldığından emin ol.
Predicate'in şekli performansı doğrudan etkiler. Raporlama rolü için eklediğimiz OR IS_ROLEMEMBER(...), planı seek'ten scan'e çevirdi:
|--Index Scan(OBJECT:([sqls_17].[dbo].[Orders].[IX_Orders_CustomerId]), WHERE:([sqls_17].[dbo].[Orders].[CustomerId]=CONVERT(int,session_context(N'CustomerId'),0) OR is_rolemember(N'reporting')=(1)))
Table 'Orders'. Scan count 1, logical reads 1748, ...
|--Index Seek(OBJECT:([sqls_17].[dbo].[Orders].[IX_Orders_CustomerId]), SEEK:([sqls_17].[dbo].[Orders].[CustomerId]=CONVERT(int,session_context(N'CustomerId'),0)) ORDERED FORWARD)
Table 'Orders'. Scan count 1, logical reads 35, ...
OR'suz versiyonda aynı sayım 1.748 yerine 35 logical read. Raporlama gibi geniş erişim gereken iş yüklerini predicate'e OR ile eklemek yerine ayrı bir kullanıcı ve ayrı bir yoldan (örneğin rapor replikası ya da imzalı bir procedure) çözmek daha iyi. Predicate function'ı, her sorguya gömülen bir inline TVF olduğu için 15. yazıdaki kurallar aynen geçerli.
Dynamic Data Masking Bir Güvenlik Sınırı Değildir
DDM (Dynamic Data Masking), yetkisiz kullanıcıya kolonun maskelenmiş hâlini gösterir. Destek ekibinin müşteri e-postalarını görmemesi için:
ALTER TABLE dbo.Customers ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');
CREATE USER support_agent WITHOUT LOGIN;
GRANT SELECT ON dbo.Customers TO support_agent;
GO
EXECUTE AS USER = 'support_agent';
SELECT CustomerId, FirstName, Email FROM dbo.Customers WHERE CustomerId = 42;
SELECT CustomerId, Email FROM dbo.Customers WHERE Email = 'customer42@example.com';
SELECT COUNT(*) AS starts_with_customer4 FROM dbo.Customers WHERE Email LIKE 'customer4%';
REVERT;
CustomerId|FirstName|Email
----------|---------|-----
42|Linus|cXXX@XXXX.com
CustomerId|Email
----------|-----
42|cXXX@XXXX.com
starts_with_customer4
---------------------
11111
Ekranda maske var, ama WHERE gerçek değer üzerinde çalışıyor. Destek kullanıcısı bir e-posta adresinin hangi müşteriye ait olduğunu doğruladı ve LIKE ile karakter karakter tahmin yürütebiliyor. DDM, uygulama ekranlarında yanlışlıkla gösterilmeyi önleyen bir kolaylıktır; doğrudan sorgu yazabilen birine karşı koruma değildir. Gerçek gizlilik için kolon yetkisi (DENY), RLS ya da şifreleme gerekir.
Şifreleme: TDE, Always Encrypted ve Bağlantı
Üç ayrı tehdit, üç ayrı araç. TDE (Transparent Data Encryption), veri ve log dosyalarını ve backup'ları diskte şifreler; çalınan bir disk ya da backup dosyası okunamaz, ama veritabanına bağlanan herkes veriyi düz görür. Always Encrypted ise belirli kolonları istemcide şifreler; anahtar uygulamadadır ve SQL Server, hatta sysadmin bile, düz değeri görmez. Bedeli sorgu yeteneğidir: eşitlik araması deterministic şifrelemeyle yapılabilir, ama aralık sorguları ve LIKE için secure enclave gerekir.
Üçüncü tehdit ağ. Bağlantı TLS (Transport Layer Security) ile şifrelenmeli ve istemci, sunucunun sertifikasını doğrulamalı. Laboratuvar container'ına üç farklı ayarla bağlandık:
string[] variants =
[
"Encrypt=Mandatory;TrustServerCertificate=True",
"Encrypt=Mandatory;TrustServerCertificate=False",
"Encrypt=Strict;TrustServerCertificate=True",
];
Encrypt=Mandatory;TrustServerCertificate=True: connected, encrypt_option=TRUE, protocol=TSQL
Encrypt=Mandatory;TrustServerCertificate=False: SqlException -2146893019: A connection was successfully established with the server, but then an error occurred during the pre-login handshake. (provider: TCP Provider, error: 35 - An internal exception was caught)
inner: AuthenticationException: Certificate failed chain validation. Error(s): 'The certificate was not trusted., [Status: UntrustedRoot]
Encrypt=Strict;TrustServerCertificate=True: SqlException 0: The client was unable to establish a connection because of an error during connection initialization process before login. ... (provider: SSL Provider, error: 31 - Encryption(ssl/tls) handshake failed)
İlk satır en tehlikelisi: bağlantı şifreli (encrypt_option=TRUE), ama TrustServerCertificate=True sertifikayı hiç doğrulamadığı için araya giren biri kendi sertifikasını sunabilir ve trafik onun üzerinden akar. İkinci satır doğru davranış: container kendi imzaladığı bir sertifika kullanıyor, istemci ona güvenmiyor ve bağlantıyı reddediyor. Production'da çözüm, sunucuya güvenilen bir CA'dan (Certificate Authority) sertifika koymak, TrustServerCertificate'ı değil. Üçüncü satır Tabular Data Stream (TDS) 8.0'ın strict modu: TLS, TDS mesajlarından önce kurulur ve Microsoft dokümanına göre bu modda TrustServerCertificate yok sayılır. Sertifika doğrulanamadığı için el sıkışma tamamlanmadı; strict, "sertifikayı kontrol etmeyi unut" seçeneğini ortadan kaldırıyor. Bu yazının ve serinin diğer örneklerindeki TrustServerCertificate=True yalnız yerel container için; production connection string'inde yeri yok.
SQL Server 2019/2022'de Farkı
Login/user ayrımı, rol modeli, RLS (2016'dan beri) ve DDM 2019 ve 2022'de aynı çalışır. Farklar: SQL login parolalarının PBKDF2 ile saklanması 2025'e özgü; 2019 ve 2022'de hash versiyonu 0x02 ve tek iterasyonlu SHA-512. TDS 8.0 ve Encrypt=Strict SQL Server 2022 ile geldi; 2019'a strict modda bağlanamazsın, Mandatory ve doğrulanan bir sertifika kullanırsın. Always Encrypted'ın secure enclave desteği Windows'ta 2019 ile geldi. SQL Server 2022 ayrıca ledger tablolarını getirdi: değişikliklerin sonradan kurcalanmadığını kriptografik olarak kanıtlayan bir audit seçeneği.
Üretimde Dikkat
Yetkileri düzenli olarak dök ve gözden geçir. sys.database_role_members ve sys.database_permissions'tan kimin hangi role ve yetkiye sahip olduğunu çıkaran bir sorgu, her sürümde değişiklik farkını görmeni sağlar. db_owner üyeliği bir alarm olmalı.
Secret'ları kodda tutma. Connection string'deki parola bir secret store'dan (Azure Key Vault, Kubernetes secret, ortam değişkeni) gelir; mümkünse parolasız kimlik, yani Microsoft Entra ID managed identity kullan. Web Güvenliği serisinin parola saklama ve secret yönetimi yazısındaki kurallar burada da geçerli.
RLS'yi test et. Her tablo için "context yokken 0 satır", "başka müşterinin satırı görünmüyor" ve "başka müşteri adına yazılamıyor" testleri yaz. RLS, yalnız predicate'in eklendiği tablolarda çalışır; yeni bir tablo eklendiğinde policy'ye eklemeyi unutmak, sessiz bir açık demektir.
Audit ekle. SQL Server Audit, başarısız login'leri, yetki değişikliklerini ve hassas tablolara erişimi kaydeder. 18. yazıdaki Extended Events teşhis içindir; audit ise uyumluluk ve olay incelemesi içindir.
Sık Düşülen Tuzaklar
Uygulamayı sa ya da db_owner ile çalıştırmak. Bu yazının açılışı; geliştirme ortamında "kolaylık" diye başlar ve production'a taşınır.
Herkesi aynı kullanıcıyla bağlamak. Uygulama, rapor aracı ve arka plan işleri aynı SQL kullanıcısını paylaşırsa, ne yetkiyi daraltabilir ne de blocking'de program_name dışında kimin kim olduğunu ayırt edebilirsin.
SESSION_CONTEXT'i istemciden gelen bir değerle doldurmak. RLS'nin güvenliği, context'e yazılan değerin güvenilir olmasına bağlıdır. Müşteri kimliği kimlik doğrulanmış token'dan gelmeli, query string'den değil; ve @read_only = 1 ile kilitlenmeli.
DDM'yi şifreleme sanmak. Maske, sorgu yazabilen birine karşı koruma değildir; yukarıdaki LIKE deneyi bunu gösteriyor.
Küçük Bir Deneme
★☆☆ sqls_17_api'ye api schema'sında yalnız EXECUTE yetkisi veren bir rol kur ve dbo üzerindeki GRANT'i kaldır (REVOKE). 14. yazıdaki procedure'ler hâlâ çalışıyor mu?
★★☆ OrderItems tablosuna da müşteri izolasyonu ekle. Tabloda CustomerId yok; predicate function'ı Orders ile join ederek yaz ve planda nasıl göründüğünü incele.
★★★ Bir ASP.NET Core uygulamasında, her istek için connection açıldıktan sonra JWT'deki müşteri kimliğini sp_set_session_context ile yazan bir EF Core DbConnectionInterceptor yaz. Connection pool'dan gelen bir connection'da bir önceki isteğin context'inin kalmadığını test et.
Güvenlik modeli, kimin neye erişebileceğini belirliyor. Ama bir gün yine biri gelip "veritabanı yavaş" diyecek. Bir sonraki yazıda bu şikâyete ölçerek yaklaşan bir teşhis yöntemi kuruyoruz: wait statistics, DMV'ler (Dynamic Management View) ve Extended Events.