冪等なキー発行とテナント安全な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 つの intent が 2 つのテナントに触れているのはデータ整合性インシデントであり、
rows[0]で取り繕うべきものではありません。 - 未知の入力は無視し、作成しない。 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 での明示的な墓標 |
共通する筋道はこうです。不変条件をできるだけデータの近くに移し、不正な状態を「起こりにくい」ではなく「表現できない」ものにすること。 アプリケーションレベルのチェックは優れた第一防衛線ですが、ミスがドアを解錠してしまうシステムでは、最終防衛線はコードが言い逃れできない制約でなければなりません。