0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

同じ外部アカウントの二重ひも付けをどう防ぐか — KV の事前チェックではなく、DB の一意制約に任せる

0
Posted at

はじめに

外部アカウント連携の機能では、次のような制約が必要になることがあります。

「この外部アカウントは、すでに別のユーザーにひも付いていないか?」

たとえば、ある SaaS で GitHub や Slack などの外部アカウントを内部ユーザーに連携できるとします。このとき、同じ外部アカウントが複数のユーザーにひも付くと困ります。

userA: github / 123456
userB: github / 123456

一見すると、KV に登録済みの外部アカウント ID を保存しておき、登録前に KV を読めばよさそうに見えます。しかし、この実装には TOCTOU、つまり「チェックした時点」と「実際に使う時点」のずれによる競合が入り込む余地があります。

この記事では、KV で所有確認を行う実装のどこが危険なのか、そしてなぜ PostgreSQL の一意制約に委ねると安全になるのかを説明します。

想定環境

この記事では、次のような構成を想定します。

  • Cloudflare Workers KV または Fastly KV Store
  • PostgreSQL 14 以上
  • node-postgres (pg) 8.13.x
  • TypeScript

ただし、考え方そのものは特定のサービスに依存しません。重要なのは、KV を「ひも付けの正しさを決める場所」として使わず、データベースを「正しい状態を保持する場所」として扱うことです。

TOCTOU とは何か

TOCTOU は Time-Of-Check / Time-Of-Use の略です。日本語では、「確認した時点」と「実際に使う時点」の間に状態が変わってしまう問題、と考えると分かりやすいです。

たとえば、次のような処理を考えます。

const existing = await kv.get(`github:${externalAccountId}`);

if (existing && existing !== userId) {
  throw new Error('already_linked');
}

await kv.put(`github:${externalAccountId}`, userId);

このコードは、次のような流れに見えます。

1. KV を読む
2. すでに別ユーザーがひも付けていたらエラーにする
3. 問題なければ KV に書き込む

単体で見ると自然です。しかし、同時に 2 つのリクエストが来ると壊れる可能性があります。

リクエスト A: KV GET → null
リクエスト B: KV GET → null

リクエスト A: KV PUT github:123456 = userA
リクエスト B: KV PUT github:123456 = userB

どちらのリクエストも、最初の GET では「まだ存在しない」と判断しています。その後、両方が PUT するため、結果として後から書いた側が勝ちます。

github:123456 = userB

この時点で、リクエスト A もリクエスト B も確認を通過しています。つまり、「同じ外部アカウントは 1 人にしかひも付けられない」というルールが崩れています。

これが TOCTOU です。

時系列で見ると、問題は次のようになります。

確認と書き込みが分かれているため、その間に別のリクエストが割り込めるのが本質です。

なぜ KV で防ぎにくいのか

この問題を防ぐには、理想的には次のような操作が必要です。

「まだ存在しなければ書き込む」を、1 回の不可分な操作として実行する

つまり、次の 2 つを分けないことが重要です。

存在確認
書き込み

この 2 つが別々の操作になっている限り、その間に別のリクエストが割り込めます。

多くの KV ストアは、単純な getput には向いています。一方で、リレーショナルデータベースの一意制約のように、重複を強く防ぐ仕組みを同じ形で提供しているとは限りません。

そのため、所有関係のように排他性が重要な情報を KV だけで管理すると、アプリケーション側で競合を完全に閉じるのが難しくなります。

解決策は「先に確認する」ではなく「INSERT して DB に判定させる」

この問題は、アプリケーションコードで頑張って解決するよりも、データベースの制約に任せる方が安全です。

PostgreSQL には一意制約があります。一意制約を使うと、同じ値を持つレコードが複数登録されることをデータベースが防いでくれます。

今回のように、「同じ連携種別の同じ外部アカウント ID は 1 つのユーザーにしかひも付けられない」としたい場合は、次のような一意インデックスを作ります。

CREATE UNIQUE INDEX linked_external_accounts_provider_account_unique
  ON linked_external_accounts (provider, external_account_id);

これで、同じ外部アカウントを 2 回登録しようとすると、PostgreSQL が重複を検出します。

アプリケーション側では、事前に「存在するか」を確認しません。代わりに、まず INSERT します。

try {
  const row = await withUserClient(pool, auth.userId, (client) =>
    insertLinkedExternalAccount(client, {
      userId,
      provider,
      externalAccountId,
      displayName,
    }),
  );

  return row;
} catch (err) {
  const pgErr = err as { code?: string; constraint?: string };

  if (
    pgErr.code === '23505' &&
    pgErr.constraint === 'linked_external_accounts_provider_account_unique'
  ) {
    throw ApiErrors.conflict(
      'external_account_already_linked',
      'This external account is already linked to another user',
    );
  }

  throw err;
}

PostgreSQL のエラーコード 23505unique_violation、つまり一意制約違反を表します。このエラーを捕まえて、API としては 409 Conflict に変換します。

なぜこれで TOCTOU が消えるのか

KV を使った旧実装では、処理が次のように分かれていました。

1. KV GET
2. アプリケーションで判定
3. KV PUT

この GETPUT の間に、別のリクエストが割り込めます。

一方、データベースの一意制約を使う実装では、流れが次のように変わります。

1. DB に INSERT する
2. 成功すれば登録完了
3. 一意制約違反なら 409 を返す

重複チェックと書き込みをデータベースに任せるため、アプリケーション側に「確認してから使う」という危険な時間差が残りません。

図にすると、違いは次のようになります。

旧実装では、確認と書き込みが分かれています。新実装では、データベースの INSERT が重複チェックも兼ねています。ここが一番重要な違いです。

連携種別と外部アカウント ID は正規形にそろえる

外部アカウント連携では、少なくとも連携種別 (provider) は表記をそろえて扱う方が安全です。

GitHub
github
GITHUB

これらを別物として扱うと、同じ外部アカウントでも別の連携種別として登録されてしまう余地が生まれます。

そのため、アプリケーション側では登録前に正規形へそろえます。

const normalizedProvider = provider.toLowerCase();
const normalizedExternalAccountId = externalAccountId.trim();

ただし、アプリケーション側の正規化だけに依存すると、将来の改修で漏れる可能性があります。別のコードパスから GitHub のまま登録されることもありえます。

そのため、データベース側でも正規形を前提にした一意制約を作る方が安全です。

CREATE UNIQUE INDEX linked_external_accounts_provider_account_unique
  ON linked_external_accounts (LOWER(provider), external_account_id);

こうしておくと、アプリケーション側で正規化が漏れても、データベースが最後の防衛線になります。

GitHub / 123456
github / 123456

この 2 つを別物として登録することはできません。

KV は何に使うべきか

ここで重要なのは、KV を使ってはいけないという話ではありません。KV は便利です。特に、エッジで連携状態や表示名を高速に読む用途には向いています。

たとえば、次のような情報を KV に置くのは自然です。

user_by_external:github:123456   → userA
display_name:github:123456       → "kazuhiko"
avatar_url:github:123456         → "https://..."

ただし、KV は「ひも付けの正しさを決める場所」にはしません。役割を分けます。

DB:
  linked_external_accounts テーブル
  ひも付けの正しい状態を保持する
  一意制約で重複を防ぐ

KV:
  参照用のキャッシュ
  DB の内容をエッジから高速に読むためのコピー

登録処理の流れは、次のようにします。

1. DB に INSERT する
2. DB の一意制約で重複を防ぐ
3. INSERT に成功したら KV に反映する
4. KV 書き込みに失敗しても、DB 登録自体は成功とする

KV 書き込みはベストエフォート、つまり「できる限り反映する」扱いにします。KV への反映が一時的に失敗しても、201 Created の返却は止めません。

ひも付けの正しい状態はデータベースにあるため、KV は後続のジョブや再試行処理で再同期できます。

23505 を catch するときの注意点

23505 は一意制約違反を表します。ただし、テーブルに一意制約が複数ある場合、どの制約に違反しても同じ 23505 になります。

たとえば、linked_external_accounts テーブルに次のような複数の制約があるとします。

linked_external_accounts_provider_account_unique
linked_external_accounts_user_provider_unique
linked_external_accounts_provider_display_name_unique

この場合、単に code === '23505' だけで判定すると、どの制約に違反したのか分かりません。そのため、constraint も確認した方が安全です。

try {
  const row = await insertLinkedExternalAccount(client, {
    userId,
    provider,
    externalAccountId,
    displayName,
  });

  return row;
} catch (err) {
  const pgErr = err as { code?: string; constraint?: string };

  if (pgErr.code === '23505') {
    if (pgErr.constraint === 'linked_external_accounts_provider_account_unique') {
      throw ApiErrors.conflict(
        'external_account_already_linked',
        'This external account is already linked to another user',
      );
    }

    throw err;
  }

  throw err;
}

こうしておくと、外部アカウントの重複だけを 409 Conflict に変換できます。想定外の一意制約違反まで、誤って external_account_already_linked として扱わずに済みます。

実装方針のまとめ

この設計では、責務を次のように分けます。

PostgreSQL:
  ひも付けの正しい状態を保持する
  重複登録を防ぐ
  一意制約で不可分に判定する

アプリケーション:
  INSERT を試す
  23505 を 409 Conflict に変換する
  制約名を見て、意図したエラーだけを扱う

KV:
  エッジで読むための参照キャッシュとして使う
  DB 登録後にできる限り更新する
  所有確認には使わない

この分担にすると、登録処理はシンプルになります。

登録前に KV を読む
存在するか判定する
なければ KV に書く

ではなく、

DB に INSERT する
成功したら登録完了
一意制約違反なら 409 を返す

という形になります。

まとめ

KV で次のような所有確認を行うと、TOCTOU が発生する可能性があります。

const existing = await kv.get(`github:${externalAccountId}`);

if (existing && existing !== userId) {
  throw new Error('already_linked');
}

await kv.put(`github:${externalAccountId}`, userId);

この実装では、GETPUT の間に別のリクエストが割り込めます。その結果、複数のリクエストが同時に「まだ存在しない」と判断し、後から書いた値で上書きされる可能性があります。

ひも付けのように排他性が重要なデータは、KV ではなくデータベースを正しい状態の保存先にします。PostgreSQL の一意制約を使えば、重複チェックと書き込みをデータベース側で不可分に処理できます。

アプリケーション側では、事前確認ではなく INSERT を試みます。重複した場合は 23505 unique_violation を捕まえて、409 Conflict に変換します。

KV は、ひも付けの正しさを決める場所ではなく、参照用のキャッシュとして使います。この設計にすると、TOCTOU を避けながら、エッジでの高速な参照も維持できます。

参考情報

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?