使用 Workload Identity 實現無金鑰 GitHub Actions GCP 部署
停止將服務帳戶 JSON 金鑰貼入 GitHub Secrets。工作負載身分識別聯盟可讓 Actions 使用短期的 OIDC 權杖向 GCP 進行驗證。
讓 GitHub Actions 部署到 Google Cloud 的通常做法是建立一個服務帳戶、下載其 JSON 金鑰,並將該金鑰貼入 GitHub Secret。這種方法一次就能成功,這也正是它廣為流傳的原因。它同時也將一個長期有效的憑證交給一個您無法完全控制的系統,該憑證沒有到期日,也沒有簡易的稽核軌跡。Workload Identity Federation 完全移除了金鑰。GitHub Actions 在每次執行時都會簽發一個經過簽署的 OIDC 權杖,而您可以讓 GCP 信任該權杖,並指定給某個特定的儲存庫和分支。由於不會產生金鑰,所以金鑰也就不會外洩、需要輪替,或被遺忘在密鑰儲存區中。這篇文章將展示完整的設定過程、那個決定安全成敗的單一屬性條件,以及在何種情況下,使用傳統金鑰仍然是更快的選擇。
將服務帳號金鑰存放在 secret 中的問題
服務帳號金鑰是一種持有者憑證。任何持有該 JSON 檔案的人,都可以扮演該服務帳號的角色,直到有人刪除該金鑰為止,而且根據預設,它永遠不會過期。僅僅是這個事實就帶來了大部分的麻煩。
如果金鑰透過被入侵的依賴項、範圍設定不當的日誌、具有 secret 存取權限的分支或筆記型電腦的備份而外洩,攻擊者就能在金鑰的生命週期內,一直擁有您的部署身分。您通常不會知道這件事已經發生,因為這些呼叫看起來與您的 CI 所發出的完全相同。輪替是一件沒人想做的手動苦差事:產生新金鑰、更新 secret、確認沒有東西壞掉、刪除舊金鑰。團隊會跳過這個步驟,因此金鑰會不斷累積。稽核也很薄弱。金鑰不會記錄其使用來源,所以您無法輕易證明只有您的 CI 曾用它來進行呼叫。
這些都不是金鑰格式本身的缺陷。靜態憑證本來就帶有靜態憑證的風險,而 CI 是個不適合存放它的地方,因為其爆炸半徑就是您的生產環境部署路徑。
Workload Identity Federation 的運作方式
Workload Identity Federation 是外部身分提供者與 GCP 之間的聯合信任。GCP 不再鑄造您帶到 GitHub 的金鑰,而是由 GitHub 鑄造 GCP 已信任的權杖。
每次 GitHub Actions 執行都可以向 GitHub 的身分提供者請求 OIDC 權杖。該權杖是由 GitHub 簽署的短期 JWT,其宣告描述了該次執行的資訊:哪個儲存庫、哪個分支或標籤、哪個工作流程、哪個環境。GCP 會託管一個 Workload Identity Pool,其提供者將 GitHub 的發行者指定為受信任的。當您的工作流程出示 GitHub 權杖時,GCP 會驗證簽章,根據您設定的條件檢查宣告,如果通過,就會將該權杖交換為一個模擬您所選服務帳戶的短期 Google 存取權杖。
這個鏈條值得清楚說明。GitHub 簽署一個權杖來證明該次執行的身分。GCP 信任 GitHub 的簽章和您的條件。服務帳戶授予實際的部署權限。沒有任何密鑰跨越邊界,而回傳的 Google 權杖會在幾分鐘內過期。
使用 gcloud 設定集區與供應商
您需要一次建立三樣東西:一個集區、集區內的一個 OIDC 供應商,以及服務帳戶上的一個權限繫結。請先設定您的專案變數。
PROJECT_ID="my-project"
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
REPO="my-org/my-repo"
DEPLOY_SA="deploy@${PROJECT_ID}.iam.gserviceaccount.com"
先建立集區,再建立信任 GitHub 簽發者的供應商。屬性對應會將 GitHub 權杖中的宣告複製到您稍後可以參照的屬性中。
gcloud iam workload-identity-pools create github-pool \
--project="$PROJECT_ID" \
--location="global" \
--display-name="GitHub Actions"
gcloud iam workload-identity-pools providers create-oidc github-provider \
--project="$PROJECT_ID" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub OIDC" \
--issuer-uri="https://token.actions.githubusercontent.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository == '${REPO}' && assertion.ref == 'refs/heads/main'"
attribute-condition 是安全邊界,下一節將完全圍繞它進行討論。目前請注意,它將信任固定在一個儲存庫的 main 分支上。來自任何其他儲存庫或分支的權杖在到達您的服務帳戶之前,就會交換失敗。
將信任鎖定至單一 repo 和分支
整個設定中最重要的一行就是屬性條件。如果設定得太寬鬆,你就不是在建立無金鑰部署,而是在建立一個世界上任何儲存庫都能模擬的服務帳號。
這種失敗模式很細微,因為寬鬆的版本對你來說仍然有效。如果你對應了 attribute.repository 卻從未要求特定值,那麼任何 GitHub 儲存庫,包括陌生人的儲存庫,都可以出示一個有效的 GitHub 簽署權杖並通過驗證。簽章是真實的。你跳過的是檢查那是誰的簽章。GitHub 樂於為平台上的每個 repo 核發正確簽署的權杖,因此僅憑身分識別提供者的簽章無法證明任何所有權。你的條件才是將信任與你綁定的關鍵。
務必要求確切的儲存庫。當只有一個分支應該部署時,請加上分支。
# Good: exactly your repo, main branch only.
--attribute-condition="assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'"
# Dangerous: any repo whose name happens to match a prefix.
--attribute-condition="assertion.repository.startsWith('my-org/')"
# Broken: no ownership check at all, every GitHub repo passes.
--attribute-condition="assertion.repository != ''"
服務帳號上的綁定應符合相同的範圍。將 workloadIdentityUser 角色授予集區,但將成員限制在確切的儲存庫屬性上,這樣模擬權限就不會是集區範圍的。
gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA" \
--project="$PROJECT_ID" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"
現在有兩層防護守衛著同一扇門。提供者條件在交換時拒絕外來權杖,而成員綁定則限制了即使在你的集區內部,哪些身分可以模擬此服務帳號。也要讓服務帳號本身的權限保持最小。如果它只需要部署到 Cloud Run,就給予它所使用的 Cloud Run 和 Artifact Registry 角色,不要給予更廣泛的權限。服務帳號的最小權限原則可以在 CI 邏輯被誘騙執行非預期操作時,限制損害的範圍。
工作流程方面
在 GitHub 方面,您需要一個權限和一個驗證步驟。id-token: write 權限讓工作完全可以請求 OIDC 權杖。若沒有此權限,驗證步驟會失敗並顯示一個令人困惑的錯誤,而這也是人們最常忘記的事情。
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
service_account: ${{ vars.GCP_DEPLOY_SA }}
- uses: google-github-actions/setup-gcloud@v2
- run: |
gcloud run deploy my-service \
--image "$IMAGE" \
--region asia-northeast3
請注意,這兩個輸入來自 vars,而非 secrets。這就是重點。這兩個值都不是敏感資訊。提供者名稱和服務帳號電子郵件是識別碼,而非憑證,因此它們以純文字的儲存庫變數形式存在,任何審查工作流程的人都可以讀取。
從 CLI 設定一次:
gh variable set GCP_WIF_PROVIDER \
--body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
gh variable set GCP_DEPLOY_SA --body "$DEPLOY_SA"
auth 操作會請求 GitHub OIDC 權杖,將其傳送給提供者,接收一個短期的 Google 存取權杖,並寫入 setup-gcloud 和 SDK 會自動取得的憑證。auth 之後的每一步都已通過驗證,而儲存庫中任何地方都沒有金鑰。
安全性權衡的並列比較
優點不僅僅是整潔。它改變了攻擊者從外洩中能得到的東西,以及您需要維護的內容。
| 屬性 | 服務帳戶金鑰 | 工作負載身分聯盟 |
|---|---|---|
| 憑證生命週期 | 直到手動刪除,無到期日 | 數分鐘,每次執行時鑄造 |
| 外洩的密鑰會洩漏什麼 | 完整的 SA 存取權限,無限期 | 什麼都沒有,因為沒有儲存的憑證 |
| 信任範圍 | 任何持有該檔案的人 | 您指定的一個 repo 和分支 |
| 輪替 | 手動,容易忘記 | 無,權杖是短暫的 |
| 儲存在 GitHub 中的密鑰 | JSON 金鑰 | 無,只有公開識別碼 |
| 稽核 | 薄弱,金鑰不記錄來源 | 權杖宣告會記錄 repo、ref、workflow |
其核心在於短生命週期的權杖。一個在幾分鐘內到期的 GitHub 權杖和一個隨後不久到期的 Google 權杖,在執行結束後幾乎毫無價值。沒有可供竊取的持久性產物。repo 和分支的範圍限定意味著,您組織中其他地方的洩漏,或惡意的 fork,都無法借用此身分,因為它們的權杖帶有不同的 repository 宣告且無法滿足條件。而且因為您不儲存任何金鑰,所以沒有什麼需要輪替,這悄悄地從您的維運工作中移除了一整個週期性任務。
坦白說,成本在於設定的複雜性。金鑰只需要兩個指令:建立、下載。聯盟則需要一個集區、一個帶有精心措辭條件的提供者、一個成員綁定,以及兩個 workflow 輸入。特別是條件,很容易在設定得太寬鬆的方向上出現細微的錯誤,而寬鬆的條件看起來又能正常運作。請編列時間來精確地撰寫它,並測試來自不同分支的權杖確實會被拒絕。不過,在完成首次設定後,維護成本幾乎為零,因為沒有需要費心照看的金鑰。
服務帳號金鑰仍是簡便之選的時機
對於部署到生產環境的 CI 而言,身分聯合是正確的預設選項,但它並非沒有成本,且有少數情況確實更適合使用金鑰。
如果呼叫端不是一個支援 OIDC 的身分提供者,身分聯合就沒有可以信任的對象。在任意機器上的 cron job、開發者筆電上的腳本,或是一個沒有權杖簽發者的舊版系統,都無法提供交換所需的簽署聲明。將這些橋接到工作負載身分池,通常比它省下的功夫還要費工,而一個範圍受限、輪替週期短的金鑰會是個務實的答案。
一個用完即丟、沒有真實資料且生命週期只有幾天的沙箱專案,是另一種情況。當整個專案下週就要被刪除時,外洩金鑰的爆炸半徑很小,而且設定的成本幾乎換不到什麼好處。針對模擬器進行的本機開發完全不需要雲端憑證,所以兩種方法都不適用。
大致的分界如下。如果工作負載在會簽發 OIDC 權杖的地方執行(GitHub Actions、GitLab CI 和大多數現代平台都會),就優先選擇身分聯合並刪除金鑰。如果它在沒有可信賴權杖簽發者的地方執行,或者專案是用完即丟的,那麼一個範圍嚴格受限、輪替週期短的金鑰就是一個站得住腳的選擇。然而,對於使用 GitHub Actions 部署到 GCP 的常見情況,金鑰能為你現下省下幾分鐘,但在它存在的期間,卻是一個持續存在的責任。身分聯合需要花費一個較長的下午一次性設定,之後就不會再妨礙你。