スマートロックのチェックインフローにおけるFail-Closedなアクセス制御
はじめに
ドアが開くかどうかをSDKが判断するとき、あらゆるデフォルト値、あらゆるフォールバック、あらゆる欠落したバリデーションがセキュリティ上の意思決定となります。UnlockOS SDK の直近の開発サイクルでは、ゲストのチェックインフローを端から端まで見直しました。誰がチェックインをトリガーできるのか、ドアキーはいつ有効になるのか、予約がキャンセルされたときそのキーはどうなるのか、そしてデータベース自身がどのように行の漏洩を拒むのか、という範囲です。
本記事では、その作業から一般化できるエンジニアリングパターンを抽出します。どれも奇をてらったものではありません。しかしそのすべてが、ロックされている「ように見える」システムと、実際にロックされているシステムとの差を生みます。
1. 空の設定は「許可」ではなく「拒否」を意味する
あらゆるアクセス制御層において最も危険なコードのひとつが、未設定のリストを「すべて」として扱う便利なショートカットです。
ウォークイン料金の設定には、どの会員ティアが無料で入館できるかを列挙するスコープフィールドがありました。当初の実装は、空のスコープを「制限なし」と解釈していました。つまり、単にそのフィールドを設定していなかった施設では、全員に無料入館を黙って許可してしまっていたのです。
type WalkInScope = { freeTierIds: string[] } | null;
// Dangerous: absence of configuration becomes universal permission.
function isFreeLegacy(scope: WalkInScope, tierId: string | null): boolean {
if (!scope || scope.freeTierIds.length === 0) return true;
return tierId !== null && scope.freeTierIds.includes(tierId);
}
// Fail-closed: absence of permission is not permission.
function isFree(scope: WalkInScope, tierId: string | null): boolean {
if (!scope) return false;
if (scope.freeTierIds.length === 0) return false;
if (!tierId) return false;
return scope.freeTierIds.includes(tierId);
}現在、コードベース全体に適用しているルールは次のとおりです。
パーミッション判定関数が
trueを返してよいのは、明示的にtrueだと述べているデータが存在する場合だけです。欠落・空・不正な設定は常にfalseに解決されます。
これはテストで強制するのが容易であり、テストテーブルそのものがドキュメントになります。
describe('isFree', () => {
it.each([
[null, 'tier_a', false],
[{ freeTierIds: [] }, 'tier_a', false],
[{ freeTierIds: ['tier_a'] }, null, false],
[{ freeTierIds: ['tier_a'] }, 'tier_b', false],
[{ freeTierIds: ['tier_a'] }, 'tier_a', true],
])('scope=%j tier=%s -> %s', (scope, tier, expected) => {
expect(isFree(scope as WalkInScope, tier)).toBe(expected);
});
});2. ボタンを隠すだけのゲートはゲートではない
今回のサイクルで行った2つの別々の修正は、ひとつの根本原因を共有していました。制限がUIには存在するがサーバーには存在しなかったのです。削除済みの予約プランが作成エンドポイントでは依然として受け付けられていました。会員ティアによるゲートは、強制されるルールではなくUI上のアフォーダンスとして記述されていました。
対策のパターンは、すべてのゲートを明示的に分類し、enforced とラベル付けされたものにはサーバーサイドのアサーションと、エンドポイントを直接呼び出すテストを必ず持たせることです。
type GateLevel = 'cosmetic' | 'advisory' | 'enforced';
interface FeatureGate {
id: string;
level: GateLevel;
/** Required when level === 'enforced'. Runs on the server. */
assert?: (ctx: RequestContext) => void;
}そしてエンドポイントは、クライアントが主張したことをすべて再検証します。
export async function createReservation(
input: CreateReservationInput,
ctx: RequestContext,
): Promise<Reservation> {
const plan = await plans.findById(input.planId);
if (!plan) {
throw new DomainError('PLAN_NOT_FOUND', 'Unknown plan.');
}
if (plan.facilityId !== ctx.facilityId) {
// Cross-tenant guard: never trust an id supplied by the caller.
throw new DomainError('FORBIDDEN', 'Plan does not belong to this facility.');
}
if (plan.deletedAt !== null) {
throw new DomainError('PLAN_NOT_AVAILABLE', 'This plan is no longer bookable.');
}
if (plan.checkInConfigId && plan.checkInConfigId !== input.checkInConfigId) {
// Configuration must be derived, not accepted from the request body.
input = { ...input, checkInConfigId: plan.checkInConfigId };
}
return reservations.insert(input, ctx);
}最後の分岐は強調に値します。関連するバグでは、設定IDがリクエストから渡されていたために、管理者が作成した予約が別のプランのチェックイン設定に紐づいてしまっていました。どのドアが開くかを決定する識別子は、クライアントからエコーバックするのではなく、権威あるレコードからサーバーサイドで導出すべきです。
3. コミットの時点で認証する
チェックインのホストアプリケーションは、宿泊者名簿の送信そのものがチェックインになるように再構成されました。以前は認証が早い段階で行われ、失敗後により信頼度の低い入館画面へフォールバックし得る設計でした。つまり、拒否された予約チェックインが匿名のウォークインに成り下がる可能性があったのです。
修正は、認証済みセッションを後から参照するフラグではなく状態の型の一部とする、明示的な有限状態機械としてフローをモデル化することでした。
interface FacilitySession {
readonly sessionId: string;
readonly subjectId: string;
readonly facilityId: string;
readonly issuedAt: string;
}
type CheckInState =
| { step: 'entrance' }
| { step: 'search' }
| { step: 'confirm'; reservationId: string }
| { step: 'authenticate'; reservationId: string }
| { step: 'register'; reservationId: string; session: FacilitySession }
| { step: 'failed'; reservationId: string; reason: RefusalReason }
| { step: 'completed'; reservationId: string; checkInId: string };
type CheckInEvent =
| { type: 'SEARCH' }
| { type: 'SELECT'; reservationId: string }
| { type: 'CONFIRM' }
| { type: 'AUTHENTICATED'; session: FacilitySession }
| { type: 'SUBMIT_REGISTER'; register: GuestRegister }
| { type: 'REFUSED'; reason: RefusalReason };register 状態だけが session を持つため、特権的な副作用を実行する関数にはセッションなしでは到達できません。順序を強制するのはコードレビューではなくコンパイラです。
export function reduce(state: CheckInState, event: CheckInEvent): CheckInState {
switch (state.step) {
case 'confirm':
if (event.type === 'CONFIRM') {
return { step: 'authenticate', reservationId: state.reservationId };
}
break;
case 'authenticate':
if (event.type === 'AUTHENTICATED') {
return {
step: 'register',
reservationId: state.reservationId,
session: event.session,
};
}
if (event.type === 'REFUSED') {
// Stay on the reservation. Never drop to 'entrance'.
return {
step: 'failed',
reservationId: state.reservationId,
reason: event.reason,
};
}
break;
default:
break;
}
throw new InvalidTransitionError(state.step, event.type);
}この設計からは3つの性質が導かれます。
- 暗黙のダウングレードがない。 拒否は
failedに着地し、理由がオペレーターに表示されます。別の、より弱いポリシーが適用されるウォークイン入口へ黙って戻ることはありません。 - 未認証での完了は表現不可能。
completeCheckInはExtract<CheckInState, { step: 'register' }>のみを受け取ります。 - 未知の遷移は例外を投げる。 予期しないイベントを無視する状態機械は未定義動作を蓄積しますが、例外を投げる状態機械は結合テストの段階でバグを浮かび上がらせます。
export async function completeCheckIn(
state: Extract<CheckInState, { step: 'register' }>,
register: GuestRegister,
deps: CheckInDeps,
): Promise<CheckInState> {
// state.session is statically guaranteed to exist.
await deps.repo.saveGuestRegister(state.reservationId, register, state.session);
const result = await deps.api.checkIn({
reservationId: state.reservationId,
sessionId: state.session.sessionId,
});
return { step: 'completed', reservationId: state.reservationId, checkInId: result.id };
}順序に注目してください。名簿はチェックイン完了の前に永続化されます。鍵の発行は成功したのにコンプライアンス記録が失われた場合、監査証跡なしに開いたドアが残ることになります。規制下の施設にとってこれは最悪の障害です。
4. クレデンシャルは付与ではなくリース
いくつかの修正はひとつの考えに収束しました。ドアキーとは、主体・理由・時間枠にスコープされたリースであり、その理由が消滅したときには失効されなければならない、ということです。
interface DoorKeyLease {
readonly keyId: string;
readonly lockIds: string[];
readonly subjectId: string; // who may use it
readonly reservationId: string; // why it exists
readonly validFrom: string; // ISO 8601, resolved in facility timezone
readonly validUntil: string;
readonly revokedAt: string | null;
}
export function isUsable(lease: DoorKeyLease, now: Date): boolean {
if (lease.revokedAt !== null) return false;
const t = now.getTime();
return t >= Date.parse(lease.validFrom) && t < Date.parse(lease.validUntil);
}失効は、夜間バッチやUIではなく、ステータス遷移そのものによって駆動されなければなりません。
const TERMINATING_STATUSES = new Set([
'cancelled',
'refused',
'no_show',
'checked_out',
]);
export async function applyStatusUpdate(
reservation: Reservation,
next: ReservationStatus,
deps: StatusDeps,
): Promise<Reservation> {
if (!isAllowedTransition(reservation.status, next)) {
throw new DomainError('INVALID_TRANSITION', `${reservation.status} -> ${next}`);
}
const updated = await deps.repo.updateStatus(reservation.id, next);
if (TERMINATING_STATUSES.has(next)) {
// Idempotent: revoking an already-revoked lease is a no-op.
await deps.keys.revokeByReservation(reservation.id, { reason: `status:${next}` });
}
await deps.audit.record({
kind: 'reservation.status_changed',
reservationId: reservation.id,
from: reservation.status,
to: next,
actorId: deps.actorId,
at: new Date().toISOString(),
});
return updated;
}間違えやすい点が2つあります。
- 失効は冪等でなければならない。 ステータス更新はwebhook、管理操作、PMS同期から到着します。二重の失効はエラーではなく成功として扱い、延々とリトライしないようにします。
- 表示は強制内容と一致しなければならない。 関連する修正では、予約枠が実際に始まるまでクライアント上にキーを表示し続けていました。リースの
validFromが2時間先なのにUIが「キーの準備ができました」と伝えるなら、それはサポートチケットを自ら作り出し、ユーザーにロックを信用しないよう教えているのと同じです。
5. 時刻は表示上の問題ではなくバリデーションの次元
チェックイン検索は、チェックアウト時刻をすでに過ぎた予約を提示していました。期限切れのレコードは単なる古いUIではありません。それを選択することは、許可された時間枠の外でドアを開けようとする行為です。
クエリ層でフィルタし、不正な選択肢が人間の目に届かないようにします。
SELECT r.id, r.guest_name, r.check_in_at, r.check_out_at
FROM reservations r
WHERE r.facility_id = $1
AND r.status = 'confirmed'
AND r.check_in_at - make_interval(hours => $2) <= now()
AND r.check_out_at > now()
ORDER BY r.check_in_at
LIMIT 50;そしてタイムゾーンの解決はサーバー側に置きます。端末の時計とタイムゾーンは攻撃者が操作できる入力です。施設にとっての「今日」は施設の属性であって、ロビーに立っているスマートフォンの属性ではありません。
6. スカラーだけでなくコレクションを検証する
宿泊者名簿の実装では、部屋ごとの大人・子どもの人数と、ゲストレコードのリストを導入しました。スカラー値のバリデーション(adults >= 1)だけでは不十分です。不変条件はフィールド間の関係に宿ります。ある修正では、1部屋のゲスト配列が空であったり余分なエントリを含んでいたりする場合を明示的に拒否しました。
import { z } from 'zod';
const GuestSchema = z.object({
name: z.string().trim().min(1).max(100),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
nationality: z.string().length(2).optional(),
});
export const RoomOccupancySchema = z
.object({
roomId: z.string().uuid(),
adults: z.number().int().min(1).max(20),
children: z.number().int().min(0).max(20),
guests: z.array(GuestSchema).max(40),
})
.superRefine((value, ctx) => {
const declared = value.adults + value.children;
if (value.guests.length !== declared) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['guests'],
message: `Expected ${declared} guest record(s), received ${value.guests.length}.`,
});
}
});
export const StaySchema = z
.object({ rooms: z.array(RoomOccupancySchema).min(1) })
.superRefine((value, ctx) => {
const ids = value.rooms.map((r) => r.roomId);
if (new Set(ids).size !== ids.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['rooms'],
message: 'Duplicate roomId in stay.',
});
}
});
export type StayOccupancy = z.infer<typeof StaySchema>;同じスキーマが予約クライアントとチェックインエンドポイントの両方で動作します。クライアント版は親切なフィールドエラーを生成し、サーバー版が最終的な判断を下します。フォームが描画される場所だけでなく、ロックが開く境界で再検証することこそが、ゲートを本物にします。
レガシーなインポート経路がサービス層を迂回できないよう、データベースにも不変条件を持たせます。
ALTER TABLE reservations
ADD COLUMN declared_adults smallint,
ADD COLUMN declared_children smallint,
ADD CONSTRAINT declared_counts_nonneg
CHECK (
(declared_adults IS NULL AND declared_children IS NULL)
OR (declared_adults >= 1 AND declared_children >= 0)
);NULL許容カラムは意図的な選択であり、ADRに記録しています。既存のPMS予約には申告人数が存在せず、NULL は「ゼロ」ではなく「不明」を意味します。不明をなしと区別して表現することで、後続のゲートが欠落データを「要件を満たしている」と扱うことを防げます。これはセクション1と同じfail-closedの原則を、スキーマ設計に適用したものです。
7. 失ってはならない書き込み:冪等性を伴う有界リトライ
下流の書き込みが一時的に失敗したとき、ゲスト人数が時折失われていました。修正はリトライでしたが、リトライが安全なのは操作が冪等であるか、冪等キーを伴う場合だけです。
interface RetryOptions {
attempts?: number;
baseDelayMs?: number;
isRetryable: (error: unknown) => boolean;
}
export async function withRetry<T>(
fn: (attempt: number) => Promise<T>,
{ attempts = 3, baseDelayMs = 100, isRetryable }: RetryOptions,
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn(attempt);
} catch (error) {
lastError = error;
if (attempt === attempts || !isRetryable(error)) throw error;
const jitter = Math.random() * baseDelayMs;
await sleep(baseDelayMs * 2 ** (attempt - 1) + jitter);
}
}
throw lastError;
}利用側ではリトライ述語を狭く保ちます。409 Conflict やバリデーション失敗を再試行してはいけません。拒否されたリクエストを繰り返しても負荷を増幅させるだけだからです。
await withRetry(
() => repo.upsertGuestCounts(reservationId, counts, { idempotencyKey }),
{
isRetryable: (e) =>
e instanceof TransientDbError || (e instanceof HttpError && e.status >= 500),
},
);キーの発行や失効といった特権的な操作では、リトライ述語をさらに厳格にすべきであり、物理クレデンシャルの重複を事後に追跡できるよう、すべての試行を監査ログに記録すべきです。
8. 信頼境界をデータベースまで押し下げる
アプリケーション層の認可はひとつのレイヤーにすぎません。今回のサイクルのセキュリティ修正では、publicスキーマに残っていた SECURITY DEFINER ビューを削除し、新たなビューが現れたらビルドを失敗させるCIガードを追加しました。
definerモードのビューは所有者の権限で実行されるため、行レベルセキュリティを黙って迂回します。invokerモードであれば呼び出し元のポリシーが適用されます。
ALTER VIEW public.reservation_summary SET (security_invoker = true);
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY reservations_tenant_isolation ON reservations
USING (facility_id = current_setting('app.facility_id', true)::uuid);その状態を維持するためのガードは次のとおりです。
SELECT c.relname AS offending_view
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'v'
AND n.nspname = 'public'
AND NOT COALESCE(
array_to_string(c.reloptions, ',') LIKE '%security_invoker=true%',
false
);このクエリが1行でも返せば、パイプラインは失敗します。CIでアサートされないセキュリティ特性は、数スプリントのうちに劣化します。
ラチェットパターン
補助的なガードとして、ランタイムアサーションの呼び出し箇所を数え、その数が減ることを拒否するものがあります。
#!/usr/bin/env bash
set -euo pipefail
MIN_GUARDS=36
count=$(grep -R --include='*.ts' -o 'assertUnitLockSource(' src | wc -l | tr -d ' ')
if [ "$count" -lt "$MIN_GUARDS" ]; then
echo "unit-lock-source guards dropped: $count < $MIN_GUARDS" >&2
exit 1
fi
echo "unit-lock-source guards: $count (min $MIN_GUARDS)"ラチェットは粗い指標であり、それは意図的なものです。正しさを証明することはできませんが、安全チェックの黙った削除を不可能にします。削除するには閾値の編集が必要になり、それはレビューで可視化されるからです。
9. リスクに追随するテスト
2つのテスト関連のコミットは強調する価値があります。グリーンなビルドでは見えない障害モードに対処しているからです。
古くなったモック。 手書きのSDKモックに対してアサートするテストスイートは、実際のSDKの形が変わった後も通り続けます。緩和策は、公開された型からモックを導出し、シグネチャの変更がコンパイルエラーになるようにすることです。
import type { CheckInClient } from '@unlockos/sdk';
export function createCheckInClientMock(
overrides: Partial<CheckInClient> = {},
): CheckInClient {
// satisfies forces the mock to stay structurally compatible.
return {
lookupReservation: vi.fn(async () => stubReservation()),
checkIn: vi.fn(async () => ({ id: 'checkin_1' })),
saveGuestRegister: vi.fn(async () => undefined),
...overrides,
} satisfies CheckInClient;
}代替の入口経路。 宿泊者名簿はメインフローでは強制されていましたが、状態を再取得する予約直リンク経由ではスキップできてしまいました。入口が複数ある特権フローには、入口ごとのテストが必要です。
describe.each(['search', 'direct-link', 'qr-scan'] as const)('entry=%s', (entry) => {
it('refuses to complete check-in without a saved guest register', async () => {
const ctx = await startCheckIn({ entry, reservation: gatedReservation });
await expect(ctx.submit({ register: null })).rejects.toThrow('GUEST_REGISTER_REQUIRED');
expect(ctx.deps.keys.issue).not.toHaveBeenCalled();
});
});重要なのは最後のアサーションです。リクエストが失敗したことだけでなく、副作用としてクレデンシャルが一切発行されなかったことを検証しています。
まとめ
今回のサイクルから抽出した、物理的なアクセスを制御するあらゆるシステムに適用できるチェックリストです。
- デフォルトは拒否。 空・欠落・不正な設定は「権限なし」に解決します。
- 不明となしを区別する。
NULLが要件チェックを満たしてはいけません。 - サーバーで強制する。 コントロールを隠すのはUXの判断であり、リクエストを拒否することがセキュリティ制御です。
- 導出せよ、エコーするな。 どのロックが開くかを決める識別子は、リクエストボディではなく権威あるレコードから取得します。
- フローを状態機械としてモデル化する。 セッションを状態の型に含め、認証なしに特権的な遷移へ到達できないようにします。
- 黙ってダウングレードしない。 高信頼パスの失敗は拒否として表示し、低信頼パスにフォールバックしません。
- クレデンシャルをリースとして扱う。 主体・理由・時間枠に束縛し、終端となるステータス遷移のたびに冪等に失効させます。
- フィールドだけでなく関係を検証する。 フィールド間やコレクションの不変条件にこそ、本当のバグが潜みます。
- 安全にリトライできるものだけをリトライする。 有界な試行回数、狭い述語、冪等キー、監査エントリ。
- セキュリティ特性をCIでアサートする。 invokerモードのビュー、ガード数のラチェット、入口ごとのテストが、昨日の修正が静かに消えるのを防ぎます。
これらのどれも新しいフレームワークを必要としません。必要なのは、各判断ポイントを一度きちんと書き下し、意味のある境界で強制し、失敗したときに大きな声を上げるテストで守ることだけです。