Skip to content
BEE
Backend Engineering Essentials

[BEE-19043] 審計日誌架構

INFO

審計日誌是誰在何時對哪個資源執行了什麼操作的只追加、防篡改記錄——服務於合規性、安全調查和問責制,而非運維可觀測性。

背景

運維日誌(結構化日誌、追蹤、指標)回答:「系統健康嗎?瓶頸在哪裡?」審計日誌回答不同的問題:「誰存取了這筆病患記錄?誰批准了那筆付款?誰刪除了那個帳戶?」這些面向根本不同的使用者——開發者和 SRE 閱讀運維日誌;合規官、安全團隊和審計師閱讀審計日誌——工程需求也隨之不同。

NIST 特別出版物 800-92(「電腦安全日誌管理指南」,2006 年)建立了基礎框架:審計日誌必須捕獲操作者(誰)、動作(什麼)、資源(對什麼)、時間戳記(何時)和結果(成功還是失敗)。OWASP 日誌速查表在此基礎上提供應用層指導:記錄所有身份驗證事件(成功和失敗)、所有授權失敗、所有高價值交易和所有管理操作。

合規框架形式化了保留和保護需求。PCI-DSS 要求 10 規定審計日誌必須保留 12 個月,最近 3 個月立即可供分析。HIPAA 164.312(b) 要求審計控制和 6 年保留期。GDPR 第 5 條的問責原則要求控制者能夠展示合規性,這在實踐中意味著在監管風險持續期間保留審計證據。SOC 2 CC7 要求集中記錄和監控安全事件。

2011 年 HBGary Federal 的事件——攻擊者在洩露資料後通過刪除伺服器日誌掩蓋痕跡——成為教科書案例,說明了為什麼審計日誌必須與其監控的系統分開存儲、防止修改,理想情況下需要簽名或哈希鏈接,使篡改可被偵測即使無法阻止。

設計思維

審計日誌 vs. 運維日誌 vs. 應用日誌

三者通常存儲在一起,但服務於不同目的,有不同的需求:

屬性運維日誌審計日誌
主要使用者開發者、SRE合規、安全、審計師
主要問題「它正常運作嗎?」「誰在何時做了什麼?」
保留期數天到數週(熱存儲)數月到數年(合規驅動)
可變性只追加(實際上)只追加(必須如此)
防篡改不要求必須要求
個人識別資訊最小化包含問責所需的
搜尋模式時間窗口、錯誤率操作者、資源、動作、時間範圍

將審計日誌與運維日誌存儲在同一個 Elasticsearch 集群中是危險的:Elasticsearch 允許文件更新和刪除,這破壞了只追加的保證。審計日誌屬於只追加的存儲:不可變的 S3 儲存桶(帶 Object Lock)、具有無限保留期和受控消費者群組的 Kafka 主題,或具有撤銷 UPDATE/DELETE 權限的只寫資料庫表。

完整審計事件的組成

一個完整的審計事件包含:

json
{
  "event_id":    "uuid-v4",               // 全域唯一,冪等鍵
  "timestamp":   "2024-03-15T10:23:45Z",  // RFC 3339 UTC;伺服器時鐘,非客戶端
  "actor": {
    "id":        "user-42",               // 已驗證的身份
    "type":      "user",                  // user | service | system
    "ip":        "203.0.113.7",           // 來源 IP(IPv4 或 IPv6)
    "session_id":"sess-abc123"            // 用於關聯會話的操作
  },
  "action":      "document.delete",       // namespace:verb 形式
  "resource": {
    "type":      "document",
    "id":        "doc-789",
    "tenant_id": "tenant-5"              // 多租戶系統用
  },
  "outcome":     "success",              // success | failure | error
  "context": {
    "request_id": "req-xyz",             // 分散式追蹤關聯
    "user_agent": "Mozilla/5.0 ...",
    "reason":     "user initiated"       // 可選;用於管理員模擬
  },
  "diff": {                              // 對於更新動作:前後狀態
    "before": {"status": "active"},
    "after":  {"status": "archived"}
  }
}

diff 欄位記錄了什麼發生了變化,而不僅僅是有變化發生。沒有它,審計師可以確認 user-42 在 10:23 更新了 document-789,但無法確定更改了什麼——這使日誌不足以進行鑑識調查。

哈希鏈接的防篡改性

哈希鏈接使事後篡改可被偵測。每個審計事件包含前一個事件的 SHA-256 哈希。驗證鏈的審計師重新計算每個哈希並檢查連續性:空缺、不匹配或亂序的時間戳記表示插入、刪除或修改。

AWS CloudTrail 在生產中實現了這一點:每個每小時摘要文件包含該時期每個日誌文件的 SHA-256 哈希加上前一個摘要的哈希。摘要文件使用 RSA 簽名。這種構造使得加密可驗證:交付和驗證之間沒有日誌文件被刪除或修改。

RFC 6962(憑證透明度,2013 年)使用 Merkle 樹進一步推進了這個想法:可以證明個別憑證存在於日誌中(包含證明),且無需下載每個條目即可驗證樹的一致性(一致性證明)。相同的構造可以應用於應用程式審計日誌。

最佳實踐

必須(MUST)將審計日誌存儲在具有撤銷寫更新刪除權限的只追加存儲中。 寫入審計事件的應用帳戶必須(MUST)只有對審計表的 INSERT 權限,而非 UPDATE 或 DELETE。對於物件存儲,使用不可變的儲存桶策略(AWS S3 Object Lock 的合規模式、GCS 保留策略)。對於 Kafka,設置 cleanup.policy=delete 並有長保留期,並從應用帳戶移除消費者群組管理存取。

必須不(MUST NOT)在日誌中記錄敏感值。 密碼、私密金鑰、session 令牌和信用卡號碼必須不(MUST NOT)出現在審計日誌中——如果日誌是不可變的,則事後無法進行修訂。當審計事件涉及憑證更改時,記錄動作和受影響的憑證識別符(例如,api_key_id: "key-123"),但不記錄憑證值。超出問責所需(操作者身份、資源身份)的個人識別資訊應該(SHOULD)最小化:記錄使用者 ID,而非姓名或電子郵件地址(當 ID 足以問責時)。

必須(MUST)從業務邏輯而非資料庫觸發器發出審計事件。 資料庫觸發器對 INSERT/UPDATE/DELETE 產生變更記錄,但缺乏業務上下文:它們在資料層捕獲了什麼發生了變化,卻不知道是哪個業務動作導致的、哪個使用者發起的,或預期的結果是什麼。業務邏輯擁有這些上下文;在動作發生點發出審計事件。使用事件匯流排(Kafka、SQS、發件箱表)將發射與存儲解耦——服務記錄發生了什麼;單獨的審計日誌服務持久地存儲它。

必須(MUST)為高合規要求的場景實現哈希鏈接。 對於受 SOC 2、HIPAA 或金融法規約束的系統,計算 hash = SHA256(event_id + timestamp + actor + action + resource + outcome + prev_hash) 並與每個事件一起存儲。第一個事件的 prev_hash 是已知常數(例如全零)。定期的離線驗證任務檢查鏈的連續性。這不能防止存儲受到損害,但使篡改可被偵測。

應該(SHOULD)通過訊息匯流排扇出審計事件,而非直接寫入審計存儲。 從應用程式碼直接寫入審計資料庫會產生耦合和同步故障模式:如果審計資料庫不可用,應用程式必須決定是讓業務操作失敗還是靜默丟失審計事件。發件箱模式(在與業務事件相同的事務中將審計事件寫入 audit_outbox 表,然後異步中繼它們)提供至少一次傳遞而無需同步耦合。

必須(MUST)按合規要求定義保留期並強制執行自動過期。 在單一存儲中混合具有不同保留要求的審計事件使清除複雜化。按保留類別分區:安全審計事件(身份驗證、授權失敗)保留 1 年;HIPAA 環境中的管理操作保留 6 年;高價值交易事件按適用的金融法規保留。將自動清除實現為計劃任務——手動清除在運維上不可靠且會產生審計空缺。

應該(SHOULD)使審計日誌可以按操作者、資源和時間範圍搜尋。 主要查詢模式是:「使用者 X 在過去 30 天的所有動作」、「所有對文件 Y 的存取」、「過去一小時內來自 IP Z 的所有登入失敗嘗試」。對 (actor_id, timestamp)(resource_type, resource_id, timestamp)(action, outcome, timestamp) 建立索引。不要在沒有全文搜尋引擎的情況下對自由文字欄位(reason、user_agent)建立索引;這些是低基數的存取器。

視覺說明

範例

使用發件箱模式發出審計事件(Python):

python
import hashlib
import json
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass

@dataclass
class AuditEvent:
    actor_id: str
    actor_type: str          # "user" | "service" | "system"
    actor_ip: str
    action: str              # namespace:verb,例如 "document.delete"
    resource_type: str
    resource_id: str
    outcome: str             # "success" | "failure"
    tenant_id: str | None = None
    diff: dict | None = None # 更新動作的前後狀態
    request_id: str | None = None

def emit_audit_event(db_conn, event: AuditEvent, prev_hash: str) -> str:
    """
    在呼叫者的事務中將審計事件寫入發件箱表。
    發件箱中繼將其異步傳遞到審計存儲。
    prev_hash 將此事件與前一個連結以進行鏈驗證。
    """
    event_id = str(uuid.uuid4())
    timestamp = datetime.now(timezone.utc).isoformat()

    # 哈希覆蓋所有問責欄位;prev_hash 連結鏈
    payload = json.dumps({
        "event_id": event_id,
        "timestamp": timestamp,
        "actor_id": event.actor_id,
        "action": event.action,
        "resource_type": event.resource_type,
        "resource_id": event.resource_id,
        "outcome": event.outcome,
        "prev_hash": prev_hash,
    }, sort_keys=True)
    event_hash = hashlib.sha256(payload.encode()).hexdigest()

    db_conn.execute(
        """INSERT INTO audit_outbox
               (event_id, timestamp, actor_id, actor_type, actor_ip,
                action, resource_type, resource_id, tenant_id,
                outcome, diff, request_id, event_hash, prev_hash)
           VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
        (event_id, timestamp, event.actor_id, event.actor_type, event.actor_ip,
         event.action, event.resource_type, event.resource_id, event.tenant_id,
         event.outcome, json.dumps(event.diff), event.request_id,
         event_hash, prev_hash)
    )
    # 返回哈希,以便呼叫者可以將其作為下一個事件的 prev_hash 傳遞
    return event_hash

# 用法:在業務事務中
def delete_document(conn, user_id: str, doc_id: str, request_id: str):
    with conn.transaction():
        # 1. 記錄前狀態以用於 diff
        doc = conn.fetchone("SELECT * FROM documents WHERE id = %s", (doc_id,))

        # 2. 業務操作
        conn.execute("DELETE FROM documents WHERE id = %s", (doc_id,))

        # 3. 審計事件在相同事務中 — 與業務變更原子性
        prev_hash = get_latest_audit_hash(conn)
        emit_audit_event(conn, AuditEvent(
            actor_id=user_id, actor_type="user", actor_ip=get_request_ip(),
            action="document.delete",
            resource_type="document", resource_id=doc_id,
            outcome="success",
            diff={"before": dict(doc), "after": None},
            request_id=request_id,
        ), prev_hash=prev_hash)

審計日誌表 Schema(應用角色僅有 INSERT 存取):

sql
CREATE TABLE audit_log (
    event_id      UUID PRIMARY KEY,
    timestamp     TIMESTAMPTZ NOT NULL,
    actor_id      TEXT NOT NULL,
    actor_type    TEXT NOT NULL,
    actor_ip      INET,
    action        TEXT NOT NULL,
    resource_type TEXT NOT NULL,
    resource_id   TEXT NOT NULL,
    tenant_id     TEXT,
    outcome       TEXT NOT NULL CHECK (outcome IN ('success', 'failure', 'error')),
    diff          JSONB,
    request_id    TEXT,
    event_hash    TEXT NOT NULL,  -- 此事件欄位 + prev_hash 的 SHA-256
    prev_hash     TEXT NOT NULL,  -- 前一個事件的 SHA-256(鏈接)
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 三種主要查詢模式的索引
CREATE INDEX idx_audit_actor    ON audit_log (actor_id, timestamp DESC);
CREATE INDEX idx_audit_resource ON audit_log (resource_type, resource_id, timestamp DESC);
CREATE INDEX idx_audit_action   ON audit_log (action, outcome, timestamp DESC);

-- 從應用角色撤銷 UPDATE 和 DELETE
REVOKE UPDATE, DELETE ON audit_log FROM app_role;

合規參考

框架記錄什麼保留期審查頻率
GDPR 第 5 條處理活動的問責性與目的相稱按需
PCI-DSS 要求 10使用者動作、特權存取、身份驗證事件、變更12 個月(3 個月熱存儲)每日
HIPAA 164.312(b)對 ePHI 的系統活動6 年按需
SOC 2 CC7安全事件、存取、變更通常 1 年持續(SIEM)
NIST 800-92按 AU-2 控制選擇按 AU-11(基於風險)按 AU-6

相關 BEE

  • BEE-14002 -- 結構化日誌:運維結構化日誌使用與審計日誌相同的 JSON 格式和工具,但服務於不同的使用者,有不同的保留和可變性要求
  • BEE-7005 -- 時序與審計資料設計:涵蓋只追加 Schema 模式和時間分區,這些是審計日誌資料表設計的基礎
  • BEE-19041 -- 軟刪除與資料保留:軟刪除是在需要已刪除記錄的審計追蹤時硬刪除的替代方案;審計日誌補充了這兩種方法
  • BEE-19018 -- 變更資料擷取:CDC 串流可以饋送審計管道,但缺少應用層審計發射所捕獲的業務層上下文(哪個使用者、哪個業務動作)

參考資料