はじめに
退会フローの設計には、相反する 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 delete | deletedAt セット + 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 を残すことについては、当社では削除権の要件と矛盾しないと整理しています。