スマートロックの多層防御:RLS、token、状態設計
はじめに
スマートロックの SDK は、単なる CRUD アプリではありません。クライアントに書き込みを許したすべての行、優雅に扱われずに失効させた token、逆方向に進むことを許した状態遷移のひとつひとつが、「間違った人の前でドアが開く」——あるいは「正しい人の前でドアが閉じたままになる」という物理的な結果に直結します。
本記事では、1 スプリント分のハードニング作業から得られた知見を、セキュリティクリティカルなあらゆるシステムに適用できるパターンとして整理します。すなわち、データ層のロックダウン、特権操作のサーバサイドへの隔離、資格情報の失効をエラーではなく通常の状態として扱う設計、そして時間を逆行しない通知・課金の state machine の設計です。
1. データベースこそが最後の防衛線
ブラウザがデータ API 経由で Postgres と直接会話する構成では、アプリケーションコードは 助言 であって 強制 ではありません。細工されたリクエストを実際に止められるのは、Row Level Security と権限付与(grant)だけです。
私たちが発見し、修正した頻出の失敗パターンは 3 つあります。
1.1 「authenticated なら全開放」ポリシー
最もよくあるアンチパターンは、USING (auth.role() = 'authenticated') と書かれたポリシーです。これは あらゆる テナントのログイン済みユーザ全員に、あらゆる 行へのアクセスを与えてしまいます。テストは通ってしまいます——テストにはたいていテナントが 1 つしか存在しないからです。
-- ANTI-PATTERN: any authenticated user of any facility can read/write.
create policy plan_groups_rw on public.plan_groups
for all to authenticated
using (true) with check (true);これを claims ベースで作り直し、認可を「呼び出し側が操作できる行の内容」ではなく「検証済みの JWT」から導出するようにします。
drop policy if exists plan_groups_rw on public.plan_groups;
create policy plan_groups_select on public.plan_groups
for select to authenticated
using (facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid);
create policy plan_groups_write on public.plan_groups
for all to authenticated
using (
facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid
and (auth.jwt() -> 'app_metadata' ->> 'role') in ('facility_owner', 'facility_admin')
)
with check (
facility_id = (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid
and (auth.jwt() -> 'app_metadata' ->> 'role') in ('facility_owner', 'facility_admin')
);重要なポイントが 2 つあります。
WITH CHECKは省略可能ではありません。USINGは「何が見えるか」をフィルタし、WITH CHECKは「何が書けるか」を制約します。これがなければ、ユーザは行を別テナントへ移動させられます。- 読み取りと書き込みを分離しましょう。 プラングループの閲覧と変更は別の権限です。これらを
FOR ALLにまとめると、書き込みポリシーは必ず読み取りポリシーと同じ緩さになります。
1.2 SECURITY DEFINER 関数は設計上のバイパス
SECURITY DEFINER の RPC は オーナーの 権限で実行されます——意図的に RLS を飛び越えるのです。もし anon や authenticated がこれに EXECUTE 権限を持っているなら、それは権限昇格エンドポイントを公開しているのと同じです。
-- Default-deny EXECUTE for everything, then grant deliberately.
revoke execute on all functions in schema public from anon, authenticated;
grant execute on function public.get_public_facility_summary(uuid) to anon, authenticated;
-- And put the authorization check *inside* the definer function.
create or replace function public.soft_delete_plan(p_plan_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare v_facility uuid;
begin
select facility_id into v_facility from plans where id = p_plan_id;
if v_facility is null then
raise exception 'plan not found' using errcode = 'P0002';
end if;
if v_facility <> (auth.jwt() -> 'app_metadata' ->> 'facility_id')::uuid then
raise exception 'forbidden' using errcode = '42501';
end if;
update plans set deleted_at = now() where id = p_plan_id;
end;
$$;set search_path = public に注目してください。これがないと、definer 関数は呼び出し側が制御できるスキーマからテーブルや演算子を解決させられる可能性があります——古典的な権限昇格の経路です。
1.3 共有テーブルにはカラム単位のスコープを
一部のテーブルは、クライアントとサーバの双方から正当に書き込まれます。たとえばチェックイン記録では、フロントエンドは「キーを表示した」フラグを立てる必要がありますが、payment_status、issued_key_id、checked_out_at を書き込めては絶対にいけません。
RLS は行に作用し、grant はカラムに作用します。両方を使いましょう。
revoke insert, update on public.check_ins from authenticated;
grant update (key_shown_at, key_view_count, guest_acknowledged_at)
on public.check_ins to authenticated;
create policy check_ins_client_update on public.check_ins
for update to authenticated
using (guest_user_id = auth.uid())
with check (guest_user_id = auth.uid());それ以外——ドアが開くか、あるいはお金が動くかを決めるカラム——は、Edge Function から service role だけが書き込みます。経験則は次のとおりです。
認可または決済の判断に関与するカラムは、クライアントが書き込めてはならない。例外なく。
同じ論理はストレージバケットにも当てはまります。オブジェクト書き込みポリシーをテナント ID を含むパスプレフィックスにスコープし、ある施設が他施設のプラン画像を上書きできないようにします。
2. 特権操作はサーバの背後に置く
返金はその典型例です。paymentIntentId を受け取って決済プロバイダを呼ぶ返金エンドポイントは、ブラウザから到達可能であれば「認証なしの資金流出口」そのものです。
ハードニングの手立ては 2 つです。
- 返金経路を service role でゲートする——ゲートウェイだけでなく、関数内部で呼び出し側の JWT role claim を検証する。
- 返金対象が 呼び出し側が所有する予約から到達可能 であることを必須にする。これにより ID の漏洩だけでは不十分になる。
export async function handleRefund(req: Request): Promise<Response> {
const claims = await verifyJwt(req.headers.get('authorization'));
if (claims?.role !== 'service_role') {
return json({ error: 'forbidden', code: 'REFUND_REQUIRES_SERVICE_ROLE' }, 403);
}
const { reservationId, amount } = RefundInput.parse(await req.json());
// The PaymentIntent is derived from the reservation, never taken from input.
const payment = await db.findPaymentByReservation(reservationId);
if (!payment) {
return json({ error: 'not_found', code: 'REFUND_TARGET_UNRESOLVED' }, 404);
}
if (amount > payment.capturedAmount) {
return json({ error: 'invalid', code: 'REFUND_EXCEEDS_CAPTURE' }, 422);
}
return json(await provider.refund(payment.intentId, amount));
}識別子の正規化はセキュリティ課題である
返金の突合処理が、プロバイダが PaymentIntent 参照として返す 2 つの形式(素の ID と、ネストされたオブジェクト/展開フィールド)のうち片方しか認識していないというバグを発見しました。正当な返金がすべて拒否されていたのです。この失敗は「開く」方向ではなく 閉じる 方向だったので、方向としては正しいものでした——しかし教訓は一般化できます。
type IntentRef = string | { id: string } | { payment_intent: string | { id: string } };
export function normalizeIntentId(ref: IntentRef | null | undefined): string | null {
if (!ref) return null;
if (typeof ref === 'string') return ref;
if ('id' in ref && typeof ref.id === 'string') return ref.id;
if ('payment_intent' in ref) return normalizeIntentId(ref.payment_intent);
return null;
}信頼境界をまたぐ識別子を比較するときは、必ず両側を単一のテスト済み関数で正規化してください。多相的なペイロードに対するその場しのぎの === は、誤った拒否(障害)か誤った受理(侵害)のどちらかを生みます。
3. テスト専用のバイパスをビルドに残さない
どんなコードベースにも、いずれ権限チェックの中に if (isTestUser) return true; が生えてきます。CI では無害ですが、本番では致命的です。
バイパスを 静的に除去可能 にしたうえで、その再導入を CI の失敗にしましょう。
export function canManageFacility(user: User, facilityId: string): boolean {
// `import.meta.env.DEV` is a compile-time constant; the whole branch is
// dead-code-eliminated from the production bundle.
if (import.meta.env.DEV && user.email?.endsWith('@e2e.test')) {
return true;
}
return user.roles.some(
(r) => r.facilityId === facilityId && MANAGE_ROLES.has(r.name),
);
}コンパイラによる保証は統制の半分にすぎません。ビルド成果物に対するアサーションを追加しましょう。
#!/usr/bin/env bash
set -euo pipefail
pnpm build
if grep -rqE "e2e\.test|PERMISSION_BYPASS" dist/assets/*.js; then
echo "FAIL: test-only permission bypass found in production bundle" >&2
exit 1
fi
echo "OK: no permission bypass markers in bundle"これは明言する価値のある一般原則です。CI でアサートできないセキュリティ特性は、必ず退行します。 出荷される成果物に対する grep は、粗雑で、速く、そして反論の余地がありません。
4. 資格情報の失効は「例外」ではなく「状態」
私たちのロックベンダープロキシには、どちらも「キーが表示されない」という形で現れる 2 つの異なる失敗モードがありました。
- プロキシが失効した token をリフレッシュせずに 拒否 していた。
- セッションがアイドルになると、ベンダーが
{"code":"E0000","message":"not logged in"}のようなボディを HTTP 200 で返していた——そのためステータスコードベースのエラーハンドリングは成功とみなしていた。
対処は、セッションの所有権をプロキシに集約し、レスポンスをトランスポートのステータスではなく セマンティクス で分類することです。
const REAUTH_CODES = new Set(['E0000', 'E0401', 'SESSION_EXPIRED']);
function needsReauth(status: number, body: unknown): boolean {
if (status === 401 || status === 403) return true;
if (status === 200 && isRecord(body) && typeof body.code === 'string') {
return REAUTH_CODES.has(body.code);
}
return false;
}
export async function callVendor<T>(path: string, init: RequestInit): Promise<T> {
let token = await tokenStore.get();
if (!token || tokenStore.isExpired(token, { skewSeconds: 60 })) {
token = await tokenStore.refresh(); // proactive: don't wait for a 401
}
let res = await fetch(path, withAuth(init, token));
let body = await res.json().catch(() => null);
if (needsReauth(res.status, body)) {
token = await tokenStore.refresh();
res = await fetch(path, withAuth(init, token)); // exactly one retry
body = await res.json().catch(() => null);
if (needsReauth(res.status, body)) {
throw new VendorAuthError('VENDOR_REAUTH_FAILED', { path });
}
}
if (!res.ok) throw new VendorError('VENDOR_CALL_FAILED', { path, status: res.status });
return body as T;
}設計上のポイント:
- クロックスキューを見込んだ先回りリフレッシュ。 60 秒早くリフレッシュすることで、token 検証とリクエスト到着の間に生じる競合状態のクラス全体が消えます。
- リトライはちょうど 1 回。 認証失敗時の無制限リトライは、資格情報の問題をベンダーに対する自作自演の DoS に変えてしまいます。
- 所有権は一箇所に。 プロキシがリフレッシュを所有した以上、呼び出し側は 追加で リフレッシュしてはいけません。レンダリング層のテストを更新して「リフレッシュはプロキシが所有する」ことを明示的にアサートし、将来の貢献者がクライアント側リフレッシュを再導入したらテストが壊れるようにしました。
静かな失敗を声に出させる
関連するバグのクラスとして、静かに失敗するバックグラウンドポーラーがあります。検知ポーリングやアンカーのロールバックが失敗しても、ゲストが施錠されたドアの前に立つまで誰も気づきません。補完し合う 2 つの統制があります。
- 失敗を運用 UI でスタッフに見せること。その際、レンダリング済みの英文ではなく、安定した機械可読コードを使うこと。
- 集約された失敗でアラートを出すこと。たとえば「施設 X でキー発行が N 回連続で失敗」——さらにアラートに施設名を含め、オンコール担当が照会なしに動けるようにすること。
export type KeyIssueFailure =
| { code: 'KEY_VENDOR_UNAVAILABLE'; retryable: true }
| { code: 'KEY_CONFIG_MISSING'; retryable: false }
| { code: 'KEY_WINDOW_EXPIRED'; retryable: false };
// UI maps `code` -> i18n message; logs/alerts key off `code`, never off the text.安定したコードがあるからこそ、メッセージのローカライズ、カテゴリ単位のアラート、そしてアサーションの記述を——すべて同じ値から——実現できます。
5. 時間を逆行できない state machine
通知も、ロックと同じく state machine です。私たちの実装には古典的なバグが 2 つありました。
5.1 無関係な遷移で終端状態を復活させない
過去の期限で予定されていた通知が、親となる予約の状態が変わるたびに pending にリセットされていました。結果として、すでに終わったイベントに対する通知が一気に送信されました。
修正は、「期限が過去である」ことを吸収条件として扱い、呼び出し側ではなくガードの中で評価することです。
type NotificationState = 'pending' | 'sent' | 'skipped' | 'cancelled';
interface Notification {
state: NotificationState;
dueAt: Date;
}
const TERMINAL: ReadonlySet<NotificationState> = new Set(['sent', 'skipped', 'cancelled']);
export function reconcile(n: Notification, now: Date): NotificationState {
if (TERMINAL.has(n.state)) return n.state; // terminal is terminal
if (n.dueAt.getTime() <= now.getTime()) return 'skipped'; // never backfill
return 'pending';
}一般化できるルールは、照合(reconciliation)関数は冪等かつ単調でなければならないということです。2 回実行しても結果は変わらず、レコードをより「終端でない」方向へ動かしてはいけません。もし reconciler が sent -> pending を生み出せるなら、いずれユーザにスパムを送るか、ロックシステムであれば失効済みのキーを再発行することになります。
5.2 アラートは対応可能な時間窓に閉じる
3 週間前のイベントに対して発火する検知失敗アラートはノイズです。そこで境界を設けました。
const ACTIONABLE_WINDOW_MS = 24 * 60 * 60 * 1000;
export function shouldAlert(event: { detectedAt: Date; occurredAt: Date }): boolean {
const age = event.detectedAt.getTime() - event.occurredAt.getTime();
return age >= 0 && age <= ACTIONABLE_WINDOW_MS;
}同じガードは、機能を既存の履歴データに対して初めてデプロイしたときの バックフィル嵐 も防ぎます——驚くほどよくある本番インシデントです。既存テーブルに新しいアラートを追加するときは必ず「全履歴に対する初回実行で何が起きるか?」と問いましょう。
5.3 不可能な操作は正面から拒否する
同じスプリントの別の事例として、管理 UI が 失効済み のドアキーの有効期限延長を許していました。ここで黙って成功してしまうと、UI は「アクセスを延長した」と主張する一方で、ロックはそれに同意していない状態になります。
export function assertExtendable(key: DoorKey): void {
if (key.status === 'revoked') {
throw new DomainError('KEY_REVOKED_NOT_EXTENDABLE', { keyId: key.id });
}
if (key.status === 'expired' && key.expiredAt < subDays(new Date(), 30)) {
throw new DomainError('KEY_TOO_OLD_TO_EXTEND', { keyId: key.id });
}
}そして決定的に重要なこと。UI で成功を主張する前に、ベンダー側の変更を確認すること。 楽観的 UI は「いいね」ボタンには適切ですが、物理的なアクセスには不適切です。予約の時間枠が動いたら、キーの有効期間もそれに合わせて動かなければならず、UI はロックが実際に保持している状態を反映しなければなりません。
6. 時刻の計算は正しさの境界である
1 スプリントで発生した 3 つの別々のバグが、いずれも時刻処理に起因していました。これは偶然ではありません——時刻こそ、ドメインルールと機械表現が衝突する場所だからです。
6.1 リセット境界はサーバではなく施設のタイムゾーンで
UTC の深夜にリセットされる日次の料金上限は、UTC にない施設すべてにとって誤りであり、そのエラーはゲストが二重に請求されるまで見えません。
import { formatInTimeZone, toDate } from 'date-fns-tz';
export function dayKey(at: Date, timeZone: string): string {
return formatInTimeZone(at, timeZone, 'yyyy-MM-dd');
}
export function startOfFacilityDay(at: Date, timeZone: string): Date {
return toDate(`${dayKey(at, timeZone)}T00:00:00`, { timeZone });
}6.2 意図的に切り捨てる
「現在時刻」から計算されたリセット境界がミリ秒を持ち込んでいたため、本来 [00:00:00.000, 24:00:00.000) であるべき窓が [00:00:00.317, ...) になっていました——317 ミリ秒の穴が空き、リクエストがときどきそこに落ちていたのです。
export function truncateToSecond(d: Date): Date {
return new Date(Math.floor(d.getTime() / 1000) * 1000);
}境界がビジネスルールの一部であるなら、切り捨て忘れたタイムスタンプからではなく、カレンダー 値から計算してください。そして常に半開区間 [start, end) を使い、隣接する窓が重なることも隙間を作ることもないようにします。
6.3 価格を計算した時間窓に署名する
見積もりは、それを計算した対象区間についてのみ有効です。見積もり発行後にクライアントが区間を変更できるなら、その価格は参考値にすぎません。私たちは署名に時間窓を含めることで、見積もりを 拘束力のあるもの にしました。
import { createHmac, timingSafeEqual } from 'node:crypto';
export interface BindingQuote {
planId: string;
startAt: string; // ISO-8601 with offset
endAt: string;
amount: number;
currency: 'JPY';
issuedAt: string;
signature: string;
}
function payload(q: Omit<BindingQuote, 'signature'>): string {
return [q.planId, q.startAt, q.endAt, q.amount, q.currency, q.issuedAt].join('|');
}
export function signQuote(q: Omit<BindingQuote, 'signature'>, secret: string): BindingQuote {
const signature = createHmac('sha256', secret).update(payload(q)).digest('hex');
return { ...q, signature };
}
export function verifyQuote(q: BindingQuote, secret: string, now: Date): boolean {
const expected = createHmac('sha256', secret).update(payload(q)).digest();
const actual = Buffer.from(q.signature, 'hex');
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return false;
return now.getTime() - Date.parse(q.issuedAt) <= 15 * 60 * 1000;
}いずれにせよサーバは権威あるデータから料金の時間窓を再計算します。署名が存在する理由は、不一致を「静かな食い違い」ではなく 検知可能な改ざんイベント にするためです。timingSafeEqual を使い、必ず有効期限を含めましょう——期限のない署名付き token はリプレイの原材料です。
7. 本当に信頼を勝ち取るテスト
すべてのテストが等価なわけではありません。このスプリントでは、3 つのカテゴリが不釣り合いなほど大きな価値を持ちました。
7.1 変換境界におけるコントラクトテスト
価格まわりのバグの大半は、データベース表現とドメインモデルの間の 変換 層に潜んでいました——たとえば、コードが amount || null を使っていたために、¥0 のプランがゼロ金額の見積もりではなく null を返していた、といった具合です。
import { describe, expect, it } from 'vitest';
import { toFeeQuote } from './convert';
describe('toFeeQuote contract', () => {
it('preserves a zero-amount plan as a quote, not null', () => {
const quote = toFeeQuote({ planId: 'p1', amountMinor: 0, currency: 'JPY' });
expect(quote).not.toBeNull();
expect(quote?.amount).toBe(0);
});
it('rejects negative amounts instead of coercing them', () => {
expect(() => toFeeQuote({ planId: 'p1', amountMinor: -1, currency: 'JPY' }))
.toThrowError(/NEGATIVE_AMOUNT/);
});
});一般的な教訓は、0、''、false は正当なドメイン値であるということです。数値やブール値のフィールドに対する || の使用はすべて潜在的なバグです。?? を優先し、境界ごとに「falsy だが正当」なケースの明示的なテストを書きましょう。
7.2 お金とアクセス経路には本番カナリア
ユニットテストはコードが自己整合的であることを証明します。しかし本番の設定、鍵、環境モードが正しいことは証明できません。私たちは、固定された既知の答えを持つケースで本番の実際の料金計算関数を呼び出すカナリアを追加しました。
const CANARY_CASES = [
{ name: 'hourly-2h-weekday', input: FIXED_INPUT_A, expectedAmount: 2000 },
{ name: 'flat-plus-overtime', input: FIXED_INPUT_B, expectedAmount: 5500 },
] as const;
export async function runFeeCanary(): Promise<CanaryResult[]> {
return Promise.all(CANARY_CASES.map(async (c) => {
const started = Date.now();
const quote = await invokeFeeFunction(c.input);
return {
name: c.name,
ok: quote.amount === c.expectedAmount,
actual: quote.amount,
expected: c.expectedAmount,
latencyMs: Date.now() - started,
};
}));
}この方法で、環境まわりのバグのクラス全体を捕捉できました。関数が誤った決済モードを読み取り、組織の設定モードにかかわらず常にテスト鍵を使っていたのです。ユニットテストではこれを見ることはできません。カナリアなら数分で見つけます。(関連するガード:live プレフィックスの鍵がテスト鍵フィールドに保存された場合、書き込み時に明確なバリデーションエラーで明示的に拒否すること。)
7.3 flaky なテストの修正はセキュリティ作業である
flaky な統合テストは、テストがないより悪いものです。グリーンになるまで CI を再実行する習慣をチームに植え付け、まさにそのやり方で本物の退行が出荷されるからです。2 つの失敗中のユニットテストがリリースをブロックしており、予約フローの統合テストが断続的に失敗していました。どちらも雑用ではなく、リリースブロッカーとして扱いました。
flakiness はほぼ常に周辺状態——実時計、共有フィクスチャ、await されていない副作用——から生じます。
import { afterEach, beforeEach, vi } from 'vitest';
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-01T09:00:00+09:00'));
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});テストが Date.now() に依存するなら凍結しましょう。順序に依存するならソート済みの射影に対してアサートしましょう。ネットワークに依存するなら境界を明示してモックし——実際の境界はカナリアでカバーしましょう。
まとめ
以上のパターンは、アクセス・お金・状態に触れるあらゆる変更に対して私たちが現在適用しているチェックリストへと組み上がります。
| レイヤ | 統制 |
|---|---|
| データベース | USING と WITH CHECK の両方を備えた RLS。claims ベースにし、authenticated = true は使わない |
| 関数 | EXECUTE はデフォルト拒否。SECURITY DEFINER の 内部 で認可する。search_path を固定する |
| カラム | 非特権カラムにのみ書き込み権限を付与。決済/キーのカラムは service role 専用 |
| ビルド | テスト用バイパスは import.meta.env.DEV の背後に置き、さらに 出荷バンドルへの CI grep を行う |
| 資格情報 | スキューを見込んだ先回りリフレッシュ、ステータスコードではなくセマンティックなエラー分類、リトライはちょうど 1 回 |
| 状態 | 冪等かつ単調な reconciler、終端状態は吸収的、対応可能な時間窓に境界を設ける |
| 時刻 | 施設タイムゾーンの日付境界、意図的な切り捨て、半開区間、署名された見積もり時間窓 |
| テスト | falsy だが正当な値へのコントラクトテスト、本番カナリア、flaky ゼロトレランス |
どれ一つとして単体では巧妙なものではありません。価値は層を重ねることにあります。攻撃者やバグは、JWT claim のチェック、行ポリシー、カラム grant、サーバ側での導出、そして状態ガードをすべて突破しなければなりません——そして万一すり抜けたとしても、カナリアとアラートが、誰かが開かないドアの前に立つその瞬間ではなく、数分以内にそれを表面化させます。