Terraform'da Secret'lar: sensitive, Ephemeral Value'lar, Write-only Argument'lar ve Key Vault
sensitive yalnız ekrandaki çıktıyı gizler; değer state'te, kaydedilmiş plan'da ve output -json'da düz metin kalır, state dışında tutmanın yolu ephemeral value'ları write-only argument'lara vermektir. Secret'ların sızdığı yerleri gerçek çıktılarla buluyor, ephemeral değerlerin nerede kullanılabildiğini görüyor, write-only şifreyi ve sürümle rotation'ı gerçek bir veritabanında deniyor ve Azure SQL yönetici şifresini state'e hiç yazmadan SQL Server'a ve Key Vault'a veriyoruz.
Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, kreuzwerker/docker provider 4.7.0, cyrilgdn/postgresql provider 1.27.0, hashicorp/random 3.9.1, PostgreSQL 18 ve Docker Engine 29 ile denenmiştir. AzureRM 5.14.0 kodu için init ve validate çalıştırıldı ve mock_provider'lı bir terraform test ile plan alındı; Azure'a plan/apply yapılmadı.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) state backend'ini kuran bootstrap-local klasöründe PostgreSQL bağlantı bilgisini sensitive = true bir output olarak veriyoruz. terraform output onu <sensitive> diye gösteriyor. Peki şifre bu klasörün state dosyasında kaç yerde duruyor? terraform output -json ne gösterir? Ödeme API key'ini taşıyan dev ortamında plan -out=tfplan ile kaydedilen plan dosyasını eline geçiren biri key'i okuyabilir mi?
sensitive Yalnız Ekranı Gizler
İlk iki sorunun cevabı:
$ cd infra/bootstrap-local
$ terraform output
pg_conn_str = <sensitive>
$ terraform output -json
{
"pg_conn_str": {
"sensitive": true,
"type": "string",
"value": "postgres://terraform:<password>@localhost:15480/terraform_state?sslmode=disable"
}
}
$ grep -n "$PASSWORD" terraform.tfstate
8: "value": "postgres://terraform:<password>@localhost:15480/terraform_state?sslmode=disable",
55: "POSTGRES_PASSWORD=<password>",
244: "result": "<password>",
$ ls -l terraform.tfstate
-rw-r--r-- 1 user wheel 8424 Nov 14 20:35 terraform.tfstate
Şifre state'te üç yerde düz metin olarak duruyor: random_password'un ürettiği değer, container'ın POSTGRES_PASSWORD environment variable'ı ve output. -json çıktısı da değeri olduğu gibi veriyor; sensitive işareti JSON'da yalnız bir alan. Dosya izinleri de koruma sağlamıyor: Terraform lokal state'i özel izinlerle yazmıyor, bu makinede dosyayı herkes okuyabiliyor.
Üçüncü soru, dev ortamındaki ödeme API key'i:
$ cd infra/local/envs/dev
$ terraform state pull | grep -c "$TF_VAR_payments_api_key"
1
$ terraform plan -out=tfplan
$ terraform show -json tfplan | grep -o "Bookstore__Payments__ApiKey=[^\"]*"
Bookstore__Payments__ApiKey=<key>
Bookstore__Payments__ApiKey=<key>
Kaydedilmiş plan dosyası, apply'ın aynı değerlerle çalışabilmesi için bütün değerleri içeriyor; terraform show -json secret'ı düz metin veriyor. 11. yazıda plan'ı bir job'da alıp apply job'una artifact olarak taşıyacağız; o artifact'ı state kadar korumak gerekiyor.
Gözden kaçan bir yer daha var: backend ayarları. Bağlantı bilgisini -backend-config ile verirsen init onu .terraform/terraform.tfstate'e yazıyor; 4. yazıdan beri kullandığımız PG_CONN_STR environment variable'ı ise hiçbir dosyaya yazılmıyor:
$ terraform init -backend-config="conn_str=$PG_CONN_STR"
$ grep conn_str .terraform/terraform.tfstate
"conn_str": "postgres://terraform:<password>@localhost:15480/terraform_state?sslmode=disable",
$ rm -rf .terraform && terraform init
$ grep conn_str .terraform/terraform.tfstate
"conn_str": null,
Kısacası sensitive, değeri plan ve apply çıktısında ve terraform output'ta gizler; başka hiçbir şey yapmaz. State, kaydedilmiş plan ve -json çıktıları değeri tam olarak taşır. 4. yazıda state'e erişimi kısıtlamanın neden şart olduğunu bu yüzden konuşmuştuk. Ama erişimi kısıtlamak, secret'ı state'e yazmamaktan her zaman daha zayıf bir koruma.
Ephemeral Value'lar
Terraform 1.10 ile değerlerin bir kısmı "ephemeral" olabiliyor: Terraform'un çalışma sırasında kullandığı ama state'e ve plan dosyasına hiç yazmadığı değerler. Üç biçimi var:
- Ephemeral variable:
variableblock'undaephemeral = true. DeğerTF_VAR_ya da-varile gelir, hiçbir dosyada kalmaz. - Ephemeral resource:
ephemeralblock'u. Her çalışmada yeniden açılır ya da okunur; örneğinephemeral "random_password"her plan ve apply'da bellekte yeni bir şifre üretir, AzureRM'inephemeral "azurerm_key_vault_secret"'ı Key Vault'tan bir secret okur. - Ephemeral output:
outputblock'undaephemeral = true. Yalnız child module'lerde kullanılabiliyor; ephemeral bir değerin module'den dışarı çıkmasını sağlıyor.
Bu değerlerin gidebileceği yerler sınırlı: provider configuration, başka bir ephemeral variable ya da output, provisioner'lar ve write-only argument'lar. Normal bir resource argument'ına veremezsin, çünkü o argument state'e yazılır. Denemek için dev ortamındaki ödeme API key'ini ephemeral yaptım:
variable "payments_api_key" {
type = string
description = "Key for the payment provider's sandbox."
sensitive = true
ephemeral = true
}
$ terraform validate
Error: Ephemeral value not allowed
on main.tf line 14, in module "bookstore":
14: payments_api_key = var.payments_api_key
This input variable is not declared as accepting a ephemeral values, so it
cannot be set to a result derived from an ephemeral value.
Ephemeral bir değer, onu kabul ettiğini açıkça söylemeyen bir module input'una geçemiyor. bookstore ve bookstore-service module'lerinde ilgili variable'ları da ephemeral yapınca hata en alta, değerin gerçekten kullanıldığı yere iniyor:
$ terraform plan
...
Error: Invalid use of ephemeral value
with module.bookstore.module.api.docker_container.this,
on ../../../modules/bookstore-service/main.tf line 41, in resource "docker_container" "this":
41: env = local.env
Ephemeral values are not valid for "env", because it is not a write-only
attribute and must be persisted to state.
Docker provider'ının env argument'ı write-only değil; container'ın environment variable'ları state'e yazılmak zorunda. Bu yüzden yerel örnekte ödeme key'ini state dışında tutmanın Terraform tarafında bir yolu yok. Azure'da bu sorunu farklı çözüyoruz: Web App key'i Terraform'dan değil, Key Vault reference ile çalışma anında Key Vault'tan alıyor (6. yazıdaki @Microsoft.KeyVault(...) app setting'i). Terraform key'in değerini hiç görmüyor, yalnız nerede durduğunu biliyor.
Write-only Argument'lar
Terraform 1.11 ile gelen write-only argument'lar, ephemeral değerin bir kaynağa gidebildiği yer. Provider bu argument'ı create ve update sırasında API'ye gönderir, ama Terraform onu state'e ve plan'a yazmaz; state'te değeri her zaman null'dır. Hangi kaynağın write-only argument'ı olduğu provider'a bağlı. Kurduğum provider'ların şemalarına baktım: AzureRM 5.14'te yedi tane var, aralarında azurerm_mssql_server'ın administrator_login_password_wo'su ve azurerm_key_vault_secret'ın value_wo'su. kreuzwerker/docker'da hiç yok.
Azure SQL'e gitmeden davranışı gerçek bir veritabanında görmek için, state backend'imizin PostgreSQL'inde bir rol açan ayrı bir deneme klasörü kurdum. cyrilgdn/postgresql provider'ının postgresql_role kaynağında da aynı ikili var:
terraform {
required_version = "~> 1.17"
required_providers {
postgresql = {
source = "cyrilgdn/postgresql"
version = "~> 1.27"
}
}
}
variable "admin_password" {
type = string
ephemeral = true
}
variable "reporting_password" {
type = string
ephemeral = true
}
variable "reporting_password_version" {
type = number
default = 1
}
provider "postgresql" {
host = "localhost"
port = 15480
database = "terraform_state"
username = "terraform"
password = var.admin_password
sslmode = "disable"
}
resource "postgresql_role" "reporting" {
name = "bookstore_reporting"
login = true
password_wo = var.reporting_password
password_wo_version = var.reporting_password_version
}
İki şifre de ephemeral variable: biri provider'ın bağlanması için, diğeri rolün şifresi. Ephemeral değerlerin provider configuration'a gidebildiğini burada görüyoruz.
$ terraform plan -out=tfplan
...
# postgresql_role.reporting will be created
+ resource "postgresql_role" "reporting" {
...
+ login = true
+ name = "bookstore_reporting"
+ password_wo = (write-only attribute)
+ password_wo_version = "1"
...
$ terraform apply tfplan
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ grep -c "$TF_VAR_reporting_password" terraform.tfstate
0
$ terraform show -json tfplan | grep -c "$TF_VAR_reporting_password"
0
$ grep -n '"password' terraform.tfstate
29: "password": null,
30: "password_wo": null,
31: "password_wo_version": "1",
Plan, değer yerine (write-only attribute) yazıyor. Apply'dan sonra şifre ne state'te ne plan dosyasında var; state'te alan null. Rol bu şifreyle veritabanına bağlanabiliyor.
Peki Terraform değeri bilmiyorsa, şifrenin değiştiğini nasıl anlıyor? Anlamıyor. Bu yüzden her write-only argument'ın yanında bir _wo_version argument'ı var ve şifre yalnız o sayı değişince gönderiliyor:
$ export TF_VAR_reporting_password=<new password>
$ terraform plan
No changes. Your infrastructure matches the configuration.
$ docker exec tfseries-state psql \
"postgresql://bookstore_reporting:$TF_VAR_reporting_password@<ip>:5432/terraform_state" \
-tAc "select current_user"
psql: error: connection to server at "<ip>", port 5432 failed: FATAL: password authentication failed for user "bookstore_reporting"
$ terraform apply -var reporting_password_version=2
...
~ password_wo_version = "1" -> "2"
...
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
$ docker exec tfseries-state psql \
"postgresql://bookstore_reporting:$TF_VAR_reporting_password@<ip>:5432/terraform_state" \
-tAc "select current_user"
bookstore_reporting
Yeni şifreyi verdim ama sürümü artırmadım: plan "No changes" dedi ve rol eski şifrede kaldı. Sürümü 2 yapınca update gitti, yeni şifre çalıştı. Rotation'ı hatırlamak artık sürüm numarasını artırmak demek; secret'ı değiştirip sürümü unutmak, şifrenin değişmediği hâlde değiştiğini sanmak demek.
Azure SQL Şifresi State'e Hiç Yazılmadan
3. yazıdan beri SQL yöneticisinin şifresi sensitive bir variable'dan geliyor ve state'te düz metin duruyordu. Şimdi Terraform'un şifreyi kendisi üretmesini, kimsenin bilmesine gerek olmadan SQL Server'a ve Key Vault'a yazmasını istiyoruz. infra/azure/bookstore/secrets.tf:
# Her çalışmada bellekte üretilir; Terraform onu state'e ya da plan'a hiç yazmaz
ephemeral "random_password" "sql_admin" {
length = 32
special = true
override_special = "!#%*-_=+"
min_lower = 2
min_upper = 2
min_numeric = 2
min_special = 2
}
# İnsanlar için acil durum kopyası; uygulama bu şifreyle değil managed identity'siyle bağlanır
resource "azurerm_key_vault_secret" "sql_admin_password" {
name = "bookstore-sql-admin-password"
key_vault_id = data.azurerm_key_vault.shared.id
content_type = "password"
value_wo = ephemeral.random_password.sql_admin.result
value_wo_version = var.sql_admin_password_version
}
SQL Server artık şifreyi write-only argument'tan alıyor:
resource "azurerm_mssql_server" "bookstore" {
name = "sql-${local.suffix}"
resource_group_name = azurerm_resource_group.bookstore.name
location = azurerm_resource_group.bookstore.location
version = "12.0"
administrator_login = var.sql_admin_login
minimum_tls_version = "1.2"
tags = local.tags
# Write-only: yalnız sürüm değişince Azure'a gönderilir, state'e ya da plan'a hiç yazılmaz
administrator_login_password_wo = ephemeral.random_password.sql_admin.result
administrator_login_password_wo_version = var.sql_admin_password_version
}
Akış şöyle. İlk apply'da ephemeral random_password bellekte bir şifre üretiyor; aynı değer hem SQL Server'a hem Key Vault secret'ına write-only olarak gidiyor. Sonraki her plan'da yeni bir şifre üretiliyor, ama iki kaynağın sürümü değişmediği için hiçbir yere gönderilmiyor. Rotation için sql_admin_password_version'ı bir artırıyorsun; aynı apply'da yeni şifre iki yere birden gidiyor ve ikisi tutarlı kalıyor. Root'taki sql_admin_password variable'ı ve üç ortam klasöründeki variables.tf silindi; artık kimsenin TF_VAR_sql_admin_password vermesi gerekmiyor.
Bu kodu Azure'a uygulamadım. validate üç ortamda da geçti; Azure'a gitmeden mock_provider ile bir plan aldım ve ephemeral kaynak, write-only argument'lar ve Key Vault secret'ı sorunsuz planlandı. Plan'ın tek şikâyeti, Web App'in health check'inin ancak apply'dan sonra bilinebilmesiydi (5. yazıdaki check block'u). Bu deneme yönteminin ayrıntısı bir sonraki yazıda.
Bunun bir bedeli var: Terraform'un çalıştığı identity'nin, platform ekibinin Key Vault'una secret yazabilmesi gerekiyor (Key Vault Secrets Officer rolü). Okuma yetkisi yetmez. Platform ekibi bunu istemiyorsa Bookstore için ayrı bir Key Vault açmak daha temiz.
Uygulamaya Secret Vermek: Key Vault ve Managed Identity
Key Vault'taki yönetici şifresi insanlar için bir acil durum kopyası; uygulamanın kullanacağı bir şey değil. Bookstore API'si için doğru model, secret'ı hiç taşımamak: Web App'in user-assigned managed identity'si (6. yazıdaki bookstore-webapp module'ü) Key Vault'tan secret okuyabiliyor ve Azure SQL'e Entra ID ile bağlanabiliyor. Terraform burada yalnız altyapıyı kuruyor: identity'yi, role assignment'ları, Key Vault reference'ı. Secret'ın değeri Terraform'un hiçbir dosyasına girmiyor.
Azure SQL'de bir adım daha ileri gidilebilir: azuread_administrator block'unda azuread_authentication_only = true ile SQL kimlik doğrulamasını tamamen kapatmak. O zaman yönetici şifresi de gerekmez; AzureRM dokümanı bu durumda iki şifre argument'ının da zorunlu olmadığını söylüyor. Uygulamanın Key Vault ve managed identity ile secret'ları çalışma anında nasıl okuduğu, rotation ve cache ayrıntıları Configuration ve Secret Management serisinin konusu.
bootstrap-local: State Backend'inin Şifresi
Başta gördüğümüz üç kopyaya dönelim. bootstrap-local, state backend'ini kuran PostgreSQL container'ını yönetiyor ve container'ın şifresi env argument'ından geçiyor; yukarıda gördüğümüz gibi Docker provider'ında write-only argument yok. Şifreyi ephemeral yapmak hata verir, dışarıdan vermek de state'e yazılmasını engellemez. Bu klasör için yaptıklarım ve yapmadıklarım:
- State lokal kalıyor ve
.gitignoreonu repo'ya almıyor; 4. yazıda dediğimiz gibi bu stack platformu kuran kişinin makinesinde, bir kez çalışır. - Bağlantı bilgisi diğer klasörlere
-backend-configile değilPG_CONN_STRile gidiyor; yukarıdaki denemede gördüğümüz gibi böylece.terraformklasörlerine yazılmıyor. - Şifre yalnız
localhost'taki bir container'ı açıyor. Aynı tasarım paylaşılan bir sunucuda kabul edilemezdi.
Azure'daki karşılığı (bootstrap-azure) bu sorunu yaşamıyor, çünkü orada şifre yok: storage account'ta shared key kapalı, backend Entra ID ile (use_azuread_auth) bağlanıyor. Bu yazının asıl dersi de bu: en iyi secret, hiç olmayan secret. Identity tabanlı kimlik doğrulamanın mümkün olduğu her yerde şifreyi Terraform'a taşımak yerine identity kullan; şifrenin şart olduğu yerde write-only argument'ı olan kaynakları seç.
Üretimde Dikkat
Write-only argument'a geçmek, eski değeri state'ten silmez. Var olan bir SQL Server'da administrator_login_password'dan _wo'ya geçtiğinde state'in yeni sürümünde alan null olur, ama backend'in tuttuğu eski state sürümlerinde (4. yazıdaki blob versioning) eski şifre durmaya devam eder. Geçişten sonra şifreyi bir kez rotate et ki eski kopyalar işe yaramaz hâle gelsin.
Ephemeral kaynaklar her plan ve apply'da yeniden çalışır. Key Vault'tan okuyan bir ephemeral "azurerm_key_vault_secret", her plan'da Key Vault'a bir istek demek; CI'da plan job'unun identity'si bu secret'ı okuma yetkisine sahip olmalı. Plan için gereken yetkiler bu yüzden apply'ınkilere yaklaşabilir, 11. yazıda identity'leri ayırırken bunu hesaba katacağız.
Provider ve backend credential'larını ne tfvars dosyasına ne de koda yaz. AzureRM'in kimlik bilgisi Azure CLI oturumundan ya da CI'da OIDC'den (OpenID Connect; pipeline'ın secret saklamadan kısa ömürlü token'la kimlik doğrulaması) gelmeli; client secret gerekiyorsa bile ARM_CLIENT_SECRET gibi bir environment variable'dan.
Sık Düşülen Tuzaklar
terraform output -json çıktısını CI log'una basmak. Başta gördüğümüz gibi -json, sensitive output'ları düz metin verir. Pipeline'da bir output'u bir sonraki adıma aktarırken terraform output -raw <ad> ile yalnız ihtiyacın olanı al ve platformun secret maskeleme özelliğine kaydet; JSON'un tamamını log'a yazdırma.
Provider credential'ını tfvars'a yazmak. terraform.tfvars çoğu zaman commit'lenir, commit'lenmese bile ekip içinde dolaşır. Variable'a giden bir credential ayrıca state'e de yazılabilir; provider'lar kimlik bilgisini kendi environment variable'larından okuyabiliyor.
Write-only argument'ın sürümünü artırmayı unutmak. Secret'ı değiştirdin, plan "No changes" dedi ve rotation yapıldı sandın. Rotation'ı otomatikleştiriyorsan, secret'ı değiştiren adım sürüm numarasını da artırmalı.
Küçük Bir Deneme
★☆☆ Dev ortamında terraform state pull çıktısında sensitive olan değerleri ara. Hangi kaynakların hangi attribute'ları secret taşıyor?
★★☆ Deneme klasöründeki reporting_password variable'ını ephemeral variable yerine ephemeral "random_password" ile değiştir. İkinci terraform plan'da ne görüyorsun? Ürettiğin şifreyi kimse bilmiyorsa onu nasıl kullanırsın?
★★★ bootstrap-local'de backend için superuser yerine yalnız bookstore_dev schema'sına yetkili bir rol aç; şifresini password_wo ile ver. Hangi PostgreSQL yetkileri gerekti? Bu, 7. yazıdaki "ortam başına ayrı erişim" hedefine ne kadar yaklaştırdı?
Bir sonraki yazıda Terraform kodunu Azure'a gitmeden doğrulamanın ve test etmenin katmanlarına geçiyoruz: fmt, validate, tflint, terraform test, bu yazıda da kullandığımız mock_provider ve policy kontrolleri.