テナント境界、信頼できる時計、フェイルクローズな施錠
はじめに
CRUD アプリケーションのバグの多くは、画面に誤った数字を表示するだけで済みます。しかし、物理的なドアロックに対して認証情報を発行するシステムでは、同じ種類のバグが「見知らぬ人が他人の部屋に立っている」あるいは「料金を支払った宿泊客が午前2時に開かないドアの前で締め出される」という結果を生みます。
本記事では、スマートロック管理 SDK において私たちが最も強く依拠しているエンジニアリングパターンをまとめます。すなわち、あらゆる書き込み境界で所有権を証明すること、自分で制御していない時計を信頼しないこと、暗号学的な識別子を相関不可能にすること、ダウンタイムなしで webhook の署名鍵をローテーションすること、そして——これが決定的に重要ですが——個々の修正を恒久的かつ自動化されたガードへと変換し、半年後に同じ穴が再び開かないようにすることです。
以下の例はいずれも一般化可能です。具体例は私たちのものですが、その「形」は、書き込みが物理的な帰結を持つあらゆるマルチテナントシステムで再利用できるはずです。
1. 認可とは「呼び出し元はログインしているか?」ではない
マルチテナントのロックシステムにおいて最も危険なバグパターンは confused deputy(混乱した代理人) です。施設 A の認証済みオペレーターが、施設 B に属するリソースを指定したリクエストを送り、サーバーは——認証は検証したものの、参照された ID の所有権は検証していないため——そのまま書き込みを実行してしまいます。
典型例は、部屋へのロック ID の割り当てです。呼び出し元はロック識別子の配列を渡します。ここで「このユーザーはこの部屋を編集できるか?」しかチェックしていないなら、「このユーザーはこれらのロックを所有しているか?」をチェックしていないことになります。
interface AssignLocksInput {
roomId: string;
lockIds: string[];
actor: { userId: string; organizationId: string };
}
export async function assignLocks(input: AssignLocksInput): Promise<void> {
const room = await db.rooms.findOwnedBy(input.roomId, input.actor.organizationId);
if (!room) throw new ForbiddenError('room_not_in_scope');
// The critical second check: every referenced lock must belong to the same tenant.
const owned = await db.locks.findAllByIds(input.lockIds, input.actor.organizationId);
const ownedIds = new Set(owned.map((lock) => lock.id));
const foreign = input.lockIds.filter((id) => !ownedIds.has(id));
if (foreign.length > 0) {
await audit.record('lock_assign.rejected', { actor: input.actor, foreign });
throw new ForbiddenError('lock_not_in_scope');
}
await db.rooms.setLockIds(room.id, input.lockIds);
}ここから導いたルールは次の通りです。API 境界を越えてくる識別子はすべて信頼できない入力である。たとえそれが UUID であっても、自社の UI が描画したドロップダウン由来であっても同じ。 UUID はケイパビリティではありません。
同じルールは推移的にも適用されます。プロキシエンドポイントがベンダー API へリクエストを転送する場合、プロキシは呼び出し元が指定したスコープをそのまま通すのではなく、セッションからスコープを再導出しなければなりません。さもなければ、そのプロキシは信頼された認証情報を使って不正リクエストをロンダリングする権限昇格ガジェットになってしまいます。
2. 組織単位だけでなく、施設単位でリソースをスコープする
組織レベルのスコープは最低限の前提にすぎません。ホスピタリティ業界では、スタッフは通常特定の物件に割り当てられており、ある建物のフロントデスク端末が別の建物の部屋・宿泊・鍵発行を列挙できてはなりません。
私たちはこれを、リクエストごとに一度解決して各クエリへ引き回す スコープ述語 としてモデル化しています。
export interface RequestScope {
organizationId: string;
facilityIds: readonly string[];
}
export function assertFacilityInScope(scope: RequestScope, facilityId: string): void {
if (!scope.facilityIds.includes(facilityId)) {
throw new ForbiddenError('facility_not_in_scope');
}
}
export async function listStays(scope: RequestScope, facilityId: string) {
assertFacilityInScope(scope, facilityId);
return db.stays.where({ organizationId: scope.organizationId, facilityId });
}私たちが実際に両方踏んだため、2つの失敗モードを挙げておきます。
- 緩すぎる。 複数あるエンドポイントのうち1つで述語を忘れる。スコープのない一覧エンドポイントが1つあるだけで、全物件が漏洩します。
- 厳しすぎる。 古いトークンのクレームからスコープを導出してしまい、正当に複数施設を管理しているスタッフが誤って
403を受け取る。スコープはリクエスト時に権威あるストアから解決し、意図的にキャッシュすべきであって、長寿命のトークンに焼き込んで放置すべきではありません。
どちらもセキュリティに関わります。フロントデスクでの誤った 403 は、スタッフに回避策を探す習慣を植え付け、回避策こそが境界を崩壊させる原因です。
3. 境界をデータベースまで押し下げる
アプリケーションレベルのチェックは必要ですが十分ではありません。アプリケーションコードこそが日々変わるレイヤーだからです。多層防御とは、クライアントから決して来るべきでない書き込みをデータベースが独立して拒否することを意味します。
-- Client roles have no business writing workflow/state tables directly.
revoke insert, update, delete on public.notification_workflow_runs from authenticated, anon;
revoke insert, update, delete on public.reservation_keys from authenticated, anon;
-- Read paths stay policy-scoped.
alter table public.reservation_keys enable row level security;
create policy reservation_keys_read_in_scope on public.reservation_keys
for select to authenticated
using (facility_id in (select facility_id from public.staff_facility_scope where user_id = auth.uid()));繰り返し学んだ教訓があります。不要になった grant は、それを必要としていたコードよりも長く生き残る。 テーブルがリファクタリングされ、クライアント書き込みを必要としていた機能が削除されても、grant は残り続けます。私たちは現在「どのロールがこのテーブルに書き込めるか」をレビュー対象の成果物として扱い、期待される書き込み面をピン留めし、それが広がったら失敗する CI チェックを設けています。
自動的にガードすべき RLS のアンチパターンが2つあります。
- ステートメントごとに一度ではなく、行ごとに
auth.uid()を呼び出すポリシー(RLS を無効化したくなるほどの性能崖を生みます)。 using (true)と書かれ、実際のフィルタがアプリケーションコード側にあるポリシー。
4. 自分が所有していない時計で課金や有効期限を判定しない
ロックの認証情報には有効期間があります。宿泊にも利用期間があります。どちらかの端点が ゲストの端末の時計 から計算されているなら、ゲストはそれを変更できます。これは仮想的な悪用ケースではなく、どんなスマートフォンでも設定を1行変えるだけの話です。
// Wrong: the browser decides when the stay ends.
const endedAt = new Date();
// Right: the authoritative clock lives where the row lives.
const { now } = await db.rpc('server_now');
const endedAt = now;その系として、表示 される時刻と 強制 される時刻は別の関心事です。強制は UTC のデータベース時計を使います。表示——そして重要なことに 日付の境界——は施設のタイムゾーンを使い、ブラウザのものでもサーバーのデプロイリージョンのものでもありません。
export function stayWindowForLocalDay(
localDate: string,
facilityTimeZone: string,
checkInLocalTime: string,
checkOutLocalTime: string,
): { startsAt: Date; endsAt: Date } {
const startsAt = zonedTimeToUtc(`${localDate}T${checkInLocalTime}`, facilityTimeZone);
const endsAt = zonedTimeToUtc(`${localDate}T${checkOutLocalTime}`, facilityTimeZone);
if (endsAt <= startsAt) throw new RangeError('invalid_stay_window');
return { startsAt, endsAt };
}ここを誤ると、最悪の種類の障害が生まれます。自分のタイムゾーンでは完璧に動くのに、DST 境界の向こう側にある物件では鍵が1時間早く——あるいは遅く——静かに失効するのです。RangeError に注目してください。有効になりえない期間は、負の寿命を持つ鍵を生成するのではなく、構築時に大きな音を立てて失敗すべきです。
関連して、ベンダーが寿命付きのトークンを返す場合は、楽観的なフィールドを信じるのではなく、保存する expires_at を 実際の 寿命にクランプしてください。私たちはかつて、長い寿命を主張しながら約1時間のアイドルで死ぬトークンを扱っていました。クランプによって、謎めいた断続的障害が決定論的なリフレッシュに変わりました。
5. 相関できない識別子: 鍵付き・スコープ付きのフィンガープリント
生の個人識別子を保存せずに「誰がこの鍵を受け取ったか」を表す安定したフィンガープリントが必要でした。素朴な実装は単純なハッシュです。
// Weak: a bare SHA-256 of a low-entropy identifier is trivially reversed
// by dictionary attack, and the same input yields the same digest everywhere,
// which makes recipients correlatable across stays and facilities.
const fingerprint = sha256(email);メールアドレスや電話番号は、小さく列挙可能な空間から来ます。むき出しのダイジェストは 仮名 であって、保護ではありません。解決策は、コンテキストごとのソルトを伴う鍵付き MAC です。これにより出力は鍵なしでは推測不能となり、かつコンテキストをまたいで相関できなくなります。
import { createHmac } from 'node:crypto';
export function recipientFingerprint(params: {
identifier: string;
stayId: string;
pepper: string;
}): string {
const normalized = params.identifier.trim().toLowerCase();
return createHmac('sha256', params.pepper)
.update(`stay:${params.stayId}|id:${normalized}`)
.digest('hex');
}ここで重要な性質は3つあります。
- 鍵付きであること。 pepper(管理されたシークレットとして保存し、リポジトリには決して置かない)がなければ、データベースの読み取り権限を得た攻撃者でも原像を総当たりできません。
- スコープ付きであること。 stay ID を束縛することで、同じ人物でも宿泊ごとに異なるフィンガープリントとなり、漏洩したテーブルから移動履歴を構築できなくなります。
- 正規化されていること。 ハッシュ前に決定論的な正規化を行わなければ、その「安定した」識別子は安定していません。
6. メンテナンスウィンドウなしで webhook 署名鍵をローテーションする
受信 webhook は、認証面であることを忘れられがちな認証面です。2つのハードニング手法が相乗効果を生みます。
組織ごとの署名鍵。 グローバルなシークレットが1つだけだと、それを知ったテナントは他のすべてのテナントのイベントを偽造できます。組織ごとに鍵を導出または保存すれば、グローバルな侵害が封じ込められた侵害になります。
旧鍵に対する有限の受理ウィンドウ。 ローテーションは、送信側と自分側の完全な同時性を要求しない場合にのみ安全です。
import { createHmac, timingSafeEqual } from 'node:crypto';
const PREVIOUS_KEY_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
interface SigningKeys {
current: string;
previous?: { secret: string; rotatedAt: number };
}
function matches(secret: string, payload: string, signature: string): boolean {
const expected = createHmac('sha256', secret).update(payload).digest();
const provided = Buffer.from(signature, 'hex');
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
}
export function verifyInbound(
keys: SigningKeys,
payload: string,
signature: string,
now: number,
): 'current' | 'previous' | null {
if (matches(keys.current, payload, signature)) return 'current';
if (keys.previous && now - keys.previous.rotatedAt < PREVIOUS_KEY_GRACE_MS) {
if (matches(keys.previous.secret, payload, signature)) return 'previous';
}
return null;
}timingSafeEqual と長さの事前チェックに注目してください。=== による署名比較はタイミングを通じて情報を漏らします。また 'previous' という結果は メトリクスとして記録 すべきです。猶予期間が終わりに近づいてもなお旧鍵のトラフィックがゼロでないなら、誰かがローテーションを完了しておらず、それは切り替え後ではなく切り替え前に知りたい情報です。
署名の検証は仕事の半分にすぎません。イベントはなおテナントを名乗っており、その名前は署名に使われた鍵と突き合わせて検証されなければなりません。
const keyOrigin = await resolveOrganizationForSigningKey(usedKeyId);
if (keyOrigin !== event.organizationId) {
await audit.record('webhook.tenant_mismatch', { keyOrigin, claimed: event.organizationId });
return respond(202); // Acknowledge, but perform no side effects.
}このチェックは、副作用を持つ すべての 分岐に必要です。後から追加された分岐も含みます。私たちの経験では、最初の実装はメインパスでは正しく、3週間後に追加された分岐で漏れます——まさにそれが次のセクションの存在理由です。
7. 認証失敗を1つにまとめず分類する
401 は、まったく異なる複数の運用状況を隠している単一のステータスコードです。それらをひとまとめにすると、対応する能力が失われます。
export type AuthFailure =
| { kind: 'credentials_rejected'; retryable: false }
| { kind: 'token_expired'; retryable: true }
| { kind: 'upstream_unavailable'; retryable: true };
export function classify(response: Response, body: unknown): AuthFailure {
if (response.status >= 500) return { kind: 'upstream_unavailable', retryable: true };
if (isExpiredTokenBody(body)) return { kind: 'token_expired', retryable: true };
return { kind: 'credentials_rejected', retryable: false };
}運用上の見返りは具体的です。credentials_rejected はリトライを止めて人を呼び出すことを意味します——ローテーション済みの認証情報をループでリトライすると、自社が使うロックベンダーからレート制限で締め出されます。token_expired は一度リフレッシュして続行することを意味します。upstream_unavailable はジッター付きバックオフを意味します。
同じくらい重要なこと。安定した機械可読コード と理由をログに記録し、上流の生エラーが決してゲストの画面に届かないようにしてください。ドアの前にいるゲストには、その人の言語で実行可能な案内を見せるべきであり、診断用の文字列は監査ログに属します。
8. フェイルクローズし、対策は脅威に見合う範囲にとどめる
逆方向に引っ張り合う、しかし同時に保持しなければならない2つの教訓があります。
アイデンティティはフェイルクローズ。 解決ステップが所有権を 証明 できない場合——たとえば外部プラットフォーム ID をテナントにマッピングする処理——それは拒否しなければならず、デフォルトへフォールバックしてはなりません。所有権リゾルバにおけるフォールバックは、親切な帽子をかぶった認可バイパスです。
export async function resolveTenantForExternalId(externalId: string): Promise<string> {
const mapping = await db.externalMappings.find(externalId);
if (!mapping) throw new ForbiddenError('ownership_unproven'); // never: return DEFAULT_TENANT
return mapping.organizationId;
}しかし鈍器の適用範囲は絞ること。 私たちはオンプレミスの QR 鍵フロー向けに IP ベースの制限をリリースしましたが、建物全体が1つの NAT 出口アドレスを共有していることが判明しました。悪意ある1人がいれば、その物件のすべてのゲストをブロックしてしまうところでした。修正は、IP シグナルを 強制 から 検知 へ格下げすることでした。現在これは異常検知ログに供給され、実際のゲーティングは暗号学的チェックが行っています。
一般化できる原則はこうです。防ごうとする攻撃よりも影響範囲が大きい対策は、自ら招いたサービス拒否である。 認証は署名と所有権の証明で行い、粗いネットワークシグナルは可観測性に使いましょう。同様に、ボット判定ウィジェットは公開サインアップ面に置くべきものであって、署名済みの使い捨て認証情報を持ってドアの前に立つゲストの前に置くべきものではありません。そこでのチャレンジ失敗は、誰かが廊下で寝ることを意味します。
9. 各修正を実行可能なガードで恒久化する
上記の項目はすべて、かつてはバグでした。バグを直すのは安上がりです。その 再発 を防ぐことこそが本当のエンジニアリング作業です。なぜなら、それを再導入する人はあなたのポストモーテムを読んでいないからです。
私たちのパターンは、ソースとスキーマを grep して構造的な不変条件を表明する、高速で常時有効な CI ジョブです。
#!/usr/bin/env bash
set -euo pipefail
# Every side-effecting webhook branch must assert the tenant boundary.
branches=$(grep -rlE 'case .WEBHOOK_EVENT_' src/webhooks | sort)
for file in $branches; do
if ! grep -q 'assertTenantMatches(' "$file"; then
echo "::error file=${file}::missing assertTenantMatches in a side-effecting branch"
exit 1
fi
done苦労して得た3つの改良点があります。
ガードは双方向にラチェットすること。 私たちはガードの数を数え、その数が減った場合(ガードが削除された)も、記録された下限を更新せずに増えた場合(ガードが登録なしで追加された)も CI を失敗させます。一方向のラチェットは削除を黙認します。
ソース検査型のガードには既知の盲点がある。 私たちのガードは、括弧付きの呼び出し形式、エイリアスされた import、複数行のフォーマットを見逃しました。grep ベースのガードを破る「形」のカタログを文書として維持し、新たな盲点が見つかるたびに回帰テストとともに追加しています。信頼しているのに発火しないガードは、ガードがないよりも悪いものです。誤った安心感を製造するからです。
ガードが実際に失敗することを検証する。 私たちのセルフレビューのチェックリストには「ローカルで意図的にバグを再導入し、CI が赤くなることを確認する」が含まれています。検証されていないガードはコメントにすぎません。
これらのチェックはすべてのパイプラインで走るため、数十の1秒程度のジョブを1つの常時有効ジョブにまとめました。安価なガードは残り、遅いガードはインシデント時に無効化されて二度と有効化されません。
10. レンダリングではなく境界をピン留めするテスト
ここでは2つのテスト習慣が不釣り合いなほど重要です。
未認証ロールに公開される射影をピン留めする。 匿名の読み取りパスが返すカラムを 厳密に 表明するテストは、誰かが SELECT * を追加した日に失敗します。まさにその日こそ、知らせてほしい日です。
it('exposes only the guest-safe columns to anonymous callers', async () => {
const row = await getCheckInData(anonClient, token);
expect(Object.keys(row).sort()).toEqual([
'checkInAt', 'facilityName', 'reservationCode', 'roomLabel', 'status',
]);
});ロケール整形された文字列に対して表明しない。 "9/21 15:00" のような描画テキストを比較する時間ウィンドウのテストは、ロケールや CI ランナー構成をまたぐと壊れます。さらに悪いことに、チームは何もテストしなくなるまで表明を緩めることで「修正」してしまいます。代わりに、基礎となる時点に対して表明しましょう。
// Fragile: depends on runner locale and formatter version.
expect(view.validUntilLabel).toBe('9/21 15:00');
// Durable: asserts the invariant that actually matters.
expect(view.validUntil.toISOString()).toBe('2026-09-21T06:00:00.000Z');
expect(view.validUntil.getTime()).toBeGreaterThan(view.validFrom.getTime());同様に、時計を注入することで時間依存のテストを実時計から切り離しましょう。深夜までは green であるテストは、テストではありません。
11. シークレットを在庫として扱う
分類されていないシークレットは積み上がります。私たちの場合、「この値はどの環境向けで、誰がローテーションし、漏れたら何が壊れるのか」に誰も答えられない状態に達しました。現在はすべてのシークレットに分類メタデータを持たせることを必須とし、各環境に存在する キーの集合(値は決して比較しません)を比較して乖離を報告する 環境横断ドリフトチェック を実行しています。
ドリフトは、実際に起きた2種類のインシデントの先行指標です。必要なキーが欠けた本番デプロイ(障害)と、本番の認証情報を保持したステージング環境(侵害)です。どちらも検知は安価で、別の方法で発覚するとコストが高くつきます。
まとめ
元を取ったパターンは次の通りです。
- 受信するすべての識別子は信頼できない。 呼び出し元の認証だけでなく、参照されたリソースの所有権を検証すること——とりわけプロキシ経路で。
- 施設単位でスコープし、データベースでも強制する。 不要になったクライアント書き込み grant を revoke し、書き込み面を CI でピン留めする。
- 自分の時計を持つ。 強制は UTC のデータベース時計で行い、日付境界は施設のタイムゾーンで計算し、外部から与えられた有効期限はクランプする。
- 鍵付き・コンテキストスコープ付きのフィンガープリント。 低エントロピー識別子のむき出しのハッシュは仮名であって保護ではない。
- テナントごとの署名鍵と有限の旧鍵猶予ウィンドウ、定数時間比較、そして副作用を持つすべての分岐でのテナント突き合わせ。
- 認証失敗を分類する。 リトライ可能かどうかで分け、安定したコードをログに残し、ゲストには実行可能なメッセージを見せる。
- アイデンティティはフェイルクローズ、ただし鈍い対策は範囲を絞る。 防御が攻撃を上回らないように。
- すべての修正を、双方向にラチェットされ検証された CI ガードへ変換し、ガードが見落とす「形」のカタログを維持する。
これらはいずれも風変わりな暗号技術ではありません。呼び出し元は識別子について嘘をつき、デバイスは時刻について嘘をつき、将来のメンテナはあなたが防ごうとしているインシデントを聞いたこともない——そう想定する規律にほかなりません。