身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計
權限一旦散落,後面每一層都建在沙上。網路收斂得再好,只要一把權限過大的長期憑證流出,攻擊者就能繞過所有邊界直接動到核心資源;環境分得再乾淨,只要 production 跟 staging 共用同一組身分,一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力,因為它決定了「誰能動什麼」這個問題有沒有可信的答案。
IAM 的心智模型
IAM(Identity and Access Management)是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件:identity(身分,發起動作的主體)、policy(政策,描述允許或拒絕的規則)、role(角色,一組可以被臨時取得的權限集合)。理解這三者的分工,是後面所有憑證決策的前提。
identity:長期主體 vs 臨時假扮
identity 分兩類,這個區分在後面設計權限邊界時會反覆用到。一類是 user,代表一個長期存在的主體,通常對應到一個真人或一個固定的服務帳號,本身可以持有長期憑證(密碼或 access key)。另一類是 role,代表一組權限的暫時授予 — 沒有自己的長期密碼,而是讓某個被信任的身分「假扮(assume)」成它、換取一段有時效的臨時憑證。
把 identity 想成「護照」和「通行證」的差別:user 是護照,長期有效、全程攜帶;role 是通行證,到了管制區域臨時換發、離開就失效。多數安全事故源自於把通行證當護照用 — 某個 role 被長期假扮且從未被撤回,或某個 user 持有永不輪替的 access key。
policy:描述「允許對什麼做什麼」
policy 是貼在 user 或 role 上的規則文件,列出 Action(能做什麼,如 s3:GetObject)、Resource(對哪個資源,如特定 bucket 的 ARN)、Effect(Allow 或 Deny)。一條 policy 可以包含多個 statement,每條 statement 描述一組操作許可。
1# 最小權限範例:CI 只能讀寫特定 bucket,不給整個 S3
2data "aws_iam_policy_document" "ci_artifacts" {
3 statement {
4 effect = "Allow"
5 actions = ["s3:GetObject", "s3:PutObject"]
6 resources = ["arn:aws:s3:::myapp-artifacts/*"]
7 }
8}這段 policy 只允許對 myapp-artifacts 這一個 bucket 做讀寫。如果寫成 resources = ["*"],同一把身分被攻破時,攻擊者就能讀寫帳號內所有 bucket — 差別不在語法,在 Resource 欄位收到多緊。
role:臨時身分的載體
role 本身不持有長期密碼。它靠 trust policy(信任政策)定義「誰能假扮我」,靠 permissions policy 定義「假扮後能做什麼」。trust policy 和 permissions policy 是兩份獨立的文件,分別回答「誰進得來」與「進來後能做什麼」。
1# trust policy:只允許 ECS 服務假扮此 role
2data "aws_iam_policy_document" "ecs_trust" {
3 statement {
4 actions = ["sts:AssumeRole"]
5 principals {
6 type = "Service"
7 identifiers = ["ecs-tasks.amazonaws.com"]
8 }
9 }
10}
11
12resource "aws_iam_role" "api_task" {
13 name = "api-task-prod"
14 assume_role_policy = data.aws_iam_policy_document.ecs_trust.json
15}trust policy 裡的 principals 決定能進門的身分。上面這段把進門權限限給 ECS 服務本身,意味著只有跑在 ECS 上的 task 才能取得這個 role 的臨時憑證 — 一個在本地筆電跑的程式呼叫 AssumeRole 會被拒絕。
最小權限:持續收斂而非一次設定
最小權限(least privilege)是貫穿整套系統的設計原則:一個身分只應該拿到完成它本職工作所需的最小權限集合。多一個 action 是多一條攻擊面,多一個 resource 是多一個爆炸半徑。
最小權限是持續收斂的過程,而非一次設定就結束的靜態狀態。服務初期常為了快速上線給寬鬆權限 — 一個新的 ECS task role 掛上 AmazonS3FullAccess 讓它能跑起來,半年後這個 role 實際只用了 s3:GetObject 和 s3:PutObject 兩個 action、針對一個 bucket,但 policy 裡寫的還是全部 S3 操作對所有 bucket。
收斂的工具是 access analyzer。AWS IAM Access Analyzer 能分析 CloudTrail 日誌,列出某個 role 在過去 N 天內實際用了哪些 action 與 resource,據此產出一份建議的最小 policy。用它的步驟是:開著寬 policy 跑一段時間 → 用 access analyzer 產出實際使用清單 → 把 policy 收斂到這份清單 → 確認服務仍正常。
1# 產出建議 policy:分析 api-task-prod role 過去 90 天的實際用量
2aws accessanalyzer generate-policy \
3 --policy-generation-details '{
4 "principalArn": "arn:aws:iam::123456789012:role/api-task-prod",
5 "cloudTrailDetails": {
6 "trailArn": "arn:aws:cloudtrail:ap-northeast-1:123456789012:trail/main",
7 "startTime": "2026-03-01T00:00:00Z",
8 "endTime": "2026-06-01T00:00:00Z"
9 }
10 }'一個快速的盤點方式:列出所有掛著 AdministratorAccess、PowerUserAccess、*FullAccess 這類寬鬆 managed policy 的 role,每個命中都問一次「這個 role 確實需要這些權限嗎」。CI role 的 policy 裡出現 *:* 更是明確的收斂目標。
長期 access key 的風險
長期 access key 是一組沒有到期時間的靜態憑證(access key ID + secret),任何持有它的人或程式都能以對應身分的全部權限呼叫 API,直到有人手動撤銷為止。它最大的問題是「沒有時效」這個性質本身,會在三個方向上累積風險,而且風險隨團隊規模與時間單調上升。
散落
長期 key 為了被程式使用,會被複製進 .env 檔、CI 設定、本機 ~/.aws/credentials、Slack 訊息、甚至誤推進 git 歷史。每多一個副本就多一個外洩點。一把 key 在半年內可能被貼到六個地方 — 部署腳本、兩個 CI 平台的環境變數、某台共用跳板機的 profile、一封交接信、一位已離職同事的筆電 — 而這六個副本沒有任何中央清單能列舉。
權限過大
因為輪替麻煩,團隊傾向給一把 key 配足夠寬的權限「一次搞定」。建立時圖方便掛了 AdministratorAccess,打算「等穩定了再收斂」,但那天從來沒有到來。於是一把本來只該讀 artifact 的 key 同時握有刪除 production 資料庫的能力。
難以輪替
輪替一把長期 key 意味著找出所有副本、同步替換、確認沒有遺漏。這個成本高到讓多數團隊選擇拖延,於是 key 的有效期變成「無限」,外洩後的曝險窗口也跟著變成無限。用一個問題辨認風險:能不能在五分鐘內回答「這把 key 被用在哪些地方、上次輪替是什麼時候」?答不出來,它就已經是技術債。
常見的散落路徑:部署腳本使用的 admin key 留在 CI 環境變數,建立者離職後沒人知道這把 key 的存在與權限範圍。這類情境的風險在於外洩後沒有手段限制影響範圍 — key 的權限有多大,影響範圍就有多大。用 credential report 定期盤點帳號內所有 access key 的建立時間與使用時間,見模組負一:還沒有 infra 的環境。
長期憑證風險的實際規模可以從兩個案例看到。Snowflake 2024 事件中,攻擊者利用外洩的長期憑證登入缺少 MFA 的客戶環境,執行大量資料匯出,造成跨客戶的資料竊取與勒索(見 Snowflake 2024:憑證濫用與資料竊取)。LastPass 2022 事件則顯示備份路徑的憑證管理缺口會讓影響範圍沿信任鏈擴散——開發環境取得的資訊被用來存取雲端備份,整條路徑的金鑰隔離不足是根因(見 LastPass 2022:備份路徑與鏈式入侵)。兩個案例的共同教訓是:長期憑證的風險不止於外洩本身,而在於外洩後缺乏限制影響範圍的機制。
OIDC:給 CI/CD 的短期憑證
OIDC(OpenID Connect)聯合讓 CI/CD 平台用一段每次執行才簽發、幾分鐘後就失效的短期憑證取代長期 key,從根本上消掉「靜態密鑰散落」這個問題。它的運作方式是建立信任關係:雲端帳號信任某個外部 identity provider(如 GitHub Actions 的 OIDC issuer),當管線執行時,CI 平台簽發一個帶有可驗證 claim 的 token(描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」),雲端用這個 token 換出一段臨時憑證。沒有任何長期 secret 需要被儲存在 CI 設定裡。
trust policy 的收斂
關鍵設計在 role 的 trust policy 上 — 它規定「哪個外部身分被允許假扮成這個 role」。trust policy 要用 token 的 claim 把假扮條件收到最緊。
1# OIDC trust policy:只允許特定 repo 的 main branch 假扮此 role
2data "aws_iam_policy_document" "ci_trust" {
3 statement {
4 actions = ["sts:AssumeRoleWithWebIdentity"]
5
6 principals {
7 type = "Federated"
8 identifiers = [aws_iam_openid_connect_provider.github.arn]
9 }
10
11 condition {
12 test = "StringEquals"
13 variable = "token.actions.githubusercontent.com:aud"
14 values = ["sts.amazonaws.com"]
15 }
16
17 condition {
18 test = "StringLike"
19 variable = "token.actions.githubusercontent.com:sub"
20 values = ["repo:my-org/my-app:ref:refs/heads/main"]
21 }
22 }
23}每個 condition 各守一段邊界。aud 的 StringEquals 確認 token 是發給 AWS STS 的(防止用錯 audience 的 token 闖入)。sub 的 StringLike 把假扮限定在特定 repo 的 main branch — 設成 repo:my-org/* 等於讓組織內任何 repo 的任何 branch 都能假扮這個 role,這是常見的設定陷阱。
收斂 trust policy 的判讀問法是:「如果 my-org 底下某個公開 fork 跑了一個惡意 workflow,它能不能假扮這個 role?」如果答案是能,sub 條件就太鬆了。
分離 plan 與 apply 的 role
進一步的收斂是替 plan 和 apply 分別建立 role。plan 只需要唯讀存取(讀 state、讀雲端現況),apply 需要寫入權限。把兩者分成獨立 role,讓 PR 階段的 CI 用唯讀 role 跑 plan、合併後才用寫入 role 跑 apply。任何拿到 plan role 的 token 無法修改基礎設施。
1# plan role:只需讀取 state 與雲端現況
2resource "aws_iam_role" "ci_plan" {
3 name = "infra-ci-plan"
4 assume_role_policy = data.aws_iam_policy_document.ci_trust.json
5}
6
7resource "aws_iam_role_policy_attachment" "ci_plan_read" {
8 role = aws_iam_role.ci_plan.name
9 policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
10}
11
12# apply role:需要寫入權限,trust policy 限定只有 main branch
13resource "aws_iam_role" "ci_apply" {
14 name = "infra-ci-apply"
15 assume_role_policy = data.aws_iam_policy_document.ci_trust_main_only.json
16}這一章把 role 與 trust policy 設計好,OIDC 的實際回報要到模組七:infra 走 PR 流程建管線時才兌現 — 屆時管線用這裡定義好的 role 取得短期權限執行 plan 與 apply,CI 環境裡不需要存任何 access key。
權限邊界設計
權限邊界是把不同類型的身分與不同環境之間的權限刻意隔開,讓任何一個身分被攻破時,爆炸半徑都被限制在它本職的範圍內。邊界設計有兩條軸線需要分別處理:人 vs 機器,以及環境之間。
人 vs 機器
兩者的存取模式根本不同,混在同一個身分上會同時喪失兩邊的保護。
人類身分需要互動式登入、應該強制 MFA、權限隨職責變動,且通常透過 SSO 集中管理。機器身分(CI runner、ECS task、Lambda function)需要的是程式化、無人值守的存取,應該用 role 假扮取得短期憑證,永遠不該配長期 key。
機器身分還要再依「跑在哪裡」分兩類。跑在雲上的 workload(EC2 instance、ECS task、Lambda)由平台直接把 role 綁在執行環境上 — AWS 用 instance profile 把 role 掛在 EC2、用 task role 掛在 ECS task,workload 從實例 metadata 端點自動取得輪替的短期憑證。跑在雲外的 CI/CD(GitHub Actions、GitLab CI)拿不到實例 metadata,需要前面那套 OIDC 信任關係換憑證。
一個常見陷阱是工程師用自己的個人 key 跑自動化腳本 — 這把人的廣泛權限直接送進了無人值守的執行環境,MFA 保護形同虛設(API 呼叫不需要 MFA challenge),權限範圍比任何 CI role 都大。
環境之間
環境之間的邊界,目的是讓 production 的權限與 staging、dev 完全不交叉。驗證邊界的方式是用 dev 環境的 CI role 嘗試列出或刪除 production 的資源——能做到,就代表邊界沒有建立。
帳號級護欄:SCP
Organizations 把環境拆成獨立帳號,再用 SCP(Service Control Policy)對整個帳號或組織單位設定權限天花板,連帳號內的管理員都越不過去。SCP 是 deny-based 的頂層限制 — 它不授予任何權限,只限制「即使有人給了權限也不准做」。
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "DenyLeaveOrg",
6 "Effect": "Deny",
7 "Action": ["organizations:LeaveOrganization"],
8 "Resource": "*"
9 },
10 {
11 "Sid": "DenyDisableCloudTrail",
12 "Effect": "Deny",
13 "Action": [
14 "cloudtrail:StopLogging",
15 "cloudtrail:DeleteTrail"
16 ],
17 "Resource": "*"
18 }
19 ]
20}這份 SCP 掛在整個組織底下的所有帳號上,確保任何帳號都不能關閉稽核日誌或退出組織 — 即使該帳號裡有人持有 AdministratorAccess。SCP 的定位是組織層的不可踰越底線。
Role 級護欄:Permissions Boundary
Permissions Boundary 是掛在單一 role 上的權限上限。它跟 SCP 的差別在粒度:SCP 管整個帳號,Permissions Boundary 管單一身分。即使有人後來給一個 role 貼了過寬的 policy,Boundary 也會擋住超出上限的部分。
1# Permissions Boundary:CI role 最多只能操作特定服務
2resource "aws_iam_policy" "ci_boundary" {
3 name = "ci-boundary-prod"
4 policy = jsonencode({
5 Version = "2012-10-17"
6 Statement = [
7 {
8 Effect = "Allow"
9 Action = ["ecs:*", "ecr:*", "s3:*", "logs:*"]
10 Resource = "*"
11 },
12 {
13 Effect = "Deny"
14 Action = ["iam:*", "organizations:*", "account:*"]
15 Resource = "*"
16 }
17 ]
18 })
19}
20
21resource "aws_iam_role" "ci_apply" {
22 name = "infra-ci-apply"
23 assume_role_policy = data.aws_iam_policy_document.ci_trust.json
24 permissions_boundary = aws_iam_policy.ci_boundary.arn
25}SCP 與 Permissions Boundary 疊起來的效果是:SCP 在帳號層鎖住最危險的操作(關日誌、退組織),Boundary 在 role 層限制單一身分最多能做什麼,permissions policy 在這兩層天花板之內授予實際需要的權限。三者各管一層,缺一層就少一道屏障。
身分控制面本身的韌性在兩個案例中被檢驗。Azure AD 2021 事件中,身分服務的控制面故障導致所有依賴身份驗證的服務同時受影響,事故處理需要在身份恢復與服務降級策略之間排優先序(見 Azure AD:Identity Control-plane 事件)。Microsoft Storm-0558 事件則顯示簽章金鑰一旦失守,token 驗證的信任鏈會跨租戶失效,修復不只是修補漏洞、而是重建整條 key lifecycle 與 issuer 驗證流程(見 Microsoft:Storm-0558 簽章金鑰事件)。這兩個案例揭露的是:權限邊界只管「某個身分能做什麼」,但身分系統本身的控制面如果失效,所有建立在它之上的邊界都跟著失效。
環境隔離的更完整實作(帳號結構、模組化參數)會在模組四:環境分離與模組化展開。
身分層 vs 應用層 secret 的邊界
這一章談的是身分與憑證 — 誰是誰、怎麼證明、能動什麼。憑證背後引用的應用層 secret(資料庫密碼、第三方 API key)怎麼安全儲存與注入,屬於模組八:治理好習慣的 secret management 範圍。兩者的交集是:身分層決定「誰能讀到 secret store」,secret 層決定「secret 怎麼存與輪替」。把 IAM role 的 policy 收到只能讀取該服務路徑下的 secret(如 prod/payments/*),是同時落實最小權限與 secret 隔離的結合點。
身分與憑證的地基備妥後,下一步是劃清服務之間的網路邊界——這正是模組三:網路地基的範圍。
跨分類引用
- → 模組負一:還沒有 infra 的環境:長期 key 盤點與護欄
- → 模組三:網路地基:身分備妥後,劃清服務之間的網路邊界
- → 模組四:環境分離與模組化:環境之間的帳號結構與隔離強度
- → 模組七:infra 走 PR 流程:CI/CD 管線用 OIDC 取得短期權限
- → 模組八:治理好習慣:應用層 secret 的儲存與引用
- → backend 模組七:資安與資料保護:Secret Management 與憑證管理交集
- → Access Key 輪替手冊:key 盤點與輪替的操作步驟
- → OIDC Trust Policy 設定指南:GitHub Actions OIDC 的 step-by-step 設定