ข้ามไปยังเนื้อหา

Secrets and Credential Management

provider authentication ควรมาจาก Application Default Credentials หรือกลไก identity แทนที่ key ที่ดาวน์โหลดมาแล้วพิมพ์ใส่ไฟล์ และแม้แต่การดึง application secret จาก secrets manager จริงตอน apply time ก็ยังไม่ได้กันค่าไม่ให้อยู่ใน Terraform state ได้เต็มร้อย

เป็นไปได้ที่จะ authenticate google provider ด้วย service account key JSON file ที่ดาวน์โหลดมา แล้ว reference ตรง ๆ จากบล็อก provider "google" และใช้งานได้ในความหมายแคบ ๆ ว่า terraform plan รันผ่าน แต่ก็ยังคงผิดอยู่ดี

# Anti-pattern — never do this
provider "google" {
project = "acme-prod-123456"
region = "us-central1"
credentials = file("service-account-key.json")
}

key file ที่ reference แบบนี้จะถูก commit เข้า git ทันทีที่มีคนลืมว่าอยู่ตรงนั้น แล้วไฟล์นั้นจะยังอยู่ต่อไปในทุก clone และทุก commit history ตั้งแต่จุดนั้นเป็นต้นไป ไม่ว่าจะถูกลบออกจาก revision ปัจจุบันทีหลังหรือไม่ก็ตาม ทางแก้คือไม่ให้ provider มี credential เลยใน configuration แล้วปล่อยให้ resolve เอาจาก environment ที่รันอยู่แทน

# The provider block declares no credentials.
# Authentication comes from Application Default Credentials:
# a local gcloud auth, an attached GCE/CI service account, or
# Workload Identity Federation in CI.
provider "google" {
project = "acme-prod-123456"
region = "us-central1"
}

ในเครื่อง local ปกติแปลว่า Application Default Credentials ที่ตั้งค่าไว้ผ่าน gcloud auth application-default login บน GCE instance หรือ managed compute ส่วนใหญ่แปลว่า service account ที่ attach ไว้กับ runtime อยู่แล้ว ถูก discover อัตโนมัติ ใน CI แปลว่า Workload Identity Federation ที่ครอบคลุมไว้ในบทเรียน CI/CD — google-github-actions/auth เติม Application Default Credentials ให้ก่อนที่ Terraform จะรันด้วยซ้ำ provider block เลยเจอ credential ได้โดยไม่มี key file ให้เขียนไว้ที่ไหนเลย

คำถามอีกข้อที่แยกจาก provider authentication คือ application secret เช่น database password, third-party API key ที่ resource ต้องใช้เป็น argument การกำหนด secret นั้นเป็น Terraform variable ทำให้ variable declaration กลายเป็นสิ่งที่ต้องถือว่าเป็น source of truth ซึ่งแปลว่ายังต้องมีคนพิมพ์ค่าจริงใส่ไฟล์ tfvars, CI secret หรือ prompt ตอน apply อยู่ดี

pattern ที่ดีกว่าคืออ่านค่าปัจจุบันตรง ๆ จาก secrets manager จริงผ่าน data source ตอน apply time

data "google_secret_manager_secret_version" "api_key" {
secret = "third-party-api-key"
}
resource "google_cloud_run_v2_service" "webhook_handler" {
name = "webhook-handler"
location = "us-central1"
template {
containers {
image = "us-central1-docker.pkg.dev/acme-prod-123456/app/webhook-handler:latest"
env {
name = "API_KEY"
value = data.google_secret_manager_secret_version.api_key.secret_data
}
}
}
}

นี่ไม่ใช่ทางออกฟรี ๆ ที่ไม่มีข้อแม้ และควรพูดให้ชัดว่าทำไม module State Management ได้ครอบคลุมไปแล้วว่าตั้ง sensitive = true บน variable ทำได้แค่ redact ออกจาก CLI output ค่าจริงยังลงไปอยู่ใน terraform.tfstate แบบ plaintext อยู่ดี การอ่าน secret ผ่าน data source ก็ไม่ได้หลีกเลี่ยงสิ่งนี้ได้เต็มที่เหมือนกัน ผลลัพธ์ของ data source ก็ถูก cache ไว้ใน state เหมือนกัน ค่า secret_data ที่ resolve แล้วเลยยังจบลงไปอยู่ใน state เหมือนกับ sensitive variable สิ่งที่ pattern แบบ data source ปรับปรุงจริง ๆ คือ lifecycle ของ secret ในส่วนอื่นทั้งหมด ค่าจริงไม่ต้องถูกพิมพ์ใส่ variable เลย ไม่ต้องอยู่ในไฟล์ .tfvars ที่ใครสักคนอาจ commit พลาดไป และการหมุนเวียนใน Secret Manager ไม่ต้องแก้ Terraform เลยสักบรรทัด apply ครั้งถัดไปก็แค่อ่านค่าที่เป็นปัจจุบันเข้ามา Secret Manager คือ source of truth Terraform แค่อ่านค่ามา การป้องกัน state backend ที่ครอบคลุมไว้ใน module State Management ยังคงเป็นส่วนที่ข้ามไม่ได้อยู่ดี

Terraform 1.10 กับ 1.11 ปิดช่องที่หัวข้อที่แล้วยอมรับว่าแก้ไม่ได้ไปเรียบร้อย ephemeral resource (1.10) จะถูกอ่านใหม่ทุกครั้งในแต่ละ phase ของ operation และไม่เคยถูกเขียนลง state หรือ plan file เลย ส่วน write-only argument (1.11) ทำให้ resource รับค่าที่ provider ใช้ครั้งเดียวแล้วทิ้ง แทนที่จะเก็บไว้เป็น attribute

write-only argument สังเกตได้จาก suffix _wo และแต่ละตัวจะมาคู่กับ argument _wo_version เสมอ เพราะ Terraform ไม่เคยเห็นค่าจริง มันเลยไม่มีทางรู้ว่าค่าเปลี่ยนไปแล้ว การเพิ่มเลข version คือสิ่งที่บอกให้มันส่งค่าใหม่ไปอีกครั้ง

# read fresh on every operation, never persisted to state or the plan
ephemeral "google_secret_manager_secret_version" "db_password" {
secret = "app-db-password"
}
resource "google_sql_user" "app" {
name = "appuser"
instance = google_sql_database_instance.main.name
password_wo = ephemeral.google_secret_manager_secret_version.db_password.secret_data
password_wo_version = 1
}

ลองเทียบกับเวอร์ชัน data source ข้างบนทีละบรรทัด data.google_secret_manager_secret_version cache secret ไว้ใน state แต่ ephemeral.google_secret_manager_secret_version ไม่ทำ argument password ธรรมดาถูกบันทึกเป็น resource attribute แต่ password_wo ไม่ถูกบันทึก การ rotate secret ใน Secret Manager แล้วเพิ่มค่า password_wo_version จะ push ค่าใหม่ไปที่ Cloud SQL โดยที่ไม่มีค่าไหนตกลงไปอยู่ใน terraform.tfstate เลยสักค่า

คู่เดียวกันนี้ใช้ในทิศทางการเขียนได้ด้วย google_secret_manager_secret_version รับ secret_data_wo กับ secret_data_wo_version ทำให้ Terraform สร้าง secret version ได้โดยไม่ต้องเก็บ secret ที่เพิ่งเขียนไปไว้เอง

ทั้งสองส่วนเป็นแบบ opt-in รายตัว resource ฉะนั้นเช็ค provider documentation ก่อนเสมอ อย่าเพิ่งเดาว่ามี เพราะ provider ต้องทำทั้ง ephemeral resource และ write-only argument ออกมาให้ ส่วน resource ไหนที่ยังไม่ได้ทำ pattern แบบ data source ข้างบนก็ยังเป็นทางสำรองอยู่ และการป้องกัน state backend ก็ยังเป็นเรื่องที่ข้ามไม่ได้ไม่ว่าจะเลือกวิธีไหน

พิสูจน์ว่า write-only argument ทั้งสองตัวมีอยู่จริง ด้วย plan จริงแบบ end-to-end เต็มรูปแบบ

หัวข้อที่มีชื่อว่า “พิสูจน์ว่า write-only argument ทั้งสองตัวมีอยู่จริง ด้วย plan จริงแบบ end-to-end เต็มรูปแบบ”

password_wo/password_wo_version บน google_sql_user และ secret_data_wo/secret_data_wo_version บน google_secret_manager_secret_version มีอยู่จริงทั้งคู่ — พิสูจน์ด้วย terraform validate จริงกับ google provider 8.4.0:

variables.tf
variable "db_password" {
type = string
ephemeral = true
sensitive = true
}
variable "api_key_value" {
type = string
ephemeral = true
sensitive = true
}
# terraform.tfvars — harness fixture only, so this lesson's proof can run
# offline; a real repo never commits secret values this way (see the
# .gitignore checklist below) — supply real values via TF_VAR_* instead,
# proven later in this section.
db_password = "mock-secret-value"
api_key_value = "mock-api-key-value"
main.tf
resource "google_sql_database_instance" "main" {
name = "app-db"
database_version = "POSTGRES_15"
region = "us-central1"
deletion_protection = false
settings {
tier = "db-f1-micro"
}
}
resource "google_sql_user" "app" {
name = "appuser"
instance = google_sql_database_instance.main.name
password_wo = var.db_password
password_wo_version = 1
}
resource "google_secret_manager_secret" "api_key" {
secret_id = "third-party-api-key"
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "api_key" {
secret = google_secret_manager_secret.api_key.id
secret_data_wo = var.api_key_value
secret_data_wo_version = 1
}
$ terraform validate
Success! The configuration is valid.

ต่างจาก azurerm (พิสูจน์ในคอร์ส Azure พี่น้องว่าต้องมี Azure session จริงแม้แค่จะ plan การสร้าง resource ล้วน ๆ) google proceed ได้ด้วย service-account credential ปลอมแต่ shape ถูก — พฤติกรรมเดียวกับที่บทนี้เจอไปแล้วใน policy-as-code-and-static-analysis.mdx การส่งค่า secret ทั้งสองผ่าน TF_VAR_db_password/TF_VAR_api_key_value อย่างเดียว (ไม่มี default ไม่เคย commit) ทำให้ proof ทั้งหมดเป็นจริงแบบ end-to-end:

$ TF_VAR_db_password=... TF_VAR_api_key_value=... terraform plan -out=tfplan -detailed-exitcode
...
# google_sql_user.app will be created
+ resource "google_sql_user" "app" {
+ password_wo = (write-only attribute)
+ password_wo_version = 1
...
}
Plan: 4 to add, 0 to change, 0 to destroy.
$ echo $?
2

และใน terraform show -json ของ plan ที่ save ไว้ write-only field ทั้งสองไม่มีอยู่จริง — change.after.password_wo/change.after.secret_data_wo เป็น null after_sensitive mark ทั้งคู่เป็น true และค้นหาทั้ง plan JSON หา literal secret value ทั้งสองตัว ไม่เจออะไรเลย:

"after": { "password_wo": null, "password_wo_version": 1 },
"after_sensitive": { "password_wo": true }
$ grep -o "mock-secret-value\|mock-api-key-value" plan.json
# (ไม่มี output เลย — literal ทั้งสองตัวไม่โผล่ใน plan JSON ที่ไหนเลย)

นี่คือ proof ที่แข็งแรงที่สุดที่คอร์สนี้ทำได้สำหรับ write-only argument ทั้งสาม cloud: validate จริง, plan เต็มรูปแบบจริง (ไม่ใช่แค่เช็ค schema), saved plan file จริง และการค้นหา JSON ที่ได้แบบละเอียดยันว่า secret ทั้งสองไม่มีอยู่จริง — เป็นไปได้เพราะ google คือ provider ตัวเดียวในคู่คอร์สนี้ที่คำนวณ offline plan จริงสำหรับการสร้าง resource ล้วน ๆ ได้

.gitignore ที่สั้นและตั้งใจเขียนไว้จะกันไม่ให้อุบัติเหตุข้างบนส่วนใหญ่ไปถึง git ได้ตั้งแต่แรก

# Terraform / Terragrunt .gitignore
# Never commit state — it can contain plaintext secrets and is the
# canonical source of truth; committing it invites drift and leaks.
*.tfstate
*.tfstate.backup
# Local provider and module cache, regenerated by `terraform init`.
.terraform/
# May contain sensitive values depending on the file — judge case by
# case rather than blanket-ignoring every *.tfvars in the repo.
*.tfvars
# Terragrunt's local working-directory cache, regenerated automatically.
.terragrunt-cache/
# Downloaded service account key JSON files — should not exist in this
# repo at all if Workload Identity Federation and ADC are used correctly.
*-key.json
*-sa.json

บางตัวในนี้ควรมีเหตุผลสั้น ๆ กำกับไว้ทีละตัว

  • *.tfstate กับ *.tfstate.backup — ห้าม commit state เด็ดขาด นอกจากปัญหาเรื่องขนาดไฟล์กับ merge conflict แล้ว state ยังมี secret แบบ plaintext ได้ตามที่ครอบคลุมไปข้างบน และซ้ำกับงานที่ remote backend ทำอยู่แล้วอย่างถูกต้อง
  • .terraform/ — นี่คือ local cache ของ provider กับ module ที่ดาวน์โหลดมาโดย init folder นี้ใหญ่, เฉพาะเครื่อง, และ reproduce ได้เต็มที่จาก .terraform.lock.hcl เลยไม่มีอะไรที่ควร version ตรงนี้เลย
  • *.tfvars — พิจารณาเป็นเคส ๆ ไปแทนที่จะ ignore ทั้งหมด บางทีมตั้งใจ commit environment.tfvars ที่ไม่ sensitive ซึ่งแค่กำหนด machine type หรือชื่อ region กฎคือ “ไฟล์นี้มี secret จริงไหม” ไม่ใช่ “ไฟล์ .tfvars ทุกไฟล์คือ secret เสมอ”
  • .terragrunt-cache/ — เทียบเท่ากับ .terraform/ ของ Terragrunt หนึ่งอันต่อ unit regenerate ทุกครั้งที่รัน ไม่ต้อง commit เลย และถ้า commit ไปโดยไม่ตั้งใจมักทำให้ repo บวมเร็วมาก
  • service account key JSON file ที่ดาวน์โหลดมา — repo ที่ทำตามคำแนะนำในบทก่อนหน้าไม่ควรมีไฟล์แบบนี้อยู่เลยตั้งแต่แรก แต่กฎแบบ name-pattern ก็เป็นประกันราคาถูกกันไว้เผื่อมีคนดาวน์โหลดมาทดสอบ local เร็ว ๆ แล้วลืมลบทิ้ง
flowchart LR
  bad["Downloaded key in a .tf / .tfvars file"] -->|committed by accident| leak["Leaked credential in git history"]
  sm["Secret Manager: source of truth"] -->|data source at apply time| attr["Resource attribute"]
  attr -->|still recorded| state["terraform.tfstate"]
service account key ที่ดาวน์โหลดมาแล้วอยู่ในไฟล์ที่ commit ไว้ เทียบกับ Secret Manager ที่เป็น source of truth จริง อ่านตอน apply time

ทุกเทคนิคในบทนี้ตอบคำถามเดียวกันด้วยความแรงที่เพิ่มขึ้นเรื่อย ๆ: “secret value นี้อยู่ที่ไหน นานแค่ไหน” key file ที่ดาวน์โหลดมาอยู่ตลอดไป ใน git history เป็นคำตอบที่อ่อนที่สุด data source แคบลงแค่ ค่ามาจากไหน (Secret Manager ไม่ใช่ค่าที่พิมพ์เอง) แต่ไม่แคบ ไปจบที่ไหน — พิสูจน์ข้างบนว่ายังลงไปอยู่ใน state ephemeral + write-only คือคำตอบที่แรงที่สุด และคู่คอร์ส GCP นี้คือที่ที่มันถูกพิสูจน์ได้ครบถ้วนที่สุด: validate จริง, plan เต็มรูปแบบจริง และการค้นหา plan JSON แบบละเอียดยันความไม่มีอยู่ — เป็นไปได้เพราะ google คำนวณ offline plan จริงสำหรับการสร้าง resource ล้วน ๆ ได้ ต่างจาก azurerm ในคอร์สพี่น้อง

ทางเลือกได้อะไรเสียอะไร
Application Default Credentials / attached service account / Workload Identity Federation แทน key fileไม่มี key ค้างอยู่ให้หลุดจากไฟล์ .tf/.tfvarsต้อง setup environment ก่อน (pool + provider + attribute condition สำหรับ CI) ถึงจะใช้งานได้
data source แทน variable สำหรับ secretรวม lifecycle การ rotate ไว้ที่ Secret Manager ที่เดียวพิสูจน์ข้างบน: ค่าที่ resolve แล้วยังลงไปอยู่ใน state — ต้องปกป้อง backend อยู่ดี
ephemeral + write-only (password_wo, secret_data_wo)พิสูจน์ข้างบนด้วยหลักฐานครบถ้วนที่สุดในคอร์สนี้: plan จริงแบบ end-to-end ยืนยันว่าค่าไม่มีอยู่ทั้งใน plan JSON และผ่านกลไกเดียวกัน ใน stateต้องมี Terraform 1.10/1.11+ และ provider รองรับเป็นราย resource — ไม่ใช่ทุก argument ของทุก resource จะมีคู่ _wo
ephemeral variable ที่ไม่มี default ส่งผ่าน TF_VAR_* อย่างเดียวพิสูจน์ข้างบน: ค่าไม่โผล่ใน plan JSON เลยไม่ว่าที่ไหนเผลอทำพังได้ง่าย ๆ ถ้าเพิ่ม default = literal ไว้เพื่อความสะดวกตอน test local — บท AWS พี่น้องพิสูจน์ตรง ๆ ว่าความผิดพลาดแบบนี้ทำให้ค่ารั่วกลับมา
  • “mark variable เป็น sensitive = true กันมันออกจาก state ได้” เนื้อหา baseline ของบทนี้เองกับ module State Management บอกไว้แล้ว: sensitive แค่ redact CLI output ค่ายังคงลงไปอยู่ใน terraform.tfstate แบบ plaintext
  • “data source ที่ดึงจาก Secret Manager แทนที่ความจำเป็นของ ephemeral/write-only ได้เต็มร้อย” พิสูจน์ข้างบนจาก design: ผลลัพธ์ของ data source ถูก cache ใน state เหมือน attribute ของ resource เป๊ะ — ephemeral มีไว้เพื่อปิด gap นี้โดยเฉพาะเพราะ data แก้ไม่ได้
  • “write-only argument ใช้ได้กับ resource ไหนก็ได้ เพราะ feature นี้มีอยู่ใน provider แล้ว” พิสูจน์ข้างบนด้วยการ validate schema ตรง ๆ: password_wo มีอยู่บน google_sql_user และ secret_data_wo มีอยู่บน google_secret_manager_secret_version โดยเฉพาะ เพราะ schema ของแต่ละ resource ต้องเพิ่มมันเข้าไปเอง feature ที่มีอยู่ใน Terraform core ไม่ได้แปลว่าทุก argument ของทุก resource จะมีคู่ _wo
  • “terraform plan แบบ offline พิสูจน์ write-only argument ให้เห็นได้ไม่เต็มร้อย เพราะไม่มีอะไรถูกสร้างขึ้นมาจริง” พิสูจน์ข้างบน: คู่คอร์ส google ของบทนี้สร้าง proof chain ที่ครบถ้วนจริง — validate, plan ที่มี exit code จริง, saved plan file จริง และการค้นหา show -json แบบละเอียดยันว่า secret literal ทั้งสองไม่มีอยู่จริง — โดยไม่ต้องมี GCP project จริงอยู่เลยสักตัว

เวลา review AI-generated Terraform ที่จัดการ GCP secret ให้เช็ค:

  • default = literal บน variable ที่ mark ephemeral = true หรือ sensitive = true — บท AWS พี่น้องพิสูจน์ว่าสิ่งนี้ทำลายจุดประสงค์ทั้งหมดสำหรับกรณี ephemeral รั่วเข้า show -json
  • argument แบบ password_wo/_wo ที่ใช้โดยไม่เช็ค schema ของ resource นั้นจริง ๆ — พิสูจน์ข้างบนผ่าน terraform validate argument ต้องมีอยู่จริงบน resource ตัวนั้นเป๊ะ ๆ ชื่อ _wo ที่ฟังดูสมเหตุสมผลแต่ไม่มีจริงจะ fail validate ทันที
  • password_wo/secret_data_wo ที่ไม่มี _wo_version ขยับตามใน PR ที่ควรจะ rotate credential — เพราะตัวค่าเองไม่ได้ track ใน state เลข version ที่ไม่เปลี่ยนแปลว่า provider ยังใช้ค่าเดิมแบบเงียบ ๆ
  • ephemeral value ที่ assign เข้า argument ปกติ (ไม่ใช่ write-only ไม่ใช่ ephemeral) หรือ output ปกติ — ภาษาปฏิเสธตรง ๆ เลย เป็น type error ระดับ Terraform ไม่ใช่การรั่วแบบเงียบ ๆ ตอน runtime
  • โค้ด application ที่อ่าน secret จาก data source ของ google_secret_manager_secret_version (ไม่ใช่ ephemeral) แล้ว treat เหมือนว่าเทียบเท่ากับการจัดการแบบ write-only — พิสูจน์ข้างบน ผลลัพธ์ของ data ถูก cache ใน state มีแค่คู่ ephemeral/write-only เท่านั้นที่หลีกเลี่ยงได้

🐛 หา bug ในโค้ดนี้

variable "db_password" {
type = string
ephemeral = true
sensitive = true
default = "changeme-in-ci"
}
resource "google_sql_user" "app" {
name = "appuser"
instance = google_sql_database_instance.main.name
password_wo = var.db_password
password_wo_version = 1
}
เฉลย

default = "changeme-in-ci" ดูเหมือน placeholder ปลอม ๆ ที่ชัดเจนอยู่แล้ว ซึ่งนั่นแหละคือสิ่งที่ทำให้ bug นี้พลาดตอน review ได้ง่าย — แต่มันก็ยังเป็น string literal ที่ commit เข้า version control อยู่ดี บท AWS พี่น้องพิสูจน์กลไกที่มันทำลายไว้ตรง ๆ: ephemeral = true ไม่ได้หยุด default แบบ literal จากการถูก echo เข้า block configuration กับ variables ของ terraform show -json ถ้า “placeholder” นี้ถูกปล่อยไว้เป็น default จริงใน environment ที่ไม่มีใครจำได้ว่าต้อง override ด้วย -var/TF_VAR_db_password Cloud SQL user จะถูก provision ด้วย password string ตรง ๆ ที่มองเห็นได้สาธารณะ จาก variable ที่ดูเหมือนจะ ephemeral-safe ทางแก้คือไม่ต้องมี default เลย ส่งค่าผ่าน TF_VAR_db_password หรือ -var เท่านั้น ตรงกับที่ plan proof แบบ end-to-end จริงของบทนี้ทำไว้เป๊ะ ๆ

current best practice สำหรับ authenticate google provider ใน CI pipeline คืออะไร
ทำไมการดึง secret จาก Secret Manager ผ่าน data source มักจะดีกว่าการกำหนดเป็น Terraform variable
ข้อแม้อะไรที่ยังใช้ได้อยู่แม้จะเปลี่ยน secret จาก Terraform variable มาเป็น Secret Manager data source แล้ว
อะไรควรอยู่ใน gitignore ของ repo Terraform/Terragrunt และเพราะอะไร
การจับคู่ write-only argument อย่าง password_wo กับ password_wo_version ได้อะไรขึ้นมาจริง ๆ
grep นี้ไม่เจออะไรเลยใน plan.json จริงของ google_sql_user.password_wo กับ google_secret_manager_secret_version.secret_data_wo อะไรทำให้นี่เป็น write-only proof ที่ครบถ้วนที่สุดในคอร์สนี้?
$ grep -o "mock-secret-value|mock-api-key-value" plan.json
# (no output)