回部落格
iThome 鐵人賽Vibe CodingFirebaseFirestoreSecurity Rules育兒

iThome 鐵人賽 Day 19:用 Security Rules 限制陣列只能追加,留言不能被改寫,以及空陣列第一次追加被誤擋

兩個人共用的卡片加上留言,要讓資料庫自己守住只能新增、不能改寫別人的話。用 Firestore Security Rules 限制陣列只能多一筆、作者必須是自己;寫規則時踩到空陣列第一次追加被誤擋,以及新欄位寫進舊文件會被規則擋下兩個坑。

Steven Chang
Steven Chang
2026年10月3日 01 Min read
訂閱
iThome 鐵人賽 Day 19:用 Security Rules 限制陣列只能追加,留言不能被改寫,以及空陣列第一次追加被誤擋

本文同步發表於 iThome 鐵人賽,系列:神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App

兩個人共用一張卡片,各自能在上面留言。要怎麼讓資料庫自己守住「只能新增,不能改寫別人的話」,而不是靠 App 自己小心?

先講結果:卡片多了留言,存在卡片文件裡的一個陣列欄位;Security Rules 要求每次更新只能在舊陣列後面多一筆,而且作者必須是自己。寫規則時踩到兩個坑:空陣列的第一則留言被誤擋,以及新欄位寫進舊文件會被規則看成「欄位被改動」。規則測試從 79 個增加到 93 個,verify.sh 通過。

1. 留言存在哪裡

Firestore 有兩種放法:卡片底下的子集合,或卡片文件裡的陣列欄位。這次選陣列:

做法好處代價
卡片文件裡的陣列留言和「對方需要知道」是同一次寫入,不用跨文件的批次寫入兩個人同時留言時,其中一筆會被規則拒絕
子集合並行留言不互相影響要同時寫兩個文件,規則要用 getAfter() 對照另一個文件

留言之後,卡片要重新出現在對方的「待你知道」,所以同一次寫入還要把已知狀態清掉。放在同一個文件裡,一次寫入就是一個完整的動作。兩個人的家庭,同時留言的機會很低,後送出的一筆被拒絕時,重送一次就好。

2. 規則:只能在後面多一筆

目標有四條:只能新增、新增的作者是自己、文字長度有限制、數量有上限。寫成規則:

function isCommentsChangeValid(data, changed) {
  let before = resource.data.get('comments', []);
  let after = data.get('comments', []);
  return !changed.hasAny(['comments'])
    || (after is list
        && after.size() == before.size() + 1
        && after.size() <= 100
        && (before.size() == 0 || after[0:before.size()] == before)
        && after[before.size()].author == request.auth.uid
        && after[before.size()].text is string
        && after[before.size()].text.size() >= 1
        && after[before.size()].text.size() <= 500);
}

幾個細節:

  • changed 是 request.resource.data.diff(resource.data).affectedKeys(),也就是這次更新動到哪些欄位。沒動到 comments,這個函式一律放行。
  • after[0:before.size()] == before 用的是官方文件的範圍運算子 x[i:j],取出從 i 到 j 的子清單。新陣列的前半段如果跟舊陣列完全一樣,就代表既有留言沒有被改寫、也沒有被刪掉。
  • 改寫某一則的文字、刪掉一則、一次加兩則、冒用對方當作者,都會讓其中一個條件不成立。

另外有一個條件放在同一條更新路徑上:改到留言時,ackBy 和 acknowledgedBy 必須是 null。這是為了不讓 App 悄悄留言而不通知對方。

3. 第一個坑:第一則留言被擋

規則寫好,跑 14 個新測試,結果 8 個過、6 個失敗。失敗的測試有個共同點:其中 5 個都需要「先成功追加第一則留言」,也就是從空陣列變成一則。

問題出在 after[0:before.size()]:before 是空陣列時,範圍是 [0:0]。加上 before.size() == 0 || 這個保護之後,這 5 個通過了。至於是 [0:0] 這種空範圍本身的行為,還是別的原因,我沒有再深入驗證,這裡只能說:加了保護就解決。

另外 1 個失敗是測試本身的問題。「超過 100 則會被拒」的測試,我原本寫成一次寫入 100 則。這個寫法照規則本來就會被拒絕(一次只能多一筆)。要種出已經有 99 則、100 則留言的狀態,得繞過規則直接寫入:

await adminSet(CARD, { ...base, comments: make(99) });

adminSet 是用官方測試工具的 withSecurityRulesDisabled 包的,專門拿來準備測試資料。

4. 第二個坑:新欄位寫進舊文件

這次還加了 completedAt、completedBy 兩個欄位。App 儲存卡片時,用的是 setData(全部欄位, merge: true),一開始我把沒有值的欄位寫成 null、空留言寫成 []。

我想過這樣可能會出問題:Firestore 裡舊的卡片文件根本沒有這些欄位,寫入 null 或 [] 會被規則的 affectedKeys() 看成「新增了欄位」,連一般的「知道了」都過不了(確認只允許改 ackBy、acknowledgedBy)。我用規則測試驗證,同一張沒有新欄位的舊卡片:

對方按「知道了」時,額外帶了…結果
completedAt: null被規則拒絕
comments: []被規則拒絕
不帶新欄位通過

所以 App 這一端改成:沒有值就不寫。留言是空的就整個欄位不寫;取消完成要真的清掉雲端的值,改用 FieldValue.delete()。這個寫法的測試是 CardDocumentCompletionTests 的三個案例:有值時來回轉換正確、舊文件缺欄位讀得進來、沒有值時不會寫出 null 或 []。

5. 目前的狀態

項目結果
規則測試93 個(原有 79 個不修改,新增 14 個)
App 測試167 個
verify.shVERIFY: PASS
兩支手機已更新到這個版本
新規則部署到正式的 Firebase已部署(firebase deploy --only firestore:rules),規則編譯通過、已發佈
正式環境實測我的 iPhone 上留言成功,沒有被新規則擋下;太太那一側還沒測

新規則上線後,我在自己的 iPhone 上對一張卡片留言,寫入成功,留言顯示作者與時間,卡片也標著「對方還沒看到」,表示已知狀態有被清掉:

新規則部署後,在實機上留言成功

今天順手處理了兩件小事:App 圖示原本是空的(AppIcon 只有 Contents.json、沒有圖片,建置不會出錯),補上淺色、深色、tinted 三張;Dark 模式的三個燈號顏色,從「提亮 10%」的三種讀法裡選了一種,並算過對比值都超過 4.5。

還沒做的部分:

  • 太太那一側:確認她收到留言、能回覆
  • 兩個人同時留言,看被拒絕時的提示是不是夠清楚

明天

Day 20 繼續在兩支 iPhone 上實際使用,遇到什麼問題就修什麼。

參考資源

官方文件

分享

iThome 鐵人賽 Day 19:用 Security Rules 限制陣列只能追加,留言不能被改寫,以及空陣列第一次追加被誤擋
iThome 鐵人賽 Day 19:用 Security Rules 限制陣列只能追加,留言不能被改寫,以及空陣列第一次追加被誤擋

兩個人共用的卡片加上留言,要讓資料庫自己守住只能新增、不能改寫別人的話。用 Firestore Security Rules 限制陣列只能多一筆、作者必須是自己;寫規則時踩到空陣列第一次追加被誤擋,以及新欄位寫進舊文件會被規則擋下兩個坑。

Gooliya 鼓櫟數位
Steven Chang

Steven Chang

軟體工程師,平常主要有個人接案、DevOps、系統開發、維運。研究 AI 是為了省下時間陪小朋友。

專案卡住了,或是想把重複的工作交出去?

我做 AI 專案救援、系統架構開發、AI Agent 與流程自動化。先聊聊你的情況,不用先想清楚要做什麼。

免費訂閱 Gooliya 鼓櫟數位電子報

雜談 AI、開發與自動化,新文章上線時寄給你。

訂閱服務由 Kit 提供,Email 只用來寄送新文章