<?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>Authorization on Tarragon</title><link>https://tarrragon.github.io/blog/tags/authorization/</link><description>Recent content in Authorization on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/authorization/index.xml" rel="self" type="application/rss+xml"/><item><title>Collector Access Control 實作</title><link>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/07-security-privacy/collector-access-control/</guid><description>&lt;p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。&lt;/p>
&lt;h2 id="認證來源是誰">認證：來源是誰&lt;/h2>
&lt;p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。&lt;/p>
&lt;h3 id="api-key-認證">API Key 認證&lt;/h3>
&lt;p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">authMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span> &lt;span class="p">{&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">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-API-Key&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="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nf">isValidKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">)&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;unauthorized&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusUnauthorized&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="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &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="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。&lt;/p>
&lt;h3 id="mtlsmutual-tls">mTLS（Mutual TLS）&lt;/h3>
&lt;p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。&lt;/p>
&lt;p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。&lt;/p>
&lt;h2 id="授權允許做什麼">授權：允許做什麼&lt;/h2>
&lt;p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。&lt;/p>
&lt;h3 id="角色分離">角色分離&lt;/h3>
&lt;p>最簡單的授權模型是兩個角色：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Writer&lt;/strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。&lt;/li>
&lt;li>&lt;strong>Reader&lt;/strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。&lt;/li>
&lt;/ul>
&lt;p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。&lt;/p>
&lt;h3 id="寫入限制">寫入限制&lt;/h3>
&lt;p>即使認證通過、角色正確，collector 也可以對寫入加上限制：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Rate limit&lt;/strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。&lt;/li>
&lt;li>&lt;strong>Payload size limit&lt;/strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。&lt;/li>
&lt;li>&lt;strong>Schema validation&lt;/strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。&lt;/li>
&lt;/ul>
&lt;h2 id="access-log誰做了什麼">Access Log：誰做了什麼&lt;/h2>
&lt;p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。&lt;/p>
&lt;p>Access log 的用途：&lt;/p>
&lt;p>&lt;strong>安全審計&lt;/strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。&lt;/p>
&lt;p>&lt;strong>問題排查&lt;/strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。&lt;/p>
&lt;p>&lt;strong>用量統計&lt;/strong>：每個 client 送了多少事件、佔多少儲存。&lt;/p>
&lt;p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>SDK 端的 redaction → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計&lt;/a>&lt;/li>
&lt;li>Transport 層的加密 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全&lt;/a>&lt;/li>
&lt;li>資料儲存後的去識別化 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略&lt;/a>&lt;/li>
&lt;li>Client-side credential 暴露的根本限制 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/client-sdk-authentication/" data-link-title="Client-side SDK 認證的根本限制" data-link-desc="嵌在 client 端的 credential 必然可被提取 — 認清 architecture 天花板後的多層緩解策略，從 origin 驗證到 device attestation">Client-side SDK 認證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Collector access control 管理「誰可以對 collector 做什麼操作」。三層控制各自回答不同的問題：認證回答「來源是誰」，授權回答「這個來源被允許做什麼」，access log 回答「誰在什麼時候實際做了什麼」。</p>
<h2 id="認證來源是誰">認證：來源是誰</h2>
<p>認證驗證送出資料的 client 是否合法。未認證的 request 應該被拒絕，避免任意來源向 collector 寫入資料。</p>
<h3 id="api-key-認證">API Key 認證</h3>
<p>每個合法的 SDK client 有一個 API key。Collector 檢查 request header 中的 API key 是否在合法清單中。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">authMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">key</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-API-Key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nf">isValidKey</span><span class="p">(</span><span class="nx">key</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;unauthorized&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusUnauthorized</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</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="p">}</span></span></span></code></pre></div><p>自用工具場景下，一個 API key 對應一個 client 通常就足夠。多個 client（例如同一個 app 的 iOS 和 Android 版本）可以用同一個 key，或每個平台一個 key 以便在 access log 中區分來源。</p>
<h3 id="mtlsmutual-tls">mTLS（Mutual TLS）</h3>
<p>Client 和 server 互相驗證對方的憑證。安全性比 API key 高 — 攻擊者即使取得 API key，沒有 client 憑證也無法連線。</p>
<p>mTLS 的設定成本較高（每個 client 需要產生和管理憑證），適合對安全性要求較高的環境。自用工具通常不需要 mTLS。</p>
<h2 id="授權允許做什麼">授權：允許做什麼</h2>
<p>授權控制已認證的 client 可以執行哪些操作。Collector 的操作通常分為兩類：寫入事件和查詢事件。</p>
<h3 id="角色分離">角色分離</h3>
<p>最簡單的授權模型是兩個角色：</p>
<ul>
<li><strong>Writer</strong>：只能寫入事件（POST /events）。SDK client 使用這個角色。</li>
<li><strong>Reader</strong>：只能查詢事件（GET /events、GET /query）。開發者的 CLI 工具使用這個角色。</li>
</ul>
<p>角色分離的價值在於限制洩漏的影響範圍。如果 SDK 的 API key 被洩漏，攻擊者只能寫入（產生垃圾事件），不能讀取（看到歷史事件中的敏感資訊）。</p>
<h3 id="寫入限制">寫入限制</h3>
<p>即使認證通過、角色正確，collector 也可以對寫入加上限制：</p>
<ul>
<li><strong>Rate limit</strong>：每個 API key 每分鐘最多 N 個 request。防止 client 端 bug 導致事件風暴。</li>
<li><strong>Payload size limit</strong>：每個事件最大 M KB。防止異常大的 event data 消耗儲存。</li>
<li><strong>Schema validation</strong>：事件必須符合定義的 JSON schema。格式不正確的事件拒絕存入。</li>
</ul>
<h2 id="access-log誰做了什麼">Access Log：誰做了什麼</h2>
<p>Access log 記錄每個到達 collector 的 request — 來源 IP、API key（或 key 的 hash）、操作類型、時間戳、response status。</p>
<p>Access log 的用途：</p>
<p><strong>安全審計</strong>：發現異常行為 — 未知 IP 的大量寫入、非工作時間的讀取、連續的認證失敗。</p>
<p><strong>問題排查</strong>：SDK 說事件送出成功但 collector 沒有收到 — access log 可以確認 request 是否到達、response 是什麼。</p>
<p><strong>用量統計</strong>：每個 client 送了多少事件、佔多少儲存。</p>
<p>Access log 本身也是監控資料，但和業務事件分開儲存。Access log 存在 collector 本機的 log 檔中，用系統的 logrotate 管理輪替。</p>





<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">2026-06-19T10:30:00Z POST /events key=sk_mon_ab...cd ip=192.168.1.50 status=200 size=1234
</span></span><span class="line"><span class="ln">2</span><span class="cl">2026-06-19T10:30:01Z POST /events key=INVALID ip=10.0.0.99 status=401 size=0
</span></span><span class="line"><span class="ln">3</span><span class="cl">2026-06-19T10:31:00Z GET /query key=sk_read_ef...gh ip=192.168.1.1 status=200 size=8901</span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>SDK 端的 redaction → <a href="/blog/monitoring/07-security-privacy/sdk-redaction-api/" data-link-title="SDK Redaction API 設計" data-link-desc="預設 redaction rule 過濾已知敏感欄位、自訂 pattern 擴展應用特有的 secret 格式 — redaction 在 SDK 端執行，敏感資料不離開 client">SDK Redaction API 設計</a></li>
<li>Transport 層的加密 → <a href="/blog/monitoring/07-security-privacy/transport-security/" data-link-title="Transport 安全" data-link-desc="HTTPS / basic auth / 同區網也要加密的理由 — 監控資料在傳輸途中的保護機制">Transport 安全</a></li>
<li>資料儲存後的去識別化 → <a href="/blog/monitoring/07-security-privacy/anonymization-strategy/" data-link-title="去識別化策略" data-link-desc="IP 截斷 / user agent 簡化 / stack trace 路徑清理 / session UUID — 四種去識別化技術的適用場景和實作方式">去識別化策略</a></li>
<li>Client-side credential 暴露的根本限制 → <a href="/blog/monitoring/07-security-privacy/client-sdk-authentication/" data-link-title="Client-side SDK 認證的根本限制" data-link-desc="嵌在 client 端的 credential 必然可被提取 — 認清 architecture 天花板後的多層緩解策略，從 origin 驗證到 device attestation">Client-side SDK 認證</a></li>
</ul>
]]></content:encoded></item><item><title>Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &amp;#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore&lt;/a> overview 的 deep article。寫作參照 &lt;a href="https://tarrragon.github.io/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論&lt;/a>。規則語法以 &lt;a href="https://firebase.google.com/docs/firestore/security/get-started">官方 Security Rules 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;h2 id="問題情境授權沒有後端可以藏">問題情境：授權沒有後端可以藏&lt;/h2>
&lt;p>自建後端的授權有一個天然的藏身處：所有讀寫都過 API，權限檢查寫在 service 層，前端拿不到的資料就是拿不到。Firestore 的 client 直連模型把這個藏身處拿掉了——前端 SDK 直接連資料庫，唯一擋在「任何人都能讀整個 collection」與「正確授權」之間的，就是 Security Rules。規則寫錯一條，等於把資料庫對公網敞開。&lt;/p>
&lt;p>這個責任轉移最常見的引爆點是上線後的滲透測試或 bug bounty：報告指出「未登入就能用 REST API 拉出整張 &lt;code>users&lt;/code> collection」。根因幾乎都是同一類——開發期為了方便把規則設成 &lt;code>allow read, write: if true&lt;/code>，上線忘了收。Firestore 的規則是控制面的全部，這篇處理它的求值模型、如何把它寫成可測試的程式碼、以及它撐不住時的退場路線。&lt;/p>
&lt;h2 id="核心概念規則的求值模型">核心概念：規則的求值模型&lt;/h2>
&lt;p>Firestore Security Rules 是一套宣告式 DSL，掛在 &lt;code>match&lt;/code> path 上、對每個讀寫請求求值。理解它要抓住四個跟後端授權不同的點：&lt;/p>
&lt;p>&lt;strong>規則不是 filter，是 allow/deny 判定&lt;/strong>。一條 &lt;code>allow read: if &amp;lt;condition&amp;gt;&lt;/code> 不會「只回傳符合條件的 document」——它是對「這次請求能不能執行」的布林判定。query 若可能讀到任何不符合規則的 document，整個 query 被拒絕，不是默默過濾。這逼著 client 的 query 必須自帶與規則一致的條件（例如 &lt;code>where('ownerId', '==', uid)&lt;/code>），規則才放行。&lt;/p>
&lt;p>&lt;strong>規則預設拒絕&lt;/strong>。沒有 &lt;code>match&lt;/code> 命中的 path 一律拒絕。&lt;code>rules_version = '2'&lt;/code> 下，&lt;code>match /{document=**}&lt;/code> 遞迴匹配所有 subcollection，要小心別用一條寬鬆的遞迴規則蓋掉底下該嚴格的 path。&lt;/p>
&lt;p>&lt;strong>請求脈絡來自 &lt;code>request&lt;/code> 與 &lt;code>resource&lt;/code>&lt;/strong>。&lt;code>request.auth&lt;/code> 是已驗證的身分（&lt;code>request.auth.uid&lt;/code>、&lt;code>request.auth.token&lt;/code> 的 custom claims）；&lt;code>request.resource.data&lt;/code> 是寫入後的 document 狀態；&lt;code>resource.data&lt;/code> 是寫入前的既有狀態。授權與資料驗證都在這幾個物件上展開。&lt;/p>
&lt;p>&lt;strong>跨 document 查詢用 &lt;code>get()&lt;/code> / &lt;code>exists()&lt;/code>&lt;/strong>。判斷「這個 user 是不是這個 project 的成員」要去讀另一份 document，用 &lt;code>get(/databases/$(database)/documents/projects/$(pid)/members/$(uid))&lt;/code>。每個 &lt;code>get()&lt;/code> 是一次額外讀取、計入計費，也有每請求次數上限（規則內 document access 有上限，設計時要省著用）。&lt;/p>
&lt;p>基本骨架：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nx">rules_version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;2&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="nx">service&lt;/span> &lt;span class="nx">cloud&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">databases&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&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="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">notes&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">noteId&lt;/span>&lt;span class="p">}&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="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="nx">allow&lt;/span> &lt;span class="nx">create&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="nx">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>read&lt;/code> 用 &lt;code>resource.data&lt;/code>（既有 document），&lt;code>create&lt;/code> 用 &lt;code>request.resource.data&lt;/code>（沒有既有狀態），&lt;code>update&lt;/code> 兩者都要看——把 &lt;code>read&lt;/code> / &lt;code>create&lt;/code> / &lt;code>update&lt;/code> / &lt;code>delete&lt;/code> 分開是建模的起點，混成一條 &lt;code>allow read, write&lt;/code> 是後面所有漏洞的源頭。&lt;/p>
&lt;h2 id="配置把授權拆成可組合-function">配置：把授權拆成可組合 function&lt;/h2>
&lt;p>規則一旦超過幾個 collection，inline 的 &lt;code>if&lt;/code> 條件會重複且難讀。把授權判斷抽成 &lt;code>function&lt;/code>，讓每條規則讀起來像在描述意圖，是讓規則可維護的核心手段：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="nx">rules_version&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;2&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="nx">service&lt;/span> &lt;span class="nx">cloud&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">firestore&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">databases&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&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="kd">function&lt;/span> &lt;span class="nx">isSignedIn&lt;/span>&lt;span class="p">()&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="k">return&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&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="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 class="kd">function&lt;/span> &lt;span class="nx">isOwner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">docData&lt;/span>&lt;span class="p">)&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="k">return&lt;/span> &lt;span class="nx">isSignedIn&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">docData&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ownerId&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&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 class="k">return&lt;/span> &lt;span class="nx">isSignedIn&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">exists&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sr">/databases/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">members&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="kd">function&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">role&lt;/span>&lt;span class="p">)&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="k">return&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&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="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sr">/databases/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">database&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">documents&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">members&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">role&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">role&lt;/span>&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="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 class="c1">// 寫入時欄位白名單：禁止 client 竄改 ownerId / createdAt
&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 class="kd">function&lt;/span> &lt;span class="nx">fieldsUnchanged&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fields&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">diff&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">affectedKeys&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">hasOnly&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fields&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">projects&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;admin&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">fieldsUnchanged&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;description&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;updatedAt&amp;#39;&lt;/span>&lt;span class="p">]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">hasRole&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;owner&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">match&lt;/span> &lt;span class="o">/&lt;/span>&lt;span class="nx">tasks&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">taskId&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">read&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">create&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">createdBy&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">request&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">auth&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">uid&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">allow&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">delete&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">isProjectMember&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">projectId&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡有三個建模手段值得展開。第一，&lt;code>isProjectMember&lt;/code> / &lt;code>hasRole&lt;/code> 把「成員資格」與「角色」的判斷集中成單一定義，授權邏輯改一處全站生效，避免同一條規則散落在十個 collection。第二，&lt;code>fieldsUnchanged&lt;/code> 用 &lt;code>diff().affectedKeys().hasOnly()&lt;/code> 把「這次 update 只准動哪些欄位」寫成白名單——這擋掉 client 直接改 &lt;code>ownerId&lt;/code> 把別人的資料佔為己有的攻擊，是 client 直連模型必備的欄位級防護。第三，custom claims（&lt;code>request.auth.token.role&lt;/code>）適合放跨專案、低頻變動的全域角色；per-resource 的成員資格用 &lt;code>get()&lt;/code> 查 membership document，因為 claims 改動要等 token 刷新、不適合表達即時變動的權限。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore</a> overview 的 deep article。寫作參照 <a href="/blog/posts/vendor-%E6%B7%B1%E5%BA%A6%E6%8A%80%E8%A1%93%E6%96%87%E7%AB%A0%E6%96%B9%E6%B3%95%E8%AB%96%E7%9A%84%E6%BC%94%E5%8C%96%E7%B4%80%E9%8C%84%E5%90%8C-vendor-%E7%B3%BB%E5%88%97%E7%9A%84%E9%96%8B%E5%A0%B4%E8%BC%AA%E6%9B%BF%E9%A9%97%E8%AD%89/" data-link-title="Vendor 深度技術文章方法論的演化紀錄：同 vendor 系列的開場輪替驗證" data-link-desc="vendor overview 飽和後要寫單一功能深度文章、需要選題與結構依據時回來。這套方法論的驗證來源與 cadence variant 在高風險場景（同 vendor sub-tool 系列）的實證。">Vendor 深度技術文章寫作方法論</a>。規則語法以 <a href="https://firebase.google.com/docs/firestore/security/get-started">官方 Security Rules 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<h2 id="問題情境授權沒有後端可以藏">問題情境：授權沒有後端可以藏</h2>
<p>自建後端的授權有一個天然的藏身處：所有讀寫都過 API，權限檢查寫在 service 層，前端拿不到的資料就是拿不到。Firestore 的 client 直連模型把這個藏身處拿掉了——前端 SDK 直接連資料庫，唯一擋在「任何人都能讀整個 collection」與「正確授權」之間的，就是 Security Rules。規則寫錯一條，等於把資料庫對公網敞開。</p>
<p>這個責任轉移最常見的引爆點是上線後的滲透測試或 bug bounty：報告指出「未登入就能用 REST API 拉出整張 <code>users</code> collection」。根因幾乎都是同一類——開發期為了方便把規則設成 <code>allow read, write: if true</code>，上線忘了收。Firestore 的規則是控制面的全部，這篇處理它的求值模型、如何把它寫成可測試的程式碼、以及它撐不住時的退場路線。</p>
<h2 id="核心概念規則的求值模型">核心概念：規則的求值模型</h2>
<p>Firestore Security Rules 是一套宣告式 DSL，掛在 <code>match</code> path 上、對每個讀寫請求求值。理解它要抓住四個跟後端授權不同的點：</p>
<p><strong>規則不是 filter，是 allow/deny 判定</strong>。一條 <code>allow read: if &lt;condition&gt;</code> 不會「只回傳符合條件的 document」——它是對「這次請求能不能執行」的布林判定。query 若可能讀到任何不符合規則的 document，整個 query 被拒絕，不是默默過濾。這逼著 client 的 query 必須自帶與規則一致的條件（例如 <code>where('ownerId', '==', uid)</code>），規則才放行。</p>
<p><strong>規則預設拒絕</strong>。沒有 <code>match</code> 命中的 path 一律拒絕。<code>rules_version = '2'</code> 下，<code>match /{document=**}</code> 遞迴匹配所有 subcollection，要小心別用一條寬鬆的遞迴規則蓋掉底下該嚴格的 path。</p>
<p><strong>請求脈絡來自 <code>request</code> 與 <code>resource</code></strong>。<code>request.auth</code> 是已驗證的身分（<code>request.auth.uid</code>、<code>request.auth.token</code> 的 custom claims）；<code>request.resource.data</code> 是寫入後的 document 狀態；<code>resource.data</code> 是寫入前的既有狀態。授權與資料驗證都在這幾個物件上展開。</p>
<p><strong>跨 document 查詢用 <code>get()</code> / <code>exists()</code></strong>。判斷「這個 user 是不是這個 project 的成員」要去讀另一份 document，用 <code>get(/databases/$(database)/documents/projects/$(pid)/members/$(uid))</code>。每個 <code>get()</code> 是一次額外讀取、計入計費，也有每請求次數上限（規則內 document access 有上限，設計時要省著用）。</p>
<p>基本骨架：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">rules_version</span> <span class="o">=</span> <span class="s1">&#39;2&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">service</span> <span class="nx">cloud</span><span class="p">.</span><span class="nx">firestore</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">match</span> <span class="o">/</span><span class="nx">databases</span><span class="o">/</span><span class="p">{</span><span class="nx">database</span><span class="p">}</span><span class="o">/</span><span class="nx">documents</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">match</span> <span class="o">/</span><span class="nx">notes</span><span class="o">/</span><span class="p">{</span><span class="nx">noteId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                  <span class="o">&amp;&amp;</span> <span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">      <span class="nx">allow</span> <span class="nx">create</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                    <span class="o">&amp;&amp;</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="nx">allow</span> <span class="nx">update</span><span class="p">,</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                            <span class="o">&amp;&amp;</span> <span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</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="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><code>read</code> 用 <code>resource.data</code>（既有 document），<code>create</code> 用 <code>request.resource.data</code>（沒有既有狀態），<code>update</code> 兩者都要看——把 <code>read</code> / <code>create</code> / <code>update</code> / <code>delete</code> 分開是建模的起點，混成一條 <code>allow read, write</code> 是後面所有漏洞的源頭。</p>
<h2 id="配置把授權拆成可組合-function">配置：把授權拆成可組合 function</h2>
<p>規則一旦超過幾個 collection，inline 的 <code>if</code> 條件會重複且難讀。把授權判斷抽成 <code>function</code>，讓每條規則讀起來像在描述意圖，是讓規則可維護的核心手段：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nx">rules_version</span> <span class="o">=</span> <span class="s1">&#39;2&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">service</span> <span class="nx">cloud</span><span class="p">.</span><span class="nx">firestore</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">match</span> <span class="o">/</span><span class="nx">databases</span><span class="o">/</span><span class="p">{</span><span class="nx">database</span><span class="p">}</span><span class="o">/</span><span class="nx">documents</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="kd">function</span> <span class="nx">isSignedIn</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="k">return</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <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 class="kd">function</span> <span class="nx">isOwner</span><span class="p">(</span><span class="nx">docData</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="k">return</span> <span class="nx">isSignedIn</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="nx">docData</span><span class="p">.</span><span class="nx">ownerId</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</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="kd">function</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="k">return</span> <span class="nx">isSignedIn</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="o">&amp;&amp;</span> <span class="nx">exists</span><span class="p">(</span><span class="sr">/databases/</span><span class="nx">$</span><span class="p">(</span><span class="nx">database</span><span class="p">)</span><span class="o">/</span><span class="nx">documents</span><span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span><span class="o">/</span><span class="nx">members</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</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></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="kd">function</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="nx">role</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">      <span class="k">return</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="o">&amp;&amp;</span> <span class="nx">get</span><span class="p">(</span><span class="sr">/databases/</span><span class="nx">$</span><span class="p">(</span><span class="nx">database</span><span class="p">)</span><span class="o">/</span><span class="nx">documents</span><span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span><span class="o">/</span><span class="nx">members</span><span class="o">/</span><span class="nx">$</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">)).</span><span class="nx">data</span><span class="p">.</span><span class="nx">role</span> <span class="o">==</span> <span class="nx">role</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <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 class="c1">// 寫入時欄位白名單：禁止 client 竄改 ownerId / createdAt
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"></span>    <span class="kd">function</span> <span class="nx">fieldsUnchanged</span><span class="p">(</span><span class="nx">fields</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">      <span class="k">return</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">diff</span><span class="p">(</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">).</span><span class="nx">affectedKeys</span><span class="p">().</span><span class="nx">hasOnly</span><span class="p">(</span><span class="nx">fields</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">match</span> <span class="o">/</span><span class="nx">projects</span><span class="o">/</span><span class="p">{</span><span class="nx">projectId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">      <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">      <span class="nx">allow</span> <span class="nx">update</span><span class="o">:</span> <span class="k">if</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="s1">&#39;admin&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">                    <span class="o">&amp;&amp;</span> <span class="nx">fieldsUnchanged</span><span class="p">([</span><span class="s1">&#39;name&#39;</span><span class="p">,</span> <span class="s1">&#39;description&#39;</span><span class="p">,</span> <span class="s1">&#39;updatedAt&#39;</span><span class="p">]);</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">      <span class="nx">allow</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">hasRole</span><span class="p">(</span><span class="nx">projectId</span><span class="p">,</span> <span class="s1">&#39;owner&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">      <span class="nx">match</span> <span class="o">/</span><span class="nx">tasks</span><span class="o">/</span><span class="p">{</span><span class="nx">taskId</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="nx">allow</span> <span class="nx">read</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="nx">allow</span> <span class="nx">create</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">                      <span class="o">&amp;&amp;</span> <span class="nx">request</span><span class="p">.</span><span class="nx">resource</span><span class="p">.</span><span class="nx">data</span><span class="p">.</span><span class="nx">createdBy</span> <span class="o">==</span> <span class="nx">request</span><span class="p">.</span><span class="nx">auth</span><span class="p">.</span><span class="nx">uid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="nx">allow</span> <span class="nx">update</span><span class="p">,</span> <span class="k">delete</span><span class="o">:</span> <span class="k">if</span> <span class="nx">isProjectMember</span><span class="p">(</span><span class="nx">projectId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡有三個建模手段值得展開。第一，<code>isProjectMember</code> / <code>hasRole</code> 把「成員資格」與「角色」的判斷集中成單一定義，授權邏輯改一處全站生效，避免同一條規則散落在十個 collection。第二，<code>fieldsUnchanged</code> 用 <code>diff().affectedKeys().hasOnly()</code> 把「這次 update 只准動哪些欄位」寫成白名單——這擋掉 client 直接改 <code>ownerId</code> 把別人的資料佔為己有的攻擊，是 client 直連模型必備的欄位級防護。第三，custom claims（<code>request.auth.token.role</code>）適合放跨專案、低頻變動的全域角色；per-resource 的成員資格用 <code>get()</code> 查 membership document，因為 claims 改動要等 token 刷新、不適合表達即時變動的權限。</p>
<h2 id="配置用-emulator-把規則寫成單元測試">配置：用 emulator 把規則寫成單元測試</h2>
<p>規則是安全邊界，改一條就要驗證沒開新洞——這要求規則像程式碼一樣有測試。Firebase Emulator + <code>@firebase/rules-unit-testing</code> 讓規則在本地用真實求值引擎跑斷言，不必碰雲端：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// rules.test.js — 用 Jest / Mocha 跑
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">const</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nx">initializeTestEnvironment</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nx">assertFails</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">assertSucceeds</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;@firebase/rules-unit-testing&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kr">const</span> <span class="p">{</span> <span class="nx">setDoc</span><span class="p">,</span> <span class="nx">getDoc</span><span class="p">,</span> <span class="nx">doc</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;firebase/firestore&#39;</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 class="kd">let</span> <span class="nx">testEnv</span><span class="p">;</span>
</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="nx">beforeAll</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">testEnv</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">initializeTestEnvironment</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">projectId</span><span class="o">:</span> <span class="s1">&#39;demo-notes&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">firestore</span><span class="o">:</span> <span class="p">{</span> <span class="nx">rules</span><span class="o">:</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;fs&#39;</span><span class="p">).</span><span class="nx">readFileSync</span><span class="p">(</span><span class="s1">&#39;firestore.rules&#39;</span><span class="p">,</span> <span class="s1">&#39;utf8&#39;</span><span class="p">)</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <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></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nx">afterAll</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">cleanup</span><span class="p">();</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nx">beforeEach</span><span class="p">(</span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span> <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">clearFirestore</span><span class="p">();</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 class="nx">test</span><span class="p">(</span><span class="s1">&#39;owner 能讀自己的 note&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="c1">// 用 admin context 預先種一筆資料、繞過規則
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="c1"></span>  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">  <span class="kr">const</span> <span class="nx">alice</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;alice&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertSucceeds</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">alice</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;非 owner 不能讀別人的 note&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">  <span class="kr">const</span> <span class="nx">bob</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;bob&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">bob</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;未登入完全擋下&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">  <span class="kr">const</span> <span class="nx">anon</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">unauthenticatedContext</span><span class="p">().</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">getDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">anon</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">
</span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;client 不能竄改 ownerId&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">  <span class="kr">await</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">withSecurityRulesDisabled</span><span class="p">(</span><span class="kr">async</span> <span class="p">(</span><span class="nx">ctx</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="kr">await</span> <span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">firestore</span><span class="p">(),</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;alice&#39;</span><span class="p">,</span> <span class="nx">text</span><span class="o">:</span> <span class="s1">&#39;hi&#39;</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">  <span class="kr">const</span> <span class="nx">alice</span> <span class="o">=</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">authenticatedContext</span><span class="p">(</span><span class="s1">&#39;alice&#39;</span><span class="p">).</span><span class="nx">firestore</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">  <span class="kr">await</span> <span class="nx">assertFails</span><span class="p">(</span><span class="nx">setDoc</span><span class="p">(</span><span class="nx">doc</span><span class="p">(</span><span class="nx">alice</span><span class="p">,</span> <span class="s1">&#39;notes/n1&#39;</span><span class="p">),</span> <span class="p">{</span> <span class="nx">ownerId</span><span class="o">:</span> <span class="s1">&#39;bob&#39;</span><span class="p">,</span> <span class="nx">text</span><span class="o">:</span> <span class="s1">&#39;hi&#39;</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>啟動方式 <code>firebase emulators:exec --only firestore &quot;npm test&quot;</code>，讓測試在 CI 跑。測試要覆蓋的不只是 happy path——每條規則至少要有「正向放行」「越權拒絕」「未登入拒絕」「欄位竄改拒絕」四類斷言。<code>assertFails</code> 比 <code>assertSucceeds</code> 更重要：它證明的是「該擋的有擋住」，正是滲透測試會打的點。把這套測試接進 release gate，規則變更才有 evidence 可交（對應 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>）。</p>
<h2 id="故障演練五個把規則寫成漏洞的-production-踩坑">故障演練：五個把規則寫成漏洞的 production 踩坑</h2>
<h4 id="case-1allow-read-write-if-true-上線沒收">Case 1：<code>allow read, write: if true</code> 上線沒收</h4>
<p>開發期為了快，把規則開全放，上線忘改。任何人用公開的 project config（前端 bundle 裡就有）就能 REST 拉整個資料庫。修法：規則預設從 deny 起手，開發期的寬鬆規則進不了 main branch；CI 跑一條 lint 掃 <code>if true</code>，命中即 fail。這是 <a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a> 越權查詢路徑的最便宜目標。</p>
<h4 id="case-2read-沒拆-get-與-list">Case 2：<code>read</code> 沒拆 <code>get</code> 與 <code>list</code></h4>
<p><code>allow read</code> 同時涵蓋讀單一 document（<code>get</code>）與查整個 collection（<code>list</code>）。規則只想開「讀自己那筆」，卻因為沒拆 <code>list</code>，讓 client 能 <code>list</code> 整個 collection 撈別人的資料。修法：對 collection-level query 敏感的 path，把 <code>read</code> 拆成 <code>allow get</code> 與 <code>allow list</code>，<code>list</code> 條件更嚴或直接關閉、改走後端彙整。</p>
<h4 id="case-3信任-requestresourcedata-的內容沒驗證">Case 3：信任 <code>request.resource.data</code> 的內容沒驗證</h4>
<p><code>create</code> 規則只檢查 <code>request.auth != null</code>，沒驗證寫入內容。client 自己塞 <code>role: 'admin'</code> 或 <code>balance: 999999</code> 進 document。修法：寫入規則要驗證關鍵欄位的值與型別（<code>request.resource.data.role == 'member'</code>、<code>request.resource.data.amount is int</code>），敏感欄位（角色、金額、狀態）的權威值不該由 client 寫入、改由 Cloud Function 或後端寫。</p>
<h4 id="case-4遞迴-match-document-蓋掉嚴格規則">Case 4：遞迴 <code>match /{document=**}</code> 蓋掉嚴格規則</h4>
<p>頂層放一條 <code>match /{document=**} { allow read: if isSignedIn(); }</code> 圖方便，結果它遞迴命中所有 subcollection，把底下本來該按成員資格嚴格控管的 <code>members</code> collection 也開成「登入即可讀」。修法：避免寬鬆的遞迴萬用規則；授權顆粒不同的 path 各自寫明確 <code>match</code>。</p>
<h4 id="case-5規則複雜到沒人能-review">Case 5：規則複雜到沒人能 review</h4>
<p>授權邏輯長到幾百行、巢狀 <code>get()</code> 互相依賴，改一條沒人敢保證沒開新洞、也沒有測試。修法：這是規則撐不住的訊號（見下方邊界段）——超過這個複雜度，授權該拉回後端中介層，而不是繼續在 DSL 裡長。</p>
<h2 id="容量與觀測get-計費與規則複雜度上限">容量與觀測：<code>get()</code> 計費與規則複雜度上限</h2>
<p>規則內的每個 <code>get()</code> / <code>exists()</code> 是一次 document 讀取，計入計費，且單次請求的 document access 有數量上限（以 <a href="https://firebase.google.com/docs/firestore/security/rules-conditions">官方限制</a> 為準）。高頻讀取路徑若每次都 <code>get()</code> 查 membership，成本與延遲都會浮現。優化方向有二：把低頻變動的權限（全域角色）放進 custom claims，從 token 直接讀、零額外 document access；把成員資格設計成可由 document path 直接判斷（例如 membership document 的 ID 就是 uid，用 <code>exists()</code> 而非 <code>get()</code> 撈整份）。</p>
<p>觀測上，授權問題不會在規則層留下豐富 log——被拒的請求 client 端收到 <code>permission-denied</code>。要把這類錯誤從 client 回報、或在關鍵寫入路徑改走 Cloud Function 以取得 server 端 audit log，接回 <a href="/blog/backend/07-security-data-protection/" data-link-title="模組七：資安與資料保護" data-link-desc="以問題驅動方式擴充資安知識網：先定義服務環節問題，再以案例作為觸發式參考">7.7 稽核軌跡</a>。規則本身的變更要進版本控制、每次 deploy 留 diff，授權變更才可回溯。</p>
<h2 id="邊界與整合規則撐不住時把授權拉回後端">邊界與整合：規則撐不住時把授權拉回後端</h2>
<p>Security Rules 適合表達「資源的擁有者與成員能做什麼」這類 resource-scoped 授權。它撐不住的訊號很明確：授權依賴跨多個 document 的複雜聚合判斷、需要呼叫外部系統、規則複雜到無法 review、或業務規則頻繁變動到規則 deploy 跟不上。撞到這些訊號時，正確的動作是把該塊授權移出 client 直連路徑，而非把規則寫得更巧：</p>
<ul>
<li><strong>敏感寫入改走 Cloud Function / 後端 API</strong>：金額、狀態機轉換、跨實體一致性的寫入，由 server 端驗證後以 admin 權限寫入，規則對 client 直接關閉這些 path 的寫入</li>
<li><strong>複雜授權整體下沉</strong>：當規則複雜度本身成為風險，這是 <a href="/blog/backend/01-database/vendors/firestore/migrate-to-relational/" data-link-title="從 Firestore 遷往自建 relational：撞牆驅動的 Type E 重建模、存取模型反轉與並行期" data-link-desc="Firestore → 自建後端 &#43; relational 不是匯資料而是反轉存取模型：client 直連變 API 中介、Security Rules 授權變後端授權、document 反正規化變正規 schema、realtime listener 與 offline 同步要重建；本文走 Type E paradigm shift 結構、展開為何字面遷移不成立、哪些該遷哪些先留、dual-write &#43; shadow read 階段化與遷出代價判讀">Firestore → 自建 relational</a> playbook 裡「授權控制面失控」這面牆——把授權拉回後端中介層是遷移的 driver 之一</li>
</ul>
<p>判讀的單位仍是逐路徑：簡單的 owner-scoped 資料留在規則 + client 直連，複雜或敏感的部分走後端。不是非此即彼。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上層：<a href="/blog/backend/01-database/vendors/firestore/" data-link-title="Firestore" data-link-desc="Firebase / Google Cloud 的 serverless document database、collection / document 模型、client 直連 &#43; Security Rules、realtime listener 與 offline 同步、BaaS bundle 的資料層面">Firestore overview</a>（服務定位與查詢邊界）</li>
<li>安全驗證：<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a>（越權查詢與資料外洩路徑）</li>
<li>遷移 driver：<a href="/blog/backend/01-database/vendors/firestore/migrate-to-relational/" data-link-title="從 Firestore 遷往自建 relational：撞牆驅動的 Type E 重建模、存取模型反轉與並行期" data-link-desc="Firestore → 自建後端 &#43; relational 不是匯資料而是反轉存取模型：client 直連變 API 中介、Security Rules 授權變後端授權、document 反正規化變正規 schema、realtime listener 與 offline 同步要重建；本文走 Type E paradigm shift 結構、展開為何字面遷移不成立、哪些該遷哪些先留、dual-write &#43; shadow read 階段化與遷出代價判讀">Firestore → 自建 relational</a>（授權控制面失控的退場）</li>
<li>發布證據：<a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a>（規則測試接進 gate）</li>
<li>官方：<a href="https://firebase.google.com/docs/firestore/security/get-started">Security Rules get started</a>、<a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing</a>、<a href="https://firebase.google.com/docs/firestore/security/rules-conditions">Rules conditions limits</a></li>
</ul>
]]></content:encoded></item><item><title>Authorization</title><link>https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/</guid><description>&lt;p>Authorization 的核心概念是「判斷已識別主體是否能執行某個操作」。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">Authentication&lt;/a> 回答你是誰；authorization 回答你能做什麼、對哪個資源做、在什麼條件下做。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Authorization 是資料保護與操作安全的核心邊界。它可以用 role、permission、policy、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tenant-boundary/" data-link-title="Tenant Boundary" data-link-desc="說明多租戶系統如何隔離不同客戶或組織的資料與資源">tenant boundary&lt;/a>、resource owner 或 attribute-based rule 表達。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>系統需要授權模型的訊號是角色、資料範圍或操作風險開始分級。客服可以查看訂單狀態，但調整退款、下載 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/pii/" data-link-title="PII" data-link-desc="說明可識別個人的資料如何影響權限、遮罩、保留與稽核">PII&lt;/a> 或修改權限應需要更高權限與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a>。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>授權設計要定義主體、資源、操作、條件、拒絕原因與稽核欄位。測試要覆蓋跨 tenant 存取、低權限升級、高風險操作、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/function-level-authorization/" data-link-title="Function-Level Authorization" data-link-desc="說明功能操作本身也需要授權，不只資源 ID 需要授權">function-level authorization&lt;/a> 與資料匯出。&lt;/p></description><content:encoded><![CDATA[<p>Authorization 的核心概念是「判斷已識別主體是否能執行某個操作」。<a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">Authentication</a> 回答你是誰；authorization 回答你能做什麼、對哪個資源做、在什麼條件下做。</p>
<h2 id="概念位置">概念位置</h2>
<p>Authorization 是資料保護與操作安全的核心邊界。它可以用 role、permission、policy、<a href="/blog/backend/knowledge-cards/tenant-boundary/" data-link-title="Tenant Boundary" data-link-desc="說明多租戶系統如何隔離不同客戶或組織的資料與資源">tenant boundary</a>、resource owner 或 attribute-based rule 表達。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>系統需要授權模型的訊號是角色、資料範圍或操作風險開始分級。客服可以查看訂單狀態，但調整退款、下載 <a href="/blog/backend/knowledge-cards/pii/" data-link-title="PII" data-link-desc="說明可識別個人的資料如何影響權限、遮罩、保留與稽核">PII</a> 或修改權限應需要更高權限與 <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a>。</p>
<h2 id="設計責任">設計責任</h2>
<p>授權設計要定義主體、資源、操作、條件、拒絕原因與稽核欄位。測試要覆蓋跨 tenant 存取、低權限升級、高風險操作、<a href="/blog/backend/knowledge-cards/function-level-authorization/" data-link-title="Function-Level Authorization" data-link-desc="說明功能操作本身也需要授權，不只資源 ID 需要授權">function-level authorization</a> 與資料匯出。</p>
]]></content:encoded></item></channel></rss>