UnlockOS Developers
← 記事一覧に戻る
🔐

冪等なキー発行とテナント安全なWebhook

2026年9月21日→2026年9月27日
9 分
251 commits
深度 8/10
securityreliabilitystate-machinepostgresqltesting

冪等なキー発行とテナント安全なWebhook

はじめに

CRUD アプリにバグが紛れ込んだとき、誰かが画面上で間違った数字を目にします。スマートロックのプラットフォームにバグが紛れ込んだとき、ドアは間違った人物に向かって開きます — あるいは正しい人物に対して開かなくなります。影響範囲は物理世界に及びます。

本記事では、UnlockOS のコードベースに最近加えた一連の変更を取り上げます。それらはすべて次の一つのテーマを共有しています。分散システムに、余計なクレデンシャル・余計なテナント・余計なリトライ・余計なアラートを生み出させないこと。 各セクションは、短命なアクセス情報を発行するあらゆるシステムに応用できるパターンとして書いています。


1. 並行実行下でもチェックインごとにキーは 1 つだけ

ゲストが キーを表示 をタップします。画面が二重にマウントされます(React StrictMode、再レンダリング、不安定なネットワークによるリトライ)。2 つの refreshKey 呼び出しが同時にロックベンダーへ飛びます。発行経路が冪等でなければ、1 回の滞在に対して 2 つの有効な PIN が存在することになり、しかも失効リストで追跡されているのはそのうち 1 つだけ、という状態になります。

ここで修正すべきレイヤーは 3 つあり、その 3 つすべてが必要です。

レイヤー 1: プロセス内の single-flight

同一プロセス内の並行呼び出しを 1 つの promise にまとめます。

const inflight = new Map<string, Promise<AccessKey>>();
export function refreshKey(checkInId: string): Promise<AccessKey> {
  const existing = inflight.get(checkInId);
  if (existing) return existing;
  const task = issueKey(checkInId).finally(() => {
    inflight.delete(checkInId);
  });
  inflight.set(checkInId, task);
  return task;
}

これは低コストで、実運用上は重複の 90% を取り除きます。しかし同時に、これは正しさの保証ではありません。サーバーインスタンスが 2 つあれば、あるいは serverless の呼び出しが 2 つ走れば、それぞれが自分の Map を持つからです。

レイヤー 2: 不変条件を破れなくするデータベース制約

create unique index access_keys_one_active_per_checkin
  on access_keys (check_in_id)
  where status = 'active';

部分ユニークインデックスは、チェックインごとにアクティブなキーは最大 1 つ というビジネスルールを、すべての書き込み手を見渡せる唯一の場所にエンコードします。2 番目の発行者は静かに成功するのではなく、ユニーク制約違反として明確に失敗するようになります。

レイヤー 3: read-then-write を直列化する

ユニークインデックスは競合をエラーに変えます。アドバイザリロックは競合を待機に変えるため、敗者はエラーページではなく同じキーを返せるようになります。

create or replace function issue_checkin_key(p_check_in_id uuid)
returns access_keys
language plpgsql
as $$
declare
  v_key access_keys;
begin
  perform pg_advisory_xact_lock(hashtextextended(p_check_in_id::text, 0));
  select * into v_key
    from access_keys
   where check_in_id = p_check_in_id
     and status = 'active'
     and expires_at > now()
   limit 1;
  if found then
    return v_key;
  end if;
  insert into access_keys (check_in_id, status, pin, expires_at)
  values (p_check_in_id, 'active', generate_pin(), now() + interval '1 day')
  returning * into v_key;
  return v_key;
end;
$$;

アドバイザリロックはトランザクションスコープなので、コミット時またはロールバック時に解放されます。ベンダー呼び出しが失敗してもロックが漏れることはありません。

経験則: アプリケーションコードだけで実装された冪等性はパフォーマンス最適化です。データベース制約として実装された冪等性は保証です。


2. クレデンシャルにはライフサイクルがある — 明示的にモデル化する

関連するバグの一種があります。ゲストが深夜に到着できるよう、予約時点で PIN を先行発行するケースです。ゲストが実際にチェックインすると、滞在スコープの新しいキーが発行されます。誰も最初のキーを引退させなければ、予約時の PIN が予約より長く生き残ってしまいます。

解決策は、キーのステータスを自由形式の文字列として扱うのをやめ、正当な遷移のホワイトリストを持つ有限状態機械として扱うことです。

export type KeyState =
  | 'pre_issued'   // minted from a reservation, before arrival
  | 'active'       // bound to a live check-in
  | 'revoked'      // withdrawn on purpose
  | 'expired';     // aged out

const LEGAL: Record<KeyState, readonly KeyState[]> = {
  pre_issued: ['active', 'revoked', 'expired'],
  active: ['revoked', 'expired'],
  revoked: [],
  expired: [],
};

export function assertTransition(from: KeyState, to: KeyState): void {
  if (!LEGAL[from].includes(to)) {
    throw new IllegalKeyTransitionError(`${from} -> ${to} is not allowed`);
  }
}

遷移先リストが空の終端状態こそが重要な部分です。失効済みのキーは、リトライされた webhook や順序が前後したジョブによって決して蘇ることはありません。

チェックインは明示的な引き継ぎとなり、2 つのキーが同時にアクティブにならないよう 1 つのトランザクション内で実行されます。

export async function completeCheckIn(db: Tx, reservationId: string) {
  return db.transaction(async (tx) => {
    const checkIn = await createCheckIn(tx, reservationId);
    const eager = await findPreIssuedKeys(tx, reservationId);
    for (const key of eager) {
      assertTransition(key.state, 'revoked');
      await revokeAtVendor(key.vendorKeyId);
      await markRevoked(tx, key.id, { reason: 'superseded_by_checkin' });
      await appendAudit(tx, {
        actor: 'system',
        action: 'key.revoked',
        subject: key.id,
        reason: 'superseded_by_checkin',
      });
    }
    return issueCheckInKey(tx, checkIn.id);
  });
}

すべての失効操作は、誰が・何を・なぜを記録した監査行を書き込みます。半年後にインシデントレビューを可能にするのは reason です。これがなければ、キーが失効されたことは証明できても、正しい理由で失効されたことは証明できません。


3. 触れるすべての行でテナントを解決する

決済 webhook は、テナントをまたいだ漏洩が起きやすい典型的な場所です。素朴なハンドラは payment-intent ID で1 行を検索し、その行のテナントを検証し、そのうえで同じ intent を共有するすべての行に書き込みます。1 つの intent が複数の予約(グループ予約)をカバーしうる瞬間から、そのチェックは書き込みの一部分しかカバーしなくなります。

export async function onPaymentSucceeded(event: StripeEvent) {
  const intentId = event.data.object.id;
  const rows = await findReservationsByIntent(intentId);
  if (rows.length === 0) {
    // Unknown intent: ignore, do not create anything.
    return { status: 'ignored' as const, reason: 'no_matching_reservation' };
  }
  const tenants = new Set(rows.map((r) => r.tenantId));
  if (tenants.size !== 1) {
    throw new TenantIntegrityError(`intent ${intentId} spans ${tenants.size} tenants`);
  }
  const [tenantId] = [...tenants];
  if (tenantId !== event.account_tenant_id) {
    throw new TenantMismatchError(intentId);
  }
  for (const row of rows) {
    await markPaid(row.id, { tenantId, intentId });
  }
}

真似する価値のある性質が 3 つあります。

  1. デフォルトで複数形。 1 行しか想定していない場合でも、リゾルバはリストを返します。コードの形がカーディナリティについて嘘をつかなくなります。
  2. 曖昧さに対してはフェイルクローズ。 1 つの intent が 2 つのテナントに触れているのはデータ整合性インシデントであり、rows[0] で取り繕うべきものではありません。
  3. 未知の入力は無視し、作成しない。 webhook は、認識できないオブジェクトに対する暗黙の INSERT 経路になってはいけません。

同じ規律は通常の REST ルートにも当てはまります。API キーだけでフィルタするリストエンドポイントや、ID で取得してから読み取り後に権限をチェックする詳細エンドポイントは、どちらも漏洩します。クエリ自体をスコープしましょう。

select id, name, last_used_at
  from api_keys
 where tenant_id = $1          -- always, not just on the list route
   and id = coalesce($2, id);

4. Base64 は暗号化ではない、そして RPC はロールを継承する

頻繁にセットで現れる 2 つの失敗モードがあります。

  • シークレットが暗号化ではなくエンコードされているため、行のダンプを持つ者は誰でも平文を手にできる。
  • シークレットを復号する security definer 関数が、ログイン済みユーザーロールから実行可能なまま残されており、読み取り制限されたカラムが事実上の公開 API になってしまう。

堅牢化は次のようになります。

-- 1. Real encryption, keyed outside the table.
create or replace function store_provider_secret(p_facility uuid, p_secret text)
returns void
language plpgsql
security definer
set search_path = public, pg_temp
as $$
begin
  insert into provider_secrets (facility_id, ciphertext, key_version)
  values (p_facility, pgp_sym_encrypt(p_secret, current_setting('app.data_key')), 2)
  on conflict (facility_id) do update
     set ciphertext = excluded.ciphertext,
         key_version = excluded.key_version,
         rotated_at = now();
end;
$$;
-- 2. Decryption is reachable only by trusted server roles.
revoke execute on function read_provider_secret(uuid) from anon, authenticated;
grant execute on function read_provider_secret(uuid) to service_role;

key_version に注目してください。どの行がまだ古い鍵のままなのか、そしてどの行がもはや読めない形式のコードバージョンで書かれたのかを判別できて初めて、鍵のローテーションは運用可能になります。

書き込み側における鏡像が row-level security です。エンタイトルメント(サブスクリプション、メンバーシップ、クレジット)を付与するテーブルにクライアントが INSERT できるなら、クライアントは自分自身にアクセス権を付与できてしまいます。

alter table subscriptions enable row level security;
revoke insert, update, delete on subscriptions from authenticated;
create policy subscriptions_read_own on subscriptions
  for select to authenticated
  using (user_id = auth.uid());

書き込みは、まず支払いを検証するサーバーサイドの経路を通します。同様に、監査目的の書き込み(決済イベント、入室ログ)は呼び出し元のクライアントではなく service role を使うべきです。そうしないと、呼び出し元の RLS が、フォレンジックで頼りにしているまさにその記録を静かに落としてしまう可能性があります。


5. 成功しえないものをリトライしない

ロックベンダーは、ボディにビジネスエラーを入れた HTTP 200 を返すのが大好きです。クライアントがステータスコードを起点にリトライロジックを組んでいると、認可の失敗が一時的な不具合に見えてしまい、トークンリフレッシュでベンダーを永遠に叩き続けることになります。

type VendorResult<T> =
  | { ok: true; data: T }
  | { ok: false; code: string; retryable: boolean };

export async function callVendor<T>(path: string, init: RequestInit): Promise<VendorResult<T>> {
  const res = await fetch(path, init);
  const body = await res.json();
  if (res.status >= 500) {
    return { ok: false, code: `http_${res.status}`, retryable: true };
  }
  if (typeof body.code === 'string' && body.code !== 'SUCCESS') {
    // 200 OK with a business error: authorization problems are NOT transient.
    const retryable = TRANSIENT_CODES.has(body.code);
    return { ok: false, code: body.code, retryable };
  }
  return { ok: true, data: body.data as T };
}

補完的な制御として、キャッシュしたクレデンシャルの有効期間に硬い上限を設けます。上流の expires_in を信頼しつつ、誤った値が古いトークンをメモリに固定してしまわないようクランプします。

const MAX_TOKEN_LIFETIME_MS = 95 * 60 * 1000;
const SAFETY_MARGIN_MS = 60 * 1000;
export function cacheTtl(expiresInSeconds: number): number {
  const advertised = expiresInSeconds * 1000 - SAFETY_MARGIN_MS;
  return Math.max(0, Math.min(advertised, MAX_TOKEN_LIFETIME_MS));
}

同じ系統のもう一つの習慣として、上流の失敗をログに残すときは、エラーコード、エンドポイント、相関 ID を記録し、リクエストボディは決して記録しないことです。webhook やメッセージングのペイロードにはユーザー識別子やトークンが含まれており、ログストアが主データベースと同等の保持期間・アクセス制御を備えていることは稀です。


6. グループ操作はオール・オア・ナッシング

N 部屋をグループとして予約する操作はアトミックでなければなりません。N 件すべてが確保されるか、1 件も確保されないかのどちらかです。部分的な成功は最悪の結果です — ゲストは使えないグループに対して課金され、オペレーターは手作業でそれを巻き戻すことになります。

重複検出とロールバックがタダで手に入るデータベースへ、不変条件を押し込みましょう。

alter table stays
  add constraint stays_no_overlap
  exclude using gist (
    room_id with =,
    tstzrange(starts_at, ends_at, '[)') with &&
  ) where (status <> 'cancelled');

create or replace function reserve_booking_group(p_group_id uuid, p_rows jsonb)
returns setof stays
language plpgsql
as $$
declare
  r jsonb;
begin
  for r in select * from jsonb_array_elements(p_rows) loop
    return query
      insert into stays (booking_group_id, room_id, starts_at, ends_at, status)
      values (p_group_id, (r->>'room_id')::uuid,
              (r->>'starts_at')::timestamptz, (r->>'ends_at')::timestamptz, 'held')
      returning *;
  end loop;
end;
$$;

3 行目での排他制約違反は関数全体を中断させ、呼び出し元のトランザクションが 1 行目と 2 行目をロールバックします。補償ロジックを書く必要も、補償ロジックを書き間違える余地もありません。決済側はグループ ID に対して 1 つの intent を紐付けるので、返金やキャプチャは単一のオブジェクトを対象に実行できます。


7. 失敗は一度だけ数える

アラートにもそれ自身の冪等性の問題があります。毎晩のジョブが同じ自動チャージ失敗を毎回レポートすると、メトリクスは膨らみ、オンコールチームはそれを無視するよう学習してしまいます。自然キーで重複排除しましょう。

insert into billing_failures (facility_id, kind, occurred_on, detail)
values ($1, $2, $3::date, $4)
on conflict (facility_id, kind, occurred_on) do nothing;

同じ原則はスケールアップしても通用します。ヘルスチェックの失敗は、チェック名をキーとした追跡可能な issue にルーティングし、状態が続いている間は更新し、解消したらクローズします。ポーリングごとに 1 シグナルではなく、状態ごとに 1 シグナルです。


8. 削除は監査証跡を消してはならない

ゲストアカウントの削除は個人データを取り除くべきであって、ドアが開いたという記録を消すべきではありません。カスケードする外部キーは両方を消してしまいます。

alter table stays drop constraint stays_guest_id_fkey;
alter table stays add constraint stays_guest_id_fkey
  foreign key (guest_id) references guests(id) on delete set null;

滞在レコードのゲストが null になるため、運用ビューが必要とする情報(部屋のラベル、プラン名、仮名化された参照など)は書き込み時にスナップショットしておく必要があります。そして UI は、空欄ではなく 削除済みアカウント と描画しなければなりません。空のフィールドはバグに見えてサポートチケットを呼び込みますが、明示的な墓標(tombstone)はポリシーとして読み取られます。


9. 関数だけでなく制約をテストする

上記のすべての不変条件は SQL の中に存在します。部分ユニークインデックス、排他制約、RLS ポリシー、security definer の権限付与。データベースをモックしたユニットテストはそのどれも見ることができません。CI では実際の Postgres に対して振る舞いテストを実行しましょう。

begin;
select plan(3);
select lives_ok(
  $$select issue_checkin_key('11111111-1111-1111-1111-111111111111')$$,
  'first issuance succeeds');
select is(
  (select count(*)::int from access_keys
    where check_in_id = '11111111-1111-1111-1111-111111111111' and status = 'active'),
  1, 'a second call reuses the existing active key');
select throws_ok(
  $$insert into subscriptions (user_id, plan_id) values (auth.uid(), 'p1')$$,
  '42501', null, 'clients cannot insert their own subscription');
select * from finish();
rollback;

各ケースを begin ... rollback で包むことで、テストスイートは密閉性を保ちます。あわせて CI にマイグレーションゲートを用意し、対象環境をトリガーイベントからではなく、それ自身の入力から解決するようにしてください。呼び出し元から環境を推測するワークフローは、いずれ staging 向けのマイグレーションを本番に向けることになります。


まとめ

リスク 対策
並行実行下でのクレデンシャル重複 single-flight + 部分ユニークインデックス + アドバイザリロック
古い先行発行 PIN 終端状態を持つ明示的な状態機械、1 トランザクション内での引き継ぎ時失効
テナントをまたぐ書き込み 影響するすべての行を解決し、曖昧さにはフェイルクローズ、クエリをテナントでスコープ
シークレットの露出 鍵バージョニングを伴う本物の暗号化、復号 RPC は service role のみに付与
リトライストーム ボディのコードで上流エラーを分類、キャッシュトークンの有効期間をクランプ
部分的なグループ予約 排他制約 + 単一トランザクション、補償ロジック不要
アラート疲れ 自然キーで重複排除、状態ごとに 1 シグナル
監査証跡の消失 on delete set null とスナップショットしたフィールド、UI での明示的な墓標

共通する筋道はこうです。不変条件をできるだけデータの近くに移し、不正な状態を「起こりにくい」ではなく「表現できない」ものにすること。 アプリケーションレベルのチェックは優れた第一防衛線ですが、ミスがドアを解錠してしまうシステムでは、最終防衛線はコードが言い逃れできない制約でなければなりません。

主要な発見

1
信頼性

冪等性はクライアントだけでなくデータベースに属する

プロセス内の single-flight は重複するキーリフレッシュ呼び出しの大半をまとめられますが、複数インスタンスをまたいでチェックインごとにアクティブなクレデンシャルを最大 1 つに保証できるのは、部分ユニークインデックスとトランザクションスコープのアドバイザリロックだけです。

2
State Machine

クレデンシャルのライフサイクルには終端状態が必要

キーのステータスを遷移のホワイトリストを持つ明示的な有限状態機械としてモデル化し、revoked と expired の遷移先を空にすることで、リトライされた webhook や順序の狂ったジョブが失効済みクレデンシャルを蘇らせるのを防げます。

3
セキュリティ

ハンドラが書き込むすべての行でテナントを検証する

1 つの payment intent が複数の予約行に解決されうる場合、rows[0] のテナントを検証しても書き込みの一部しかカバーできません。複数形で解決し、曖昧なテナント集合は拒否し、未知の入力はレコードを作らず無視しましょう。

4
セキュリティ

エンコードは暗号化ではなく、security definer 関数は呼び出し元ロールを引き継ぐ

シークレットにはローテーション用の鍵バージョンを伴う本物の暗号化が必要で、復号 RPC はクライアントロールから revoke しなければ、読み取り制限されたカラムが公開 API になってしまいます。

5
エラーハンドリング

上流のエラーは HTTP ステータスではなくボディで分類する

ビジネスエラーコードを HTTP 200 で返すベンダーは、認可の失敗を無限リトライループに変えてしまいます。一時的でないコードはリトライ不可としてマークし、キャッシュしたトークンの有効期間に硬い上限を設けましょう。

6
テスト

SQL に存在する制約は実際の SQL に対してテストする

排他制約、RLS ポリシー、部分ユニークインデックスは、データベースをモックしたユニットテストからは見えません。不変条件が実際に成立していることを証明するのは、CI で実行するトランザクショナルな振る舞いテストです。