CodeGym /課程 /ChatGPT Apps /Audit & lifecycle: audit logs, data retention, 依請求刪除,...

Audit & lifecycle: audit logs, data retention, 依請求刪除, continuity/backups

ChatGPT Apps
等級 15 , 課堂 4
開放

1. 為什麼在 ChatGPT App 中需要 audit & lifecycle

當你寫原型時,用戶是你自己,資料庫是本機 SQLite,而「事故」用 git reset --hard 就能治好,一切看起來既可愛又單純。

但一旦你的 GiftGenius(或其他 ChatGPT 應用(App))迎來真正的使用者,尤其牽涉付款與 PII,立刻會冒出:

  • 客戶的資安團隊會問:「誰能看到我們的訂單?誰改過它們?」;
  • 法務會問:「你們資料保存多久?如何執行『刪除我的資料』的請求?」;
  • 生產環境的現實會問:「如果工程師在 prod 掉了資料表會怎樣?」。

在本講座我們會拆成四個支柱:

  1. Audit 日誌 — 用於安全與稽核的獨立日誌層。
  2. Data retention — 各類資料的生命週期與落地方式。
  3. 依使用者請求刪除 — 技術上落實「被遺忘權」
  4. Business continuity & backups — 如何撐過故障,不丟臉也不丟資料。

所有範例會盡量對齊我們的教學用 App(以 Next.js + Apps SDK + MCP 的 GiftGenius 為例)。

2. Audit 日誌:誰、做了什麼、何時,以及結果如何

Audit 日誌與一般應用日誌有何不同

一般 application 日誌是寫給工程師看的友善訊息。裡面有 stack trace、debug 資訊、奇怪的變數值、除錯用的 console.log("這裡絕對不應該是 null")。 它們保存不久,供工程人員閱讀。

Audit 日誌是另一個世界。它的主要受眾是資安人員、稽核、偶爾還有法務。他們不需要「第 55 行出現 NullPointer」這種訊息,而需要的是 「使用者 X 在某時修改了租戶 Y 的付款設定,結果——成功」這樣的記錄。Audit 記錄通常保存更久(以年計),在調查時可視為證據。

關鍵差異:

特性 Application Logs Audit Logs
目的 偵錯、診斷 安全、法遵、調查
受眾 開發者、SRE 資安人員、法務、偶爾還有監管單位
資料內容 技術細節、stack trace 誰/做了什麼/何時/對哪個資源/結果如何
保存期限 數週至一個月 數年(常見為 ≥ 1 年)
對日誌的操作 可以刪除/覆寫 最好為 append‑only,不允許 UPDATE/DELETE

OWASP 等指南特別強調:Audit 日誌最好放在獨立的儲存體或資料表,不要與一般應用日誌混在一起。

在 ChatGPT 應用的情境中要記錄什麼

對於 ChatGPT 應用,尤其有商務功能時,稽核的最低限度包括:

  • 身分驗證事件:登入、登出、登入嘗試;
  • 關鍵資料的操作:建立/更新/刪除個人檔案、訂單、付款設定;
  • 管理性操作:變更角色、修改租戶設定;
  • 對敏感 MCP/Agents 工具的呼叫:create_ordercharge_customercancel_subscription 等等。

一個好直覺:凡是在事件時你會想問「誰做的、透過什麼做的?」的東西,都應該進稽核。

稽核事件的結構

方便的心智模型:每條記錄 = 「誰 / 做了什麼 / 針對什麼 / 在什麼背景 / 結果如何」。 常見結構為 whoactionresourcecontextoutcome

以我們的 GiftGenius 為例,用 TypeScript 描述介面:

// lib/audit.ts
export type AuditAction =
  | "auth.login"
  | "auth.logout"
  | "order.create"
  | "order.cancel"
  | "account.delete"
  | "giftidea.generate";

export interface AuditEvent {
  eventId: string;          // uuid
  timestamp: string;        // ISO
  actor: {
    userId: string | null;  // 在登入前可能為 null
    tenantId?: string | null;
    ip?: string | null;
    client: "chatgpt-app" | "admin-panel" | string;
  };
  action: AuditAction;
  resource?: {
    type: string;           // "order", "user", ...
    id?: string;
  };
  context?: {
    mcpTool?: string;
    requestId?: string;
  };
  outcome: {
    status: "success" | "failure";
    reason?: string | null;
  };
}

請注意,事件中不應包含完整的電子郵件、卡號等 PII。 根據前面課程,我們已學會在沒有必要時進行遮罩並避免記錄。

在哪裡以及如何儲存 Audit 日誌

對儲存層的最低需求:

  • 將其與一般日誌分開為獨立資料表或甚至獨立資料庫,降低不小心覆蓋/刪除的風險;
  • 盡量採用 append‑only:技術上可用政策「我們從不對此表做 UPDATE/DELETE」,並配置僅有 INSERTSELECT 權限的資料庫角色;
  • 存取受限:並非所有工程師都應擁有讀取完整 audit 的權限。

若你透過 Prisma/Drizzle 使用 PostgreSQL,模型可能如下(簡化範例):

CREATE TABLE audit_events (
  event_id   uuid PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now(),
  actor_user_id text,
  actor_tenant_id text,
  actor_ip      inet,
  action        text NOT NULL,
  resource_type text,
  resource_id   text,
  context_mcp_tool text,
  context_request_id text,
  outcome_status text NOT NULL,
  outcome_reason text
);

Schema 可依你的需求調整,但關鍵是結構化。 將 JSON 垃圾塞成一行,將來你會自己咒罵自己。

在我們的 App 中實作稽核

在 Next.js 應用中寫個小 helper(例如於 MCP 伺服器或 API 路由的 Node 環境):

// lib/audit.ts
import { randomUUID } from "crypto";
import { db } from "./db"; // 你的資料庫客戶端

export async function logAudit(event: Omit<AuditEvent, "eventId" | "timestamp">) {
  const full: AuditEvent = {
    ...event,
    eventId: randomUUID(),
    timestamp: new Date().toISOString(),
  };

  // 真實環境中—透過佇列/背景處理,這裡先直接插入
  await db.insertInto("audit_events").values({
    event_id: full.eventId,
    created_at: full.timestamp,
    actor_user_id: full.actor.userId,
    action: full.action,
    outcome_status: full.outcome.status,
    outcome_reason: full.outcome.reason ?? null,
    // ...其餘欄位
  });
}

現在把呼叫加入建立訂單的處理器中(假設這是 MCP 工具或伺服端 endpoint):

// app/api/orders/route.ts
export async function POST(req: Request) {
  const user = await requireUser(req); // 來自驗證模組
  const body = await req.json();
  const order = await createOrderInDb(user, body);

  await logAudit({
    actor: { userId: user.id, client: "chatgpt-app" },
    action: "order.create",
    resource: { type: "order", id: order.id },
    context: { mcpTool: "create_order_tool" },
    outcome: { status: "success" },
  });

  return Response.json(order);
}

對於高風險操作亦然——取消訂單、變更付款資料、刪除帳號。

我們已有獨立的結構化稽核層——很好。下一個自然問題是: 要保存多久這些事件(以及其他使用者資料),期限到了又該怎麼處理?

3. Data retention:你的資料能活多久

為什麼不能把所有東西永遠留著

工程師的「以防萬一」本能,在使用者資料的情境下非常危險。

首先,資料越多、留越久,外洩時後果越嚴重: 油桶越大,火災越可怕。許多資料保護指南把資料稱作「有毒資產」:要有價值地保存,但同時盡量減少容量與保存時間。

其次,法規如 GDPR/CCPA 引入「僅保存到達成處理目的之必要期限」的原則。 也就是說,不能為了「以備不時之需」而無限期保存個資。對每種資料都應有明確的保存期限與刪除或匿名化程序。

第三,雲端儲存是要花錢的。 大型日誌表與聊天歷史成長很快,一年後你會驚覺,供應商帳單的一半是「昨天的垃圾」。

不同資料—不同期限

公司的實務與公開指南大致呈現如下:

資料類型 典型保存期限
Debug 日誌、技術度量 1 至 12 個月
Audit 日誌 ≥ 12 個月,有時 2–5 年
訂單、付款、發票 3–7 年(依會計/稅務要求)
Session、臨時 token 數小時至數天
原始聊天 / 請求 幾週到幾個月,或乾脆不保存
匿名彙總(分析) 更久,因為已無 PII

重點:這不是法律建議,而是工程參考。真正的產品要與法務協調期限, 但在技術上你已該準備好實作不同的 TTL。

如何在程式中實作留存策略

最常見的模式:資料表有 created_atexpires_at, 由週期性程序刪除或匿名化過舊的資料。

範例:清理超過 90 天的一般日誌。

// scripts/cleanup-logs.ts
import { db } from "../lib/db";

async function cleanup() {
  await db
    .deleteFrom("app_logs")
    .where("created_at", "<", new Date(Date.now() - 90 * 24 * 60 * 60 * 1000));
  console.log("Old logs removed");
}

cleanup().catch(console.error);

此腳本可用 cron、GitHub Actions,或雲端排程器定期執行。

對於 PII,常以匿名化替代刪除。比如,將超過 N 年的訂單移除與特定使用者的連結:

UPDATE orders
SET user_id = NULL
WHERE created_at < now() - interval '3 years';

這樣會保留金額、商品與其他「會計」資訊,但去除與個人的關聯。

別忘了,備份也應有自己的保存期限。 備份的頻率與保存時長我們會在備份章節另談,但理念相同: 連封存檔也不能無限期保留,否則「被遺忘權」會變成口號。

4. 依使用者請求刪除:把「被遺忘權」落實到程式

來源是什麼

歐盟 GDPR(與類似法規)引入所謂的「被遺忘權」:使用者可以要求刪除其個人資料, 公司則必須在不合理延誤內完成。

對開發者而言,遲早會收到「刪除我所有資料」的請求 (或你自己做了「Delete my data」按鈕),屆時不只要刪掉 users 資料表中的那筆記錄, 還要沿著所有足跡清理:訂單、Session、token、操作日誌、CRM、金流等等。

但也有法律要求你必須保存某些資料:例如財務交易。 因此這裡的法律複雜度往往高於技術。

究竟要清哪些

以 GiftGenius 為例,最低清單:

  • 使用者檔案(姓名、電子郵件、設定);
  • Session、refresh token、與 OAuth 供應商的關聯;
  • 訂單(若不需要「可識別個人」的形態,或可做匿名化);
  • 包含 PII 的日誌與 audit 記錄(例如未遮罩的 e‑mail)。

同時會保留對報表重要、但已去識別的資料——訂單金額、交易數量、各國彙總等。

刪除流程範例

情境流程:

  1. 使用者(已登入)按下「刪除我的帳號」。
  2. 伺服器收到帶有 userId 的請求。
  3. 伺服器:
    • 刪除/匿名化相依記錄(訂單、Session、整合);
    • 清理個人檔案中的 PII;
    • 在 audit 日誌寫入「處理資料刪除請求」。

為簡化,我們只示範幾個資料表。實際產品會在這個核心上擴充更多實體 (整合、第三方服務等)。

Next.js 的服務端程式(精簡示例):

// app/api/delete-me/route.ts
import { db } from "@/lib/db";
import { logAudit } from "@/lib/audit";

export async function POST(req: Request) {
  const user = await requireUser(req);

  await db.transaction(async (tx) => {
    await tx.deleteFrom("sessions").where("user_id", "=", user.id);
    await tx.deleteFrom("orders").where("user_id", "=", user.id);

    await tx.updateTable("users")
      .set({
        is_deleted: true,
        name: null,
        email: null,
      })
      .where("id", "=", user.id);

    await logAudit({
      actor: { userId: user.id, client: "chatgpt-app" },
      action: "account.delete",
      outcome: { status: "success" },
    });
  });

  return new Response(null, { status: 204 });
}

在真實世界你會加入外部 API 呼叫(例如 Stripe——解除綁定 customer), 也會將交易處理做得更完整。但原則已在:集中處理,並有 audit 記錄。

與備份的相互影響

「備份怎麼辦」這一段有很多眉角。即使你已從生產資料庫刪除了使用者, 他/她的資料仍可能殘留在夜間快照中。為了避免「實際上永遠不會真的刪除」的狀況,有兩種做法:

  1. 備份本身只活一定時間(例如 30–90 天),到期後連同資料一起消失。當 retention 到期,無論主資料庫或封存都不再包含該使用者。
  2. 若你確實從備份還原系統,必須持有「已刪除」ID 的登錄,並在還原後再次執行刪除/匿名化腳本。

大型公司有時會採用 crypto‑shredding:使用者的 PII 以獨立金鑰加密,收到刪除請求時銷毀金鑰。 即使某處還有加密過的副本(在日誌、備份裡),沒有金鑰也只是垃圾。很酷,但對新創而言有點像火箭科學。

重要的 UX 重點

記住,刪除不只是 SQL。使用者期望:

  • 清楚的申請方式(按鈕、表單、電子郵件);
  • 合理的處理時程(實務上通常在 30 天內);
  • 成功通知或具體拒絕理由(例如部分資料依法必須保存)。

從技術面你已備妥:你會清理、會記錄、也不會在備份裡保存不該保存的東西。

5. Business continuity & 備份

現在想像上述一切都運作完美……直到發生致命的 DROP TABLE orders、 雲端故障或整個區域掛掉。我們需要機制來在合理時間內恢復服務,且不遺失關鍵資料。

RTO 與 RPO — 決定痛苦程度的兩個指標

災難復原的兩個基本參數:

  • RTO(Recovery Time Objective) — 你可以接受的停機時間。 例如,如果 RTO = 1 小時,代表嚴重故障後你必須在最多一小時內讓系統恢復。
  • RPO(Recovery Point Objective) — 你可以接受的資料時間損失。 若 RPO = 10 分鐘,代表恢復時最多只能遺失最近 10 分鐘的資料。

產品越關鍵(銀行、交易系統),兩者越接近 0。 對教學用的 GiftGenius,RTO 可容許數小時、RPO 約 15–60 分鐘,但仍需確實落地。

你的技術棧會出哪些差錯

在 Vercel + 雲端資料庫 + 外部 API 的 ChatGPT 應用情境下,常見問題包括:

  • OpenAI API 無法連線:你的 App 在 tool‑calls 上回錯誤。
  • Vercel(或其他)出故障:小工具(widget)無法連到你的 backend。
  • 資料庫損毀或資料被誤刪(例如 DROP TABLE)。
  • 控制基礎設施的帳號被入侵或破壞

你需要以備份複寫,以及應用在故障時的合理行為之組合來應對。

備份策略

現代雲端 Postgres/其他資料庫通常至少提供三種選項:

  1. 完整備份 + 增量備份。
    每日一次完整快照(snapshot),其間保存增量變更。還原流程:回復到指定 snapshot,再重播變更日誌。
  2. Point‑in‑Time Recovery(PITR)。
    資料庫寫入交易日誌(WAL),允許回復到任意時間點(例如「14:03:00 的狀態,在我們 drop 表之前」)。
  3. 跨區域複寫。
    在另一個區域/雲端維持被動或主動複本。當主要區域失效時,可切換到複本,只遺失尚未同步過去的資料。

對我們的規模而言,啟用供應商的 PITR 與週期性的異地(off‑site)備份通常已足夠。

簡單例子:本機/dev 資料庫的每日 dump

即便生產環境使用託管資料庫,對 staging/dev 有時仍想要簡單腳本:

# scripts/backup.sh
#!/usr/bin/env bash
set -e
DATE=$(date +%F)
pg_dump "$DATABASE_URL" > "backups/backup-$DATE.sql"
echo "Backup created: backups/backup-$DATE.sql"

可從 cron 或 GitHub Actions 執行。重點是——備份也要依保存期限刪除

外部服務當機時 App 的行為

備份與 PITR 解的是「若一切全壞或資料毀損,怎麼辦」。但商業現實更常見的是部分故障—— 上游 API 掛了、網路被切、金流卡住。

當 OpenAI API 或金流當機時,最糟的策略是回 500 且附上無意義的 stack trace。理想上:

  • 後端回傳結構化錯誤,例如 { error: "upstream_unavailable" }
  • 前端小工具顯示對人友善的訊息:「服務暫時無法使用,請稍後再試」;
  • 系統不要對掛掉的 API 無限重試(Circuit Breaker 等模式我們會在韌性模組中詳談)。

考量上游錯誤的 MCP 工具處理器範例:

// mcp/tools/createGiftIdea.ts
export async function createGiftIdea(args: Input): Promise<Output> {
  try {
    return await callOpenAiModel(args);
  } catch (err) {
    await logAudit({
      actor: { userId: args.userId ?? null, client: "chatgpt-app" },
      action: "giftidea.generate",
      outcome: { status: "failure", reason: "openai_unavailable" },
    });
    throw new Error("UPSTREAM_UNAVAILABLE");
  }
}

接著你在 MCP 與小工具之間的中介層即可在 UI 以合宜方式呈現此錯誤。

復原演練:沒有 restore 的備份,只是個檔案

典型反模式:每天做備份,大家都很安心……直到發現根本無法還原 (格式變了、金鑰遺失、空間不足)。

最低限度計畫:

  • 定期(例如每月)用備份啟動一個 staging 環境;
  • 走過基本情境:登入、建立訂單、App 的主要流程;
  • 確認實際恢復時間與資料損失量符合你的 RTO/RPO。

不必把這堂課變成「DevOps 宗教」,你只需理解:備份流程是 App 架構的一部分, 而不是「雲端某個人會幫你弄好」的東西。

6. 視覺化:資料與事件的生命週期

為了不只停留在文字,我們畫兩個簡單示意圖。

使用者資料的生命週期

flowchart TD
  A["資料建立<br/>(註冊、下單)"] --> B["儲存與使用<br/>(prod DB)"]
  B --> C["封存/彙總<br/>(匿名指標)"]
  B --> D[刪除請求]
  D --> E[在 prod DB 進行刪除/匿名化]
  E --> F["備份到期<br/>(retention)"]
    

重點:資料生命週期不在生產資料庫就結束——它也延伸到備份。

危險操作的稽核流程

sequenceDiagram
  participant User as 使用者
  participant ChatGPT as ChatGPT
  participant App as 你的後端/MCP
  participant DB as 資料庫
  participant Audit as Audit 儲存

  User->>ChatGPT: "取消訂單 #123"
  ChatGPT->>App: callTool cancel_order
  App->>DB: UPDATE orders SET status='canceled'
  App->>Audit: INSERT audit_event {actor, action, resource, outcome}
  App-->>ChatGPT: 操作結果
  ChatGPT-->>User: 結果訊息
    

7. audit & lifecycle 的常見錯誤

錯誤 №1:把 Audit 日誌和一般日誌混在一起。
當所有訊息都進同一個 logs 索引,半年後誰也分不清 「使用者變更了管理員角色」和「我們又遇到 null reference」。Audit 應記錄結構化、貼近商務層的事件 (見稽核事件結構段落),並置於權限受限的獨立儲存。

錯誤 №2:在稽核與 debug 日誌中記錄 PII。
完整電子郵件、電話、配送地址、卡片末四碼——這些經常不小心出現在日誌裡。 它提高外洩風險,也違反隱私建議。請改記錄識別碼與遮罩後的值。

錯誤 №3:沒有留存政策——「全部永遠留著」。
在 MVP 階段看似無傷,但一年後你的資料表會長得巨大,任何分析查詢 都像在 DDoS 自己的資料庫。同時你也違反現代資料法規的最小化原則。 必須先想好各類資料的最小 TTL,並自動化清理。

錯誤 №4:「依請求刪除」 == DELETE FROM users.
若你只刪掉使用者那一列,但在訂單、Session 與日誌裡仍留著他的 PII,那等於沒刪。 正確做法是以交易方式清理所有關聯實體;無法刪除之處,則匿名化。 別忘了把刪除本身記為一條 audit 事件。

錯誤 №5:刪除資料時忽略備份。
在 prod 刪了使用者——很好,但他的資料還在舊快照裡活了一年。從中還原後又「復活」, 你就違反了給使用者與在 Privacy Policy 裡的承諾。要嘛限制備份的保存期, 要嘛在還原後有再次套用刪除的程序。

錯誤 №6:「我們有開備份就好」,但從未嘗試還原。
從未驗證可還原性的備份,只是昂貴的檔案。沒有定期的還原演練,你不知道實際的 RTO/RPO, 也不知道你的 DR 計畫是否可行。最低限度是——按清單定期用備份拉起一個 staging 做測試。

錯誤 №7:文件與實際不符。
你在 Privacy Policy 寫「日誌保存 30 天,依請求刪除資料」,但在程式裡一切永存。 ChatGPT 商店、企業客戶與稽核很快會問「請出示留存表」與 「示範刪除指定使用者」。最好先做到,再寫文件。

1
問卷/小測驗
安全,等級 15,課堂 4
未開放
安全
安全與合規
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION