Lock SDKにおけるテナント分離と安全な状態遷移
はじめに
スマートロックプラットフォームは、ドアが付属しただけのCRUDアプリではありません。予約、スタッフアカウント、チェックインといったあらゆるレコードは、最終的に物理的なイベントへと解決されます。つまり、ドアが開くか、開かないかです。そのため、次の2種類のバグは許容できません。
- 認可のバグ — あるテナントが別のテナントのデータを読み取ったり変更したりできてしまうもの。
- 状態のバグ — 予約が物理世界では実現できないステータスに陥るもの(「チェックアウト済み」なのに認証情報を発行し続ける予約、支払われていないのに「支払い済み」になっている予約など)。
本記事では、この両方のクラスのバグを排除するためにUnlockOS SDKで適用しているパターンを解説します。サーバー側で厳密に導出するテナンシー、ガード付き遷移を備えた明示的な予約ステートマシン、サーバーサイドでの決済照合、冪等なチェックイン処理、そして競合に強い外部カレンダー同期です。すべての例は一般化されており、あらゆるマルチテナントかつ物理アクセス制御を伴うシステムに転用できるパターンになっています。
1. クライアントから渡されるテナント識別子を決して信用しない(IDOR)
マルチテナントにおける最も一般的な脆弱性は、同時に最も退屈なものでもあります。エンドポイントがリクエストボディから organizationId を受け取り、それをクエリのスコープに使ってしまうというものです。テナントAの認証済みユーザーであれば誰でもテナントBのリソースを列挙・変更できてしまう、教科書どおりの IDOR(Insecure Direct Object Reference) です。
脆弱なコードの形は、たいてい無害そうに見えます。
// ❌ ANTI-PATTERN: tenant scope comes from the request payload
export async function updateFrontdeskStaff(req: Request) {
const { organizationId, staffId, role } = req.body;
return db.frontdeskStaff.update({
where: { id: staffId, organizationId },
data: { role },
});
}有効なセッションがあったとしても、スコープを制御しているのは呼び出し側です。修正は、例外なくあらゆる箇所で強制されなければならない1つのルールです。
テナントスコープはサーバー側で認証済みプリンシパルから導出する。リクエストから読み取ってはならない。
import { z } from 'zod';
// Schema intentionally omits organizationId; `.strict()` rejects it outright.
const UpdateStaffInput = z
.object({
staffId: z.string().uuid(),
role: z.enum(['frontdesk', 'manager', 'viewer']),
})
.strict();
export async function updateFrontdeskStaff(req: AuthenticatedRequest) {
const input = UpdateStaffInput.parse(req.body);
const organizationId = req.principal.organizationId; // server-derived, signed session
const updated = await db.frontdeskStaff.updateMany({
where: { id: input.staffId, organizationId },
data: { role: input.role },
});
if (updated.count === 0) {
// Do not distinguish "not found" from "other tenant": avoid an existence oracle.
throw new NotFoundError('STAFF_NOT_FOUND');
}
return updated;
}見た目以上に重要な点が2つあります。
- 暗黙的な除去ではなく
.strict()を使う。 クライアントがorganizationIdを送ってきた場合、リクエストは明示的に失敗します。これにより、攻撃になり得たものが400エラーとログ1行に変わり、安全でない形に依存していた内部の呼び出し元も浮かび上がります。 - テナント横断アクセスには一律で404を返す。 別テナントに存在するレコードに対して
403 Forbiddenを返すと、その存在が漏れてしまいます。存在しないIDに対して返すのと同じエラーを返しましょう。
多層防御:境界をデータベースでも強制する
アプリケーションコードは締め切りに追われた人間が書くものです。不変条件を1レイヤー下に押し下げ、WHERE 句の書き忘れが侵害につながらないようにしましょう。
ALTER TABLE frontdesk_staff ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON frontdesk_staff
USING (organization_id = current_setting('app.organization_id')::uuid)
WITH CHECK (organization_id = current_setting('app.organization_id')::uuid);リクエストのミドルウェアが、セッションから app.organization_id をトランザクションごとに1回設定します。忘れられがちなのが WITH CHECK の部分です。これがないと、RLSはテナント横断の読み取りは防ぎますが、行を別のテナントへ移動させる INSERT や UPDATE は許してしまいます。
コードレビューの習慣ではなく、リグレッションテストにする
describe('cross-tenant access', () => {
it('rejects an organization_id supplied by the client', async () => {
const res = await client
.as(userInTenantA)
.patch('/frontdesk/staff')
.send({ staffId: staffInTenantB.id, role: 'manager', organizationId: tenantB.id });
expect(res.status).toBe(400);
});
it('returns 404 (not 403) for a resource owned by another tenant', async () => {
const res = await client
.as(userInTenantA)
.patch('/frontdesk/staff')
.send({ staffId: staffInTenantB.id, role: 'manager' });
expect(res.status).toBe(404);
expect(await db.frontdeskStaff.findUnique({ where: { id: staffInTenantB.id } }))
.toMatchObject({ role: 'frontdesk' });
});
});有用なプラクティスとして、変更系エンドポイントごとにこの種のテストを1つ書き、その一覧をルートテーブルから生成しておくと、分離テストのない新しいルートがCIで失敗するようになります。
2. 予約ライフサイクルを明示的なステートマシンとして表現する
予約ステータスは、認証情報を発行するか、ドアコードが有効か、そして決済をまだキャプチャできるかを決定する唯一のフィールドです。これを自由形式の文字列として表現し、十数か所の呼び出し箇所から更新するようなやり方は、終了済みの滞在に対してアクセス権を発行してしまうシステムへの近道です。
明示的にモデル化しましょう。
export type ReservationStatus =
| 'pending'
| 'confirmed'
| 'checked_in'
| 'checked_out'
| 'canceled';
type Actor = { role: 'guest' | 'staff' | 'platform_admin'; id: string };
type Transition = {
to: ReservationStatus;
allow: (actor: Actor) => boolean;
requiresReason?: boolean;
};
const TRANSITIONS: Record<ReservationStatus, Transition[]> = {
pending: [
{ to: 'confirmed', allow: () => true },
{ to: 'canceled', allow: () => true },
],
confirmed: [
{ to: 'checked_in', allow: () => true },
{ to: 'canceled', allow: (a) => a.role !== 'guest' || withinCancellationWindow() },
],
checked_in: [{ to: 'checked_out', allow: () => true }],
checked_out: [], // terminal
canceled: [
// Reopening a canceled booking is privileged and always audited.
{ to: 'confirmed', allow: (a) => a.role === 'platform_admin', requiresReason: true },
],
};
export function assertTransition(
from: ReservationStatus,
to: ReservationStatus,
actor: Actor,
reason?: string,
): void {
const t = TRANSITIONS[from].find((x) => x.to === to);
if (!t) throw new InvalidTransitionError(`${from} -> ${to} is not allowed`);
if (!t.allow(actor)) throw new ForbiddenError('TRANSITION_NOT_PERMITTED');
if (t.requiresReason && !reason?.trim()) throw new ValidationError('REASON_REQUIRED');
}この設計からは、3つの性質が自動的に得られます。
終端状態は本当に終端である。 checked_out は遷移リストが空なので、「完了済み予約の編集」はドメイン層で不可能になります。単にUIで隠しているのとは違います。UIは同じテーブルから操作可能性を導出し、ルールを再実装しません。
export const canEdit = (s: ReservationStatus) => TRANSITIONS[s].length > 0;
export const canCheckIn = (s: ReservationStatus) =>
TRANSITIONS[s].some((t) => t.to === 'checked_in');ボタンの表示可否とサーバーの認可チェックが1つのテーブルを参照している限り、両者が食い違うことはありません。
特権的なリカバリは明示的で、監査される。 実運用にはエスケープハッチが必要です。誤ってキャンセルされた予約は復旧できなければなりません。誤った答えは、バックオフィスでの直接 UPDATE です。正しい答えは、ステートマシン上に存在し、特定のロールに限定され、理由を必須とし、変更不可能な監査レコードを出力する遷移です。
export async function reopenCanceledReservation(
reservationId: string,
actor: Actor,
reason: string,
) {
return db.$transaction(async (tx) => {
const r = await tx.reservation.findUniqueOrThrow({ where: { id: reservationId } });
assertTransition(r.status, 'confirmed', actor, reason);
const next = await tx.reservation.update({
where: { id: reservationId, status: r.status }, // optimistic guard on observed state
data: { status: 'confirmed' },
});
await tx.auditLog.create({
data: {
entity: 'reservation',
entityId: reservationId,
action: 'status.reopen',
actorId: actor.id,
actorRole: actor.role,
from: r.status,
to: 'confirmed',
reason,
occurredAt: new Date(),
},
});
return next;
});
}where: { id, status: r.status } に注目してください。直前に観測したステータスを WHERE 句に含めることで、read-modify-writeがcompare-and-swapに変わります。並行リクエストがすでに予約を進めていた場合、更新対象は0行となり、黙って上書きする代わりにトランザクションが失敗します。
監査エントリは状態変更と同じトランザクションで書き込む。 記述対象の事実とは別々にコミットできてしまう監査ログは、監査ログではありません。両方が確定するか、どちらも確定しないかのいずれかであるべきです。
3. 決済状態はサーバーサイドで確認しなければならない
リダイレクト方式の決済プロバイダは、ブラウザのURL経由で制御をアプリに返します。そのURLは攻撃者が操作可能であり、リプレイされる可能性があり、さらにもっと平凡な理由——ゲストがトンネルの中でタブを閉じた——によって、そもそも到達しないこともあります。リダイレクトを支払いの証拠として扱うことは、セキュリティホールであると同時に信頼性のホールでもあります。
正しいモデルは、リダイレクトを照合のヒントと捉え、決して証拠とはみなさないことです。
type PaymentState = 'unpaid' | 'pending' | 'paid' | 'failed' | 'refunded';
export async function reconcilePayment(reservationId: string): Promise<PaymentState> {
const reservation = await db.reservation.findUniqueOrThrow({
where: { id: reservationId },
select: { paymentRef: true, paymentState: true, organizationId: true },
});
if (reservation.paymentState === 'paid') return 'paid'; // idempotent fast path
// Source of truth is the provider, queried server-to-server with our own credentials.
const remote = await paymentProvider.getPayment(reservation.paymentRef);
const next = mapProviderStatus(remote.status);
const updated = await db.reservation.updateMany({
where: { id: reservationId, paymentState: reservation.paymentState },
data: { paymentState: next, paymentSyncedAt: new Date() },
});
if (updated.count === 0) return reconcilePayment(reservationId); // lost race: re-read
return next;
}そしてリダイレクトハンドラは、一切の権限を持ちません。
// The client says "I came back from the payment page". We verify everything ourselves.
export async function handlePaymentReturn(req: AuthenticatedRequest) {
const { reservationId } = z.object({ reservationId: z.string().uuid() }).strict().parse(req.query);
await assertOwnedByPrincipal(reservationId, req.principal); // tenancy + ownership check
const state = await reconcilePayment(reservationId);
return { state, entryInfoVisible: state === 'paid' };
}初日から組み込む価値のある補完的な安全策は次のとおりです。
- webhookとポーリングの併用(どちらか一方ではなく)。 webhookは高速ですが取りこぼしがあります。N分以上
paymentState = 'pending'のままのレコードを定期的に照合するスイープが、webhookが落としたものをすべて拾います。 - 照合が確定するまで入室情報を決して開示しない。 アクセス認証情報は サーバーで確認された 状態に基づいてゲートされるべきで、
?status=successというクエリパラメータに基づいてはいけません。 - ゲートのバイパスは明示的かつロール限定にする。 運用担当者が未払い残高のあるままチェックインを完了させなければならない場面はあります。それは名前付きで監査される権限(
payment.gate.bypass)としてモデル化すべきであり、内部画面からたまたま到達できる条件分岐にしてはいけません。
4. 冪等かつ非再入的なチェックイン操作
チェックインとチェックアウトは、ソフトウェアがハードウェアになる瞬間です。ボタンのダブルタップが、2つの認証情報、2つの監査証跡、2回の課金を生んではなりません。
クライアント側では、リクエストの実行中は操作全体を無効化し、進行中の状態を正直に描画します。
function useGuardedAction<T>(fn: () => Promise<T>) {
const [pending, setPending] = useState(false);
const inFlight = useRef(false);
const run = useCallback(async () => {
if (inFlight.current) return; // guards double-submit before React re-renders
inFlight.current = true;
setPending(true);
try {
return await fn();
} finally {
inFlight.current = false;
setPending(false);
}
}, [fn]);
return { run, pending };
}useRef によるラッチが重要です。setPending(true) は非同期なので、同じtick内での2回のクリックはどちらもstateベースのチェックを通過してしまいます。refは同期的に切り替わります。
ただし、クライアント側のガードはUXの配慮であって、正しさを保証する仕組みではありません。不安定なネットワークからのリトライリクエストは、それらを完全に迂回します。サーバー側が冪等でなければなりません。
export async function checkIn(reservationId: string, actor: Actor, idempotencyKey: string) {
const existing = await db.idempotencyRecord.findUnique({ where: { key: idempotencyKey } });
if (existing) return existing.response as CheckInResult;
return db.$transaction(async (tx) => {
const r = await tx.reservation.findUniqueOrThrow({ where: { id: reservationId } });
assertTransition(r.status, 'checked_in', actor);
const result = await issueCredentialAndAdvance(tx, r, actor);
await tx.idempotencyRecord.create({ data: { key: idempotencyKey, response: result } });
return result;
});
}最後に、遷移が成功した後はローカルstateを楽観的にパッチするのではなく、サーバーから状態を再取得しましょう。権威あるレスポンスからステータスバッジを導出し(関連するquery keyを無効化し)ておけば、バックエンドが拒否した予約について画面が checked_in を表示することは決してありません。
5. 外部カレンダーとの競合に強い同期
予約をiCalやGoogle Calendarからミラーリングする場合、素朴な同期はポーリングのたびにすべてのフィールドを上書きします。ゲストがチェックインするまではそれで問題ありませんが、次の同期でステータスが confirmed にリセットされ、消費されたはずの認証情報が再び有効化されてしまいます。
解決策は、フィールドの所有権を明示的に宣言することです。
// Statuses that can only be produced by our own domain events are never
// clobbered by an upstream calendar, which has no concept of check-in.
const LOCALLY_OWNED_STATUSES = new Set<ReservationStatus>(['checked_in', 'checked_out', 'canceled']);
export function mergeFromExternalCalendar(
local: Reservation,
remote: ExternalEvent,
): Partial<Reservation> {
const patch: Partial<Reservation> = {
startAt: remote.startAt,
endAt: remote.endAt,
guestName: remote.summary,
externalUpdatedAt: remote.updatedAt,
};
if (!LOCALLY_OWNED_STATUSES.has(local.status)) {
patch.status = mapExternalStatus(remote.status);
}
return patch;
}一般化されたルール:同期対象エンティティのすべてのフィールドについて、system of record(正の情報源)を明示する。 日時やタイトルはカレンダー由来、ライフサイクルのステータス、決済状態、発行済み認証情報は自分たち由来です。曖昧なものはすべて、午前3時のバグになります。
6. 時刻の正しさは信頼性の機能である
アクセスウィンドウには時間の境界があるため、タイムゾーンの扱いはセキュリティに隣接する関心事になります。1日ずれた境界は、開くべきでないときに開く解錠を意味します。
2つのルールで、ほとんどの障害モードをカバーできます。
UTCで保存し、施設のタイムゾーンで表示する。ブラウザのタイムゾーンでは決してない。 ある地域のフロントデスク担当者が別の地域の物件を管理する場合、物件のローカル時刻を見なければ、誤ったウィンドウを付与してしまいます。
import { formatInTimeZone } from 'date-fns-tz';
export function renderStayWindow(r: Reservation, facility: { timeZone: string }) {
return {
checkIn: formatInTimeZone(r.startAt, facility.timeZone, 'yyyy-MM-dd HH:mm'),
checkOut: formatInTimeZone(r.endAt, facility.timeZone, 'yyyy-MM-dd HH:mm'),
};
}同じことがカレンダーのグリッドにも当てはまります。「今日」は対象タイムゾーンのローカル日付から算出しましょう。そうしないと、00:30のユーザーは前の週に着地してしまいます。
導出される期間は、算術結果を信じずにクランプする。 実データには、ウィンドウの開始が実際のチェックアウトより後になる予約(早期退出、手動修正など)が含まれます。無条件に減算すると負の利用時間が生まれ、それがそのまま請求へ流れ込みます。
export function usageMinutes(params: {
reservedStart: Date;
actualCheckIn: Date | null;
actualCheckOut: Date;
}): number {
const start = params.actualCheckIn ?? params.reservedStart;
const effectiveStart = start > params.actualCheckOut ? params.actualCheckOut : start;
const minutes = Math.round(
(params.actualCheckOut.getTime() - effectiveStart.getTime()) / 60_000,
);
return Math.max(0, minutes);
}物理的にあり得ない値を生み得る計算式は、クランプしかつログに残すべきです。クランプは顧客を守り、ログは上流のデータが誤っていることを教えてくれます。
7. パイプラインの信頼性:すべてのジョブに上限を設ける
タイムアウトのないCIジョブは、無制限のリソースコミットメントです。ハングした結合テストや固まったランナーは、リリースパイプラインのロックを何時間も握り続けます。それは実質的にセキュリティ修正を出荷できないことを意味します。デプロイ経路の可用性は、セキュリティ体制の一部です。
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- run: pnpm install --frozen-lockfile
- run: pnpm test --runInBand
timeout-minutes: 15ジョブのタイムアウトは、観測されたp95所要時間のおおよそ2倍に設定します。厳しすぎるとflakyな失敗を生み、設定しなければ無期限の停滞を生みます。同じ理屈がランタイムにも当てはまります。ロックゲートウェイや決済プロバイダへのすべての外向きHTTP呼び出しには、明示的なタイムアウトと上限のあるリトライポリシーが必要です。デッドラインのないリクエストとは、永遠にハングし得るリクエストだからです。
8. 画面ではなく不変条件をテストする
UIテストはすぐに劣化します。不変条件のテストは劣化しません。このようなシステムで最も価値の高いテストスイートは次のとおりです。
describe('reservation state machine', () => {
const ALL: ReservationStatus[] = ['pending', 'confirmed', 'checked_in', 'checked_out', 'canceled'];
it('treats checked_out as terminal for every actor', () => {
for (const to of ALL) {
for (const role of ['guest', 'staff', 'platform_admin'] as const) {
expect(() => assertTransition('checked_out', to, { role, id: 'x' }))
.toThrow(InvalidTransitionError);
}
}
});
it('permits reopening a cancellation only for platform admins, with a reason', () => {
expect(() => assertTransition('canceled', 'confirmed', { role: 'staff', id: 's' }, 'typo'))
.toThrow(ForbiddenError);
expect(() => assertTransition('canceled', 'confirmed', { role: 'platform_admin', id: 'a' }))
.toThrow(ValidationError);
expect(() => assertTransition('canceled', 'confirmed', { role: 'platform_admin', id: 'a' }, 'typo'))
.not.toThrow();
});
});そして連携部分については、ルーティングと相関に関わるフィールドも含めて契約を検証します。
it('propagates sourceApp so notification links resolve to the originating app', async () => {
const spy = vi.spyOn(http, 'post');
await submitCheckIn({ reservationId, sourceApp: 'booking' });
expect(spy).toHaveBeenCalledWith(
'/reservation-checkin',
expect.objectContaining({ sourceApp: 'booking' }),
);
});これらのテストは安価で決定的であり、誰かが不変条件を弱めたときにちょうど失敗します。リグレッションスイートにおいて重要なのは、その性質だけです。
まとめ
物理アクセスシステムにおける信頼は、小さく退屈な保証の積み重ねです。
- テナンシーはサーバー側で導出し、スキーマで強制し、データベースでも強制する。 クライアントから渡された
organizationIdはサニタイズではなく拒否し、テナント横断のミスは存在オラクルを避けるため404を返し、WITH CHECK付きのRLSがアプリケーション層を backstop します。 - ライフサイクルは明示的な遷移テーブルである。 終端状態は本当に終端であり、特権的なリカバリ経路は名前付きで監査され、UIはサーバーが強制するのと同じテーブルから操作可能性を導出します。
- 決済やその他の外部状態はサーバー間で照合する。 リダイレクトのパラメータはヒント、プロバイダのAPIとwebhookが証拠であり、認証情報は確認済みの状態に基づいてゲートされます。
- 変更操作は冪等かつ非再入的にする。 compare-and-swapによる更新、冪等キー、同期的なクライアント側ラッチにより、二重送信は無害になります。
- 同期はフィールドの所有権を宣言する。 外部ソースはスケジュールデータを所有し、ドメインはライフサイクルと決済状態を所有します。
- 時刻はUTCで保存し、施設のローカル時刻で表示し、導出値はクランプする。
- すべてのジョブ、すべての外向き呼び出しにデッドラインを設ける。
これらはどれ一つとして単体では賢いものではありません。しかし合わせると、挙動を推論できるシステムと、正しいことをただ祈るだけのシステムとの違いになります。