冪等な状態遷移とクレームベースRLSの実践
スマートロックプラットフォームの信頼性は、最も弱い境界によって決まります。部屋への物理的なアクセスは、長い連鎖の最終結果です。招待が受諾され、ロールが付与され、予約が作成され、決済が照合され、チェックインが記録され、最後にクレデンシャルが発行されます。この連鎖のどこか一箇所でも曖昧さがあれば — 同じ意味を持つ2つのロール、「たぶん支払い済み」の決済、「どれか」の予約に紐づくチェックイン — システムは正当なゲストのアクセスを拒否するか、あるいは誤った相手にアクセスを許してしまいます。
本記事では、最近のアクセス制御と予約ライフサイクルの実装を形づくった4つの堅牢化パターンを解説します。認可のための正規化されたアイデンティティ、検証済みクレームを鍵とした row-level security、自己修復を伴う冪等な状態遷移、そして外向きの副作用に対するポート&アダプターです。すべての例は一般化してあり、重要なのは解決策の「形」であって、私たちの内部スキーマではありません。
1. 認可は正規化されたアイデンティティから始まる
最もよくある認可のバグは、チェックの漏れではありません。誤った対象を比較しているチェックです。ロールの仕組みは有機的に成長しがちで、表示名("Facility Manager")、ローカライズされたラベル("施設管理者")、UUID、slug(facility_manager)がすべてコードベースの中を漂うようになります。そのそれぞれが暗黙のキーとなり、比較が行われるすべての箇所がズレを生む機会になります。
解決策は、ちょうど1つの role key をアイデンティティとして選び、あらゆるバリエーションをそこへ畳み込み、それ以外はすべて表示用として扱うことです。
export const ROLE_KEYS = ['owner', 'facility_manager', 'staff', 'guest'] as const;
export type RoleKey = (typeof ROLE_KEYS)[number];
const LEGACY_ROLE_ALIASES: Record<string, RoleKey> = {
admin: 'owner',
'facility-admin': 'facility_manager',
manager: 'facility_manager',
member: 'staff',
};
export function toRoleKey(input: string | null | undefined): RoleKey | null {
if (!input) return null;
const normalized = input.trim().toLowerCase().replace(/[\s-]+/g, '_');
if ((ROLE_KEYS as readonly string[]).includes(normalized)) {
return normalized as RoleKey;
}
return LEGACY_ROLE_ALIASES[normalized] ?? null;
}ここでは2つの性質が重要です。
toRoleKeyは未知の入力に対してnullを返します。未知の入力が、黙って寛容なデフォルトへ退化することは決してあってはなりません。- union 型によって網羅的なチェックが可能になるため、ロールを追加すると、あらゆる判断箇所を見直さざるを得なくなります。
正規化されたキーがあれば、権限チェックは文字列のスープではなく全域関数になります。
type Action = 'issue_credential' | 'cancel_reservation' | 'invite_member' | 'view_audit_log';
const POLICY: Record<RoleKey, ReadonlySet<Action>> = {
owner: new Set(['issue_credential', 'cancel_reservation', 'invite_member', 'view_audit_log']),
facility_manager: new Set(['issue_credential', 'cancel_reservation', 'invite_member']),
staff: new Set(['issue_credential']),
guest: new Set([]),
};
export function can(role: RoleKey, action: Action): boolean {
return POLICY[role].has(action);
}見落とされがちですが重要な点として、マルチテナントシステムにおけるロールの所属はスコープ付きでなければなりません。施設Aのマネージャーであるユーザーは、施設Bのマネージャーではありません。したがってメンバーシップの行はスコープを保持し、参照は常に (user_id, facility_id) -> role_key であって、(user_id) -> role_key ではありません。ここを誤ると、正当な施設横断の招待が、拒否されるか権限昇格になってしまいます。
export interface Membership {
userId: string;
facilityId: string;
roleKey: RoleKey;
}
export function resolveRole(memberships: readonly Membership[], facilityId: string): RoleKey | null {
return memberships.find((m) => m.facilityId === facilityId)?.roleKey ?? null;
}2. ポリシーはアプリだけでなくデータベースでも強制する
アプリケーション層のチェックは必要ですが、十分ではありません。新しいエンドポイント、管理用スクリプト、忘れ去られたコードパスは、どれもそれを迂回しうるからです。Row-level security(RLS)は最終的な判断をデータベースに移し、どのサービスが発行したクエリであってもフィルタリングされるようにします。
スケールするパターンは、ログイン時に(auth hook を通じて)テナントとロールの情報を検証済みの JWT クレームに入れておき、そのクレームを読むポリシーを書くことです。これにより、再帰的なポリシー参照を避けられます。再帰的な参照は、無限再帰と意図しない全テーブル露出の両方を引き起こす古典的な原因です。
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members FORCE ROW LEVEL SECURITY;
CREATE OR REPLACE FUNCTION auth_facility_ids()
RETURNS uuid[] LANGUAGE sql STABLE AS $$
SELECT COALESCE(
ARRAY(SELECT jsonb_array_elements_text(
NULLIF(current_setting('request.jwt.claims', true), '')::jsonb -> 'facility_ids'
)::uuid),
ARRAY[]::uuid[]
);
$$;
CREATE OR REPLACE FUNCTION auth_role_key(target uuid)
RETURNS text LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('request.jwt.claims', true), '')::jsonb
-> 'roles' ->> target::text;
$$;
CREATE POLICY members_select_same_facility ON organization_members
FOR SELECT USING (facility_id = ANY (auth_facility_ids()));
CREATE POLICY members_write_requires_manager ON organization_members
FOR ALL USING (auth_role_key(facility_id) IN ('owner', 'facility_manager'))
WITH CHECK (auth_role_key(facility_id) IN ('owner', 'facility_manager'));RLS を導入する際に必ず適用している3つのルールがあります。
FORCE ROW LEVEL SECURITYを使い、テーブルのオーナーであってもポリシーの対象にします。これがないと、オーナー権限で動くマイグレーションやジョブがすべてを黙って迂回します。USINGとWITH CHECKを分ける。USINGは何を見て変更できるかを制御し、WITH CHECKは何を書き込めるかを制御します。USINGだけのポリシーでは、ユーザーが所属していないテナントへ行を移動できてしまう可能性があります。- service role の利用は明示的かつ限定的に。 バックエンドの関数が正当に RLS を迂回する必要がある場合(例えば、すでに認可済みのアクターに代わって監査イベントを書き込む場合など)、昇格されたクライアントは認可判断のあとに、呼び出し箇所で取得すべきであり、モジュールレベルのデフォルトにしてはいけません。
export async function recordCheckIn(ctx: RequestContext, input: CheckInInput) {
const role = resolveRole(ctx.memberships, input.facilityId);
if (!role || !can(role, 'issue_credential')) {
throw new ForbiddenError('check_in.not_authorized');
}
// Authorization decided above; elevated client used only for the audited write.
const admin = createServiceRoleClient();
return admin.from('check_ins').insert({
reservation_id: input.reservationId,
actor_id: ctx.userId,
actor_role: role,
source: input.source,
});
}順序こそが本質です。まず判断し、それから昇格する。先に昇格しておいて、後続の分岐が問題を捕まえてくれることを期待する — これが権限漏れの起き方です。
3. 予約ライフサイクルを明示的な state machine としてモデル化する
予約と決済の状態は、信頼性のバグが集中する場所です。payment intent はキャンセルされたのに行は pending のまま。返金済みの予約が再オープンされて refunded を引き継ぐ。リトライされた確認処理は成功したのに payment_status が paid に切り替わらず、ゲストがドアの前で 402 のループに陥る。
これらはすべて同じ根本原因です — 遷移が、宣言された状態空間上の単一の全域関数としてではなく、散らばった UPDATE 文として実装されていたのです。
export type PaymentStatus = 'unpaid' | 'authorized' | 'paid' | 'refunded' | 'failed';
export type ReservationStatus =
| 'pending'
| 'confirmed'
| 'checked_in'
| 'completed'
| 'cancelled'
| 'expired';
type Event =
| { type: 'CONFIRM'; paymentStatus: PaymentStatus }
| { type: 'PAYMENT_CANCELED' }
| { type: 'CHECK_IN'; at: string }
| { type: 'REOPEN' }
| { type: 'EXPIRE' };
export interface Reservation {
status: ReservationStatus;
paymentStatus: PaymentStatus;
checkedInAt: string | null;
}
const ALLOWED: Record<ReservationStatus, ReadonlySet<ReservationStatus>> = {
pending: new Set(['confirmed', 'cancelled', 'expired']),
confirmed: new Set(['checked_in', 'cancelled', 'expired']),
checked_in: new Set(['completed', 'cancelled']),
completed: new Set([]),
cancelled: new Set(['pending']),
expired: new Set(['pending']),
};この reducer は、言葉にするのは簡単で、いったん中央集約すれば破るのが難しい2つの不変条件を強制します。宣言されたグラフの外への遷移は存在しない、そして同じイベントの繰り返しは no-op であって変更ではない、という2点です。
export function reduce(state: Reservation, event: Event): Reservation {
switch (event.type) {
case 'CONFIRM': {
if (state.status === 'confirmed') {
// Idempotent replay: still reconcile payment, never rewrite identity fields.
return state.paymentStatus === event.paymentStatus
? state
: { ...state, paymentStatus: event.paymentStatus };
}
assertTransition(state.status, 'confirmed');
return { ...state, status: 'confirmed', paymentStatus: event.paymentStatus };
}
case 'PAYMENT_CANCELED': {
assertTransition(state.status, 'cancelled');
return { ...state, status: 'cancelled', paymentStatus: 'failed' };
}
case 'CHECK_IN': {
if (state.status === 'checked_in') {
// Preserve the ORIGINAL timestamp: it is audit evidence, not a cache value.
return state;
}
assertTransition(state.status, 'checked_in');
return { ...state, status: 'checked_in', checkedInAt: event.at };
}
case 'REOPEN': {
assertTransition(state.status, 'pending');
// Reopening must not inherit a terminal payment state.
return { ...state, status: 'pending', paymentStatus: 'unpaid', checkedInAt: null };
}
case 'EXPIRE': {
assertTransition(state.status, 'expired');
return { ...state, status: 'expired' };
}
}
}
function assertTransition(from: ReservationStatus, to: ReservationStatus): void {
if (!ALLOWED[from].has(to)) {
throw new InvalidTransitionError(`illegal transition ${from} -> ${to}`);
}
}上のコードには、実際の障害モードから学んだ3つの教訓が埋め込まれています。
- 冪等な分岐であっても、状態の整合は取らなければならない。
payment_statusを同期せずにショートサーキットするリトライ確認処理は、すでに支払い済みのゲストをドアの前で拒否し続けます。「すでに実行済み」は「何もしなくてよい」とは違います。 - 再オープン時には終端の決済状態をクリアしなければならない。 さもないと、返金後に再オープンされた予約が決済済みとして扱われます。
- 監査用のタイムスタンプはイミュータブルである。 同じ状態に再び入るとき、最初の発生時刻を上書きしてはいけません。下流の紛争解決がそれに依存しているからです。
自己修復スイープが外部システムとのギャップを埋める
お金やハードウェアが絡むと、ローカルの行と外部システムは乖離しうります — webhook が失われる、外部のキャンセルとローカル更新の間でプロセスが落ちる、など。外部の source of truth から正しい状態を再導出する定期的な reconciler は、恒久的な不整合を一時的な不整合へと変えます。
export async function reconcileStuckPending(now: Date, deps: Deps): Promise<void> {
const stale = await deps.repo.findPending({ olderThan: minusMinutes(now, 30) });
for (const reservation of stale) {
const remote = await deps.payments.getIntent(reservation.paymentIntentId);
const event: Event | null =
remote.status === 'canceled' ? { type: 'PAYMENT_CANCELED' }
: remote.status === 'succeeded' ? { type: 'CONFIRM', paymentStatus: 'paid' }
: null;
if (!event) continue;
const next = reduce(toState(reservation), event);
await deps.repo.applyIfUnchanged(reservation.id, reservation.version, next);
deps.audit.emit('reservation.reconciled', {
reservationId: reservation.id,
from: reservation.status,
to: next.status,
reason: remote.status,
});
}
}applyIfUnchanged(id, version, next) に注目してください — 楽観的並行性制御です。ライブのリクエストと競合したスイーパーは、より新しい状態を上書きするのではなく、きれいに負けなければなりません。
4. 境界で検証し、不正な状態を表現不能にする
本番環境の「奇妙な」挙動の意外なほど多くは、そもそも受け入れるべきではなかった入力に起因します。空白の部屋タイプ名、負のバッファ時間、正しいクレデンシャルを発行するのに必要な部屋タイプなしで作成された予約。個々には些細ですが、積み重なると腐食性を持ちます。下流のコードが、存在すべきでない値に対して防御を始めてしまうからです。
端で一度だけパースして、システムの残りが信頼できる型に変換しましょう。
import { z } from 'zod';
export const ReservationInputSchema = z.object({
facilityId: z.string().uuid(),
roomTypeId: z.string().uuid({ message: 'reservation.room_type_required' }),
guestEmail: z.string().email(),
startAt: z.string().datetime({ offset: true }),
endAt: z.string().datetime({ offset: true }),
bufferMinutes: z.number().int().min(0).max(24 * 60),
}).refine((v) => Date.parse(v.endAt) > Date.parse(v.startAt), {
message: 'reservation.end_before_start',
path: ['endAt'],
});
export type ReservationInput = z.infer<typeof ReservationInputSchema>;これを効果的にする2つの実践があります。
- 数値入力には明示的な上下限を与える。 バッファや価格に対する
min(0)は1行の変更ですが、スケジューリングと課金の異常をまるごと1クラス排除します。 - エラーメッセージは散文ではなく安定した i18n キーにする。 UI はローカライズでき、テストはアサートでき、ログ集約はグルーピングできます。自由形式の文字列は翻訳もできず grep もできません。
フィールドの変更がセキュリティに関わる出力を変える場合 — 例えば既存の予約の部屋を変更する場合 — 正しい振る舞いは行にパッチを当てることではなく、依存する成果物を再導出することです。部屋の変更はクレデンシャルを無効化して再発行しなければなりません。さもなければ、古い鍵が、ゲストがもう使っていない部屋で黙って有効なままになります。
export async function changeRoom(reservationId: string, nextRoomId: string, deps: Deps) {
const reservation = await deps.repo.get(reservationId);
if (reservation.roomId === nextRoomId) return reservation;
await deps.credentials.revoke(reservation.credentialId, { reason: 'room_changed' });
const credential = await deps.credentials.issue({ reservationId, roomId: nextRoomId });
deps.audit.emit('credential.reissued', { reservationId, from: reservation.roomId, to: nextRoomId });
return deps.repo.update(reservationId, { roomId: nextRoomId, credentialId: credential.id });
}5. 外向きの副作用をポートの背後に隔離する
通知、SMS プロバイダー、カレンダー同期、webhook — 外部との連携はどれも、サードパーティの障害が自分たちの障害になりうる場所であり、リトライが重複メッセージになりうる場所です。ports and adapters の境界は、ドメインをテスト可能に保ち、障害モードを封じ込めます。
export interface NotificationMessage {
readonly idempotencyKey: string;
readonly to: string;
readonly template: string;
readonly payload: Record<string, string | number>;
}
export interface NotificationPort {
readonly channel: 'email' | 'sms' | 'webhook';
send(message: NotificationMessage): Promise<DeliveryResult>;
}
export type DeliveryResult =
| { status: 'delivered'; providerId: string }
| { status: 'rejected'; reason: string }
| { status: 'retryable'; reason: string };アダプターの仕事は、プロバイダー固有の失敗を、この閉じた結果 union へ変換することです — ドメインが生の HTTP エラーを目にすることは決してあってはなりません。
export class SmsAdapter implements NotificationPort {
readonly channel = 'sms' as const;
constructor(private readonly client: ProviderClient) {}
async send(message: NotificationMessage): Promise<DeliveryResult> {
try {
const res = await this.client.messages.create({
to: message.to,
body: render(message.template, message.payload),
idempotencyKey: message.idempotencyKey,
});
return { status: 'delivered', providerId: res.sid };
} catch (error) {
const status = getHttpStatus(error);
if (status === 429 || (status !== undefined && status >= 500)) {
return { status: 'retryable', reason: `provider_${status}` };
}
return { status: 'rejected', reason: classify(error) };
}
}
}その上のスケジューラーは3つの結果だけを扱えばよくなり、(reservationId, template, scheduledFor) から決定論的に導出される idempotencyKey が、リトライされた cron 実行による二重送信を不可能にします。
export async function dispatchDue(now: Date, ports: Map<string, NotificationPort>, repo: Repo) {
const due = await repo.claimDue(now, { limit: 100, leaseSeconds: 60 });
for (const job of due) {
const port = ports.get(job.channel);
if (!port) {
await repo.markRejected(job.id, 'no_adapter_for_channel');
continue;
}
const result = await port.send(toMessage(job));
if (result.status === 'delivered') await repo.markDelivered(job.id, result.providerId);
else if (result.status === 'rejected') await repo.markRejected(job.id, result.reason);
else await repo.scheduleRetry(job.id, backoff(job.attempts), result.reason);
}
}リース付きの claimDue が、重なり合う cron 実行を安全にしています。同じ理屈は CI にも当てはまります。ワークフローに concurrency ガードを追加すれば、同じブランチ上で2つのデプロイやマイグレーションのパイプラインが競合するのを防げます。
6. リファクタリングに耐えるテスト
セキュリティとライフサイクルのコードは、まさにこれから自分がリファクタリングするコードです。付随的な詳細(引数の数、無関係なヘルパーの呼び出し順序)をアサートするテストは、リファクタリングのたびに壊れ、やがて無効化されます — これはテストがない状態よりも悪い状況です。
アサートすべきは観測可能な契約です。結果として得られる状態、発行された監査イベント、認可の判断結果です。
import { assertEquals, assertThrows } from '@std/assert';
Deno.test('check-in is idempotent and preserves the original timestamp', () => {
const first = reduce(
{ status: 'confirmed', paymentStatus: 'paid', checkedInAt: null },
{ type: 'CHECK_IN', at: '2026-07-20T09:00:00Z' },
);
const second = reduce(first, { type: 'CHECK_IN', at: '2026-07-20T11:30:00Z' });
assertEquals(second.checkedInAt, '2026-07-20T09:00:00Z');
assertEquals(second, first);
});
Deno.test('reopen clears terminal payment state', () => {
const reopened = reduce(
{ status: 'cancelled', paymentStatus: 'refunded', checkedInAt: '2026-07-01T00:00:00Z' },
{ type: 'REOPEN' },
);
assertEquals(reopened.paymentStatus, 'unpaid');
assertEquals(reopened.checkedInAt, null);
});
Deno.test('illegal transitions are rejected', () => {
assertThrows(() =>
reduce({ status: 'completed', paymentStatus: 'paid', checkedInAt: null }, { type: 'CHECK_IN', at: 'now' })
);
});認可については、ロール × アクションの全マトリクスをプロパティ的にカバーするのは安価であり、ロールが追加された瞬間にリグレッションを検出できます。
Deno.test('guests can perform no privileged action', () => {
const actions: Action[] = ['issue_credential', 'cancel_reservation', 'invite_member', 'view_audit_log'];
for (const action of actions) {
assertEquals(can('guest', action), false, `guest must not ${action}`);
}
});もう1つ、仕組みとして定着させる価値のある習慣があります。機能が振る舞いを変えたら、整合性テストも同じ変更セットの中で更新することです。統合ブランチで赤い CI が放置されると、チームは赤を無視することを学習してしまいます。これはセキュリティが重要なプロジェクトが身につけうる、最も高くつく習慣です。
まとめ
| 関心事 | パターン | 防げる失敗 |
|---|---|---|
| ロールのアイデンティティ | 単一の正規 role_key、エイリアスの正規化、未知は null |
誤ったフィールドを比較する権限チェック |
| マルチテナンシー | スコープ付き (user, facility) -> role、FORCE + WITH CHECK を伴う RLS |
テナントをまたいだ読み書き |
| 権限昇格 | まず認可を判断し、限定的な書き込み箇所で昇格する | service role クライアントのデフォルト利用 |
| ライフサイクル | 明示的な遷移グラフ + 冪等な reducer | 不正な状態、決済とチェックインの非同期 |
| 外部とのズレ | 楽観的並行性制御 + 監査イベントを伴う reconciler | 恒久的にスタックした行 |
| 入力 | 境界でのパース、上下限のある数値、i18n エラーキー | 不正な状態の下流への伝播 |
| 副作用 | 閉じた結果 union と冪等キーを持つ port/adapter | 二重送信、プロバイダー障害の漏れ込み |
| 変更の安全性 | 契約レベルのテスト、CI の concurrency ガード | リファクタリング中に無効化されるテスト、競合するパイプライン |
これらのパターンはどれも特殊なものではありません。価値を生むのは、あらゆる境界で一貫して適用されることです。そうすることで、「このユーザーは今このドアを開けられるか?」という問いに対して、ちょうど1つの source of truth から導かれた、ちょうど1つの答えが返ってくるようになります。