ブログ一覧に戻る
設計GDPR監査ログSaaS

退会フローで監査ログとデータ削除を両立する: tombstone モデルの設計

はじめに

退会フローの設計には、相反する 2 つの要件があります:

  • GDPR 第 17 条 / 個人情報保護法: 利用者は自分の個人情報の削除を請求できる
  • J-SOX / SOC 2 Type II: 操作証跡 (audit log) は会計年度 + 一定期間保持必須 (誰がいつ何をしたかを保つ)

最初の案は単純に「ユーザー行を物理削除する」でした。しかし、これは多くの整合性問題を引き起こし、最終的に tombstone モデル (= 行は残すが PII だけ NULL 化) に切り替えました。

本記事では、設計変更の経緯と実装パターンを共有します。

失敗案: ユーザー行の物理削除

最初の設計案 (ハイブリッド案) は、以下の方針でした:

  • 退会 → 30 日の猶予期間 → ユーザー行を物理削除
  • 監査ログの「操作者」列は外部キー制約で、参照先削除時に NULL を設定
  • 結果: 「誰が」という情報は失われるが、個人情報は完全削除

これには 3 つの問題がありました:

問題 1: 監査ログの証跡価値が損なわれる

退会した管理者が行った重大な操作 (例: 「全従業員の給与データをエクスポート」「権限を最上位に昇格」) の監査ログで操作者が NULL になると、後から「誰がやったか不明」となり、SOC 2 監査で指摘されます。

問題 2: 復活フローとの両立が難しい

niyase は退会後 30 日間の「復活窓」を設けており、その間に再ログインすれば全データが復旧します。しかしユーザー行を削除してしまうと、復活時に新しいユーザー ID が発行され、過去の監査ログ・所属スペース・割り当てタスクとの紐付けが切れてしまいます。

問題 3: 外部キーが増えるほど削除時の設計が複雑になる

監査ログだけでなく、作成者・更新者・担当者・コメント投稿者など、ユーザーを参照する外部キーは業務テーブル全体に散在します。そのすべてに「参照先削除時に NULL を設定」する設定を付けるのは現実的ではなく、抜け漏れで外部キー制約違反が発生するリスクが高いものでした。

採用案: tombstone モデル

「users 行は残すが、個人情報 (PII) だけを NULL 化する」アプローチに切り替えました。

スキーマ変更

// ユーザーテーブル定義 (抜粋)
export const users = pgTable("users", {
  id: uuid("id")
    .primaryKey()
    .default(sql`uuidv7()`),
  // PII フィールド (退会時 NULL 化)
  email: text("email"), // unique 制約は維持 (NULL 同士は衝突しない)
  displayName: text("display_name"),
  firebaseUid: text("firebase_uid").unique(),
  avatarUrl: text("avatar_url"),
  // 退会管理フィールド
  deletedAt: timestamp("deleted_at"), // 30 日 grace 開始
  tombstonedAt: timestamp("tombstoned_at"), // PII NULL 化完了
  // ... 他
});

退会処理 (deleteMe)

async deleteMe(userId: string) {
  // 1. deletedAt をセット (30 日 grace 開始)
  await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, userId));

  // 2. Firebase Auth は即時削除 (email を解放 → 新規アカウント作成可能に)
  await firebaseAdmin.auth().deleteUser(firebaseUid);

  // 3. audit_log に記録
  await auditLog.record({ event: "account.deletion.requested", actorUserId: userId });

  // 4. 退会受付メール送信 (notification: account.lifecycle カテゴリ、強制 ON)
  await mailService.sendAccountDeletionRequested(email);
}

ポイント:

  • users 行は削除しないdeletedAt をセットするだけ
  • Firebase Auth は即時削除 (email 解放、復活時は新 Firebase uid を発行)
  • 30 日 grace 中の DB データは無傷 で残る (復活可能)

30 日後の hard delete (tombstoneUser)

async tombstoneUser(userId: string) {
  // 1. 復活完了メール送信前に email を退避 (NULL 化前に取得)
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
  if (!user?.email) return; // 既に tombstone 済

  // 2. 完全削除メール送信
  await mailService.sendAccountDeletionExecuted(user.email);

  // 3. Stripe customer 削除 (関連 subscription も auto cancel)
  if (user.stripeCustomerId) {
    await stripe.customers.del(user.stripeCustomerId);
  }

  // 4. PII NULL 化 (= tombstone)
  await db.update(users).set({
    email: null,
    displayName: null,
    firebaseUid: null,
    avatarUrl: null,
    tombstonedAt: new Date(),
  }).where(eq(users.id, userId));

  // 5. audit_log に記録
  await auditLog.record({ event: "account.deletion.executed", actorUserId: userId });
}

ポイント:

  • email を NULL 化する直前にメール送信 (順序重要)
  • users 行は 残す (削除しない)
  • PII フィールドだけ NULL 化 (id・deletedAt・tombstonedAt は保持)

日次のバッチ処理で、猶予期間 (30 日) を過ぎてまだ tombstone 化されていない行を抽出し、tombstoneUser を実行する運用にしています。

復活フローとの両立

退会後 30 日以内に同じ email で再ログインを試みた場合:

// 認証ガードでの退会済みアカウント判定
if (user && user.deletedAt && !user.tombstonedAt) {
  throw new ConflictException({
    error: "ACCOUNT_DELETED_RECENTLY",
    deletedAt: user.deletedAt.toISOString(),
    scheduledHardDeleteAt: addDays(user.deletedAt, 30).toISOString(),
  });
}

フロントエンドは 409 を受けて「復活しますか? それとも新規アカウントとして始めますか?」を選択させます:

  • 復活 (restoreDeletedAccount): deletedAt をクリアし、本人が唯一の所有者であるスペースを復元し、新しい Firebase uid を紐付けます
  • 新規 (forceNewAccount): 即時に tombstoneUser を実行し、新しいユーザーを発行します

復活時は元のユーザー ID が残っているため、過去の監査ログや所属スペースとの紐付けがそのまま継続します。

監査ログへの影響

退会イベントを監査ログに記録するため、監査ログのスペース ID 列を nullable 化 しました (列の NOT NULL 制約を外すマイグレーションを追加):

// マイグレーション: 監査ログのスペース ID 列の NOT NULL 制約を外す
ALTER TABLE audit_log ALTER COLUMN workspace_id DROP NOT NULL;

理由: アカウント全体イベント (account.deletion.requested / account.deletion.canceled / account.deletion.executed) はどのスペースにも属さないためです。

3 イベントを記録することで:

  • 退会開始時刻 (requested)
  • 30 日窓内の復活 (canceled)
  • 完全削除 (executed)

の証跡を監査ログに永続化できます。

GDPR / 個人情報保護法との整合性

「行は残すが PII を NULL 化」は GDPR 第 17 条と矛盾しないか? 当社では、法務確認のうえ以下のように整理しています:

  • GDPR 第 17 条 (1) (a): 個人を識別できない状態にすれば「削除」と見なせると当社では考えています
  • PII (メールアドレス / 表示名 / 認証 ID / アバター画像) を NULL 化することで、個人を識別できなくなります
  • 行 (ID) は残りますが、ID 単独では誰のものか分からず、個人情報には当たらないと整理しています
  • 監査ログの操作者列は「削除済みユーザーの元 ID」を指すだけで、識別性は失われています

これにより、当社では GDPR 削除権の要件を満たしつつ、SOC 2 監査証跡も保持できると考えています。

実装まとめ

項目実装
30 日 soft deletedeletedAt セット + Firebase Auth 即時削除
30 日後 hard delete日次バッチ → tombstoneUser 実行
PII NULL 化メールアドレス / 表示名 / 認証 ID / アバター画像
監査ログ整合性ユーザー行残し + スペース ID の nullable 化
復活フロー30 日窓内 409 ACCOUNT_DELETED_RECENTLY → 復活または新規
Apple Guidelines 5.1.1(v)mobile アプリ内に明示的な削除導線
通知account.lifecycle カテゴリ (強制 ON / bypassesQuietHours)

まとめ

「ユーザー行を物理削除する」案は最初に思いつきますが、監査ログ・復活フロー・外部キー設計の 3 重苦になります。tombstone モデル (行残し + PII NULL 化) に切り替えることで、GDPR 削除権と SOC 2 監査証跡を両立できました。

設計判断のポイントは「PII の削除とユーザー識別情報の削除を分離する」ことです。GDPR が要求するのは前者であり、内部 ID を残すことについては、当社では削除権の要件と矛盾しないと整理しています。