Secrets and Credential Management
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”provider authentication ควรมาจาก Application Default Credentials หรือกลไก identity แทนที่ key ที่ดาวน์โหลดมาแล้วพิมพ์ใส่ไฟล์ และแม้แต่การดึง application secret จาก secrets manager จริงตอน apply time ก็ยังไม่ได้กันค่าไม่ให้อยู่ใน Terraform state ได้เต็มร้อย
อย่า hardcode credential ในบล็อก provider
หัวข้อที่มีชื่อว่า “อย่า hardcode credential ในบล็อก provider”เป็นไปได้ที่จะ authenticate google provider ด้วย service account key JSON file ที่ดาวน์โหลดมา แล้ว reference ตรง ๆ จากบล็อก provider "google" และใช้งานได้ในความหมายแคบ ๆ ว่า terraform plan รันผ่าน แต่ก็ยังคงผิดอยู่ดี
# Anti-pattern — never do thisprovider "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 ให้เขียนไว้ที่ไหนเลย
ดึง secret เข้ามาผ่าน data source ไม่ใช่ variable
หัวข้อที่มีชื่อว่า “ดึง secret เข้ามาผ่าน data source ไม่ใช่ variable”คำถามอีกข้อที่แยกจาก 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 ยังคงเป็นส่วนที่ข้ามไม่ได้อยู่ดี
กัน secret ไม่ให้เข้า state เลย: ephemeral value และ write-only argument
หัวข้อที่มีชื่อว่า “กัน secret ไม่ให้เข้า state เลย: ephemeral value และ write-only argument”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 planephemeral "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:
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"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 validateSuccess! 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 ล้วน ๆ ได้
checklist สำหรับ .gitignore ของ repo Terraform/Terragrunt
หัวข้อที่มีชื่อว่า “checklist สำหรับ .gitignore ของ repo Terraform/Terragrunt”.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 ที่ดาวน์โหลดมาโดยinitfolder นี้ใหญ่, เฉพาะเครื่อง, และ reproduce ได้เต็มที่จาก.terraform.lock.hclเลยไม่มีอะไรที่ควร version ตรงนี้เลย*.tfvars— พิจารณาเป็นเคส ๆ ไปแทนที่จะ ignore ทั้งหมด บางทีมตั้งใจ commitenvironment.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"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกเทคนิคในบทนี้ตอบคำถามเดียวกันด้วยความแรงที่เพิ่มขึ้นเรื่อย ๆ: “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 - “
datasource ที่ดึงจาก Secret Manager แทนที่ความจำเป็นของephemeral/write-only ได้เต็มร้อย” พิสูจน์ข้างบนจาก design: ผลลัพธ์ของdatasource ถูก 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 จริงอยู่เลยสักตัว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เวลา review AI-generated Terraform ที่จัดการ GCP secret ให้เช็ค:
default =literal บน variable ที่ markephemeral = trueหรือsensitive = true— บท AWS พี่น้องพิสูจน์ว่าสิ่งนี้ทำลายจุดประสงค์ทั้งหมดสำหรับกรณี ephemeral รั่วเข้าshow -json- argument แบบ
password_wo/_woที่ใช้โดยไม่เช็ค schema ของ resource นั้นจริง ๆ — พิสูจน์ข้างบนผ่านterraform validateargument ต้องมีอยู่จริงบน 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 = stringephemeral = truesensitive = truedefault = "changeme-in-ci"}
resource "google_sql_user" "app" {name = "appuser"instance = google_sql_database_instance.main.namepassword_wo = var.db_passwordpassword_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 จริงของบทนี้ทำไว้เป๊ะ ๆ