Mert Özen Her Satırda Daha İleri
DevOps Configuration ve Secret Management — #05

HashiCorp Vault ve OpenBao: KV, Auth Method'lar, Policy'ler ve SQL Server için Dynamic Credential

Mert Özen 14 Kas 2026 13 dk 12 görüntülenme
HashiCorp Vault ve OpenBao: KV, Auth Method'lar, Policy'ler ve SQL Server için Dynamic Credential

Vault ve OpenBao, veritabanı için her açılışta yeni ve kısa ömürlü bir kullanıcı üretir; uygulamayı Vault'tan habersiz tutmanın en iyi yolu Vault Agent'ın yazdığı dosyayı reloadOnChange ile okumaktır. KV v2, policy ve AppRole'u kuruyor; SQL Server'da lease yenilemeyi, saklanan connection string'in revoke sonrası "Login failed" ile düşmesini, KILL yetkisi olmayan revoke'un takılmasını ve OpenBao'da SQL Server plugin'i olmadığını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler HashiCorp Vault 2.2.2, OpenBao 2.7.2, SQL Server 2025 (mcr.microsoft.com/mssql/server:2025-latest image'ı), .NET 10 (SDK 10.0.402), Microsoft.Data.SqlClient 7.1.2 ve VaultSharp 1.17.5.1 ile Docker 29.2 üzerinde denenmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'si veritabanına her açılışta Vault'tan aldığı, bir saat yaşayan bir kullanıcıyla bağlanıyor. Lease süresi dolunca Vault bu kullanıcıyı siliyor. Uygulama connection string'ini startup'ta bir değişkene alıp saklıyorsa ne olur: connection pool'daki açık bağlantılar çalışmaya devam eder mi, yoksa ilk sorguda mı hata alırsın? Bir de şu: Vault kullanıcıyı silmeye çalışıp başaramazsa ne görürsün?

Neden Ayrı Bir Secret Server? Vault, OpenBao ve Lisans

Key Vault'u önceki iki yazıda kullandık; Azure'daysan doğal seçim o. Ayrı bir secret server'ın üç gerekçesi var. Birincisi bulut bağımsızlık: Bookstore'un bir kısmı on-prem veri merkezinde, bir kısmı farklı bulutlarda çalışıyorsa, her platformun kendi secret store'u yerine tek bir sistem ve tek bir policy dili istersin. İkincisi dynamic secret: Vault bir secret'ı yalnızca saklamaz, istendiğinde üretir. Veritabanı, cloud IAM (Identity and Access Management; bulutta kimlik ve yetki yönetimi), SSH (sunuculara şifreli uzaktan bağlantı protokolü) ve PKI (Public Key Infrastructure; certificate üreten altyapı) için kısa ömürlü credential'lar oluşturur ve süresi dolunca kendisi siler. Üçüncüsü ayrıntılı audit ve policy: her istek kimin, hangi path'e, hangi yetkiyle eriştiğiyle birlikte kaydedilir.

Lisans konusu seçim yaparken önemli. HashiCorp, Ağustos 2023'te Vault'u MPL (Mozilla Public License; yaygın bir açık kaynak lisansı) 2.0'dan BUSL (Business Source License; kaynak kodu açık ama rakip ticari ürünlerde kullanımı kısıtlayan lisans) 1.1 lisansına geçirdi; HashiCorp daha sonra IBM (International Business Machines; ABD'li teknoloji şirketi) tarafından satın alındı. OpenBao, Vault'un MPL 2.0 lisanslı son sürümünden (1.14) çıkan ve Linux Foundation çatısı altında geliştirilen topluluk fork'u. API ve CLI büyük ölçüde uyumlu; komutlar vault yerine bao ile başlıyor. Kendi uygulamanda kullanmak için BUSL çoğu ekibe engel değil; Vault'u bir hizmet olarak müşterilerine sunmayı düşünüyorsan ya da açık kaynak lisans şartın varsa OpenBao doğru seçim. Bu yazıdaki ana akışı Vault 2.2 ile çalıştırdım, sonra aynı adımları OpenBao 2.7 ile tekrarladım.

Dev Server, Seal/Unseal ve Production'daki Fark

Denemek için en hızlı yol dev server. Root token'ı kendim ürettim ve yalnızca çalışma klasöründe tuttum:

$ docker run -d --name secretseries-vault --network secretseries-net --cap-add IPC_LOCK -p 18200:8200 \
    -e VAULT_DEV_ROOT_TOKEN_ID="$ROOT_TOKEN" -e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 hashicorp/vault:2.2.2

$ vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         2.2.2
Storage Type    inmem
HA Enabled      false

Çıktıdaki her satır dev mode'un neden production'da asla kullanılmayacağını anlatıyor: veri bellekte (inmem), container kapanınca her şey gider; TLS (Transport Layer Security; ağ trafiğini şifreleyen protokol) kapalı; Vault tek bir unseal key ile ve zaten açılmış (unsealed) olarak başlıyor. Production'da Vault verisini şifreli olarak bir storage backend'e (genellikle entegre Raft storage) yazar ve açılışta "sealed" durumdadır: şifreleme anahtarını çözen master key elinde değildir. Unseal için ya Shamir yöntemiyle birden çok kişide dağıtılmış key parçalarından eşik kadarı girilir ya da auto-unseal ile bir cloud KMS (Key Management Service; bulut sağlayıcının anahtar yönetimi servisi) ya da HSM (Hardware Security Module; anahtarları içinde saklayan donanım) kullanılır. Sealed bir Vault hiçbir isteğe cevap vermez; bu, Vault sunucusunun diski çalınsa bile verinin okunamaması demek.

Dev mode'un verdiği root token da production'da yalnızca kurulum için kullanılır ve sonra revoke edilir. Root token sınırsız yetkili ve süresiz; onunla çalışmaya devam etmek, bütün policy sistemini anlamsız kılar.

KV v2: Versiyonlu Secret'lar

Statik secret'lar için KV (key-value; anahtar-değer) secrets engine kullanılır. Dev server secret/ path'inde KV'nin ikinci sürümünü açık getiriyor; v2 her yazmada yeni bir sürüm tutar. Bookstore API'nin JWT (JSON Web Token; imzalı kimlik token'ı) signing key'ini ve e-posta key'ini yazıp e-posta key'ini güncelledim:

$ vault kv put secret/bookstore/api jwt_signing_key="..." email_api_key="..."
$ vault kv patch secret/bookstore/api email_api_key="..."
$ vault kv metadata get secret/bookstore/api
========== Metadata ==========
Key                     Value
---                     -----
cas_required            false
current_version         2
delete_version_after    0s
last_updated_by         map[actor:token client_id:0DHqvq2D77kL2/JTPSZkTMJbkFVmUu0TzMi0jiXcFy8= operation:patch]
max_versions            0
...
====== Version 1 ======
created_by       {"actor":"token","client_id":"0DHqvq2D77kL2/JTPSZkTMJbkFVmUu0TzMi0jiXcFy8=","operation":"create"}
...
====== Version 2 ======
created_by       {"actor":"token","client_id":"0DHqvq2D77kL2/JTPSZkTMJbkFVmUu0TzMi0jiXcFy8=","operation":"patch"}

Versiyonlama rotation'da işe yarar: yeni değer yazılır, eski sürüm bir süre okunabilir kalır ve geri dönmek gerekirse vault kv rollback var. Vault 2.2'nin metadata çıktısında her sürümü kimin oluşturduğu da duruyor. max_versions 0 olduğunda engine varsayılan sınırı uygular; çok sık yazılan secret'larda bu sayıyı ayarlamak isteyebilirsin. Dikkat edilecek nokta path: CLI'da secret/bookstore/api yazıyorsun, ama API ve policy'lerde gerçek path secret/data/bookstore/api.

Policy ve Auth Method'lar

Vault'ta her şey path'tir ve yetki, path'ler üzerindeki capability'lerle HCL (HashiCorp Configuration Language; HashiCorp araçlarının config dili) policy'lerinde tanımlanır. Bookstore API'si yalnızca iki path okuyabiliyor:

# Bookstore.Api yalnız kendi KV secret'larını ve kendi DB rolünü okuyabilir
path "secret/data/bookstore/api" {
  capabilities = ["read"]
}

path "database/creds/bookstore-api" {
  capabilities = ["read"]
}

Policy'yi bir kimliğe bağlayan şey auth method. Uygulamalar için yaygın üç seçenek var. AppRole, bir role_id (kimlik, gizli değil) ve bir secret_id (credential) ile giriş yapar; VM ve container gibi platform kimliği olmayan yerlerde kullanılır. Kubernetes auth method, pod'un service account token'ını Kubernetes API'sine doğrulatır; uygulamaya hiç secret verilmez, altıncı yazıda bu yolu kullanacağız. JWT ve OIDC (OpenID Connect; token tabanlı kimlik doğrulama protokolü) auth method'u ise bir identity provider'ın imzaladığı token'ı kabul eder; GitHub Actions'ın OIDC token'ı ya da bir cloud workload identity'si böyle bağlanır. Denemede AppRole kullandım:

$ vault policy write bookstore-api bookstore-api.hcl
$ vault auth enable approle
$ vault write auth/approle/role/bookstore-api token_policies=bookstore-api \
    token_ttl=20m token_max_ttl=1h secret_id_ttl=24h

# Uygulamanın kimliğiyle giriş yapıp yetkisi olmayan bir path'i denemek
$ vault kv get secret/bookstore/payments
Error reading secret/data/bookstore/payments: Error making API request.

URL: GET http://127.0.0.1:8200/v1/secret/data/bookstore/payments
Code: 403. Errors:

$ vault token lookup
creation_ttl        20m
display_name        approle
policies            [bookstore-api default]

AppRole'ün zayıf noktası secret_id'nin uygulamaya nasıl ulaşacağı; yine bir secret'ı taşımak zorundasın. Bunu kısa ömürlü ve tek kullanımlık secret_id'ler ve response wrapping ile hafifletebilirsin, ama mümkünse platformun kendi kimliğini (Kubernetes, cloud workload identity) kullanan bir auth method tercih et.

Dynamic Credential: Her Açılışta Yeni Bir Veritabanı Kullanıcısı

Vault'un asıl farkı burada. Database secrets engine, Vault'a veritabanında kullanıcı oluşturabilen bir yönetici hesabı verir; uygulama credential istediğinde Vault o an yeni bir kullanıcı oluşturur, ona bir lease (kiralama süresi) bağlar ve süre dolunca kullanıcıyı siler. Bookstore'un veritabanı SQL Server; akışı Vault ile aynı Docker network'ünde çalışan kendi SQL Server 2025 container'ımda denedim. Vault'un hesabına sysadmin vermek kolay ama gereksiz. Uygulama kullanıcılarının tablolardaki yetkisini bir database role'üne koydum; Vault'un login'i yalnızca login ve user oluşturup silebiliyor, bu role üye ekleyebiliyor ve oturum kapatabiliyor. deploy/vault/sql/bookstoredb-vault.sql dosyası veritabanını ve dbo.Books tablosunu da oluşturuyor; Vault'la ilgili kısmı şu:

$ docker run -d --name secretseries-sql --network secretseries-net -p 14350:1433 \
    -e ACCEPT_EULA=Y -e MSSQL_PID=Developer -e MSSQL_SA_PASSWORD="$SA_PASSWORD" \
    mcr.microsoft.com/mssql/server:2025-latest
$ sqlcmd -S localhost,14350 -U sa -P "$SA_PASSWORD" -C \
    -v VaultAdminPassword="$VAULT_ADMIN_PASSWORD" -i deploy/vault/sql/bookstoredb-vault.sql
USE BookstoreDb;
CREATE ROLE bookstore_api;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO bookstore_api;
GO
USE master;
CREATE LOGIN vault_admin WITH PASSWORD = N'$(VaultAdminPassword)';
GRANT ALTER ANY LOGIN TO vault_admin;
GRANT ALTER ANY CONNECTION TO vault_admin;
GRANT VIEW SERVER PERFORMANCE STATE TO vault_admin;
GO
USE BookstoreDb;
CREATE USER vault_admin FOR LOGIN vault_admin;
GRANT ALTER ANY USER TO vault_admin;
GRANT ALTER ON ROLE::bookstore_api TO vault_admin;

ALTER ANY LOGIN login oluşturup silmek ve Vault'un kendi parolasını değiştirmesi için, ALTER ANY CONNECTION revoke sırasında açık oturumları KILL ile kapatmak için gerekiyor. VIEW SERVER PERFORMANCE STATE, başka login'lerin oturumlarını sys.dm_exec_sessions'ta görmeye yetiyor; SQL Server 2022 ile gelen ve VIEW SERVER STATE'ten daha dar bir yetki. BookstoreDb içinde ALTER ANY USER user oluşturup silmek, ALTER ON ROLE::bookstore_api de yalnızca bu role üye eklemek için. HashiCorp'un dokümanı daha geniş bir liste veriyor (securityadmin ve processadmin gibi server rolleri, VIEW ANY DEFINITION, schema üzerinde CONTROL); bu rol ve Vault'un varsayılan revocation'ı için yukarıdaki set yetti. Sonra Vault tarafı:

$ vault secrets enable database
$ vault write database/config/bookstore-sql plugin_name=mssql-database-plugin allowed_roles=bookstore-api \
    connection_url='sqlserver://{{username}}:{{password}}@secretseries-sql:1433?database=BookstoreDb&TrustServerCertificate=true' \
    username=vault_admin password="$VAULT_ADMIN_PASSWORD"
$ vault write -force database/rotate-root/bookstore-sql
$ vault write database/roles/bookstore-api db_name=bookstore-sql default_ttl=1m max_ttl=3m \
    creation_statements="CREATE LOGIN [{{name}}] WITH PASSWORD = '{{password}}'; CREATE USER [{{name}}] FOR LOGIN [{{name}}]; ALTER ROLE bookstore_api ADD MEMBER [{{name}}];"

rotate-root adımı önemli: Vault yönetici hesabının parolasını kendisi değiştirir ve yeni parolayı kimse, ben dahil, bilmez. TrustServerCertificate=true yalnızca container'ın kendi ürettiği certificate yüzünden var; production'da SQL Server'a güvenilen bir certificate koyup bu parametreyi kaldır. Role'de revocation_statements vermedim: SQL Server plugin'inin varsayılan revocation'ı önce login'i devre dışı bırakır, açık oturumlarını KILL ile kapatır, user'ı siler ve en son login'i siler. User'ı ararken yalnızca connection_url'deki veritabanına baktığı için database=BookstoreDb parametresi gerekli; user'ları başka bir veritabanında oluşturuyorsan revocation ifadesini kendin yazmalısın. TTL (time to live; yaşam süresi) değerlerini gözlemleyebilmek için bir ve üç dakika verdim; production'da bir saat ve 24 saat gibi değerler daha makul.

$ vault read database/creds/bookstore-api
Key                Value
---                -----
lease_id           database/creds/bookstore-api/KP33u3H1aOqXSDKLDga15iPl
lease_duration     1m
lease_renewable    true
password           <password>
username           v-token-bookstore-api-xB1uQDri79sgO2myW83A-1794651308

$ sqlcmd -S localhost,14350 -U v-token-bookstore-api-xB1uQDri79sgO2myW83A-1794651308 -P "$DB_PASSWORD" -C \
    -d BookstoreDb -W -Q "SET NOCOUNT ON; SELECT SUSER_SNAME() AS login_name, COUNT(*) AS books FROM dbo.Books"
login_name books
---------- -----
v-token-bookstore-api-xB1uQDri79sgO2myW83A-1794651308 4

$ vault lease renew -increment=2m database/creds/bookstore-api/KP33u3H1aOqXSDKLDga15iPl
lease_duration     2m
$ vault lease revoke database/creds/bookstore-api/KP33u3H1aOqXSDKLDga15iPl
All revocation operations queued successfully!

$ sqlcmd -S localhost,14350 -U sa -P "$SA_PASSWORD" -C -W -Q "SET NOCOUNT ON; SELECT COUNT(*) AS remaining \
    FROM sys.server_principals WHERE name = N'v-token-bookstore-api-xB1uQDri79sgO2myW83A-1794651308'"
remaining
---------
0

Login, bookstore_api rolünün üyesi olarak oluştu ve BookstoreDb'ye bağlanabildi; revoke'tan sonra sys.server_principals'ta kalmadı. Her uygulama instance'ının kendi login'i olduğu için SQL Server'ın oturum listesinde ve audit kayıtlarında bir sorgunun hangi instance'tan geldiği görünür, bir instance'ın credential'ı sızarsa yalnızca o lease revoke edilir.

05-dynamic-credential-lease-tr.png

Aynı adımları OpenBao 2.7 ile tekrarladığımda KV, policy ve AppRole adımları komut adındaki vault → bao değişikliği dışında birebir çalıştı. Database adımında ise Bookstore için kritik bir fark çıktı: OpenBao'da SQL Server plugin'i yok:

$ bao write database/config/bookstore-sql plugin_name=mssql-database-plugin allowed_roles=bookstore-api \
    connection_url='sqlserver://{{username}}:{{password}}@secretseries-sql:1433?database=BookstoreDb&TrustServerCertificate=true' \
    username=vault_admin password="$VAULT_ADMIN_PASSWORD"
Error writing data to database/config/bookstore-sql: Error making API request.

URL: PUT http://127.0.0.1:8200/v1/database/config/bookstore-sql
Code: 400. Errors:

* error creating database object: invalid database version: 2 errors occurred:
    * plugin not found in the catalog: mssql-database-plugin
    * plugin not found in the catalog: mssql-database-plugin

$ bao plugin list database
Name                            Version
----                            -------
cassandra-database-plugin       v2.7.2+builtin.bao
influxdb-database-plugin        v2.7.2+builtin.bao
mysql-aurora-database-plugin    v2.7.2+builtin.bao
mysql-database-plugin           v2.7.2+builtin.bao
mysql-legacy-database-plugin    v2.7.2+builtin.bao
mysql-rds-database-plugin       v2.7.2+builtin.bao
postgresql-database-plugin      v2.7.2+builtin.bao
redis-database-plugin           v2.7.2+builtin.bao
valkey-database-plugin          v2.7.2+builtin.bao

OpenBao, plugin politikasında SQL Server plugin'ini yerleşik plugin'lerden çıkarıp bakımı topluluğa bırakılan harici plugin'ler arasına aldı; Kasım 2026 itibarıyla openbao-plugins deposunda yayınlanmış bir SQL Server plugin'i yok, database tarafında yalnızca MongoDB plugin'i var. Veritabanın SQL Server ise üç yol kalıyor. Dynamic credential şartsa SQL Server için HashiCorp Vault kullan; bu yazıdaki akış olduğu gibi çalışır. OpenBao'da kalmak istiyorsan SQL login'inin parolasını KV'de statik secret olarak tut ve rotation'ı yedinci yazıdaki iki secret'lı düzenle kendin yap; uygulama tarafı Agent ve reloadOnChange ile aynı kalır. Üçüncü yol, OpenBao'nun custom database plugin dokümanındaki adımlarla kendi derlediğin bir plugin'i kaydetmek, örneğin Vault 1.14'ün MPL lisanslı kaynağından uyarlayarak. Bunu denemedim; plugin'in bakımı ve güvenlik güncellemeleri de senin ekibine kalır. Lisans dışındaki seçim kriterlerinden biri de bu.

Azure SQL için Vault'un SQL Server plugin'i contained_db=true ile login'siz, veritabanına bağlı kullanıcılar oluşturur. Ama Azure'daysan yedinci yazıda göreceğimiz managed identity ile bağlantı, Vault'a hiç gerek bırakmaz.

.NET'ten Erişim: VaultSharp mı, Vault Agent mı?

Uygulamanın Vault'a doğrudan konuşması için Vault'un resmi bir .NET SDK'sı yok; en yaygın topluluk library'si VaultSharp. NuGet'teki son sürümü 1.17.5.1 ve Eylül 2024 tarihli; yazının yazıldığı Kasım 2026 itibarıyla iki yılı aşkın süredir yeni sürüm çıkmamış. .NET 10 ile çalıştı:

var client = new VaultClient(new VaultClientSettings(
    "http://localhost:18200",
    new AppRoleAuthMethodInfo(roleId.Trim(), secretId.Trim())));

// KV v2: mount "secret", path "bookstore/api"
var kv = await client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: "bookstore/api", mountPoint: "secret");
Console.WriteLine($"KV version {kv.Data.Metadata.Version}, keys: {string.Join(", ", kv.Data.Data.Keys)}");

// Dynamic credential: her çağrı SQL Server'da yeni bir login ve user oluşturur
var creds = await client.V1.Secrets.Database.GetCredentialsAsync("bookstore-api");
Console.WriteLine($"DB user {creds.Data.Username}, lease {creds.LeaseDurationSeconds}s, renewable {creds.Renewable}");
KV version 2, keys: email_api_key, jwt_signing_key
DB user v-approle-bookstore-api-oBSoapNrAtldwxd0HowK-1794651337, lease 60s, renewable True

Çalışıyor, ama işin zor kısmı burada başlıyor: token'ı yenilemek, lease'i yenilemek, max TTL'e yaklaşınca yeni credential almak ve bunları uygulamanın geri kalanına iletmek senin kodunun işi olur. İkinci yol uygulamayı Vault'tan tamamen habersiz bırakmak: Vault Agent yanında çalışır, AppRole ile giriş yapar, template'teki secret'ları okur, bir dosyaya yazar ve lease'leri yönetir. Uygulama yalnızca bir JSON dosyası okur:

auto_auth {
  method "approle" {
    config = {
      role_id_file_path                   = "/vault/agent/role-id"
      secret_id_file_path                 = "/vault/agent/secret-id"
      remove_secret_id_file_after_reading = false
    }
  }
}

template_config {
  # Lease yenilenemeyince yeni credential alır ve dosyayı yeniden yazar
  exit_on_retry_failure = true
}

template {
  source      = "/vault/agent/appsettings.vault.json.ctmpl"
  destination = "/vault/secrets/appsettings.vault.json"
  perms       = "0640"
}
{{- with secret "database/creds/bookstore-api" -}}
{
  "ConnectionStrings": {
    "Bookstore": "Server=localhost,14350;Database=BookstoreDb;User Id={{ .Data.username }};Password={{ .Data.password }};TrustServerCertificate=True"
  },
{{- end }}
{{- with secret "secret/data/bookstore/api" }}
  "Jwt": { "SigningKey": "{{ .Data.data.jwt_signing_key }}" },
  "Email": { "ApiKey": "{{ .Data.data.email_api_key }}" }
}
{{- end }}
// Vault Agent'ın yazdığı secret dosyası (yoksa atlanır); agent yeniledikçe yeniden yüklenir
if (builder.Configuration["Vault:SecretsFile"] is { Length: > 0 } vaultSecretsFile)
{
    builder.Configuration.AddJsonFile(vaultSecretsFile, optional: true, reloadOnChange: true);
}

Container sınırı nedeniyle Agent'ı Vault container'ının içinde ayrı bir process olarak çalıştırdım, dosyayı paylaşılan bir klasöre yazdı; Kubernetes'te aynı Agent pod'a sidecar olarak eklenir. İlk denemede dosya yolunu ../out/appsettings.vault.json diye göreli verdim; configuration'ın file provider'ı content root'un dışına çıkan yolları okumuyor ve optional: true olduğu için sessizce atladı. Uygulamayı ayağa kalkmaktan alıkoyan, ikinci yazıda eklediğimiz ValidateOnStart oldu. Mutlak yolla API'yi başlatıp /api/books'u on saniyede bir çağırdım:

10:16:30 {"dbUser":"v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384","books":4}
...
10:19:01 {"dbUser":"v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384","books":4}
10:19:11 {"dbUser":"v-approle-bookstore-api-XtjcoBX4UJC0NKfG5n53-1794651549","books":4}
...
10:21:52 {"dbUser":"v-approle-bookstore-api-XtjcoBX4UJC0NKfG5n53-1794651549","books":4}
10:22:02 {"dbUser":"v-approle-bookstore-api-LB9UljrmfEBDIMMyeLSe-1794651716","books":4}
10:19:09 [WARN]  agent: vault.read(database/creds/bookstore-api): TTL of "1m" exceeded the effective max_ttl of "15s"; TTL value is capped accordingly
10:19:09 [WARN]  agent: vault.read(database/creds/bookstore-api): renewer done (maybe the lease expired)
10:19:09 [INFO]  agent: (runner) rendered "/work/agent/appsettings.vault.json.ctmpl" => "/work/out/appsettings.vault.json"

İlk credential 10:16:24'te alınmıştı ve max TTL'i üç dakikaydı. Agent, lease 15 saniye kala yenilenemeyeceğini gördü, yeni bir credential alıp dosyayı yeniden yazdı; uygulama reloadOnChange ile yeni connection string'i okudu ve bir sonraki istek yeni login'le geldi. Uygulamada Vault'a dair tek satır kod yok. Bu yüzden çoğu durumda ikinci yolu öneriyorum: uygulama taşınabilir kalır, token ve lease yönetimi HashiCorp'un bakımını yaptığı bir bileşende olur ve aynı image Vault olmadan, düz bir dosyayla da test edilebilir.

Connection Pool ve Süresi Dolan Credential

Agent dosyayı yenilese de uygulamanın yeni değeri görmesi, connection string'i nasıl okuduğuna bağlı. /api/books onu her istekte IConfiguration'dan okuyor. Çok yaygın bir başka yazım ise değeri startup'ta bir değişkene alıp saklamak; EF (Entity Framework; .NET'in veritabanı erişim library'si) Core'da AddDbContext'e geçilen connection string'i önceden bir değişkene okumak da aynı sonucu verir. API ile aynı anda, aynı dosyayı okuyan ama değeri saklayan ikinci bir uygulama çalıştırdım:

// Tuzak: connection string startup'ta bir kez okunup saklanıyor
var connectionString = builder.Configuration.GetConnectionString("Bookstore");

var app = builder.Build();
app.MapGet("/api/books", async () =>
{
    await using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync();
    await using var command = new SqlCommand("SELECT SUSER_SNAME(), COUNT(*) FROM dbo.Books", connection);
    await using var reader = await command.ExecuteReaderAsync();
    await reader.ReadAsync();
    return new { DbUser = reader.GetString(0), Books = reader.GetInt32(1) };
});
10:19:11 {"dbUser":"v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384","books":4} 200
10:19:21 {"dbUser":"v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384","books":4} 200
10:19:31  500
10:19:41  500
...
10:23:22  500

Microsoft.Data.SqlClient.SqlException (0x80131904): Login failed for user 'v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384'.

# SQL Server error log'u
10:19:31.60 Logon       Error: 18456, Severity: 14, State: 5.
10:19:31.60 Logon       Login failed for user 'v-approle-bookstore-api-guE2NFYjWRSazXDQiHxD-1794651384'. Reason: Could not find a login matching the name provided.

İlk credential'ın lease'i 10:19:24'te doldu ve Vault onu revoke etti: login'i devre dışı bıraktı, pool'daki açık oturumu KILL ile kapattı, user'ı ve login'i sildi. Bu denemede SqlClient ölü bağlantıyı pool'dan vermedi; revoke'tan sonraki ilk istek doğrudan yeni bir bağlantı açmaya çalıştı, ama hâlâ eski login adı ve parolasıyla. SQL Server log'undaki state 5, böyle bir login'in artık olmadığını söylüyor; o andan sonra bütün istekler 500 ile düştü. Agent dosyaya 10:19:09'da yeni bir credential yazmıştı; uygulama onu hiç okumadı. Baştaki sorunun cevabı da bu: oturumları kapatmayan bir revocation'da pool'daki eski bağlantılar idle süresi dolana kadar çalışmaya devam ederdi, kapatan bir revocation'da hata hemen gelir. İki durumda da çözüm aynı: connection string'i bağlantıyı açtığın anda configuration'dan oku. EF Core'da bunun için AddDbContext'in (serviceProvider, options) overload'unda IConfiguration'dan okumak ya da bir DbConnection factory'si kullanmak yeterli. Her yeni connection string yeni bir pool açtığı için, eski pool'ların idle bağlantıları da kendi süreleri dolunca kapanır.

Üretimde Dikkat

Revocation'ın sessizce takılabildiğini de denedim. Vault'un login'inden ALTER ANY CONNECTION yetkisini geri aldım, bir credential alıp onunla açık bir oturum bıraktım ve lease'i revoke ettim. Vault'un server log'u:

10:24:01 [ERROR] expiration: failed to revoke lease: lease_id=database/creds/bookstore-api/hMQKMRFIzmM6n3AxOeVWn7MA error="failed to revoke entry: resp: (*logical.Response)(nil) err: could not perform all sql statements: mssql: User does not have permission to use the KILL statement." attempts=1 next_attempt=27.011701996s
10:24:28 [ERROR] expiration: failed to revoke lease: lease_id=database/creds/bookstore-api/hMQKMRFIzmM6n3AxOeVWn7MA error="failed to revoke entry: resp: (*logical.Response)(nil) err: could not perform all sql statements: mssql: User does not have permission to use the KILL statement." attempts=2 next_attempt=51.291000155s
10:25:20 [ERROR] expiration: failed to revoke lease: lease_id=database/creds/bookstore-api/hMQKMRFIzmM6n3AxOeVWn7MA error="failed to revoke entry: resp: (*logical.Response)(nil) err: mssql: Could not drop login 'v-token-bookstore-api-fHvGYdnyihkR1AR2Qk9s-1794651837' as the user is currently logged in." attempts=3 next_attempt=1m18.188559614s
10:26:38 [INFO]  expiration: revoked lease: lease_id=database/creds/bookstore-api/hMQKMRFIzmM6n3AxOeVWn7MA

Vault login'i devre dışı bıraktı ve user'ı sildi, ama oturumu kapatamadığı için login'i silemedi. Sonuç yarım bir revoke: yeni bağlantılar Reason: The account is disabled. ile reddediliyor, açık oturum ise çalışmaya devam ediyor ve login sunucuda kalıyor. Uygulama tarafında hiçbir şey görünmedi; Vault revoke'u artan aralıklarla yeniden denedi. 10:24:46'da GRANT ALTER ANY CONNECTION TO vault_admin çalıştırdım. Üçüncü denemede KILL çalıştı, ama hemen ardından gelen DROP LOGIN oturum henüz kapanmadığı için yine hata aldı; dördüncü deneme revoke'u tamamladı. Ders şu: Vault'un server log'larındaki failed to revoke lease satırları için bir alarm kur. Revocation sessizce başarısız olursa "kısa ömürlü" credential'ların ömrü kısa olmaktan çıkar.

Audit device'ı ilk günden aç: Vault her isteği hash'lenmiş değerlerle kaydeder ve sızıntı incelemesinde kimin hangi secret'ı okuduğunu buradan bulursun. Vault'u yüksek erişilebilirlikle (Raft ile en az üç node) çalıştır; startup'ta Vault'tan credential alan bir uygulama için Vault'un erişilemez olması, veritabanının erişilemez olmasıyla aynı sonucu doğurur. Dynamic credential sayısını da izle: yüzlerce instance'ın her biri saatte bir yeni kullanıcı oluşturuyorsa, veritabanının login ve oturum sınırları hesaba girer.

Sık Düşülen Tuzaklar

Root token'la çalışmaya devam etmek ya da onu bir uygulamaya vermek. Dev mode'u "hızlı kurulum" diye production'da kullanmak. Vault'un hesabına kolaylık olsun diye sysadmin vermek. Policy'de secret/bookstore/api yazıp KV v2'nin secret/data/ önekini unutmak. Lease süresi dolan credential'ı connection pool'da ya da bir singleton'da tutmak. Vault'un hesabına revocation'ın ihtiyaç duyduğu yetkiyi vermemek; SQL Server'da oturumları kapatmak için ALTER ANY CONNECTION gerekiyor. secret_id'yi uzun ömürlü ve sınırsız kullanımlı bırakıp onu da bir environment variable'a koymak; o zaman Vault'tan önceki sorunu yeniden üretmiş olursun. VaultSharp gibi bakımı yavaşlamış bir library'ye kritik yolu bağlamak.

Küçük Bir Deneme

★☆☆ Vault ya da OpenBao'yu dev mode'da çalıştır, KV v2'ye bir secret yaz, iki kez güncelle ve vault kv get -version=1 ile ilk sürümü oku. Sonra vault kv metadata get çıktısında sürüm geçmişini incele.

★★☆ Kendi SQL Server container'ında database secrets engine'i kur, default_ttl'i bir dakika yap ve lease'i vault lease renew ile yenile. max_ttl'e ulaştığında renew'un ne döndürdüğünü ve login'in sys.server_principals'tan ne zaman silindiğini gözle.

★★★ API'yi Vault Agent'ın yazdığı dosyayla çalıştır; aynı anda connection string'i startup'ta saklayan ikinci bir kopya çalıştır. Revoke'u Vault'un login'inde ALTER ANY CONNECTION varken ve yokken ayrı ayrı dene, iki uygulamanın davranışını karşılaştır.

Vault ve OpenBao ile secret'lar artık hem merkezde hem kısa ömürlü. Kubernetes'te ise uygulama bu secret'ları nasıl alacak: Vault Agent sidecar'ı mı, bir Kubernetes Secret'ı mı, bir CSI (Container Storage Interface; Kubernetes'in depolama arayüzü) volume'ü mü? Bir sonraki yazıda External Secrets Operator ile Secrets Store CSI Driver'ı karşılaştırıyor ve pod'lara cloud kimliğini secret'sız veren workload identity'yi kuruyoruz.