<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組二：身分與憑證地基 — IAM 與 OIDC on Tarragon</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/</link><description>Recent content in 模組二：身分與憑證地基 — IAM 與 OIDC on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 26 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/infra/02-identity-credentials/index.xml" rel="self" type="application/rss+xml"/><item><title>身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/</guid><description>&lt;p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。&lt;/p>
&lt;h2 id="iam-的心智模型">IAM 的心智模型&lt;/h2>
&lt;p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述允許或拒絕的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。&lt;/p>
&lt;h3 id="identity長期主體-vs-臨時假扮">identity：長期主體 vs 臨時假扮&lt;/h3>
&lt;p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證（密碼或 access key）。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。&lt;/p>
&lt;p>把 identity 想成「護照」和「通行證」的差別：user 是護照，長期有效、全程攜帶；role 是通行證，到了管制區域臨時換發、離開就失效。多數安全事故源自於把通行證當護照用 — 某個 role 被長期假扮且從未被撤回，或某個 user 持有永不輪替的 access key。&lt;/p>
&lt;h3 id="policy描述允許對什麼做什麼">policy：描述「允許對什麼做什麼」&lt;/h3>
&lt;p>policy 是貼在 user 或 role 上的規則文件，列出 &lt;code>Action&lt;/code>（能做什麼，如 &lt;code>s3:GetObject&lt;/code>）、&lt;code>Resource&lt;/code>（對哪個資源，如特定 bucket 的 ARN）、&lt;code>Effect&lt;/code>（Allow 或 Deny）。一條 policy 可以包含多個 statement，每條 statement 描述一組操作許可。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 最小權限範例：CI 只能讀寫特定 bucket，不給整個 S3
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_artifacts&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;s3:GetObject&amp;#34;, &amp;#34;s3:PutObject&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;arn:aws:s3:::myapp-artifacts/*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段 policy 只允許對 &lt;code>myapp-artifacts&lt;/code> 這一個 bucket 做讀寫。如果寫成 &lt;code>resources = [&amp;quot;*&amp;quot;]&lt;/code>，同一把身分被攻破時，攻擊者就能讀寫帳號內所有 bucket — 差別不在語法，在 &lt;code>Resource&lt;/code> 欄位收到多緊。&lt;/p>
&lt;h3 id="role臨時身分的載體">role：臨時身分的載體&lt;/h3>
&lt;p>role 本身不持有長期密碼。它靠 trust policy（信任政策）定義「誰能假扮我」，靠 permissions policy 定義「假扮後能做什麼」。trust policy 和 permissions policy 是兩份獨立的文件，分別回答「誰進得來」與「進來後能做什麼」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># trust policy：只允許 ECS 服務假扮此 role
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_trust&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Service&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;api_task&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;api-task-prod&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_trust&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>trust policy 裡的 &lt;code>principals&lt;/code> 決定能進門的身分。上面這段把進門權限限給 ECS 服務本身，意味著只有跑在 ECS 上的 task 才能取得這個 role 的臨時憑證 — 一個在本地筆電跑的程式呼叫 &lt;code>AssumeRole&lt;/code> 會被拒絕。&lt;/p>
&lt;h2 id="最小權限持續收斂而非一次設定">最小權限：持續收斂而非一次設定&lt;/h2>
&lt;p>最小權限（least privilege）是貫穿整套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合。多一個 action 是多一條攻擊面，多一個 resource 是多一個爆炸半徑。&lt;/p>
&lt;p>最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態。服務初期常為了快速上線給寬鬆權限 — 一個新的 ECS task role 掛上 &lt;code>AmazonS3FullAccess&lt;/code> 讓它能跑起來，半年後這個 role 實際只用了 &lt;code>s3:GetObject&lt;/code> 和 &lt;code>s3:PutObject&lt;/code> 兩個 action、針對一個 bucket，但 policy 裡寫的還是全部 S3 操作對所有 bucket。&lt;/p>
&lt;p>收斂的工具是 access analyzer。AWS IAM Access Analyzer 能分析 CloudTrail 日誌，列出某個 role 在過去 N 天內實際用了哪些 action 與 resource，據此產出一份建議的最小 policy。用它的步驟是：開著寬 policy 跑一段時間 → 用 access analyzer 產出實際使用清單 → 把 policy 收斂到這份清單 → 確認服務仍正常。&lt;/p></description><content:encoded><![CDATA[<p>權限一旦散落，後面每一層都建在沙上。網路收斂得再好，只要一把權限過大的長期憑證流出，攻擊者就能繞過所有邊界直接動到核心資源；環境分得再乾淨，只要 production 跟 staging 共用同一組身分，一次誤操作就跨環境炸開。身分與憑證是地基層最先該收斂的能力，因為它決定了「誰能動什麼」這個問題有沒有可信的答案。</p>
<h2 id="iam-的心智模型">IAM 的心智模型</h2>
<p>IAM（Identity and Access Management）是雲端平台用來回答「某個身分能不能對某個資源做某件事」的授權系統。它把授權拆成三個獨立的零件：identity（身分，發起動作的主體）、policy（政策，描述允許或拒絕的規則）、role（角色，一組可以被臨時取得的權限集合）。理解這三者的分工，是後面所有憑證決策的前提。</p>
<h3 id="identity長期主體-vs-臨時假扮">identity：長期主體 vs 臨時假扮</h3>
<p>identity 分兩類，這個區分在後面設計權限邊界時會反覆用到。一類是 user，代表一個長期存在的主體，通常對應到一個真人或一個固定的服務帳號，本身可以持有長期憑證（密碼或 access key）。另一類是 role，代表一組權限的暫時授予 — 沒有自己的長期密碼，而是讓某個被信任的身分「假扮（assume）」成它、換取一段有時效的臨時憑證。</p>
<p>把 identity 想成「護照」和「通行證」的差別：user 是護照，長期有效、全程攜帶；role 是通行證，到了管制區域臨時換發、離開就失效。多數安全事故源自於把通行證當護照用 — 某個 role 被長期假扮且從未被撤回，或某個 user 持有永不輪替的 access key。</p>
<h3 id="policy描述允許對什麼做什麼">policy：描述「允許對什麼做什麼」</h3>
<p>policy 是貼在 user 或 role 上的規則文件，列出 <code>Action</code>（能做什麼，如 <code>s3:GetObject</code>）、<code>Resource</code>（對哪個資源，如特定 bucket 的 ARN）、<code>Effect</code>（Allow 或 Deny）。一條 policy 可以包含多個 statement，每條 statement 描述一組操作許可。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 最小權限範例：CI 只能讀寫特定 bucket，不給整個 S3
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_artifacts&#34;</span> {
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">    effect</span>    <span class="o">=</span> <span class="s2">&#34;Allow&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="n">    actions</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;s3:GetObject&#34;, &#34;s3:PutObject&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;arn:aws:s3:::myapp-artifacts/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>這段 policy 只允許對 <code>myapp-artifacts</code> 這一個 bucket 做讀寫。如果寫成 <code>resources = [&quot;*&quot;]</code>，同一把身分被攻破時，攻擊者就能讀寫帳號內所有 bucket — 差別不在語法，在 <code>Resource</code> 欄位收到多緊。</p>
<h3 id="role臨時身分的載體">role：臨時身分的載體</h3>
<p>role 本身不持有長期密碼。它靠 trust policy（信任政策）定義「誰能假扮我」，靠 permissions policy 定義「假扮後能做什麼」。trust policy 和 permissions policy 是兩份獨立的文件，分別回答「誰進得來」與「進來後能做什麼」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># trust policy：只允許 ECS 服務假扮此 role
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ecs_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRole&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">      type</span>        <span class="o">=</span> <span class="s2">&#34;Service&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ecs-tasks.amazonaws.com&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  }
</span></span><span class="line"><span class="ln">10</span><span class="cl">}
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;api_task&#34;</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;api-task-prod&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">ecs_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">}</span></span></code></pre></div><p>trust policy 裡的 <code>principals</code> 決定能進門的身分。上面這段把進門權限限給 ECS 服務本身，意味著只有跑在 ECS 上的 task 才能取得這個 role 的臨時憑證 — 一個在本地筆電跑的程式呼叫 <code>AssumeRole</code> 會被拒絕。</p>
<h2 id="最小權限持續收斂而非一次設定">最小權限：持續收斂而非一次設定</h2>
<p>最小權限（least privilege）是貫穿整套系統的設計原則：一個身分只應該拿到完成它本職工作所需的最小權限集合。多一個 action 是多一條攻擊面，多一個 resource 是多一個爆炸半徑。</p>
<p>最小權限是持續收斂的過程，而非一次設定就結束的靜態狀態。服務初期常為了快速上線給寬鬆權限 — 一個新的 ECS task role 掛上 <code>AmazonS3FullAccess</code> 讓它能跑起來，半年後這個 role 實際只用了 <code>s3:GetObject</code> 和 <code>s3:PutObject</code> 兩個 action、針對一個 bucket，但 policy 裡寫的還是全部 S3 操作對所有 bucket。</p>
<p>收斂的工具是 access analyzer。AWS IAM Access Analyzer 能分析 CloudTrail 日誌，列出某個 role 在過去 N 天內實際用了哪些 action 與 resource，據此產出一份建議的最小 policy。用它的步驟是：開著寬 policy 跑一段時間 → 用 access analyzer 產出實際使用清單 → 把 policy 收斂到這份清單 → 確認服務仍正常。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 產出建議 policy：分析 api-task-prod role 過去 90 天的實際用量</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws accessanalyzer generate-policy <span class="se">\
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="se"></span>  --policy-generation-details <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s1">    &#34;principalArn&#34;: &#34;arn:aws:iam::123456789012:role/api-task-prod&#34;,
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s1">    &#34;cloudTrailDetails&#34;: {
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s1">      &#34;trailArn&#34;: &#34;arn:aws:cloudtrail:ap-northeast-1:123456789012:trail/main&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">      &#34;startTime&#34;: &#34;2026-03-01T00:00:00Z&#34;,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">      &#34;endTime&#34;: &#34;2026-06-01T00:00:00Z&#34;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">    }
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  }&#39;</span></span></span></code></pre></div><p>一個快速的盤點方式：列出所有掛著 <code>AdministratorAccess</code>、<code>PowerUserAccess</code>、<code>*FullAccess</code> 這類寬鬆 managed policy 的 role，每個命中都問一次「這個 role 確實需要這些權限嗎」。CI role 的 policy 裡出現 <code>*:*</code> 更是明確的收斂目標。</p>
<h2 id="長期-access-key-的風險">長期 access key 的風險</h2>
<p>長期 access key 是一組沒有到期時間的靜態憑證（access key ID + secret），任何持有它的人或程式都能以對應身分的全部權限呼叫 API，直到有人手動撤銷為止。它最大的問題是「沒有時效」這個性質本身，會在三個方向上累積風險，而且風險隨團隊規模與時間單調上升。</p>
<h3 id="散落">散落</h3>
<p>長期 key 為了被程式使用，會被複製進 <code>.env</code> 檔、CI 設定、本機 <code>~/.aws/credentials</code>、Slack 訊息、甚至誤推進 git 歷史。每多一個副本就多一個外洩點。一把 key 在半年內可能被貼到六個地方 — 部署腳本、兩個 CI 平台的環境變數、某台共用跳板機的 profile、一封交接信、一位已離職同事的筆電 — 而這六個副本沒有任何中央清單能列舉。</p>
<h3 id="權限過大">權限過大</h3>
<p>因為輪替麻煩，團隊傾向給一把 key 配足夠寬的權限「一次搞定」。建立時圖方便掛了 AdministratorAccess，打算「等穩定了再收斂」，但那天從來沒有到來。於是一把本來只該讀 artifact 的 key 同時握有刪除 production 資料庫的能力。</p>
<h3 id="難以輪替">難以輪替</h3>
<p>輪替一把長期 key 意味著找出所有副本、同步替換、確認沒有遺漏。這個成本高到讓多數團隊選擇拖延，於是 key 的有效期變成「無限」，外洩後的曝險窗口也跟著變成無限。用一個問題辨認風險：能不能在五分鐘內回答「這把 key 被用在哪些地方、上次輪替是什麼時候」？答不出來，它就已經是技術債。</p>
<p>常見的散落路徑：部署腳本使用的 admin key 留在 CI 環境變數，建立者離職後沒人知道這把 key 的存在與權限範圍。這類情境的風險在於外洩後沒有手段限制影響範圍 — key 的權限有多大，影響範圍就有多大。用 credential report 定期盤點帳號內所有 access key 的建立時間與使用時間，見<a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>。</p>
<p>長期憑證風險的實際規模可以從兩個案例看到。Snowflake 2024 事件中，攻擊者利用外洩的長期憑證登入缺少 MFA 的客戶環境，執行大量資料匯出，造成跨客戶的資料竊取與勒索（見 <a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/snowflake-2024-credential-abuse/" data-link-title="7.R7.4.2 Snowflake 2024：憑證濫用與資料竊取" data-link-desc="外洩憑證與 MFA 缺口如何在資料平台形成高風險外送事件">Snowflake 2024：憑證濫用與資料竊取</a>）。LastPass 2022 事件則顯示備份路徑的憑證管理缺口會讓影響範圍沿信任鏈擴散——開發環境取得的資訊被用來存取雲端備份，整條路徑的金鑰隔離不足是根因（見 <a href="/blog/backend/07-security-data-protection/red-team/cases/data-exfiltration/lastpass-2022-backup-chain/" data-link-title="7.R7.4.1 LastPass 2022：備份路徑與鏈式入侵" data-link-desc="開發環境資訊外流如何沿著備份路徑擴大成資料風險">LastPass 2022：備份路徑與鏈式入侵</a>）。兩個案例的共同教訓是：長期憑證的風險不止於外洩本身，而在於外洩後缺乏限制影響範圍的機制。</p>
<h2 id="oidc給-cicd-的短期憑證">OIDC：給 CI/CD 的短期憑證</h2>
<p>OIDC（OpenID Connect）聯合讓 CI/CD 平台用一段每次執行才簽發、幾分鐘後就失效的短期憑證取代長期 key，從根本上消掉「靜態密鑰散落」這個問題。它的運作方式是建立信任關係：雲端帳號信任某個外部 identity provider（如 GitHub Actions 的 OIDC issuer），當管線執行時，CI 平台簽發一個帶有可驗證 claim 的 token（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），雲端用這個 token 換出一段臨時憑證。沒有任何長期 secret 需要被儲存在 CI 設定裡。</p>
<h3 id="trust-policy-的收斂">trust policy 的收斂</h3>
<p>關鍵設計在 role 的 trust policy 上 — 它規定「哪個外部身分被允許假扮成這個 role」。trust policy 要用 token 的 claim 把假扮條件收到最緊。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># OIDC trust policy：只允許特定 repo 的 main branch 假扮此 role
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRoleWithWebIdentity&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">principals</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      type</span>        <span class="o">=</span> <span class="s2">&#34;Federated&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_openid_connect_provider</span><span class="p">.</span><span class="k">github</span><span class="p">.</span><span class="k">arn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    }
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:aud&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    }
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    }
</span></span><span class="line"><span class="ln">22</span><span class="cl">  }
</span></span><span class="line"><span class="ln">23</span><span class="cl">}</span></span></code></pre></div><p>每個 condition 各守一段邊界。<code>aud</code> 的 <code>StringEquals</code> 確認 token 是發給 AWS STS 的（防止用錯 audience 的 token 闖入）。<code>sub</code> 的 <code>StringLike</code> 把假扮限定在特定 repo 的 main branch — 設成 <code>repo:my-org/*</code> 等於讓組織內任何 repo 的任何 branch 都能假扮這個 role，這是常見的設定陷阱。</p>
<p>收斂 trust policy 的判讀問法是：「如果 my-org 底下某個公開 fork 跑了一個惡意 workflow，它能不能假扮這個 role？」如果答案是能，<code>sub</code> 條件就太鬆了。</p>
<h3 id="分離-plan-與-apply-的-role">分離 plan 與 apply 的 role</h3>
<p>進一步的收斂是替 <code>plan</code> 和 <code>apply</code> 分別建立 role。plan 只需要唯讀存取（讀 state、讀雲端現況），apply 需要寫入權限。把兩者分成獨立 role，讓 PR 階段的 CI 用唯讀 role 跑 plan、合併後才用寫入 role 跑 apply。任何拿到 plan role 的 token 無法修改基礎設施。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># plan role：只需讀取 state 與雲端現況
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;ci_plan&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-ci-plan&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">ci_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role_policy_attachment&#34; &#34;ci_plan_read&#34;</span> {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  role</span>       <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">ci_plan</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  policy_arn</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::aws:policy/ReadOnlyAccess&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">}<span class="c1">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># apply role：需要寫入權限，trust policy 限定只有 main branch
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;ci_apply&#34;</span> {
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-ci-apply&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">ci_trust_main_only</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">}</span></span></code></pre></div><p>這一章把 role 與 trust policy 設計好，OIDC 的實際回報要到<a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>建管線時才兌現 — 屆時管線用這裡定義好的 role 取得短期權限執行 <code>plan</code> 與 <code>apply</code>，CI 環境裡不需要存任何 access key。</p>
<h2 id="權限邊界設計">權限邊界設計</h2>
<p>權限邊界是把不同類型的身分與不同環境之間的權限刻意隔開，讓任何一個身分被攻破時，爆炸半徑都被限制在它本職的範圍內。邊界設計有兩條軸線需要分別處理：人 vs 機器，以及環境之間。</p>
<h3 id="人-vs-機器">人 vs 機器</h3>
<p>兩者的存取模式根本不同，混在同一個身分上會同時喪失兩邊的保護。</p>
<p>人類身分需要互動式登入、應該強制 MFA、權限隨職責變動，且通常透過 SSO 集中管理。機器身分（CI runner、ECS task、Lambda function）需要的是程式化、無人值守的存取，應該用 role 假扮取得短期憑證，永遠不該配長期 key。</p>
<p>機器身分還要再依「跑在哪裡」分兩類。跑在雲上的 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 信任關係換憑證。</p>
<p>一個常見陷阱是工程師用自己的個人 key 跑自動化腳本 — 這把人的廣泛權限直接送進了無人值守的執行環境，MFA 保護形同虛設（API 呼叫不需要 MFA challenge），權限範圍比任何 CI role 都大。</p>
<h3 id="環境之間">環境之間</h3>
<p>環境之間的邊界，目的是讓 production 的權限與 staging、dev 完全不交叉。驗證邊界的方式是用 dev 環境的 CI role 嘗試列出或刪除 production 的資源——能做到，就代表邊界沒有建立。</p>
<h4 id="帳號級護欄scp">帳號級護欄：SCP</h4>
<p>Organizations 把環境拆成獨立帳號，再用 SCP（Service Control Policy）對整個帳號或組織單位設定權限天花板，連帳號內的管理員都越不過去。SCP 是 deny-based 的頂層限制 — 它不授予任何權限，只限制「即使有人給了權限也不准做」。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyLeaveOrg&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;organizations:LeaveOrganization&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyDisableCloudTrail&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">      <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="s2">&#34;cloudtrail:StopLogging&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="s2">&#34;cloudtrail:DeleteTrail&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">],</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這份 SCP 掛在整個組織底下的所有帳號上，確保任何帳號都不能關閉稽核日誌或退出組織 — 即使該帳號裡有人持有 AdministratorAccess。SCP 的定位是組織層的不可踰越底線。</p>
<h4 id="role-級護欄permissions-boundary">Role 級護欄：Permissions Boundary</h4>
<p>Permissions Boundary 是掛在單一 role 上的權限上限。它跟 SCP 的差別在粒度：SCP 管整個帳號，Permissions Boundary 管單一身分。即使有人後來給一個 role 貼了過寬的 policy，Boundary 也會擋住超出上限的部分。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Permissions Boundary：CI role 最多只能操作特定服務
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">resource</span> <span class="s2">&#34;aws_iam_policy&#34; &#34;ci_boundary&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;ci-boundary-prod&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  policy</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    Version</span> <span class="o">=</span> <span class="s2">&#34;2012-10-17&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    Statement</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      {
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">        Effect</span>   <span class="o">=</span> <span class="s2">&#34;Allow&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">        Action</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ecs:*&#34;, &#34;ecr:*&#34;, &#34;s3:*&#34;, &#34;logs:*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">        Resource</span> <span class="o">=</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      }<span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">        Effect</span>   <span class="o">=</span> <span class="s2">&#34;Deny&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">        Action</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;iam:*&#34;, &#34;organizations:*&#34;, &#34;account:*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">        Resource</span> <span class="o">=</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      }
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">}
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;ci_apply&#34;</span> {
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">  name</span>                 <span class="o">=</span> <span class="s2">&#34;infra-ci-apply&#34;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  assume_role_policy</span>   <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">ci_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="n">  permissions_boundary</span> <span class="o">=</span> <span class="k">aws_iam_policy</span><span class="p">.</span><span class="k">ci_boundary</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">}</span></span></code></pre></div><p>SCP 與 Permissions Boundary 疊起來的效果是：SCP 在帳號層鎖住最危險的操作（關日誌、退組織），Boundary 在 role 層限制單一身分最多能做什麼，permissions policy 在這兩層天花板之內授予實際需要的權限。三者各管一層，缺一層就少一道屏障。</p>
<p>身分控制面本身的韌性在兩個案例中被檢驗。Azure AD 2021 事件中，身分服務的控制面故障導致所有依賴身份驗證的服務同時受影響，事故處理需要在身份恢復與服務降級策略之間排優先序（見 <a href="/blog/backend/07-security-data-protection/cases/azure-ad-identity-control-plane-2021/" data-link-title="7.C3 Azure AD：2021 Identity Control-plane 事件" data-link-desc="身分控制面事件如何影響多服務信任鏈與回復優先序。">Azure AD：Identity Control-plane 事件</a>）。Microsoft Storm-0558 事件則顯示簽章金鑰一旦失守，token 驗證的信任鏈會跨租戶失效，修復不只是修補漏洞、而是重建整條 key lifecycle 與 issuer 驗證流程（見 <a href="/blog/backend/07-security-data-protection/cases/microsoft-storm-0558-signing-key-2023/" data-link-title="7.C4 Microsoft：Storm-0558 簽章金鑰事件" data-link-desc="簽章金鑰事件如何回寫 identity 信任邊界與觀測證據鏈。">Microsoft：Storm-0558 簽章金鑰事件</a>）。這兩個案例揭露的是：權限邊界只管「某個身分能做什麼」，但身分系統本身的控制面如果失效，所有建立在它之上的邊界都跟著失效。</p>
<p>環境隔離的更完整實作（帳號結構、模組化參數）會在<a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>展開。</p>
<h2 id="身分層-vs-應用層-secret-的邊界">身分層 vs 應用層 secret 的邊界</h2>
<p>這一章談的是身分與憑證 — 誰是誰、怎麼證明、能動什麼。憑證背後引用的應用層 secret（資料庫密碼、第三方 API key）怎麼安全儲存與注入，屬於<a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>的 secret management 範圍。兩者的交集是：身分層決定「誰能讀到 secret store」，secret 層決定「secret 怎麼存與輪替」。把 IAM role 的 policy 收到只能讀取該服務路徑下的 secret（如 <code>prod/payments/*</code>），是同時落實最小權限與 secret 隔離的結合點。</p>
<p>身分與憑證的地基備妥後，下一步是劃清服務之間的網路邊界——這正是<a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>的範圍。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/before-infra/" data-link-title="模組負一：還沒有 infra 的環境怎麼盡量做好" data-link-desc="手動點起家的環境怎麼守底線、降低未來納管成本、辨識何時該開始導入 IaC — 給還沒有能力上 IaC 的真實起點">模組負一：還沒有 infra 的環境</a>：長期 key 盤點與護欄</li>
<li>→ <a href="/blog/infra/03-network-foundation/" data-link-title="模組三：網路地基 — VPC 與分層" data-link-desc="VPC、public / private subnet 切分、route table、NAT、security group 設計">模組三：網路地基</a>：身分備妥後，劃清服務之間的網路邊界</li>
<li>→ <a href="/blog/infra/04-environment-separation/" data-link-title="模組四：環境分離與模組化" data-link-desc="dev / staging / prod 切分、目錄結構 vs workspace、用可重用 module 避免環境漂移">模組四：環境分離與模組化</a>：環境之間的帳號結構與隔離強度</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">模組七：infra 走 PR 流程</a>：CI/CD 管線用 OIDC 取得短期權限</li>
<li>→ <a href="/blog/infra/08-governance-habits/" data-link-title="模組八：治理好習慣 — 規模長大後不失控的最小節奏" data-link-desc="tagging 規範、secrets 不進 code、成本可見性、最小可行節奏，規模長大後不失控">模組八：治理好習慣</a>：應用層 secret 的儲存與引用</li>
<li>→ <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">backend 模組七：資安與資料保護</a>：Secret Management 與憑證管理交集</li>
<li>→ <a href="/blog/infra/02-identity-credentials/access-key-rotation-playbook/" data-link-title="Access Key 輪替手冊" data-link-desc="從 credential report 盤點散落的長期 access key，到逐把輪替、自動化輪替與 key age 監控的完整操作步驟">Access Key 輪替手冊</a>：key 盤點與輪替的操作步驟</li>
<li>→ <a href="/blog/infra/02-identity-credentials/oidc-trust-policy-setup/" data-link-title="OIDC Trust Policy 設定指南" data-link-desc="GitHub Actions 與 AWS 之間的 OIDC 聯合設定：建立 provider、設計 trust policy 的 claim 收斂、plan 與 apply role 分離、常見錯誤排查">OIDC Trust Policy 設定指南</a>：GitHub Actions OIDC 的 step-by-step 設定</li>
</ul>
]]></content:encoded></item><item><title>跨帳號策略 — Organizations、SCP 與帳號工廠</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/multi-account-strategy/</guid><description>&lt;p>單一帳號走到某個規模後，帳號本身會變成隔離的瓶頸。IAM policy 能控制「誰能做什麼」，但同一個帳號裡的所有資源共用同一組 service quota、同一份 CloudTrail、同一張帳單，一個團隊的操作失誤或資源耗盡會波及整個帳號。把環境拆成獨立帳號，讓每個帳號只承載一個職責，是 IAM 之上的第二層隔離 — &lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的身分與憑證地基&lt;/a>控制的是「誰能做什麼」，帳號邊界控制的是「做錯了波及多遠」。&lt;/p>
&lt;h2 id="單帳號-vs-多帳號什麼時候該切">單帳號 vs 多帳號：什麼時候該切&lt;/h2>
&lt;p>單帳號在早期是合理的起點 — 資源少、人少、管理成本低。帳號邊界帶來的隔離收益要跟它的管理成本比較：每多一個帳號就多一份 CloudTrail、多一組 IAM 基線、多一個需要管理的 state backend。&lt;/p>
&lt;p>三個訊號出現時，單帳號的邊際風險開始超過多帳號的管理成本：&lt;/p>
&lt;p>第一，production 和 dev 的資源開始互相影響。一個 dev 環境的壓力測試把帳號的 EC2 instance quota 吃滿，production 的 auto-scaling 因為拿不到新 instance 而失敗 — 這個故障跟程式碼品質無關，純粹是兩個環境共用同一組配額。帳號分開後，dev 吃滿自己的 quota 不會碰到 production。&lt;/p>
&lt;p>第二，權限邊界用 IAM 已經管不住。一個工程師的 IAM policy 限制他只能操作 &lt;code>env=dev&lt;/code> 的資源，但他手滑用了一個沒有 tag 條件的 policy、或者某個 IAM role 的 trust policy 太寬，他就能碰到 production 資源。帳號邊界是比 IAM policy 更硬的護欄 — 即使 IAM 設定出錯，帳號邊界本身就是物理隔離。&lt;/p>
&lt;p>第三，合規或稽核要求明確區分環境。SOC 2 或金融監管可能要求 production 環境有獨立的存取紀錄和變更審計，與開發環境完全分離。同帳號裡做這件事要靠大量的 IAM 條件和 CloudTrail filter，跨帳號則天然滿足。&lt;/p>
&lt;h2 id="ou-結構帳號怎麼分群">OU 結構：帳號怎麼分群&lt;/h2>
&lt;p>AWS Organizations 用 Organizational Unit（OU）把帳號分群，OU 是 SCP 的掛載點 — 一條 SCP 掛在 OU 上，底下所有帳號都受約束。OU 的設計決定了護欄的作用範圍。&lt;/p>
&lt;p>常見的 OU 拓撲有四層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>OU&lt;/th>
 &lt;th>底下的帳號&lt;/th>
 &lt;th>職責&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Security&lt;/td>
 &lt;td>Log Archive、Security Tooling&lt;/td>
 &lt;td>集中存放 CloudTrail / Config 日誌、安全工具帳號&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workload / Prod&lt;/td>
 &lt;td>每個產品線或服務的 production 帳號&lt;/td>
 &lt;td>承載正式流量，SCP 最嚴格&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Workload / NonProd&lt;/td>
 &lt;td>dev、staging 帳號&lt;/td>
 &lt;td>承載開發與驗證，SCP 較寬鬆但仍有底線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Sandbox&lt;/td>
 &lt;td>個人實驗帳號&lt;/td>
 &lt;td>可隨時重建，SCP 限制預算上限和禁止的服務&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>環境怎麼對應到帳號，跟&lt;a href="https://tarrragon.github.io/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四的環境分離&lt;/a>是同一個問題的不同層次 — 模組四用目錄和 state 分離環境的 IaC，這裡用帳號分離環境的雲端資源。兩者可以疊加：每個帳號裡的 IaC 仍然用獨立目錄和 state 管理。&lt;/p>
&lt;p>OU 結構的設計原則是「按信任等級分群、按職責隔離」。Prod 跟 NonProd 分開是因為信任等級不同（prod 的 SCP 更嚴格）。Security 獨立是因為它的職責是「監控其他所有帳號」— 如果 security 帳號被攻破，攻擊者能修改稽核日誌來掩蓋行蹤，所以它的存取權限要收到最小。&lt;/p>
&lt;p>一個常見的錯誤是把 OU 當成組織架構的映射（按部門分 OU）。OU 的分群依據是安全邊界和 SCP 策略，不是彙報線。兩個部門如果需要相同的 SCP，它們的帳號應該在同一個 OU 底下；一個部門如果有 prod 和 dev 環境，它們應該在不同 OU 底下。&lt;/p></description><content:encoded><![CDATA[<p>單一帳號走到某個規模後，帳號本身會變成隔離的瓶頸。IAM policy 能控制「誰能做什麼」，但同一個帳號裡的所有資源共用同一組 service quota、同一份 CloudTrail、同一張帳單，一個團隊的操作失誤或資源耗盡會波及整個帳號。把環境拆成獨立帳號，讓每個帳號只承載一個職責，是 IAM 之上的第二層隔離 — <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的身分與憑證地基</a>控制的是「誰能做什麼」，帳號邊界控制的是「做錯了波及多遠」。</p>
<h2 id="單帳號-vs-多帳號什麼時候該切">單帳號 vs 多帳號：什麼時候該切</h2>
<p>單帳號在早期是合理的起點 — 資源少、人少、管理成本低。帳號邊界帶來的隔離收益要跟它的管理成本比較：每多一個帳號就多一份 CloudTrail、多一組 IAM 基線、多一個需要管理的 state backend。</p>
<p>三個訊號出現時，單帳號的邊際風險開始超過多帳號的管理成本：</p>
<p>第一，production 和 dev 的資源開始互相影響。一個 dev 環境的壓力測試把帳號的 EC2 instance quota 吃滿，production 的 auto-scaling 因為拿不到新 instance 而失敗 — 這個故障跟程式碼品質無關，純粹是兩個環境共用同一組配額。帳號分開後，dev 吃滿自己的 quota 不會碰到 production。</p>
<p>第二，權限邊界用 IAM 已經管不住。一個工程師的 IAM policy 限制他只能操作 <code>env=dev</code> 的資源，但他手滑用了一個沒有 tag 條件的 policy、或者某個 IAM role 的 trust policy 太寬，他就能碰到 production 資源。帳號邊界是比 IAM policy 更硬的護欄 — 即使 IAM 設定出錯，帳號邊界本身就是物理隔離。</p>
<p>第三，合規或稽核要求明確區分環境。SOC 2 或金融監管可能要求 production 環境有獨立的存取紀錄和變更審計，與開發環境完全分離。同帳號裡做這件事要靠大量的 IAM 條件和 CloudTrail filter，跨帳號則天然滿足。</p>
<h2 id="ou-結構帳號怎麼分群">OU 結構：帳號怎麼分群</h2>
<p>AWS Organizations 用 Organizational Unit（OU）把帳號分群，OU 是 SCP 的掛載點 — 一條 SCP 掛在 OU 上，底下所有帳號都受約束。OU 的設計決定了護欄的作用範圍。</p>
<p>常見的 OU 拓撲有四層：</p>
<table>
  <thead>
      <tr>
          <th>OU</th>
          <th>底下的帳號</th>
          <th>職責</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Security</td>
          <td>Log Archive、Security Tooling</td>
          <td>集中存放 CloudTrail / Config 日誌、安全工具帳號</td>
      </tr>
      <tr>
          <td>Workload / Prod</td>
          <td>每個產品線或服務的 production 帳號</td>
          <td>承載正式流量，SCP 最嚴格</td>
      </tr>
      <tr>
          <td>Workload / NonProd</td>
          <td>dev、staging 帳號</td>
          <td>承載開發與驗證，SCP 較寬鬆但仍有底線</td>
      </tr>
      <tr>
          <td>Sandbox</td>
          <td>個人實驗帳號</td>
          <td>可隨時重建，SCP 限制預算上限和禁止的服務</td>
      </tr>
  </tbody>
</table>
<p>環境怎麼對應到帳號，跟<a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">模組四的環境分離</a>是同一個問題的不同層次 — 模組四用目錄和 state 分離環境的 IaC，這裡用帳號分離環境的雲端資源。兩者可以疊加：每個帳號裡的 IaC 仍然用獨立目錄和 state 管理。</p>
<p>OU 結構的設計原則是「按信任等級分群、按職責隔離」。Prod 跟 NonProd 分開是因為信任等級不同（prod 的 SCP 更嚴格）。Security 獨立是因為它的職責是「監控其他所有帳號」— 如果 security 帳號被攻破，攻擊者能修改稽核日誌來掩蓋行蹤，所以它的存取權限要收到最小。</p>
<p>一個常見的錯誤是把 OU 當成組織架構的映射（按部門分 OU）。OU 的分群依據是安全邊界和 SCP 策略，不是彙報線。兩個部門如果需要相同的 SCP，它們的帳號應該在同一個 OU 底下；一個部門如果有 prod 和 dev 環境，它們應該在不同 OU 底下。</p>
<h2 id="scp連管理員都越不過的護欄">SCP：連管理員都越不過的護欄</h2>
<p>Service Control Policy（SCP）是掛在 OU 或帳號上的權限天花板。它跟 IAM policy 的差別是層級：IAM policy 控制「這個身分能做什麼」，SCP 控制「這個帳號裡的任何身分最多能做什麼」。即使帳號內的 root user 或 AdministratorAccess role，也受 SCP 約束。</p>
<p>SCP 的設計策略以 deny-list 為主 — 預設允許所有動作，用 SCP 明確禁止少數高風險操作。相比 allow-list（預設禁止、逐一開放），deny-list 的管理成本低得多，因為 AWS 的 service 和 action 數量龐大，逐一列舉允許清單容易漏、也容易在新服務上線時擋住正常使用。</p>
<p>三條適合從第一天就掛上去的 SCP：</p>
<h3 id="禁止關閉-cloudtrail">禁止關閉 CloudTrail</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyCloudTrailDisable&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="s2">&#34;cloudtrail:StopLogging&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;cloudtrail:DeleteTrail&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="s2">&#34;cloudtrail:UpdateTrail&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">}]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>CloudTrail 是事後追溯「誰做了什麼」的唯一來源。攻擊者入侵帳號後的第一步往往是關掉稽核日誌來掩蓋行蹤，用 SCP 禁止這個動作，讓日誌在帳號層級不可關閉。</p>
<h3 id="禁止離開指定-region">禁止離開指定 region</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyOutsideRegion&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nt">&#34;NotAction&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="s2">&#34;iam:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;sts:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="s2">&#34;organizations:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="s2">&#34;support:*&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">],</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nt">&#34;Condition&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nt">&#34;StringNotEquals&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nt">&#34;aws:RequestedRegion&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;ap-northeast-1&#34;</span><span class="p">,</span> <span class="s2">&#34;us-east-1&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="p">}]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>限制資源只能建在指定 region，避免有人在沒人注意的 region（如 <code>af-south-1</code>）開資源 — 不管是誤操作還是攻擊者利用。<code>NotAction</code> 裡排除 IAM 和 STS 等全域服務，因為它們不分 region。<code>us-east-1</code> 通常要保留，因為 CloudFront、ACM（global cert）等服務的 API 端點在 us-east-1。</p>
<h3 id="禁止刪除-vpc-flow-logs">禁止刪除 VPC Flow Logs</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;Version&#34;</span><span class="p">:</span> <span class="s2">&#34;2012-10-17&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;Statement&#34;</span><span class="p">:</span> <span class="p">[{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;Sid&#34;</span><span class="p">:</span> <span class="s2">&#34;DenyDeleteFlowLogs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;Effect&#34;</span><span class="p">:</span> <span class="s2">&#34;Deny&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;Action&#34;</span><span class="p">:</span> <span class="s2">&#34;ec2:DeleteFlowLogs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nt">&#34;Resource&#34;</span><span class="p">:</span> <span class="s2">&#34;*&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}]</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>VPC Flow Logs 記錄網路層的流量軌跡，是安全事件排查的關鍵資料。跟 CloudTrail 的邏輯一樣 — 稽核資料不允許被帳號內的操作者刪除。</p>
<h3 id="scp-的繼承模型">SCP 的繼承模型</h3>
<p>SCP 沿著 OU 樹向下繼承：掛在 Root OU 的 SCP 對所有帳號生效，掛在子 OU 的 SCP 只對該 OU 底下的帳號生效。多層 SCP 的效果是交集 — 父 OU 禁止的動作，子 OU 無法用 SCP 重新允許。這個交集模型讓安全團隊能在頂層設「絕對底線」，各子 OU 只能在底線之內進一步收斂、不能放寬。</p>
<p>把 SCP 用 Terraform 管理：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_organizations_policy&#34; &#34;deny_cloudtrail_disable&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name</span>        <span class="o">=</span> <span class="s2">&#34;deny-cloudtrail-disable&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  description</span> <span class="o">=</span> <span class="s2">&#34;Prevent anyone from stopping or deleting CloudTrail&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  type</span>        <span class="o">=</span> <span class="s2">&#34;SERVICE_CONTROL_POLICY&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">  content</span>     <span class="o">=</span> <span class="k">file</span><span class="p">(</span><span class="s2">&#34;policies/deny-cloudtrail-disable.json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_organizations_policy_attachment&#34; &#34;root_deny_cloudtrail&#34;</span> {
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">  policy_id</span> <span class="o">=</span> <span class="k">aws_organizations_policy</span><span class="p">.</span><span class="k">deny_cloudtrail_disable</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">  target_id</span> <span class="o">=</span> <span class="k">aws_organizations_organization</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">roots</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">}</span></span></code></pre></div><p>SCP 的 JSON 存在 repo 的 <code>policies/</code> 目錄，變更走 PR review，讓護欄本身也在版本控制與審查流程裡。</p>
<p>控制面 token 的治理是 SCP 護欄之外需要同步處理的議題。Cloudflare 2023 事件中，控制面 token 的生命週期與最小權限沒有對齊，機器憑證形成跨服務的高權限風險（見 <a href="/blog/backend/07-security-data-protection/cases/cloudflare-control-plane-token-2023/" data-link-title="7.C2 Cloudflare：2023 Control-plane Token 事件" data-link-desc="控制面 token 事件如何回寫 secrets 與機器憑證治理。">Cloudflare：Control-plane Token 事件</a>）。Okta 2023 事件則顯示身份治理若只覆蓋生產系統而忽略支援工具鏈，支援系統的 session 和 token 會成為跨租戶的風險放大點（見 <a href="/blog/backend/07-security-data-protection/cases/okta-support-system-incident-2023/" data-link-title="7.C5 Okta：2023 Support System 事件" data-link-desc="支援系統憑證風險如何擴散到客戶租戶的案例。">Okta：Support System 事件</a>）。兩個案例的共同教訓是：SCP 管的是 AWS API 層的動作上限，但 token / session 這類應用層的機器憑證需要獨立的 lifecycle 治理。</p>
<h2 id="帳號工廠每個新帳號自帶安全基線">帳號工廠：每個新帳號自帶安全基線</h2>
<p>跨帳號策略（帳號數量、OU 結構、SCP 規則）屬於影響全組織的架構決策，建議在實施前取得技術主管或 CTO 的對齊。SCP 一旦套用到 OU，該 OU 下所有帳號立即受影響，回退需要修改 SCP 或移動帳號到不同 OU。</p>
<p>手動建帳號的問題跟手動建資源一樣 — 每次都靠人記得「開完帳號後要開 CloudTrail、要刪預設 VPC、要設基線 IAM role」。帳號工廠（Account Factory）把這些步驟自動化成一個可重複的流程：建一個帳號、自動套用安全基線、自動加進正確的 OU。</p>
<p>AWS Control Tower 是 AWS 提供的帳號工廠實作，它包裝了 Organizations、SCP、Config Rules 和 CloudFormation StackSet，提供一個「建帳號 → 自動配置」的流水線。它的好處是一鍵啟用、內建一組 AWS 建議的護欄；代價是它對 OU 結構和 SCP 有自己的意見，跟團隊已有的設計可能衝突，而且它用 CloudFormation StackSet 做基線配置，跟 Terraform 管理的資源需要劃清邊界。</p>
<p>不用 Control Tower 時，帳號工廠可以用 Terraform + 腳本自建。核心是一個 module 接受帳號名稱和 OU 作為參數，產出：帳號建立、CloudTrail trail、預設 VPC 刪除、基線 IAM role（讓管理帳號能 assume 進來做維護）、Config recorder 啟用。</p>
<p>每個新帳號該自帶的安全基線至少包含：</p>
<ul>
<li>CloudTrail 開啟並寫到集中的 Log Archive 帳號</li>
<li>預設 VPC 刪除（預設 VPC 的 security group 全通、CIDR 固定且跨帳號重複，留著是隱患）</li>
<li>基線 IAM role 讓管理帳號能 assume 進來</li>
<li>Config recorder 啟用（記錄資源設定變更歷史）</li>
<li>掛上所屬 OU 的 SCP</li>
</ul>
<p>導入時程參考：初次設定 Organizations + OU 結構 + day-1 SCP 約需 2-3 天；之後每開一個新帳號（含基線配置）約需 2-4 小時。</p>
<h2 id="跨帳號存取role-assumption">跨帳號存取：role assumption</h2>
<p>多帳號架構裡，人或自動化需要在不同帳號之間切換操作。跨帳號存取用 IAM role 的 trust policy 實現 — 目標帳號建一個 role，trust policy 允許來源帳號的特定身分 assume 這個 role。</p>
<p>AWS Organizations 在建子帳號時會自動建一個 <code>OrganizationAccountAccessRole</code>，讓管理帳號的 admin 能 assume 進去。這個 role 的權限是 AdministratorAccess — 它的用途是初始設定和緊急存取，日常操作不該用它。日常的跨帳號存取應該建立職責專用的 role：部署用的 role 只有部署相關權限、唯讀稽核用的 role 只有 read 權限。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;deploy_from_cicd&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;deploy-from-cicd-account&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">    Version</span> <span class="o">=</span> <span class="s2">&#34;2012-10-17&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">    Statement</span> <span class="o">=</span> <span class="p">[</span>{
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      Effect</span>    <span class="o">=</span> <span class="s2">&#34;Allow&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">      Principal</span> <span class="o">=</span><span class="n"> { AWS</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::111111111111:role/cicd-runner&#34;</span> }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">      Action</span>    <span class="o">=</span> <span class="s2">&#34;sts:AssumeRole&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">      Condition</span> <span class="o">=</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">        StringEquals</span> <span class="o">=</span><span class="n"> { &#34;sts:ExternalId&#34;</span> <span class="o">=</span> <span class="s2">&#34;deploy-prod-2026&#34;</span> }
</span></span><span class="line"><span class="ln">12</span><span class="cl">      }
</span></span><span class="line"><span class="ln">13</span><span class="cl">    }<span class="p">]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">}</span></span></code></pre></div><p><code>ExternalId</code> 是防止 confused deputy 攻擊的機制 — 如果 trust policy 只用帳號 ID 驗證，任何能在來源帳號建 role 的人都能 assume 目標 role。加上 ExternalId 讓 assumption 多一個只有雙方知道的驗證值。</p>
<p>跨帳號存取的設計與<a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">模組二的 OIDC 短期憑證</a>互補 — OIDC 解決「雲外到雲內」的身分聯合（CI/CD → AWS），role assumption 解決「雲內帳號之間」的身分切換。</p>
<h2 id="帳單整合">帳單整合</h2>
<p>Organizations 的附帶收益是合併帳單（Consolidated Billing）。所有子帳號的用量合併到管理帳號的帳單裡，一方面簡化付款流程（一張帳單而非多張），另一方面可以享受跨帳號的用量折扣 — 例如 S3 的定價階梯是看總用量，三個帳號各用 1TB 分開計費跟合併成 3TB 計費，後者的單位價格更低。</p>
<p>合併帳單跟成本歸屬的 tagging 互補。合併帳單讓所有費用匯到一張帳單，tagging 讓這張帳單能拆到各團隊和用途 — 這兩件事在<a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">模組八的成本可見性</a>展開。帳號邊界本身也是一層成本隔離：每個帳號的用量可以獨立查看，讓「這個帳號這個月花了多少」變成自動可查、不需要依賴 tag。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：IAM role / policy / OIDC 是帳號內的身分控制，本篇是帳號間的隔離</li>
<li>→ <a href="/blog/infra/04-environment-separation/directory-module-parameterization/" data-link-title="環境分離與模組化 — 目錄結構、module 參數化與 retrofit 路徑" data-link-desc="用目錄結構在第一天就隔開 dev 與 prod 的 state，用 module 讓環境共用同一套邏輯只差參數，以及已經單環境跑起來後怎麼安全拆分">環境分離與模組化</a>：目錄與 state 分離環境的 IaC，帳號分離是雲端資源層的對應</li>
<li>→ <a href="/blog/infra/08-governance-habits/cost-visibility-rhythm/" data-link-title="成本可見性與最小可行治理節奏" data-link-desc="用 tag 驅動的成本分攤讓帳單有人負責，以及判斷什麼治理該 day-1 就立、什麼等規模逼出來再加">成本可見性</a>：合併帳單 + tagging 的成本歸屬</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：SCP 的 JSON 存 repo、變更走 PR review</li>
</ul>
]]></content:encoded></item><item><title>團隊權限分級與存取管理</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/team-access-management/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/team-access-management/</guid><description>&lt;p>IAM 的 role 與 policy 提供「某個身分能不能對某個資源做某件事」的技術機制（見&lt;a href="https://tarrragon.github.io/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基&lt;/a>）。機制備妥後，下一個問題是組織層面的設計：團隊裡每個角色該拿到哪一級權限、臨時需要更高權限時怎麼提權、離職或合約結束時怎麼確保存取被回收。這些設計的目的是讓「誰能動什麼」在任何時間點都有可稽核的答案。&lt;/p>
&lt;h2 id="權限分級admin--operator--viewer">權限分級：admin / operator / viewer&lt;/h2>
&lt;p>團隊成員的日常操作權限用三級來劃分，每一級對應不同的操作範圍與風險。分級的依據是「這個角色的日常工作需要碰到什麼層級的資源」，不是職稱或年資。&lt;/p>
&lt;h3 id="admin">Admin&lt;/h3>
&lt;p>Admin 能修改 IAM policy、網路拓撲、帳號層級設定（Organizations、SCP、billing）。這是影響範圍最大的一級——一條 SCP 寫錯可以鎖死整個帳號的操作，一條 IAM policy 開太寬可以讓任何角色取得不該有的權限。&lt;/p>
&lt;p>持有 admin 權限的人數應該收斂到最少：通常是平台團隊的 1-2 人加上一個 break-glass 備援角色。Admin 權限不應該是某個人的「日常身分」——即使是平台工程師，日常操作也用 operator 等級，只有在需要改 IAM 或帳號設定時才 assume 到 admin role。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Admin role 的信任政策：只允許特定 IAM user assume
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;admin_trust&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;AWS&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:iam::123456789012:user/platform-lead&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:iam::123456789012:user/platform-backup&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Bool&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;aws:MultiFactorAuthPresent&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;admin&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;infra-admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">admin_trust&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="n"> max_session_duration&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3600&lt;/span>&lt;span class="c1"> # 1 小時後自動失效
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>max_session_duration&lt;/code> 限制 assume 後的有效時間。Admin session 設 1 小時是讓操作者完成當次任務後權限自動回收，不需要手動登出。MFA 條件確保即使帳號密碼外洩，沒有第二因素也無法提權。&lt;/p>
&lt;h3 id="operator">Operator&lt;/h3>
&lt;p>Operator 能部署服務、修改應用層資源（ECS task、RDS parameter group、S3 lifecycle）、查看與操作日常維運所需的一切。多數工程師的日常身分落在這一級。&lt;/p>
&lt;p>Operator 的 policy 用 resource scope 限制它碰不到 IAM 和帳號層級設定——能改 ECS service 但不能改 ECS service 用的 IAM role，能改 RDS 參數但不能改 RDS 的 subnet group。這個邊界讓 operator 的操作失誤影響範圍停在服務層，不會擴散到地基層。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;operator&amp;#34;&lt;/span> {&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1"> # 允許操作應用層資源
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;ecs:UpdateService&amp;#34;, &amp;#34;ecs:DescribeServices&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;rds:ModifyDBInstance&amp;#34;, &amp;#34;rds:DescribeDBInstances&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;s3:GetObject&amp;#34;, &amp;#34;s3:PutObject&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;logs:GetLogEvents&amp;#34;, &amp;#34;logs:FilterLogEvents&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1"> # 明確拒絕碰 IAM 和帳號設定
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Deny&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;iam:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;organizations:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;account:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Deny 語句確保即使未來有人不小心把過寬的 managed policy attach 到 operator role，IAM 和帳號操作仍然被擋。Deny 在 IAM 評估中優先於 Allow。&lt;/p></description><content:encoded><![CDATA[<p>IAM 的 role 與 policy 提供「某個身分能不能對某個資源做某件事」的技術機制（見<a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>）。機制備妥後，下一個問題是組織層面的設計：團隊裡每個角色該拿到哪一級權限、臨時需要更高權限時怎麼提權、離職或合約結束時怎麼確保存取被回收。這些設計的目的是讓「誰能動什麼」在任何時間點都有可稽核的答案。</p>
<h2 id="權限分級admin--operator--viewer">權限分級：admin / operator / viewer</h2>
<p>團隊成員的日常操作權限用三級來劃分，每一級對應不同的操作範圍與風險。分級的依據是「這個角色的日常工作需要碰到什麼層級的資源」，不是職稱或年資。</p>
<h3 id="admin">Admin</h3>
<p>Admin 能修改 IAM policy、網路拓撲、帳號層級設定（Organizations、SCP、billing）。這是影響範圍最大的一級——一條 SCP 寫錯可以鎖死整個帳號的操作，一條 IAM policy 開太寬可以讓任何角色取得不該有的權限。</p>
<p>持有 admin 權限的人數應該收斂到最少：通常是平台團隊的 1-2 人加上一個 break-glass 備援角色。Admin 權限不應該是某個人的「日常身分」——即使是平台工程師，日常操作也用 operator 等級，只有在需要改 IAM 或帳號設定時才 assume 到 admin role。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Admin role 的信任政策：只允許特定 IAM user assume
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;admin_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRole&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">      type</span>        <span class="o">=</span> <span class="s2">&#34;AWS&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="s2">&#34;arn:aws:iam::123456789012:user/platform-lead&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="s2">&#34;arn:aws:iam::123456789012:user/platform-backup&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    }
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;Bool&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;aws:MultiFactorAuthPresent&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;true&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    }
</span></span><span class="line"><span class="ln">17</span><span class="cl">  }
</span></span><span class="line"><span class="ln">18</span><span class="cl">}
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;admin&#34;</span> {
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-admin&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">admin_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="n">  max_session_duration</span> <span class="o">=</span> <span class="m">3600</span><span class="c1">  # 1 小時後自動失效
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span>}</span></span></code></pre></div><p><code>max_session_duration</code> 限制 assume 後的有效時間。Admin session 設 1 小時是讓操作者完成當次任務後權限自動回收，不需要手動登出。MFA 條件確保即使帳號密碼外洩，沒有第二因素也無法提權。</p>
<h3 id="operator">Operator</h3>
<p>Operator 能部署服務、修改應用層資源（ECS task、RDS parameter group、S3 lifecycle）、查看與操作日常維運所需的一切。多數工程師的日常身分落在這一級。</p>
<p>Operator 的 policy 用 resource scope 限制它碰不到 IAM 和帳號層級設定——能改 ECS service 但不能改 ECS service 用的 IAM role，能改 RDS 參數但不能改 RDS 的 subnet group。這個邊界讓 operator 的操作失誤影響範圍停在服務層，不會擴散到地基層。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;operator&#34;</span> {<span class="c1">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">  # 允許操作應用層資源
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="s2">&#34;ecs:UpdateService&#34;, &#34;ecs:DescribeServices&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="s2">&#34;rds:ModifyDBInstance&#34;, &#34;rds:DescribeDBInstances&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="s2">&#34;s3:GetObject&#34;, &#34;s3:PutObject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="s2">&#34;logs:GetLogEvents&#34;, &#34;logs:FilterLogEvents&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }<span class="c1">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1">  # 明確拒絕碰 IAM 和帳號設定
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span>  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">    effect</span> <span class="o">=</span> <span class="s2">&#34;Deny&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">      <span class="s2">&#34;iam:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="s2">&#34;organizations:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="s2">&#34;account:*&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="n">    resources</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  }
</span></span><span class="line"><span class="ln">23</span><span class="cl">}</span></span></code></pre></div><p>Deny 語句確保即使未來有人不小心把過寬的 managed policy attach 到 operator role，IAM 和帳號操作仍然被擋。Deny 在 IAM 評估中優先於 Allow。</p>
<h3 id="viewer">Viewer</h3>
<p>Viewer 能讀取 Console、查 log、看 metric dashboard，但不能修改任何資源。適合的角色包括：值班但不需要改設定的 on-call、需要查 log 排查問題的 support 團隊、需要看資源狀態的管理層。</p>
<p>Viewer 用 AWS 的 managed policy <code>ReadOnlyAccess</code> 作為基線，再根據需要排除敏感資料的讀取（例如 Secrets Manager 的 <code>GetSecretValue</code>）。</p>
<p>三級的對應關係：</p>
<table>
  <thead>
      <tr>
          <th>級別</th>
          <th>能做什麼</th>
          <th>典型角色</th>
          <th>人數控制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Admin</td>
          <td>改 IAM、網路、帳號設定</td>
          <td>平台 lead + break-glass</td>
          <td>2-3 人</td>
      </tr>
      <tr>
          <td>Operator</td>
          <td>部署、改服務設定、查 log</td>
          <td>工程師</td>
          <td>團隊規模</td>
      </tr>
      <tr>
          <td>Viewer</td>
          <td>讀 Console、查 log、看 metrics</td>
          <td>on-call、support、管理層</td>
          <td>依需求開放</td>
      </tr>
  </tbody>
</table>
<p>導入時程參考：三級權限的 IAM role 與 policy 建立約需 1-2 天，包含 trust policy 設定與初次分配。後續的權限變更走版本控制的 PR 流程，讓每次 policy 調整都有提案、審查與歷史紀錄（見<a href="/blog/infra/07-infra-as-pr/" data-link-title="模組七：infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>）。</p>
<h2 id="臨時提權break-glass">臨時提權（break-glass）</h2>
<p>Operator 在日常工作中偶爾需要 admin 層級的操作——排查一個涉及 IAM 的事故、緊急修改一條 security group 規則、回應安全事件。常態性地把 admin 權限開給所有 operator 會讓三級分級失效，但每次都等 admin 角色的人上線又太慢。Break-glass 流程處理的就是這個中間地帶。</p>
<h3 id="機制">機制</h3>
<p>Break-glass 的實作是一個平時不被 assume 的 admin role，加上一套提權紀錄。Operator 在需要時 assume 這個 role，取得一段時效有限的 admin session。這個 assume 動作會在 CloudTrail 留下紀錄（誰、什麼時候、session 多長），事後可稽核。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;break_glass&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  name</span>                 <span class="o">=</span> <span class="s2">&#34;infra-break-glass&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  assume_role_policy</span>   <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">break_glass_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  max_session_duration</span> <span class="o">=</span> <span class="m">3600</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">  tags</span> <span class="o">=</span><span class="n"> { Purpose</span> <span class="o">=</span> <span class="s2">&#34;emergency-escalation&#34;</span> }
</span></span><span class="line"><span class="ln">7</span><span class="cl">}</span></span></code></pre></div><p>如果團隊有 ChatOps 或 ticketing 系統，把 break-glass 的觸發綁進去可以增加一層人為確認：operator 在 Slack 或 ticket 裡申請提權、另一個人核可、系統開放 assume。這層確認的目的是在事後稽核時留下一條清楚的「誰授權了這次提權」紀錄，而非阻止操作本身。</p>
<h3 id="事後回顧">事後回顧</h3>
<p>每一次 break-glass 使用都應該進入事後回顧：為什麼需要提權？這個操作能不能改寫成 operator 層級的權限就能完成？如果某類操作反覆觸發 break-glass，代表 operator 的權限邊界需要調整——把那類操作從 admin 降到 operator，而不是讓 break-glass 變成常態。</p>
<p>回顧的輸出是權限邊界的校準，不是對操作者的檢討。</p>
<h2 id="定期-access-review">定期 access review</h2>
<p>權限分配不是一次性的設定。人會換組、離職、從 contractor 轉正職、從開發角色轉管理角色，每一次角色變動都可能讓既有的權限配置過期。定期 review 的責任是找出「權限比當前角色需要的更寬」的身分，把它們收斂回來。</p>
<h3 id="節奏與方法">節奏與方法</h3>
<p>每季做一次 access review 是多數團隊能維持的最小節奏。Review 的步驟：</p>
<ol>
<li>拉出所有 IAM user 和 role 的清單，標注每個身分目前的分級（admin / operator / viewer）</li>
<li>比對每個身分的實際角色——這個人現在還在做需要 operator 權限的工作嗎？</li>
<li>用 IAM Access Analyzer 檢查哪些權限在過去 90 天沒被使用過——沒用到的權限是收斂候選</li>
<li>特別檢查 break-glass 的使用紀錄——有沒有人的 break-glass 使用頻率高到代表他的基線權限該調整</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 產出 credential report，列出所有 user 的 key 建立時間與使用時間</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam generate-credential-report
</span></span><span class="line"><span class="ln">3</span><span class="cl">aws iam get-credential-report --output text --query Content <span class="p">|</span> base64 -d <span class="p">|</span> head -20
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 查 Access Analyzer 的 finding（哪些權限可收斂）</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">aws accessanalyzer list-findings --analyzer-arn &lt;analyzer-arn&gt; <span class="se">\
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="se"></span>  --filter <span class="s1">&#39;{&#34;status&#34;: {&#34;eq&#34;: [&#34;ACTIVE&#34;]}}&#39;</span></span></span></code></pre></div><h3 id="管理層報告">管理層報告</h3>
<p>Access review 的結果適合用兩個數字向管理層報告：<strong>覆蓋率</strong>（已 review 的身分數 / 總身分數）與<strong>異常數</strong>（權限過寬或長期未使用的身分數）。異常數的趨勢比單次數字更有意義——持續上升代表新人 onboarding 時的權限配置流程有缺口，持續下降代表 review 在發揮作用。</p>
<p>導入時程參考：第一次 access review 約需半天到一天（盤點 + 比對 + 收斂），後續每季約需 2-4 小時。</p>
<h2 id="職務交接與離職處理">職務交接與離職處理</h2>
<p>一個人離開團隊時，他持有的所有存取路徑都需要被回收。手動建立的存取路徑越多，離職處理越容易遺漏。</p>
<h3 id="離職-checklist">離職 checklist</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>操作</th>
          <th>驗證方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>IAM user / SSO 帳號</td>
          <td>停用或刪除</td>
          <td>credential report 裡不再出現</td>
      </tr>
      <tr>
          <td>長期 access key</td>
          <td>撤銷所有 key</td>
          <td><code>list-access-keys</code> 回傳空</td>
      </tr>
      <tr>
          <td>個人 MFA 裝置</td>
          <td>解除綁定</td>
          <td><code>list-mfa-devices</code> 回傳空</td>
      </tr>
      <tr>
          <td>被加進的 IAM group</td>
          <td>移除成員</td>
          <td><code>get-group</code> 裡不再出現</td>
      </tr>
      <tr>
          <td>可 assume 的 role trust policy</td>
          <td>從 principal 清單移除</td>
          <td>trust policy 裡沒有該 user ARN</td>
      </tr>
      <tr>
          <td>第三方服務的 SSO 授權</td>
          <td>撤銷（GitHub org、CI 平台、Slack workspace 等）</td>
          <td>該帳號無法登入</td>
      </tr>
      <tr>
          <td>共用密碼 / shared credential</td>
          <td>輪替（如果存在的話）</td>
          <td>Secrets Manager 版本更新</td>
      </tr>
  </tbody>
</table>
<p>權限設計越集中在 role-based（用 IAM group 或 SSO permission set），離職處理越簡單——停用 SSO 帳號就自動切斷所有透過 SSO 取得的 role。反過來，如果有大量手動 attach 的 policy 或直接寫在 trust policy 裡的 user ARN，離職時要逐一找出並移除，容易遺漏。</p>
<p>離職後的 credential rotation 有一個常被忽略的風險：輪替範圍沒有按作用域分批。一個反例是多個服務共用同一把 secret，輪替時切新憑證的服務跟還只認舊憑證的服務之間出現認證窗口不一致，導致跨系統連鎖中斷。穩定的做法是先分域隔離受影響服務、恢復雙憑證窗口、再逐批收斂（見 <a href="/blog/backend/07-security-data-protection/cases/failure-credential-rotation-without-scope/" data-link-title="7.C9 反例：憑證輪替未分 Scope" data-link-desc="憑證輪替若未分域分批，容易造成跨系統連鎖中斷。">反例：憑證輪替未分 Scope</a>）。</p>
<h3 id="交接的可執行性">交接的可執行性</h3>
<p>交接的成本取決於知識有多少沉澱在程式碼裡、有多少留在個人腦中。如果環境的建立方式是一份 IaC、變更方式是 PR 歷史，新接手的人讀 code 跟 PR 描述就能重建脈絡。如果關鍵操作（某台資料庫的特殊 parameter、某條 security group 規則的理由）只存在離職者的記憶裡，交接窗口一過就永久遺失。</p>
<p>可操作的檢驗：問「如果這個人下週離職，團隊能不能只靠讀 repo 就安全地操作他負責的環境？」答案是否定的部分，就是交接的優先補強項——優先把它們寫進 IaC 或 PR 描述，而不是寫進交接文件（交接文件會過期，IaC 跟著環境一起演進）。</p>
<p>這個議題在<a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">知識共享優於個人英雄主義</a>有組織層面的展開。</p>
<h2 id="contractor-與外部-vendor-存取">Contractor 與外部 vendor 存取</h2>
<p>外部人員（contractor、顧問、SaaS vendor 的技術支援）需要存取雲端環境時，原則是給最小範圍、設明確時限、留完整紀錄。</p>
<h3 id="範圍限制">範圍限制</h3>
<p>外部人員的 role 用 Permissions Boundary 設定權限天花板，確保即使有人誤 attach 了過寬的 policy，操作範圍也不超過 boundary 允許的上限。Scope 到具體的資源 ARN（某個 S3 bucket、某台 RDS instance），而非帳號級別的 wildcard。</p>
<p>如果團隊已經有<a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>，把外部人員的 workload 放在獨立帳號或 sandbox OU 裡，用 SCP 限制該帳號能操作的服務類型，是比 role 級別限制更強的隔離。</p>
<h3 id="時限控制">時限控制</h3>
<p>外部存取的 IAM user 或 SSO 帳號在建立時就設定到期日。多數雲端平台支援 session duration 限制（role 的 <code>max_session_duration</code>）和帳號層級的停用排程。合約結束日應該對應到存取到期日——這個對應關係寫進 IaC（用 tag 標注到期日）或團隊的 access review checklist，避免合約結束後存取仍然開著。</p>
<h3 id="稽核紀錄">稽核紀錄</h3>
<p>外部人員的操作需要比內部人員更嚴格的稽核。CloudTrail 預設記錄所有 API 呼叫，但 review 的頻率要提高——外部人員的操作紀錄每週抽查，而非等到季度 access review 才回頭看。查的是：有沒有存取超出約定範圍的資源？有沒有在非工作時間操作？有沒有大量的 read 操作指向敏感資料？</p>
<p>這些紀錄同時也是合約管理的依據——如果外部 vendor 的技術支援存取了超出約定範圍的資源，紀錄是釐清責任的事實基礎。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：IAM role / policy / OIDC 的技術機制</li>
<li>→ <a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>：用 OU 和 SCP 在帳號層級隔離外部人員</li>
<li>→ <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣</a>：tagging 標注存取到期日、secrets 不進 code</li>
<li>→ <a href="/blog/infra/09-driving-adoption/trust-alignment-knowledge-sharing/" data-link-title="怎麼把 infra 推動起來 — 信任赤字、期望值對齊與知識共享" data-link-desc="技術正確不等於推得動 — infra 在商業優先級裡吃虧的結構性原因，以及用可回退切片、期望值對齊與知識分散來跨過組織關卡">怎麼把 infra 推動起來</a>：知識共享與交接的組織面</li>
</ul>
]]></content:encoded></item><item><title>Access Key 輪替手冊</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/access-key-rotation-playbook/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/access-key-rotation-playbook/</guid><description>&lt;p>長期 access key 的風險隨時間單調上升——每多存在一天，被複製到新地方的機率就多一分，而輪替的難度也跟著副本數量增長。輪替不是「發現外洩才做」的緊急動作，而是定期執行的維運操作。本篇是操作手冊，從盤點開始、逐步完成輪替、最後建立自動化。&lt;/p>
&lt;h2 id="盤點帳號裡有哪些-key">盤點：帳號裡有哪些 key&lt;/h2>
&lt;p>第一步是拿到帳號內所有 IAM user 的 access key 清單。AWS 的 credential report 是這個問題的標準資料來源，它列出每個 user 的 key 狀態、建立時間與最後使用時間。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">aws iam generate-credential-report
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">aws iam get-credential-report &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --query &lt;span class="s1">&amp;#39;Content&amp;#39;&lt;/span> --output text &lt;span class="p">|&lt;/span> base64 -d &amp;gt; credential-report.csv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>產出的 CSV 包含每個 IAM user 的兩把 key（access_key_1、access_key_2）各自的狀態。關注的欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>user&lt;/code>&lt;/td>
 &lt;td>key 的擁有者&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_active&lt;/code>&lt;/td>
 &lt;td>key 是否啟用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_last_used_date&lt;/code>&lt;/td>
 &lt;td>最後使用時間——長期未使用代表可能是遺棄的 key&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>access_key_1_last_rotated&lt;/code>&lt;/td>
 &lt;td>建立或上次輪替的時間&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>用 csvkit 或試算表打開這份報告，按 &lt;code>access_key_1_last_rotated&lt;/code> 排序，最舊的 key 排最前面。超過 90 天未輪替的 key 列為第一批處理對象。&lt;/p>
&lt;p>以下腳本使用 gawk 的 &lt;code>systime()&lt;/code> 函式。如果系統的 awk 是 mawk（Ubuntu 預設），改用 &lt;code>gawk&lt;/code> 或用 &lt;code>date&lt;/code> 指令替代時間計算。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 快速列出所有啟用中、超過 90 天的 key&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">aws iam list-users --query &lt;span class="s1">&amp;#39;Users[].UserName&amp;#39;&lt;/span> --output text &lt;span class="p">|&lt;/span> tr &lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;\n&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="k">while&lt;/span> &lt;span class="nb">read&lt;/span> user&lt;span class="p">;&lt;/span> &lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> aws iam list-access-keys --user-name &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$user&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --query &lt;span class="s2">&amp;#34;AccessKeyMetadata[?Status==&amp;#39;Active&amp;#39;].[UserName,AccessKeyId,CreateDate]&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="se">&lt;/span> --output text
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="k">done&lt;/span> &lt;span class="p">|&lt;/span> awk -F&lt;span class="s1">&amp;#39;\t&amp;#39;&lt;/span> &lt;span class="s1">&amp;#39;{
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s1"> cmd = &amp;#34;date -d \&amp;#34;&amp;#34; $3 &amp;#34;\&amp;#34; +%s 2&amp;gt;/dev/null || date -jf \&amp;#34;%Y-%m-%dT%H:%M:%S+00:00\&amp;#34; \&amp;#34;&amp;#34; $3 &amp;#34;\&amp;#34; +%s&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s1"> cmd | getline created; close(cmd)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s1"> age = (systime() - created) / 86400
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s1"> if (age &amp;gt; 90) printf &amp;#34;%s\t%s\t%.0f days\n&amp;#34;, $1, $2, age
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s1">}&amp;#39;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="識別每把-key-的用途">識別每把 key 的用途&lt;/h2>
&lt;p>知道 key 存在之後，下一個問題是「這把 key 用在哪裡」。credential report 只告訴你 key 最後被用來呼叫什麼 service（&lt;code>access_key_1_last_used_service&lt;/code>），但不告訴你它被存放在哪裡。&lt;/p>
&lt;p>用途識別需要交叉比對多個來源：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>可能的存放位置&lt;/th>
 &lt;th>檢查方式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>CI 環境變數（GitHub Actions）&lt;/td>
 &lt;td>repo Settings → Secrets and variables → Actions&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 環境變數（GitLab CI）&lt;/td>
 &lt;td>repo Settings → CI/CD → Variables&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EC2 instance 的 user data&lt;/td>
 &lt;td>&lt;code>aws ec2 describe-instance-attribute --attribute userData&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Lambda 環境變數&lt;/td>
 &lt;td>&lt;code>aws lambda get-function-configuration --function-name NAME&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>SSM Parameter Store&lt;/td>
 &lt;td>&lt;code>aws ssm get-parameters-by-path --path / --recursive&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開發者筆電&lt;/td>
 &lt;td>&lt;code>~/.aws/credentials&lt;/code> — 需要口頭確認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>程式碼 repo&lt;/td>
 &lt;td>&lt;code>git log --all -p | grep AKIA&lt;/code> — AKIA 是 access key 的固定前綴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Slack / email 歷史&lt;/td>
 &lt;td>無法自動掃描，靠團隊回報&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>對每把要輪替的 key，在以上位置逐一確認。找不到用途的 key 可以先停用觀察（而非直接刪除），停用後如果有服務壞了就知道它用在哪裡。&lt;/p></description><content:encoded><![CDATA[<p>長期 access key 的風險隨時間單調上升——每多存在一天，被複製到新地方的機率就多一分，而輪替的難度也跟著副本數量增長。輪替不是「發現外洩才做」的緊急動作，而是定期執行的維運操作。本篇是操作手冊，從盤點開始、逐步完成輪替、最後建立自動化。</p>
<h2 id="盤點帳號裡有哪些-key">盤點：帳號裡有哪些 key</h2>
<p>第一步是拿到帳號內所有 IAM user 的 access key 清單。AWS 的 credential report 是這個問題的標準資料來源，它列出每個 user 的 key 狀態、建立時間與最後使用時間。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam generate-credential-report
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws iam get-credential-report <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --query <span class="s1">&#39;Content&#39;</span> --output text <span class="p">|</span> base64 -d &gt; credential-report.csv</span></span></code></pre></div><p>產出的 CSV 包含每個 IAM user 的兩把 key（access_key_1、access_key_2）各自的狀態。關注的欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>user</code></td>
          <td>key 的擁有者</td>
      </tr>
      <tr>
          <td><code>access_key_1_active</code></td>
          <td>key 是否啟用</td>
      </tr>
      <tr>
          <td><code>access_key_1_last_used_date</code></td>
          <td>最後使用時間——長期未使用代表可能是遺棄的 key</td>
      </tr>
      <tr>
          <td><code>access_key_1_last_rotated</code></td>
          <td>建立或上次輪替的時間</td>
      </tr>
  </tbody>
</table>
<p>用 csvkit 或試算表打開這份報告，按 <code>access_key_1_last_rotated</code> 排序，最舊的 key 排最前面。超過 90 天未輪替的 key 列為第一批處理對象。</p>
<p>以下腳本使用 gawk 的 <code>systime()</code> 函式。如果系統的 awk 是 mawk（Ubuntu 預設），改用 <code>gawk</code> 或用 <code>date</code> 指令替代時間計算。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 快速列出所有啟用中、超過 90 天的 key</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">aws iam list-users --query <span class="s1">&#39;Users[].UserName&#39;</span> --output text <span class="p">|</span> tr <span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;\n&#39;</span> <span class="p">|</span> <span class="k">while</span> <span class="nb">read</span> user<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  aws iam list-access-keys --user-name <span class="s2">&#34;</span><span class="nv">$user</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="se"></span>    --query <span class="s2">&#34;AccessKeyMetadata[?Status==&#39;Active&#39;].[UserName,AccessKeyId,CreateDate]&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="se"></span>    --output text
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">done</span> <span class="p">|</span> awk -F<span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s1">  cmd = &#34;date -d \&#34;&#34; $3 &#34;\&#34; +%s 2&gt;/dev/null || date -jf \&#34;%Y-%m-%dT%H:%M:%S+00:00\&#34; \&#34;&#34; $3 &#34;\&#34; +%s&#34;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s1">  cmd | getline created; close(cmd)
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s1">  age = (systime() - created) / 86400
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s1">  if (age &gt; 90) printf &#34;%s\t%s\t%.0f days\n&#34;, $1, $2, age
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><h2 id="識別每把-key-的用途">識別每把 key 的用途</h2>
<p>知道 key 存在之後，下一個問題是「這把 key 用在哪裡」。credential report 只告訴你 key 最後被用來呼叫什麼 service（<code>access_key_1_last_used_service</code>），但不告訴你它被存放在哪裡。</p>
<p>用途識別需要交叉比對多個來源：</p>
<table>
  <thead>
      <tr>
          <th>可能的存放位置</th>
          <th>檢查方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>CI 環境變數（GitHub Actions）</td>
          <td>repo Settings → Secrets and variables → Actions</td>
      </tr>
      <tr>
          <td>CI 環境變數（GitLab CI）</td>
          <td>repo Settings → CI/CD → Variables</td>
      </tr>
      <tr>
          <td>EC2 instance 的 user data</td>
          <td><code>aws ec2 describe-instance-attribute --attribute userData</code></td>
      </tr>
      <tr>
          <td>Lambda 環境變數</td>
          <td><code>aws lambda get-function-configuration --function-name NAME</code></td>
      </tr>
      <tr>
          <td>SSM Parameter Store</td>
          <td><code>aws ssm get-parameters-by-path --path / --recursive</code></td>
      </tr>
      <tr>
          <td>開發者筆電</td>
          <td><code>~/.aws/credentials</code> — 需要口頭確認</td>
      </tr>
      <tr>
          <td>程式碼 repo</td>
          <td><code>git log --all -p | grep AKIA</code> — AKIA 是 access key 的固定前綴</td>
      </tr>
      <tr>
          <td>Slack / email 歷史</td>
          <td>無法自動掃描，靠團隊回報</td>
      </tr>
  </tbody>
</table>
<p>對每把要輪替的 key，在以上位置逐一確認。找不到用途的 key 可以先停用觀察（而非直接刪除），停用後如果有服務壞了就知道它用在哪裡。</p>
<h2 id="輪替步驟五步流程">輪替步驟：五步流程</h2>
<p>輪替一把 key 的標準流程分五步，順序不能跳：</p>
<h3 id="第一步建立新-key">第一步：建立新 key</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam create-access-key --user-name deploy-bot</span></span></code></pre></div><p>輸出會包含新的 AccessKeyId 和 SecretAccessKey。SecretAccessKey 只在這一刻顯示一次，存進密碼管理器或 Secrets Manager，不要貼在 Slack 或 email 裡。</p>
<p>一個 IAM user 最多同時有兩把 key。如果已經有兩把，需要先刪除一把不用的才能建新的。</p>
<h3 id="第二步更新所有消費者">第二步：更新所有消費者</h3>
<p>把新 key 部署到上一節識別出的所有存放位置。CI 變數、Lambda 環境變數、SSM Parameter Store、開發者的 <code>~/.aws/credentials</code> 都要同步更新。</p>
<p>每更新一個消費者就做一次功能驗證——CI 跑一次 pipeline、Lambda 觸發一次、開發者跑一次 <code>aws sts get-caller-identity</code> 確認新 key 能用。</p>
<h3 id="第三步驗證新-key-生效">第三步：驗證新 key 生效</h3>
<p>所有消費者更新完後，等待一個完整的業務週期（至少 24 小時），確認沒有任何服務還在用舊 key。檢查方式是看舊 key 的 <code>LastUsedDate</code> 有沒有在更新之後還被使用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam get-access-key-last-used --access-key-id AKIAOLD12345</span></span></code></pre></div><p>如果 <code>LastUsedDate</code> 在你更新消費者之後仍有新的使用紀錄，代表有漏網的消費者還在用舊 key。</p>
<h3 id="第四步停用舊-key">第四步：停用舊 key</h3>
<p>確認無殘留使用後，停用（不是刪除）舊 key：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam update-access-key <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user-name deploy-bot <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --access-key-id AKIAOLD12345 <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --status Inactive</span></span></code></pre></div><p>停用是安全的中間狀態——用到這把 key 的服務會開始報 <code>InvalidClientTokenId</code> 錯誤，但 key 還在、可以隨時重新啟用。如果停用後有意料之外的服務壞了，重新啟用就能立刻恢復。</p>
<h3 id="第五步寬限期後刪除">第五步：寬限期後刪除</h3>
<p>停用後保持 7-14 天的寬限期。這段時間是「如果有漏掉的消費者」的安全網。寬限期內無異常，刪除：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">aws iam delete-access-key <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="se"></span>  --user-name deploy-bot <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --access-key-id AKIAOLD12345</span></span></code></pre></div><p>刪除後不可回復。如果有服務還在用這把 key，只能建一把新 key 然後去更新那個服務。</p>
<h2 id="自動化輪替secrets-manager">自動化輪替：Secrets Manager</h2>
<p>手動輪替的瓶頸在「找到所有消費者」這一步。如果 key 的消費者都從 Secrets Manager 讀取（而非各自存一份副本），輪替就簡化成「在 Secrets Manager 裡更新值」——所有消費者下次讀取時自動拿到新 key。</p>
<p>Secrets Manager 支援自動輪替：設定一個 Lambda function 作為 rotation function，它負責建新 key → 更新 secret value → 停用舊 key 的全流程。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_secretsmanager_secret&#34; &#34;deploy_key&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name</span> <span class="o">=</span> <span class="s2">&#34;prod/deploy-bot/access-key&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_secretsmanager_secret_rotation&#34; &#34;deploy_key&#34;</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">  secret_id</span>           <span class="o">=</span> <span class="k">aws_secretsmanager_secret</span><span class="p">.</span><span class="k">deploy_key</span><span class="p">.</span><span class="k">id</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  rotation_lambda_arn</span> <span class="o">=</span> <span class="k">aws_lambda_function</span><span class="p">.</span><span class="k">key_rotator</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="k">rotation_rules</span> {
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">    automatically_after_days</span> <span class="o">=</span> <span class="m">90</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  }
</span></span><span class="line"><span class="ln">12</span><span class="cl">}</span></span></code></pre></div><p>自動輪替的前提是所有消費者都改成從 Secrets Manager 讀 key，而非從環境變數或設定檔。這個前提本身就是一次 migration——跟手動輪替的固定成本（盤點 + 更新 + 驗證）相比，migration 的一次性成本更高，但之後的每次輪替接近零成本。</p>
<p>判斷該不該投入自動化的依據是 key 的數量和輪替頻率。3 把 key、每季輪替一次，手動流程 2-3 小時可以完成，自動化的 ROI 不高。10 把以上、或合規要求 30 天輪替，手動已經吃掉固定的工程師時間，自動化的投入才有回報。</p>
<h2 id="key-age-監控">Key age 監控</h2>
<p>輪替做完不代表可以不管——如果沒有監控，三個月後又會回到「不知道有幾把超齡的 key」的狀態。</p>
<p>最低成本的監控是一條定期跑的 check，掃描所有 key 的年齡並在超過閾值時告警：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 列出所有超過 90 天的 active key（用 AWS Config 規則更可靠）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws configservice put-config-rule --config-rule <span class="s1">&#39;{
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s1">  &#34;ConfigRuleName&#34;: &#34;access-keys-rotated&#34;,
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="s1">  &#34;Source&#34;: {
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="s1">    &#34;Owner&#34;: &#34;AWS&#34;,
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="s1">    &#34;SourceIdentifier&#34;: &#34;ACCESS_KEYS_ROTATED&#34;
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="s1">  },
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="s1">  &#34;InputParameters&#34;: &#34;{\&#34;maxAccessKeyAge\&#34;:\&#34;90\&#34;}&#34;
</span></span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="s1">}&#39;</span></span></span></code></pre></div><p>AWS Config 的 <code>ACCESS_KEYS_ROTATED</code> managed rule 會持續掃描所有 IAM user 的 key age，超過設定天數的標記為 non-compliant。把 Config 的 non-compliant 事件接到 SNS → Slack 或 email，就有了持續的 key 超齡告警。</p>
<p>Prowler 也提供 key age 檢查（<code>prowler aws --checks access_key_1_rotated</code>），適合當一次性掃描工具。Config rule 適合持續監控。</p>
<p>管理層報告可以用 Config 的 compliance dashboard：compliant key 數 / 總 key 數 = key rotation 覆蓋率，這個百分比適合放進月報。</p>
<p>IAM Access Analyzer 的 unused access 功能（需啟用 analyzer）可以持續掃描帳號內未使用的 key 和 permission，跟 Config rule 互補——Config 看 key age，Access Analyzer 看 key 是否被使用。兩者搭配可以同時回答「這把 key 多久沒輪替」和「這把 key 有沒有在用」。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：access key 風險的系統性分析、OIDC 作為長期 key 的替代方案</li>
<li>→ <a href="/blog/infra/02-identity-credentials/team-access-management/" data-link-title="團隊權限分級與存取管理" data-link-desc="用 admin / operator / viewer 三級劃分團隊成員的雲端操作權限，設計臨時提權流程、定期 access review 節奏，以及 contractor 與外部 vendor 的存取邊界">團隊權限分級與存取管理</a>：離職時的 key 撤銷流程</li>
<li>→ <a href="/blog/infra/08-governance-habits/tagging-secrets/" data-link-title="Tagging 規範與 Secrets 不進 code" data-link-desc="tag 讓資源可盤點、可清理、可歸屬；密鑰存在專用服務裡而非 code 或 state，兩者都屬於 day-1 就該立的治理地基">治理好習慣</a>：secret 的儲存與引用紀律</li>
</ul>
]]></content:encoded></item><item><title>OIDC Trust Policy 設定指南</title><link>https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/</link><pubDate>Fri, 26 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/infra/02-identity-credentials/oidc-trust-policy-setup/</guid><description>&lt;p>OIDC 聯合讓 CI/CD pipeline 用短期 token 取代長期 access key 存取雲端資源。設定本身不複雜，但 trust policy 的 claim 條件寫錯一個字就會變成「任何 repo 都能假扮這個 role」或「完全無法 assume」。本篇是 GitHub Actions 與 AWS 之間的 OIDC 聯合的完整設定步驟，從建立 provider 到 trust policy 設計到測試驗證。其他 CI 平台（GitLab CI、CircleCI）的原理相同，差別只在 issuer URL 和 claim 結構：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>平台&lt;/th>
 &lt;th>Issuer URL&lt;/th>
 &lt;th>sub claim 格式範例&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>GitHub Actions&lt;/td>
 &lt;td>&lt;code>token.actions.githubusercontent.com&lt;/code>&lt;/td>
 &lt;td>&lt;code>repo:{org}/{repo}:ref:refs/heads/{branch}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GitLab CI&lt;/td>
 &lt;td>&lt;code>gitlab.com&lt;/code>&lt;/td>
 &lt;td>&lt;code>project_path:{group}/{project}:ref_type:branch:ref:main&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CircleCI&lt;/td>
 &lt;td>&lt;code>oidc.circleci.com/org/{org-id}&lt;/code>&lt;/td>
 &lt;td>&lt;code>org/{org-id}/project/{project-id}/user/{user-id}&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>本篇以 GitHub Actions 為主，其他平台替換 issuer URL 和 sub condition 即可。&lt;/p>
&lt;h2 id="建立-oidc-provider">建立 OIDC Provider&lt;/h2>
&lt;p>OIDC provider 是 AWS 帳號裡的一個資源，聲明「我信任這個外部 identity provider 簽發的 token」。GitHub Actions 的 OIDC issuer URL 是固定的，每個 AWS 帳號只需要建一個 provider。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_openid_connect_provider&amp;#34; &amp;#34;github&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="n"> url&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://token.actions.githubusercontent.com&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n"> client_id_list&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="n"> thumbprint_list&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ffffffffffffffffffffffffffffffffffffffff&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>client_id_list&lt;/code> 設為 &lt;code>sts.amazonaws.com&lt;/code> 是 GitHub 官方建議的 audience 值。&lt;code>thumbprint_list&lt;/code> 在 2023 年之後 AWS 不再用它驗證 GitHub 的憑證鏈（改用 AWS 自己維護的根憑證清單），但欄位仍然是必填，填 40 個 &lt;code>f&lt;/code> 作為佔位值即可。&lt;/p>
&lt;p>這個 provider 建一次就好。多個 role 可以共用同一個 provider，差別在各自的 trust policy 怎麼寫。&lt;/p>
&lt;h2 id="trust-policy-設計claim-收斂">Trust Policy 設計：claim 收斂&lt;/h2>
&lt;p>Trust policy 決定「誰能假扮這個 role」。OIDC token 裡帶有多個 claim（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），trust policy 用 condition 比對這些 claim，全部命中才允許 assume。&lt;/p>
&lt;h3 id="最小可行的-trust-policy">最小可行的 trust policy&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ci_trust&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRoleWithWebIdentity&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">principals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Federated&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">aws_iam_openid_connect_provider&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">github&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:aud&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringLike&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;token.actions.githubusercontent.com:sub&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;repo:my-org/my-app:ref:refs/heads/main&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>兩個 condition 各守一個邊界。&lt;code>aud&lt;/code> 驗證 audience 對不對（防止其他用途的 token 被拿來 assume）。&lt;code>sub&lt;/code> 驗證請求來自哪個 repo 和 branch——這是最關鍵的收斂點。&lt;/p></description><content:encoded><![CDATA[<p>OIDC 聯合讓 CI/CD pipeline 用短期 token 取代長期 access key 存取雲端資源。設定本身不複雜，但 trust policy 的 claim 條件寫錯一個字就會變成「任何 repo 都能假扮這個 role」或「完全無法 assume」。本篇是 GitHub Actions 與 AWS 之間的 OIDC 聯合的完整設定步驟，從建立 provider 到 trust policy 設計到測試驗證。其他 CI 平台（GitLab CI、CircleCI）的原理相同，差別只在 issuer URL 和 claim 結構：</p>
<table>
  <thead>
      <tr>
          <th>平台</th>
          <th>Issuer URL</th>
          <th>sub claim 格式範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>GitHub Actions</td>
          <td><code>token.actions.githubusercontent.com</code></td>
          <td><code>repo:{org}/{repo}:ref:refs/heads/{branch}</code></td>
      </tr>
      <tr>
          <td>GitLab CI</td>
          <td><code>gitlab.com</code></td>
          <td><code>project_path:{group}/{project}:ref_type:branch:ref:main</code></td>
      </tr>
      <tr>
          <td>CircleCI</td>
          <td><code>oidc.circleci.com/org/{org-id}</code></td>
          <td><code>org/{org-id}/project/{project-id}/user/{user-id}</code></td>
      </tr>
  </tbody>
</table>
<p>本篇以 GitHub Actions 為主，其他平台替換 issuer URL 和 sub condition 即可。</p>
<h2 id="建立-oidc-provider">建立 OIDC Provider</h2>
<p>OIDC provider 是 AWS 帳號裡的一個資源，聲明「我信任這個外部 identity provider 簽發的 token」。GitHub Actions 的 OIDC issuer URL 是固定的，每個 AWS 帳號只需要建一個 provider。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_openid_connect_provider&#34; &#34;github&#34;</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  url</span>             <span class="o">=</span> <span class="s2">&#34;https://token.actions.githubusercontent.com&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  client_id_list</span>  <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  thumbprint_list</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;ffffffffffffffffffffffffffffffffffffffff&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p><code>client_id_list</code> 設為 <code>sts.amazonaws.com</code> 是 GitHub 官方建議的 audience 值。<code>thumbprint_list</code> 在 2023 年之後 AWS 不再用它驗證 GitHub 的憑證鏈（改用 AWS 自己維護的根憑證清單），但欄位仍然是必填，填 40 個 <code>f</code> 作為佔位值即可。</p>
<p>這個 provider 建一次就好。多個 role 可以共用同一個 provider，差別在各自的 trust policy 怎麼寫。</p>
<h2 id="trust-policy-設計claim-收斂">Trust Policy 設計：claim 收斂</h2>
<p>Trust policy 決定「誰能假扮這個 role」。OIDC token 裡帶有多個 claim（描述「這是哪個 repo、哪個 branch、哪個 workflow 在跑」），trust policy 用 condition 比對這些 claim，全部命中才允許 assume。</p>
<h3 id="最小可行的-trust-policy">最小可行的 trust policy</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">data</span> <span class="s2">&#34;aws_iam_policy_document&#34; &#34;ci_trust&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">statement</span> {
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">    actions</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts:AssumeRoleWithWebIdentity&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">principals</span> {
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">      type</span>        <span class="o">=</span> <span class="s2">&#34;Federated&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">      identifiers</span> <span class="o">=</span> <span class="p">[</span><span class="k">aws_iam_openid_connect_provider</span><span class="p">.</span><span class="k">github</span><span class="p">.</span><span class="k">arn</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    }
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:aud&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;sts.amazonaws.com&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    }
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">condition</span> {
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="n">      test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="n">      variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="n">      values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    }
</span></span><span class="line"><span class="ln">21</span><span class="cl">  }
</span></span><span class="line"><span class="ln">22</span><span class="cl">}</span></span></code></pre></div><p>兩個 condition 各守一個邊界。<code>aud</code> 驗證 audience 對不對（防止其他用途的 token 被拿來 assume）。<code>sub</code> 驗證請求來自哪個 repo 和 branch——這是最關鍵的收斂點。</p>
<h3 id="sub-claim-的結構">sub claim 的結構</h3>
<p>GitHub Actions 的 <code>sub</code> claim 格式是 <code>repo:{owner}/{repo}:{context}</code>，其中 context 隨觸發方式不同：</p>
<table>
  <thead>
      <tr>
          <th>觸發方式</th>
          <th>sub claim 值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>push to branch</td>
          <td><code>repo:my-org/my-app:ref:refs/heads/main</code></td>
      </tr>
      <tr>
          <td>pull request</td>
          <td><code>repo:my-org/my-app:pull_request</code></td>
      </tr>
      <tr>
          <td>environment deploy</td>
          <td><code>repo:my-org/my-app:environment:production</code></td>
      </tr>
      <tr>
          <td>tag push</td>
          <td><code>repo:my-org/my-app:ref:refs/tags/v1.0.0</code></td>
      </tr>
      <tr>
          <td>manual dispatch</td>
          <td><code>repo:my-org/my-app:ref:refs/heads/main</code></td>
      </tr>
  </tbody>
</table>
<p>Trust policy 的 <code>sub</code> condition 要根據實際需要選擇收斂到哪個層級。只允許 main branch 的 push 就寫 <code>repo:my-org/my-app:ref:refs/heads/main</code>；只允許 production environment 的 deploy 就寫 <code>repo:my-org/my-app:environment:production</code>。</p>
<h3 id="environment-based-收斂推薦">environment-based 收斂（推薦）</h3>
<p>GitHub Actions 的 environment 功能讓 <code>sub</code> claim 帶上 environment 名稱。搭配 environment protection rules（required reviewers、wait timer），可以在 trust policy 層和 GitHub 層各設一道 gate：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:environment:production&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>Workflow 裡對應的設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span></span></span></code></pre></div><p>只有 workflow 宣告了 <code>environment: production</code> 且通過 environment 的 protection rules 後，runner 拿到的 token 才會帶上 <code>environment:production</code> 的 sub claim，才能 assume 這個 role。</p>
<h2 id="plan-role-與-apply-role-分離">Plan Role 與 Apply Role 分離</h2>
<p>把 plan 和 apply 拆成兩個 role，各自給最小權限。plan 只需要 read 權限（讀 state、讀雲端現況），apply 需要 write 權限（建立/修改/刪除資源）。分離的好處是 PR 階段的 plan 即使被攻破，攻擊者也只能讀不能改。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;infra_plan&#34;</span> {
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-plan&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">plan_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">}
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role&#34; &#34;infra_apply&#34;</span> {
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">  name</span>               <span class="o">=</span> <span class="s2">&#34;infra-apply&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">  assume_role_policy</span> <span class="o">=</span> <span class="k">data</span><span class="p">.</span><span class="k">aws_iam_policy_document</span><span class="p">.</span><span class="k">apply_trust</span><span class="p">.</span><span class="k">json</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">}
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_iam_role_policy_attachment&#34; &#34;plan_readonly&#34;</span> {
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">  role</span>       <span class="o">=</span> <span class="k">aws_iam_role</span><span class="p">.</span><span class="k">infra_plan</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="n">  policy_arn</span> <span class="o">=</span> <span class="s2">&#34;arn:aws:iam::aws:policy/ReadOnlyAccess&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">}</span></span></code></pre></div><p>Trust policy 的差異：plan role 允許任何 branch 的 PR 觸發（<code>repo:my-org/my-app:pull_request</code>）；apply role 只允許 main branch 或 production environment（<code>repo:my-org/my-app:environment:production</code>）。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">plan</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">pull-requests</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-plan</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform plan -out=plan.tfplan</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">  </span><span class="nt">apply</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.ref == &#39;refs/heads/main&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">          </span><span class="nt">role-to-assume</span><span class="p">:</span><span class="w"> </span><span class="l">arn:aws:iam::123456789012:role/infra-apply</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">          </span><span class="nt">aws-region</span><span class="p">:</span><span class="w"> </span><span class="l">ap-northeast-1</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">terraform apply -auto-approve</span></span></span></code></pre></div><h2 id="常見設定錯誤">常見設定錯誤</h2>
<h3 id="audience-不匹配">audience 不匹配</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Error: Not authorized to perform sts:AssumeRoleWithWebIdentity</span></span></code></pre></div><p>最常見的原因是 trust policy 的 <code>aud</code> condition 值跟 OIDC provider 的 <code>client_id_list</code> 不一致。兩者都要是 <code>sts.amazonaws.com</code>。如果用了舊版的 <code>configure-aws-credentials</code> action（v1），它預設用 <code>sigstore</code> 作為 audience，跟 <code>sts.amazonaws.com</code> 對不上。確認 action 版本是 v4+。</p>
<h3 id="sub-condition-太寬">sub condition 太寬</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringLike&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/*&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>這允許 <code>my-org</code> 底下任何 repo 的任何 branch assume 這個 role。如果組織裡有公開 repo 或 fork 權限寬鬆的 repo，攻擊者可以在那些 repo 裡觸發 workflow 來 assume 生產環境的 role。至少收斂到 repo 層級（<code>repo:my-org/my-app:*</code>），生產環境收斂到 branch 或 environment。</p>
<h3 id="sub-condition-太緊">sub condition 太緊</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">condition</span> {
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">  test</span>     <span class="o">=</span> <span class="s2">&#34;StringEquals&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">  variable</span> <span class="o">=</span> <span class="s2">&#34;token.actions.githubusercontent.com:sub&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">  values</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;repo:my-org/my-app:ref:refs/heads/main&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">}</span></span></code></pre></div><p>這只允許 push to main 觸發的 workflow。PR 觸發的 workflow 拿到的 sub 是 <code>repo:my-org/my-app:pull_request</code>，跟這個 condition 不匹配，plan 階段會失敗。如果 plan 需要在 PR 階段跑，plan role 的 trust policy 要加 PR 的 sub pattern。</p>
<h3 id="忘記設-permissions">忘記設 permissions</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">    </span><span class="c"># 缺少 permissions 區塊</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">aws-actions/configure-aws-credentials@v4</span></span></span></code></pre></div><p>GitHub Actions 的 OIDC token 只有在 workflow 宣告 <code>permissions: { id-token: write }</code> 時才會簽發。缺了這一行，<code>configure-aws-credentials</code> 拿不到 token，報「OIDC token not available」。這個錯誤訊息不直觀——它說的是 token 不存在，不是權限不夠。</p>
<h3 id="多帳號時忘記指定-provider">多帳號時忘記指定 provider</h3>
<p>如果組織有多個 AWS 帳號，每個帳號都要各自建 OIDC provider。trust policy 的 <code>Federated</code> principal 要指向本帳號的 provider ARN，不能跨帳號引用。跨帳號部署時，workflow 用不同的 <code>role-to-assume</code> 切換帳號，每個帳號的 role 各自信任同一個 GitHub OIDC issuer 但是各自獨立的 provider 資源。</p>
<h2 id="測試與驗證">測試與驗證</h2>
<p>設定完成後的驗證步驟：</p>
<ol>
<li><strong>手動觸發 workflow</strong>：push 一個無害的 commit 到 main、開一個 test PR，觀察 <code>configure-aws-credentials</code> 步驟是否成功</li>
<li><strong>檢查 CloudTrail</strong>：搜尋 <code>AssumeRoleWithWebIdentity</code> 事件，確認 source identity 和 assumed role 正確</li>
<li><strong>反向驗證</strong>：從一個不在 trust policy 允許範圍的 repo 或 branch 觸發 workflow，確認 assume 被拒絕</li>
<li><strong>權限範圍驗證</strong>：在 plan job 裡嘗試一個 write 操作（如 <code>aws s3 rm</code>），確認被拒絕——驗證 plan role 的 read-only 限制確實生效</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 在 CloudTrail 搜尋 OIDC assume 事件</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">aws cloudtrail lookup-events <span class="se">\
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="se"></span>  --lookup-attributes <span class="nv">AttributeKey</span><span class="o">=</span>EventName,AttributeValue<span class="o">=</span>AssumeRoleWithWebIdentity <span class="se">\
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="se"></span>  --max-items <span class="m">5</span></span></span></code></pre></div><p>驗證通過後，這套 OIDC 設定就取代了所有存放在 CI 環境變數裡的 access key。原有的 key 可以排程停用和刪除，排程的節奏見<a href="/blog/infra/02-identity-credentials/access-key-rotation-playbook/" data-link-title="Access Key 輪替手冊" data-link-desc="從 credential report 盤點散落的長期 access key，到逐把輪替、自動化輪替與 key age 監控的完整操作步驟">access key 輪替</a>。trust policy 的持續維護重點是：新增 repo 時 sub condition 要同步更新、組織改名時 issuer 的 repo 路徑要全面修正。</p>
<p>時程參考：OIDC provider 建立 + trust policy 設計 + workflow 驗證約需 1-2 小時。OIDC provider 與 IAM role 本身不產生額外費用。</p>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/infra/02-identity-credentials/iam-oidc-privilege-boundary/" data-link-title="身分與憑證地基 — IAM 模型、OIDC 短期憑證與權限邊界設計" data-link-desc="IAM 的 identity / policy / role 三元件、最小權限的持續收斂、用 OIDC 取代長期 access key，以及 SCP 與 Permissions Boundary 的環境隔離">身分與憑證地基</a>：OIDC 的概念基礎與權限邊界設計</li>
<li>→ <a href="/blog/infra/07-infra-as-pr/plan-review-apply-guardrails/" data-link-title="infra 走 PR 流程與自動化護欄" data-link-desc="infra 變更走 PR → plan → review diff → 合併 → apply，配 fmt / validate / tflint / checkov / tfsec 與 Atlantis 自動化，讓基礎設施可審查、可回溯、可交接">infra 走 PR 流程</a>：plan/apply 的 CI pipeline 怎麼用這裡設定好的 role</li>
<li>→ <a href="/blog/infra/02-identity-credentials/multi-account-strategy/" data-link-title="跨帳號策略 — Organizations、SCP 與帳號工廠" data-link-desc="用 AWS Organizations 把環境拆成獨立帳號、用 SCP 設定連管理員都越不過的護欄、用帳號工廠讓每個新帳號自帶安全基線">跨帳號策略</a>：多帳號環境下的 OIDC provider 配置</li>
</ul>
]]></content:encoded></item></channel></rss>