デフォルトでフェイルクローズ:テナント分離とリトライ安全性
スマートロックプラットフォームの真価は、地味な部分で問われます。正しいリクエストを拒否できるか、正しい失敗をリトライできるか、そしてこの2つのケースを区別できるか。UnlockOS SDK における最近の一連の作業は、まさにこれらの問いを中心に集まっていました — クロステナント認可、行レベルセキュリティ、エラー分類、冪等な金銭操作、そして有効期間ウィンドウのゲーティングです。
本記事では、そこから一般化できるパターンを取り出します。目新しいものは何もありません。しかしそのすべてが、物理的なドアの前に置けるシステムと、置けないシステムとの差になります。
1. テナント分離は「チェック」ではなく「スタック」である
単一の認可チェックは、単一障害点です。今回のサイクルでは、独立した4つのメカニズムを階層化しました。クロステナントな読み取りを成功させるには、そのすべてを突破する必要があります。
レイヤー1: 所有者が存在しないリソースに対するリクエストスコープの認可
興味深いのは「ユーザー A がユーザー B の予約を読む」ケースではありません。所有者が いない 予約 — ウォークイン、インポートされた予約、未請求のレコード — です。resource.user_id === caller.id をチェックするコードは、resource.user_id が null のとき、静かに「許可」へと劣化します。
修正方針は、所有権だけで認可しないことです。包含するスコープにフォールバックします:
type AuthzResult =
| { allowed: true; via: 'owner' | 'facility_staff' }
| { allowed: false; reason: 'not_owner_no_facility_role' | 'wrong_facility' };
export function authorizeReservation(
caller: Caller,
reservation: { id: string; userId: string | null; facilityId: string },
): AuthzResult {
if (reservation.userId !== null && reservation.userId === caller.userId) {
return { allowed: true, via: 'owner' };
}
const role = caller.facilityRoles[reservation.facilityId];
if (!role) {
return { allowed: false, reason: 'not_owner_no_facility_role' };
}
return { allowed: true, via: 'facility_staff' };
}「形」が重要です。boolean ではなく判別可能なユニオン型を返すことで、呼び出し側に拒否の明示的なハンドリングを強制し、監査ログに機械可読な理由を与えます。reason 文字列はダッシュボードに届きますが、false はどこにも届きません。
レイヤー2: マシン資格情報はテナントのルートではなくスコープに紐付ける
API キーは最も一般的なテナント分離の穴です。通常は組織レベルで発行され、その後ファシリティレベルのリソースに対して使われるからです。キーのテナントを、キーが主張する値ではなく リソース側の ファシリティに紐付けることで、この穴は塞がれます:
export async function resolveApiKeyScope(
apiKey: ApiKey,
reservationId: string,
): Promise<{ tenantId: string; facilityId: string }> {
const reservation = await loadReservationUnsafe(reservationId);
if (!apiKey.facilityIds.includes(reservation.facilityId)) {
throw new ForbiddenError('api_key_facility_scope_mismatch', {
apiKeyId: apiKey.id,
requestedFacilityId: reservation.facilityId,
});
}
return { tenantId: reservation.tenantId, facilityId: reservation.facilityId };
}loadReservationUnsafe があえて不快な名前になっている点に注目してください。意図的に認可をスキップするローダーは、うっかり使いにくくあるべきです。
レイヤー3: 最後の砦としての行レベルセキュリティ
アプリケーションのチェックはリファクタで消えます。データベースは消えません。今回のサイクルでは2つの異なる RLS の欠陥が修正されましたが、どちらも示唆に富んでいます。
1つ目: あらゆる 認証済みユーザーがクロステナントの管理者グラフを読めてしまうポリシーです。避けるべきパターンは、USING 句が単なる認証で満たされてしまうポリシーです。
-- Anti-pattern: "is logged in" is not an authorization decision
CREATE POLICY admin_graph_read ON admin_memberships
FOR SELECT TO authenticated
USING (auth.uid() IS NOT NULL);
-- Correct: the row must be reachable from the caller's own membership
CREATE POLICY admin_graph_read ON admin_memberships
FOR SELECT TO authenticated
USING (
organization_id IN (
SELECT organization_id FROM admin_memberships
WHERE user_id = auth.uid()
)
OR is_platform_admin(auth.uid())
);2つ目: ゲストがチェックインイベントを 書き込める ため、自分自身の後払い残高をクリアできてしまいました。読み取りポリシーはレビューされますが、書き込みポリシーはコピーされます。行が金銭やアクセスの計算の入力となるテーブルはすべて、決済エンドポイントと同じ厳しさで INSERT/UPDATE ポリシーを監査する必要があります。経験則はこうです: ある行が、システムがそのユーザーについて下す判断の入力であるなら、そのユーザーはその行を書き込めてはならない。
関連するマイグレーションのバグも、スタイルの問題ではなくセキュリティの正しさの問題なので触れておく価値があります: オーバーロードされたヘルパーを明示的なキャストなしで呼ぶケースです。
-- Ambiguous when the helper is overloaded; fails or resolves surprisingly
USING (is_platform_admin(auth.uid()))
-- Explicit, and resolves to exactly one function
USING (is_platform_admin(auth.uid()::uuid))実行時にエラーになるポリシーはサービス拒否です。誤ったオーバーロードに解決されるポリシーは侵害です。どちらも、境界で明示的になることで回避できます。
レイヤー4: 特権クライアントで edge function の呼び出し元を検証する
サーバーレス関数が匿名クライアントで呼び出し元を検証すると、検証そのものが RLS の影響下に入ります。つまり、正しく制限されたテーブルによって正当な呼び出し元が未認証に見えたり、逆に誤って制限されたテーブルによって攻撃者が問題なく見えたりします。呼び出し元の検証は、可視性が呼び出し元に依存しないクライアントで実行しなければなりません:
const verifier = createClient(url, SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data, error } = await verifier.auth.getUser(bearerToken);
if (error || !data.user) {
return deny('invalid_token');
}
// Everything AFTER verification uses a caller-scoped client.
const scoped = createClient(url, ANON_KEY, {
global: { headers: { Authorization: `Bearer ${bearerToken}` } },
});規律はシンプルです: アイデンティティ には特権クライアント、データ にはスコープ付きクライアント。どちらの方向に混ぜても脆弱性になります。
補足: クライアント側のセッションストレージもスコープ化する
見落としやすいマルチテナントのバグが、グローバルなキーでセッションストレージを持つことです。2つのタブで2つのファシリティを開くと、2回目のログインが1回目を上書きし、静かに誤ったファシリティに対して操作してしまいます。ストレージキーを名前空間化すれば解決します:
export function facilityScopedStorageKey(facilityId: string): string {
return `unlockos.auth.${facilityId}`;
}これはサーバーサイドの脆弱性ではありませんが、テナント分離モデルが本来防ぐべきインシデントのクラス — 誤ったテナントに対して操作が行われること — をまさに引き起こします。
2. 一時的 vs 終端的: 最もモデル化されていない区別
今回のサイクルのいくつかの修正は、服装を変えただけの同じバグでした: 一時的な失敗が、確定的な否定の回答として扱われていた のです。
- 決済設定の不安定な読み取りが「設定が使用不能」と扱われ、メンバーチェックのスキップに使われていた。
- チェックインロックの取得失敗が「セッションが存在しない」と扱われていた。
- 上流のキャンセルからの
resource_missingが「サブスクリプションが存在しない」と扱われていた。 - 修復スイープが、決して成功し得ない鍵発行を永遠にリトライしていた。
治療法は、失敗を catch ではなく型としてモデル化することです:
export type Outcome<T> =
| { kind: 'ok'; value: T }
| { kind: 'absent' } // verified negative
| { kind: 'rejected'; code: string; message: string } // terminal, do not retry
| { kind: 'unavailable'; cause: unknown }; // transient, retryable
export function toHttpStatus(outcome: Outcome<unknown>): number {
switch (outcome.kind) {
case 'ok': return 200;
case 'absent': return 404;
case 'rejected': return 409;
case 'unavailable': return 500;
}
}ステータスのマッピングが契約です。409 は リクエストは理解されたが、安定した理由で拒否された。変更せずにリトライしても再び失敗するし、メッセージは人間に見せて安全である ことを意味します。500 は わからない。リトライが適切である ことを意味します。危険なのは unavailable を rejected に潰す方向です。それは「あなたの設定を読めなかった」を「あなたの設定が壊れている」に変換し、クライアントは成功したはずの処理のリトライをやめてしまいます。
ロックのケースはアクセス制御に隣接するため、特に強調する価値があります:
const lock = await tryAcquireCheckinLock(stayId);
if (lock.kind === 'unavailable') {
// Someone else holds the lock, or the lock store is down.
// This is NOT evidence that the session is absent.
return { kind: 'unavailable', cause: lock.cause };
}ロックのタイムアウトが「アクティブなセッションなし」を意味することを許していたら、並行リクエストが滞在を誤った状態に遷移させ得ました。曖昧さは決して寛容な回答の方向に解決してはなりません。
逆の失敗 — 終端エラーの無限リトライ — も同じくらい現実的です。修復スイープには終端性の述語と、永続化されたマーカーが必要です:
const TERMINAL_ISSUANCE_CODES = new Set([
'plan_not_found',
'window_already_past',
'device_decommissioned',
]);
if (TERMINAL_ISSUANCE_CODES.has(result.code)) {
await markIssuanceUnrecoverable(jobId, result.code);
return;
}
await scheduleRetry(jobId, backoff(attempt));これがないと、死んだジョブが毎回のスイープでワーカースロットを永遠に消費し、本物の失敗が恒久的に赤いキューのノイズに埋もれてしまいます。
3. 金銭を動かすもの、ドアを開けるものには冪等性を
ここでは2つの欠陥がありました: 既に行われた課金をリプレイし得るプラン変更の課金キーと、残高が既にゼロなのに2通目のレシートをキューに入れてしまう精算処理です。
冪等キーは、対象のエンティティだけでなく、操作の 完全な意味内容 から導出されなければなりません。plan-change:{membershipId} というキーは、2つの正当に異なる課金で衝突し、さらに悪いことに、古いリトライがその後変更された金額を再適用することを許してしまいます。
export function chargeIdempotencyKey(input: {
membershipId: string;
fromPlanId: string;
toPlanId: string;
amountMinor: number;
periodStart: string;
}): string {
return [
'plan-change',
input.membershipId,
input.fromPlanId,
input.toPlanId,
String(input.amountMinor),
input.periodStart,
].join(':');
}そして副作用を発行する前のガードは、フラグではなく状態のアサーションであるべきです:
if (settlement.balanceMinor === 0 && settlement.receiptIssuedAt !== null) {
return { kind: 'ok', value: { receipt: 'already_issued' } };
}一般則: 副作用を持つハンドラは、同じ入力で2回呼ぶことが1回呼ぶことと区別できないように書かれるべきです。webhook の配信、決済のリターン、修復スイープはいずれも、2回呼ばれることを保証してくれます。
4. 直交するライフサイクルには直交する状態を
ある予約が「未完了」と表示されていたのは、それが指す滞在がまだ checked_in だったからです。これは表示のエラーではなくモデリングのエラーです。2つの独立したライフサイクルが、1つの導出ステータスに押し込められていたのです。
予約は 商取引 のレコードです。滞在は 占有 のレコードです。両者は異なる時計で進行し、正当に食い違うことがあります — ゲストがまだ物理的に退出していない完了済みの予約は、矛盾ではなく正常な状態です。
type ReservationState = 'draft' | 'confirmed' | 'cancelled' | 'completed';
type StayState = 'pending' | 'checked_in' | 'checking_out' | 'checked_out';
export function displayStatus(r: ReservationState, s: StayState | null) {
if (r === 'cancelled') return 'cancelled';
if (r === 'completed') return 'completed'; // independent of stay
if (s === 'checked_in') return 'in_stay';
if (s === 'checking_out') return 'checking_out';
return r;
}checking_out という状態それ自体も教訓です。自動チェックアウトは非同期なので、UI は2つの終端状態の間を楽観的に切り替えるのではなく、本物の進行中状態を必要とします。外部システムを伴うあらゆる遷移 — 決済、ロックコマンド — は、その中間状態に名前を付ける必要があります。さもなければユーザーはその間ずっと嘘を見せられることになります。
同じ論理が削除ガードにも当てはまります。あるレコードが監査的なログ(たとえば入口を参照する配信ログ)から参照されている場合、正しい選択は通常、操作をブロックすることではなく、履歴的な参照を保持したまま再分類を許すことです。監査レコードは不変の 履歴 であるべきで、現在の設定に対する外部キーの手錠であってはなりません。
5. 有効期間ウィンドウ: 検証し、欠落時はフェイルクローズする
クイック発行のアクセス URL はベアラー資格情報です。2つのゲートを追加しました:
[valid_from, valid_until]のウィンドウ外で使われた URL を拒否する。valid_untilが まったく 設定されていないプランを拒否する。
2つ目はチームが省略しがちなものです。有効期限の欠落は「制限なし」ではありません — それは入力の欠落であり、ドアを開ける資格情報においては、入力の欠落は拒否でなければなりません。
export function checkIssuanceWindow(
plan: { validFrom: string | null; validUntil: string | null },
now: Date,
): Outcome<void> {
if (!plan.validUntil) {
return { kind: 'rejected', code: 'plan_missing_valid_until', message: 'Plan has no expiry.' };
}
const from = plan.validFrom ? new Date(plan.validFrom) : null;
const until = new Date(plan.validUntil);
if (Number.isNaN(until.getTime())) {
return { kind: 'rejected', code: 'plan_invalid_valid_until', message: 'Plan expiry is unparseable.' };
}
if (from && now < from) return { kind: 'rejected', code: 'window_not_started', message: 'Not yet valid.' };
if (now >= until) return { kind: 'rejected', code: 'window_expired', message: 'Expired.' };
return { kind: 'ok', value: undefined };
}関連として、日付範囲のバグはサーバーではなく ファシリティ側の 時計で範囲を計算することで修正されました。アクセス制御システムにおけるタイムゾーンの扱いは、フォーマットの問題ではなくセキュリティの問題です — 誤ったゾーンで計算されたウィンドウは、開いているべきでない9時間のあいだ開いているウィンドウです。
6. ドリフトをビルド失敗にする
ある CI の変更は、オフセットのリストを TypeScript/SQL のドリフトから守ります。これはチームが書ける中で最も安価かつ高価値なテストです: 同じ列挙が2箇所に存在する場合 — TS のユニオンと Postgres の enum、定数リストと CHECK 制約 — CI で等価性をアサートしましょう。
it('TS anchors match the SQL enum', async () => {
const sql = await readFile('supabase/migrations/anchors.sql', 'utf8');
const fromSql = [...sql.matchAll(/'([a-z_]+)'::anchor_kind/g)].map((m) => m[1]);
expect(new Set(fromSql)).toEqual(new Set(EVENT_ANCHORS));
});これが防ぐ障害モードは厄介です: コンパイルが通り、デプロイもされ、しかし値が境界の片側にしか存在しないために、実行時に静かに分岐がスキップされるのです。
7. 正しい理由で失敗するテスト
今回のバッチにおける3つのテスト修正はすべて決定性に関するもので、いずれも一般化できます:
- 時計を固定する。 日付範囲や有効期限に触れるテストはすべて
nowを注入しなければなりません。月初まで通るテストは、テストではありません。 - 遅延ロードされる UI に依存しない。 あるリードオンリーのアサーションが、デバッグパネルの遅延ロードに結合していました。たまたまレンダリングされる他の何かではなく、テスト対象のコンポーネントに対してアサートしましょう。
- 未処理の rejection を失敗として扱う。 未処理の promise rejection を飲み込むテストスイートは、本番のコードパスが throw していても平気でパスします。浮いた promise を直しましょう。警告を黙らせてはいけません。
const now = new Date('2026-03-01T00:00:00Z');
expect(checkIssuanceWindow({ validFrom: null, validUntil: '2026-02-28T00:00:00Z' }, now))
.toEqual({ kind: 'rejected', code: 'window_expired', message: 'Expired.' });型レベルの衛生も同じカテゴリに属します。状態の書き込みを行うガードの中で Supabase のルックアップを any ではなく SupabaseClient<Database> として型付けすることは、ランタイムのミスの一群をコンパイルエラーに変えます — 特にガードにおいては、静かに undefined になるフィールドはガードが何もしないことを意味するからです。
まとめ
今回の作業全体を貫くパターンは次のとおりです:
- 分離を階層化する。 アプリレベルの認可、資格情報のスコープ束縛、読み取り と 書き込みの RLS、そして特権クライアントによるアイデンティティ検証。どの層も1つは突破されると想定しましょう。
- 所有権だけで認可しない — 所有者のないリソースは、包含するスコープにフォールバックすべきであり、決して「許可」にフォールバックしてはいけません。
- 失敗を型としてモデル化する。
absent、rejected、unavailableは3つの異なる回答であり、3つの異なる HTTP ステータスと3つの異なるリトライ挙動を持ちます。 - 曖昧さは拒否の方向に解決する。 ロックのタイムアウトは「セッションなし」ではありません。不安定な読み取りは「無効な設定」ではありません。有効期限の欠落は「無期限」ではありません。
- 終端的な失敗は終わらせる。 永遠のリトライは、それ自体が障害です。
- 冪等キーは完全な意味から導出し、あらゆる副作用の前に状態をアサートしましょう。
- 直交するライフサイクルは直交させたまま保ち、進行中の状態に名前を付けましょう。
- 境界をまたぐドリフトを CI の失敗にし、失敗が意味を持つ程度にはテストを決定的にしましょう。
どれもフレームワークを必要としません。必要なのは、あらゆる分岐について「わからないときシステムはどうすべきか」を決め、その決定を型として書き下すことです。