「我們有稽核軌跡」這句話,在不同系統裡指的東西差很多。
有的是一張 change_log 表,由 trigger 自動抓變更。有的是把操作事件送進日誌管線。有的在每一列上算了 hash,前後串成一條鏈。
這三種都會被寫進合規文件,措辭也都差不多。但它們擋得住的攻擊面完全不同,而差異只有在真的需要拿紀錄出來時才會暴露——那通常是最不適合發現問題的時刻。
這篇拆開五個決定點。每一個的判準都是同一句話:假設一個有資料庫寫權限的人想改掉一筆紀錄,他會撞到什麼。
一、hash chain 給的是「可偵測」,不是「不可竄改」
hash chain 的作法是每列存 prev_hash 與 hash,後者由前一列的 hash 加上本列內容算出來。改任何一列,它之後的每一列都對不上。
聽起來很硬,但它的保證其實只有一句:單點竄改會留下痕跡。
它擋不住的是:有 UPDATE 權限的人改掉一列之後,把後面整條鏈重算一遍。改完之後鏈是自洽的,驗證腳本會回綠。hash chain 對這種攻擊沒有任何抵抗力,因為它需要的一切材料——內容、雜湊函式、鏈的順序——都在同一個資料庫裡。
所以要讓那句「不可竄改」成立,還要兩件事。
第一,在權限層把表做成 append-only。 不是靠應用程式自律,是把 UPDATE / DELETE 從權限上拿掉:
revoke update, delete on audit_schema.audit_logs from public;
grant insert, select on audit_schema.audit_logs to audit_writer_role;
-- 明確不給 update / delete
這裡有個容易搞錯的地方值得單獨講:在 Supabase 上,service_role 是 BYPASSRLS,很多人因此以為它什麼都擋不住。BYPASSRLS 繞過的是 RLS policy,不是 table 層級的 GRANT。 所以上面那道 revoke 對 service_role 一樣有效——這是少數幾條連特權寫入者都擋得住的防線,不用它很可惜。
同樣的理由:稽核表上不該有 UPDATE / DELETE 的 trigger 或 policy。加了就等於開了一條合法的改寫路徑,前面 revoke 的意義就沒了。這張表應該只有 INSERT(加上算 hash 的 BEFORE INSERT trigger)與 SELECT。
第二,在資料庫之外留一個錨點。 因為擁有資料庫最高權限的人理論上仍能同時改內容與重算鏈。定期把鏈頭的 hash(或一段區間的 Merkle root)錨到一個不可回改的地方——具備 object lock 的物件儲存、外部時戳服務、或另一套只能寫入的系統——之後任何庫內的完整竄改,都會與外部錨點對不上。
也可以改成定期把紀錄匯出到 write-once 儲存(例如開了 Object Lock 的 bucket)。兩種都行,重點是驗證用的參考點不能跟被驗證的資料放在同一個信任邊界內。
二、寫進去了,不等於跟業務同生共死
第二個決定:稽核列與它描述的那次業務變更,是不是在同一個交易裡。
這件事常被跳過,因為在應用層看起來已經做了:
await db.from('invoices').update({ status: 'refunded' }).eq('id', id)
await db.from('audit_logs').insert({ action: 'invoice.refund', /* … */ })
這段程式碼有一個安靜的假設:兩次寫入會一起成功或一起失敗。它們不會。 Supabase JS 的兩次 .from() 呼叫是兩個獨立請求,中間任何一次失敗、逾時、或執行環境被回收,你就得到一筆沒有稽核紀錄的退款——而且沒有任何錯誤,因為第一次寫入本來就成功了。
正確的形狀是把業務寫入與稽核寫入包進同一個資料庫交易,高風險操作(退款與帳務、角色與權限異動、資料匯出、正式報表定版、AI agent 自主決策)直接用一支 SQL 函式承載兩者。
把失敗模式攤開來看,才知道每一格應該長什麼樣:
| 失敗點 | 業務變更 | 稽核列 | 判定 |
|---|---|---|---|
| 業務寫入失敗 | rollback | rollback | 整個請求失敗 |
| 稽核寫入失敗 | rollback | 無 | 整個請求失敗 |
| hash trigger 失敗 | rollback | 無 | 整個請求失敗 |
| 事件串流送不出去 | commit | 有 | 業務成功,監控告警 |
前三列的共通點是稽核寫不進去就不准放行業務。這是一個真實的取捨:稽核層的故障會變成業務層的故障。願不願意付這個代價,是產品決策不是技術決策——但如果不付,那就不要在合規文件裡寫「所有操作都有紀錄」。
三、事件串流不是稽核來源
第三個決定:把操作事件送進日誌 / 可觀測性管線,算不算完成稽核。
不算。理由不是管線不可靠,而是它的語意不對:那類管線的設計目標是高吞吐、可取樣、可丟棄、短保留。這些性質對監控是優點,對稽核每一項都是缺陷。
可行的分工是:資料庫的列是唯一事實來源,事件串流是衍生視圖。 事件裡帶上稽核列的 ID,兩邊就能對得起來。這樣分工之後,「事件沒送到」被降級成監控缺口,不再動搖稽核本身——而反過來的架構(以事件為準、資料庫為輔)沒辦法做到這件事。
要讓衍生視圖可靠,做法是在資料庫裡放一張 outbox:
-- dispatcher 每輪認領一批,未完成的留著重試
select * from audit_outbox
where streamed_at is null
and next_attempt_at <= now()
order by created_at
limit 100
for update skip locked;
FOR UPDATE SKIP LOCKED 讓多個 dispatcher 同時跑而不互相搶同一列;重試狀態(attempts / next_attempt_at / lease_until)必須留在資料庫裡。放在 KV、記憶體或 worker 實例上的重試狀態,在實例被回收時會跟著消失,而那正是你最需要它的時候。
消費端要以事件 ID 做冪等去重——這條管線是 at-least-once,重送是正常運作的一部分,不是異常。
四、append-only 讓 PII 變成收不回來的負債
前面決定了「不能刪、不能改、長期保留」。這三件事湊在一起會產生一個後果,蓋掉之後才發現就來不及了:
任何寫進稽核表的個資,都刪不掉。
不是「刪起來很麻煩」,是流程上不允許——你剛剛才在權限層拿掉 DELETE,還在合規文件上寫了保留期。使用者要求刪除個資時,你會在一張設計上不可刪的表裡找到它。
所以稽核列本身不該放 IP、User-Agent、裝置指紋,也不該在結構化欄位裡塞姓名、email、原始請求內容或原始提示詞。那些欄位只放業務鍵:發票編號、報表版本、政策版本、影響列數——足以說明「這個決定為什麼成立」,但不含當事人身分資料。
需要 IP 那類資訊做安全分析時,讓它走旁邊那條短保留期的事件串流。兩條路徑的保留期本來就該不同,因為它們回答的是不同問題。
順帶一提,還有一種紀錄常常整組被漏掉:被拒絕的操作。權限不足、政策拒絕、額度不足——這些在程式碼裡通常就是一個 throw,什麼都沒留下。但稽核想回答的問題往往正是「誰試過但沒成功」。這件事不能靠每個 handler 自己記得寫,要讓權限檢查函式在拒絕的路徑上自動寫進去;靠自律的規則,第一個趕工的人就會漏掉。
五、多租戶的鏈要在哪裡上鎖
最後一個決定。hash chain 有個天生的性質:要算下一列的 hash,得先知道上一列是什麼。兩個並行的寫入同時讀到同一個前序列,就會產生兩條分叉的鏈。
多租戶系統不該用一條全域鏈——那會讓所有租戶的寫入互相排隊,而且任何一個租戶的稽核驗證都要掃全表。正確的粒度是每個租戶一條鏈,鎖也下在租戶這一層:
-- BEFORE INSERT trigger 內
chain_key := coalesce(new.tenant_id::text, 'global');
perform pg_advisory_xact_lock(hashtextextended('audit_logs:' || chain_key, 0));
select al.hash into new.prev_hash
from audit_logs al
where al.tenant_id is not distinct from new.tenant_id
order by al.created_at desc, al.event_id desc
limit 1;
pg_advisory_xact_lock 的鎖隨交易結束自動釋放,不需要額外的清理路徑——忘記解鎖這個經典問題在這裡不存在。
代價要講清楚:同一個租戶的稽核寫入被序列化了。租戶內的寫入尖峰會排隊,而排隊的長度直接加在業務請求的延遲上(因為第二節說了,稽核寫不進去業務就不放行)。單租戶系統其實不必付這個成本,一條全域鏈就夠。
看到這裡你可能會想:整篇都在為了一個「可能永遠不會發生」的內部竄改情境加成本,值得嗎?
值不值得取決於一件事——你哪一天需要拿這些紀錄出來,是你決定的嗎?功能出錯時你可以選擇要不要修,但稽核紀錄被要求出示的那一天,通常是別人決定的:客戶的資安問卷、一次爭議的釐清、一場稽核。那一天才發現紀錄擋不住質疑,你沒有第二次機會補做——因為要補的是過去的紀錄。
至於為什麼把鎖放在資料庫、而不是放在應用層或邊緣的協調元件:鏈的事實在資料庫裡,鎖就應該跟事實在同一個地方。 鎖在外面意味著多一個系統要保證可用、要跟資料庫的交易邊界對齊——而它一旦與資料庫失去同步,錯鏈仍然會發生,只是更難查。只有在沒有資料庫交易擁有者的場景(純邊緣、無中心資料庫)才需要考慮別的方案。
收束
回到開頭那句判準。把前四個決定串起來,一個想改掉某筆紀錄的人依序會撞到:
- 應用程式的角色沒有
UPDATE/DELETE權限(權限層,BYPASSRLS也繞不過) - 就算拿到了更高權限並重算整條鏈,外部錨點對不上
- 想改成「那筆操作根本沒發生」也不行,因為稽核列與業務變更在同一個交易裡
- 想靠讓事件送不出去來製造缺口也沒用,因為串流只是衍生視圖
任何一環拿掉,前面幾環的價值都會跟著掉。這也是為什麼「我們有稽核軌跡」這句話本身沒有資訊量——真正該問的是那一句:誰能在不留痕跡的情況下改掉一筆? 答得出這個問題的系統,才算真的有稽核軌跡。