GitHub Actions + GCP WIF: 用 Branch、Workflow 與 Service Account 切出最小權限

如果要把 GitHub Actions 接到 GCP, 我現在最不想再做的就是:

  • one shared deploy service account
  • one broad trust rule
  • one workflow that can hit every environment

這樣一開始很省事, 但後面只要開始切 dev / prod, 風險和 debug 成本都會一起上升。

這篇整理我最後收斂出來的做法: 用 branch、workflow、service account 三層一起切最小權限。

想解的不是登入問題, 是信任邊界

很多人第一次做 OIDC / WIF, 會把問題理解成:

  • how do I let GitHub Actions authenticate to GCP?

這只答到一半。

真正要答的是:

  • 哪條 workflow 可以模擬哪個身分?
  • from which branch?
  • against which environment?
  • 對應哪些權限?

如果這些沒寫清楚, 你只是把 long-lived credential 換成 short-lived credential, 不是把邊界收乾淨。

我最後的身分模型

我會把這三種角色分開看:

1
2
3
4
5
6
7
8
9
ci identity
  -> no GCP auth needed

infra identity
  -> used by infrastructure workflow

service deploy identities
  -> deploy-dev-sa
  -> deploy-prod-sa

這樣的核心原則是:

  • CI should not get cloud credentials if it does not need them
  • infra 不該和應用部署共用同一個身分
  • dev deploy 和 prod deploy 不該共用同一個身分

對應到 workflow 的切法

workflow 層我最後收斂成:

1
2
3
4
CI
Deploy Dev
Deploy Prod
Infrastructure

再往下綁 branch boundary:

1
2
3
4
dev branch   -> Deploy Dev
main branch  -> Deploy Prod
main branch  -> Infrastructure(shared, prod-platform)
dev branch   -> Infrastructure(dev-platform)

這樣 branch policy 和 environment policy 就能互相對齊。

WIF provider 層應該管什麼

provider 層最適合管的是「哪些 workflow + branch 組合能進來」。

概念上像這樣:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
attribute_condition = join(" && ", [
  "assertion.repository_id == \"<REPO_ID>\"",
  "assertion.repository_owner_id == \"<OWNER_ID>\"",
  "(",
  "assertion.workflow_ref.startsWith(\"org/repo/.github/workflows/deploy-dev.yml@refs/heads/dev\") ||",
  "assertion.workflow_ref.startsWith(\"org/repo/.github/workflows/deploy-prod.yml@refs/heads/main\") ||",
  "assertion.workflow_ref.startsWith(\"org/repo/.github/workflows/infra.yml@refs/heads/dev\") ||",
  "assertion.workflow_ref.startsWith(\"org/repo/.github/workflows/infra.yml@refs/heads/main\")",
  ")"
])

這樣 token 連 exchange 都過不了, 會先被擋在第一層。

為什麼只靠 provider 還不夠

provider 知道的只有 token claim。它不知道 workflow 實際想做什麼。

例如 infra.yml 會接一個 target input:

  • shared
  • dev-platform
  • prod-platform

provider 不會知道這個 input 是什麼, 所以它沒辦法單靠自己判斷:

  • shared must be main only
  • dev-platform must be dev only

這種事情還是要在 workflow 本身顯式驗證。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
- name: Validate branch
  run: |
    case "${TARGET}" in
      shared|prod-platform)
        test "${GITHUB_REF}" = "refs/heads/main"
        ;;
      dev-platform)
        test "${GITHUB_REF}" = "refs/heads/dev"
        ;;
    esac

這就是我最後採用的 defense-in-depth:

  • workflow validation
  • provider condition
  • service account binding

workflow_refcaller_workflow 這個坑

這是整個 WIF 設計裡最容易踩的坑之一。

如果 workflow 裡又呼叫 reusable workflow, job_workflow_ref 會指向 reusable workflow 本身, 不是最外層入口 workflow。

這代表你如果用錯 claim 做 binding, 很可能會出現這種狀況:

  • trust rule technically passes
  • but the wrong workflow identity is bound

我最後比較穩的做法是:

1
"attribute.caller_workflow" = "assertion.workflow_ref.split('@')[0]"

然後 service account binding 綁這個 caller workflow path。

這樣 reusable 那層和入口 workflow 那層才不會混在一起。

最後為什麼我又把 reusable workflows 拿掉

雖然這篇主題是 WIF, 但這兩件事其實有關。

reusable workflows 的問題不是不能用, 而是它會讓這些東西變得比較難看懂:

  • who owns permissions
  • where id-token: write is actually needed
  • 哪條 workflow 才是真正的操作者入口
  • what shows up in the GitHub Actions UI

所以我最後寧可接受一點重複, 也把 deploy workflows 扁平化回:

  • deploy-dev.yml
  • deploy-prod.yml

shared steps 再留在 composite actions。

這樣做完之後, 最小權限審查反而更直觀。

id-token: write 不要開太大

另一個我會特別記住的點是: 不要在 workflow scope 就直接給 id-token: write, 除非真的整條 workflow 都需要。

比較乾淨的做法是:

  • CI: no id-token
  • validate job: no id-token
  • only build/migrate/deploy jobs that need auth get id-token
1
2
3
4
5
6
7
8
permissions:
  contents: read

jobs:
  build:
    permissions:
      contents: read
      id-token: write

這樣 branch validation job 就不會莫名其妙取得 OIDC 能力。

GitHub environment-scoped vars 也屬於信任邊界

這一點很常被忽略。

如果某個 job 需要 environment-scoped variables, job 必須真的宣告對應 environment:

1
environment: dev

這不只是取值問題, 也是一種 boundary。因為 GitHub environment 本身還可以掛 approval rule。

所以 environment-scoped variables、deploy service account、branch guard, 其實是同一條安全模型上的不同層。

我現在會怎麼檢查這套設計有沒有真的最小權限

我會問這幾題:

  • Can PR CI run without any cloud credential?
  • Can dev deploy impersonate only the dev deploy SA?
  • Can prod deploy impersonate only the prod deploy SA?
  • Can infra impersonate only the infra SA?
  • Does the wrong branch fail before real work begins?
  • Does the provider reject disallowed workflow+branch combinations?

如果其中有一題答不出來, 就還不夠乾淨。

結論

GitHub Actions 接 GCP 的難點從來不是把登入做通而已。

真正難的是把信任邊界切得夠清楚:

  • correct workflow
  • correct branch
  • 正確的 service account
  • 正確的 environment
  • 正確的權限

這幾層一起成立, 才算真的做到最小權限。

References

0%