Part 3|第 8/30 篇
今日要做的事: 寫 Firebase 安全性規則(Security Rules),用 Emulator 證明 uid-A 讀不到 uid-B。
今天要解決的目的: 隔離靠伺服器規則,不靠「前端沒顯示別人的按鈕」。
Day 6 讓資料有了身份,但還沒證明身份真的能隔離資料。
昨天 Firestore 還在測試模式——誰拿到路徑都能動。
你總不會希望隔壁桌的人偷看你冰箱庫存、順便改你過敏資料吧?XDD
所以今天我要看到紅燈:uid-A 讀 uid-B → 必須被拒絕。
官方怎麼定義這件事:Security Rules 是伺服器端強制執行的規則,用來保護 Firestore/Storage 等資料——不是請前端「乖乖不要亂讀」。文件在這:
https://firebase.google.com/docs/rules?hl=zh-tw
| 項目 | 內容 |
|---|---|
| 產出 | firestore.rules、五個 allow/deny 測試、Emulator 留證 |
| 工具 | Security Rules、Emulator Suite、@firebase/rules-unit-testing |
| 不使用 | 真實過敏/照片、前端藏按鈕當安全、先 deploy 再測 |
| 完成條件 | A→A 綠、A→B 紅、未登入紅 |
本機程式:material/code/day7
問題:路徑有 uid ≠ 有權限
→ 設計:整棵 users/{userId} 只有本人
→ 寫 firestore.rules
→ CLI 丟進 Emulator 測
→ npm test 五案全過
→ Emulator UI 確認測的是本機
→ (之後)再考慮 deploy 正式專案
路徑寫成 users/{uid}/daily/... 只是資料夾整理,不會自動產生權限。
client 寫錯或惡意改路徑,照樣可以指向別人的 uid——UI 藏按鈕擋不住會開 DevTools 的人。

真正能信的只有:
request.auth.uid == path 裡的 userId
文件裡的 owner_uid、網址參數、畫面上選到的帳號——都不能替代 Auth token。
官方文件把 Security Rules 講成「細微的伺服器強制執行規則」。我用工程師白話翻譯成三條,對應今天的測試:
| 官方/規則觀念 | 人話 | 今天哪一案在證明 |
|---|---|---|
| 伺服器端執行 | 規則跑在 Firebase,不跑在你漂亮的前端 | 整份 npm test(Emulator 模擬同一套門衛) |
| 沒允許=拒絕 | 沒寫到的路徑預設 deny,不是「沒寫就隨便進」 | 未登入讀 A → deny |
| 身份來自 Auth token | 跟路徑比的是 request.auth.uid,不是表單自填 |
A 讀/寫 B → deny;偽造 owner_uid 也救不了 |
| 跟 Auth 搭配 | 先登入才有 uid 可比;規則不管「推薦合不合理」 | A 讀/寫 A → allow(本人該過) |
再講白一點:DishFlow 以後會有 pantry、過敏、今天吃了什麼。
那些是個人資料。我不會希望陌生人(或另一個測試帳)把我的庫存當開放 API。
所以今天測的不是「規則檔長得漂不漂亮」,而是——門衛會不會真的把門關上。
今天只解資料隔離。欄位合不合 schema,還是 Part 2 validator 的事;推薦能不能出現某食材,是後面 Agent C 的 hard constraint。
v1 私有資料都掛在:
users/{userId}/{document=**}
最低要測這四種情況:

| 情境 | 預期 |
|---|---|
| uid-A 讀/寫自己 | allow |
| uid-A 讀/寫 uid-B | deny |
| 未登入讀 users | deny |
寫 uid-B 路徑,body 卻寫 owner_uid: A |
仍 deny |
兩道門別搞混:
| 門 | 管什麼 |
|---|---|
| Day 7 Rules | 誰能碰哪份資料(uid-A 不能讀 uid-B) |
| Agent C hard constraint | 推薦內容能不能出現某食材 |
Rules 全綠,不代表推薦已經安全。
firestore.rules對應官方說的 Firebase 安全性規則——我這份是套在 Cloud Firestore 上的門衛(同一套概念之後也會用在 Storage)。
這不是 App UI,也不是資料庫裡的某一筆。
路徑長得像屬於某人,不代表別人就不能偷讀。所以我規定:token 裡的 uid 必須等於路徑上的 {userId},才准動整棵子樹——冰箱、過敏、今日菜單,一律同款門禁。
| 這行 | 意思 |
|---|---|
rules_version = '2' |
第 2 代規則語法 |
match /users/{userId}/{document=**} |
管住此人底下所有子孫(profile、daily、以後的 pantry…) |
request.auth != null |
必須已登入 |
request.auth.uid == userId |
登入者=路徑上的那個人 |
沒寫到的路徑預設 deny。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// v1:整棵 users/{userId} 子樹只有本人能讀寫
match /users/{userId}/{document=**} {
allow read, write: if request.auth != null
&& request.auth.uid == userId;
}
}
}
規則寫在檔案裡;CLI 負責拿去測或發布:

| 指令 | 做什麼 | 我什麼時候用 |
|---|---|---|
npm test(emulators:exec) |
本機套用規則+跑測試 | 今天主線 |
npm run emulators |
開 Emulator+瀏覽器 UI | 想肉眼看本機狀態 |
firebase deploy --only firestore:rules |
發布到正式雲端 | 本地全綠之後再說 |
Console 網頁也能改 Rules,但難跟專案一起版控,也難自動化測跨帳號 deny。
我選:檔案為準 → CLI 測試 → 需要時再 deploy。

material/code/day7/
firestore.rules ← 門衛規則
firebase.json ← 告訴 CLI 規則在哪、Emulator 埠號
package.json
tests/rules.test.js ← 測試台(不是產品 UI)
測試資料只用 fixture:users/uid-a/...、users/uid-b/...。
seed 才暫時關 rules;正式斷言一律用「假裝已登入 A」/「未登入」的 context。
cd material/code/day7
npm install
npm test
npm test=啟動 Firestore Emulator → 跑五案 → 關掉。
Emulator 需要 Java。若出現
Could not spawn java -version,裝好 JDK 後重開終端再跑。
rules.test.js 不是產品畫面,只是專門拿來撞規則的測試台。
官方文件也強調要用模擬器/測試把規則驗證掉;我今天做的就是這件事:假裝不同身份去撞門,看會不會真的被擋。
對照「別偷看我的庫存」心智模型:
| 案例 | 在演哪種人生 | 預期 |
|---|---|---|
| A 讀/寫 A | 我看自己的冰箱——應該可以 | allow ✓ |
| A 讀 B | 我偷看別人庫存——應該被轟出去 | deny ✓ |
A 寫 B(還偽造 owner_uid) |
我硬改別人資料,還在 body 假裝「這是我的」——門衛看的是路徑+token,不是你自介 | deny ✓ |
| 未登入讀 A | 路人連登入都沒登就伸手——直接拒絕 | deny ✓ |
const dbA = env.authenticatedContext("uid-a").firestore();
const dbGuest = env.unauthenticatedContext().firestore();
await assertSucceeds(getDoc(doc(dbA, "users", "uid-a", "profile", "main")));
await assertSucceeds(setDoc(doc(dbA, "users", "uid-a", "daily", "fixture-day"), { ok: true }));
await assertFails(getDoc(doc(dbA, "users", "uid-b", "profile", "main")));
await assertFails(
setDoc(doc(dbA, "users", "uid-b", "daily", "fixture-day"), { owner_uid: "uid-a" })
);
await assertFails(getDoc(doc(dbGuest, "users", "uid-a", "profile", "main")));

跑的時候若冒出 PERMISSION_DENIED,那是跨帳號被擋的 log——測試預期如此,不是失敗。
npm run emulators 之後,瀏覽器開 http://127.0.0.1:4000,就是 Firebase Emulator Suite UI。
我這次畫面裡 Firestore 是 On / Port 8080,其餘服務 Off——今天本來就只開 Firestore。


它是本機的「假 Firebase 控制台」:資料跑在 Emulator 裡,不是正式專案 dishflow 的雲端。頁面上的黃條也寫了 local environment。
| 功能 | 說明 | 今天有沒有用到 |
|---|---|---|
| Overview 看誰 On | 確認 Firestore Emulator 有起來 | 有 |
| Firestore 分頁 | 瀏覽本機假資料、路徑 | 可看可不看 |
| Authentication 分頁 | 本機假帳號 | 沒開 Auth emulator |
| Logs | Emulator 請求/錯誤 | 可看可不看 |
| 自動跑五案斷言 | 做不到,那是 npm test 的事 |
— |
| 自動發布正式環境 | 不會;關掉通常就清空 | — |
npm test |
Emulator UI | |
|---|---|---|
| 目的 | 自動證明 allow/deny | 確認測的是本機假雲端 |
| 輸出 | Terminal 五個 ✓ | 瀏覽器 Overview |
| 會不會改正式資料 | 不會 | 不會 |
Terminal 偶爾出現 demo-no-project、firebase login 之類提示,本地測 Rules 可以略過,不影響今天的斷言。
withSecurityRulesDisabled()——那只適合 seed。deploy Rules;本地通過與已發布是兩件事。allow read: if true——等於回到測試模式。npm test 證明紅燈會亮;Emulator UI 證明測的是本機。下一篇: 把餐點照片放進相同 uid 邊界的私有 Storage,並保留無照片的手動後門。
| 檔/畫面 | 用途 |
|---|---|
day7-01~04、day7-06 |
概念說明圖 |
編輯器裡的 firestore.rules |
露出 request.auth.uid == userId |
day7-05-npm-test.png |
Terminal 五案全過 |
day7-07-emulator-ui-shot.png |
Emulator UI · Firestore On |
| (可選)Console Playground | uid-A 讀 uid-B → denied |
| (可選)正式發布後的 Rules 頁 | 與本地測試分開標示 |
firebase.json(節錄){
"firestore": { "rules": "firestore.rules" },
"emulators": {
"firestore": { "host": "127.0.0.1", "port": 8080 },
"ui": { "enabled": true, "port": 4000 }
}
}
npm install
npm test 五案全過