テナント分離、アトミックなRPC、改ざん不能な履歴
はじめに
スマートロックのプラットフォームは、UIが少し洗練されただけのCRUDアプリではありません。書き込む1行1行が、最終的には「特定の人物に対して、特定の分刻みのタイミングで、物理的な扉が開くかどうか」を決めます。そのため、多くのプロダクトなら耐えられる3種類のバグが、極めて高くつきます。
- スコープのバグ — テナント境界をまたいでしまう書き込みや読み取り。
- 競合のバグ — 2つのリクエストが両方とも「最後のロッカーを確保した」と信じてしまう、あるいは両方が同じ料金期間を置き換えてしまう。
- 履歴のバグ — 後から書き換え可能なレコードのせいで、監査証跡が何が起きたのかを説明できなくなる。
本記事では、直近の一連の開発を通じて我々が収束させたパターンをまとめます。在庫を持つマルチテナントのオプション商品、期間履歴を備えた日付クラス別の料金カレンダー、アトミックなRPCで裏打ちされたプロビジョニング操作、課金台帳、そしてそれらを正しく保つバリデーション/テストの規律です。ここで扱う技法は汎用的で、Postgresをバックエンドに持つマルチテナントかつ「お金とアクセス」を扱うシステム全般に適用できます。
1. スコープはハンドラの習慣ではなく、スキーマの性質である
今回のサイクルで修正した中で最も危険な欠陥は、ごく平凡なものでした。ゲスト専用のカラムを、決して見てはならない呼び出し元に返してしまうクエリ。そして、テナントのスコープをセッションではなくリクエストボディから取得していた書き込み経路。どちらも根本原因は同じで、境界がアプリケーションコードで強制されていたことです。そのため、新しいハンドラを書くたびに、正しく境界を導出し直す必要がありました。
解決策は、データベース側に拒否させることです。Row Level Security は「開発者が覚えていた」を「エンジンが強制した」に変えます。
alter table option_products enable row level security;
alter table option_products force row level security;
create policy option_products_tenant_read on option_products
for select
using (facility_id = any (auth_facility_ids()));
create policy option_products_tenant_write on option_products
for all
using (facility_id = any (auth_facility_ids()))
with check (facility_id = any (auth_facility_ids()));ポリシーそのものよりも重要な点が2つあります。
force row level securityは、テーブル所有者に対してもポリシーを適用します。これがないと、マイグレーションやサービスロールが、せっかく書いたポリシーを黙って回避してしまいます。- クロステナントの書き込みを止めるのは
with checkです。usingは「何が見えるか」をフィルタするだけで、他人のスコープに行をinsert/updateするのを止めるのはwith checkだけです。usingしかないポリシーは、書き込みガードのふりをした読み取りガードにすぎません。
読み取り側でも、カラムの漏洩には同じ扱いが必要です。select * に加えてTypeScript側で手書きの許可リストを持つのではなく、ロールが見てよいカラムだけを含むビューを公開し、型ジェネレータにそこからクライアント型を導出させます。
create view option_products_public as
select id, facility_id, name, price, stock_visible
from option_products;こうすると、漏洩は本番障害ではなくコンパイルエラーになります。生成された型には読み取るべき guest_note フィールドがそもそも存在しないからです。
2. read-modify-write は競合である。1文にまとめよ
在庫の減算、プロビジョニング、そして「現在有効な期間を置き換える」処理は、すべて同じ形をしています。行を読み、判断し、書く。この読みと書きの間に、別のリクエストが割り込んで勝つことがあります。ロッカーやアクセス認証情報の文脈では、これは丸め誤差では済みません。2人のゲストが同じボックスを手にするということです。
我々が落ち着いたルールはこうです。現在の状態に依存するあらゆる判断は、必要なロックを取得する単一のデータベース呼び出しの中で実行しなければならない。
create or replace function reserve_option_stock(
p_product_id uuid,
p_reservation_id uuid,
p_quantity int
) returns table (unit_id uuid) language plpgsql security invoker as $$
declare
v_available int;
begin
select stock_remaining into v_available
from option_products
where id = p_product_id
for update;
if v_available is null then
raise exception 'product_not_found' using errcode = 'P0002';
end if;
if v_available < p_quantity then
raise exception 'insufficient_stock' using errcode = 'P0001';
end if;
update option_products
set stock_remaining = stock_remaining - p_quantity
where id = p_product_id;
return query
insert into option_allocations (product_id, reservation_id, quantity)
values (p_product_id, p_reservation_id, p_quantity)
returning id;
end;
$$;真似する価値のあるポイント:
- 関数内の
for updateこそが本質です。直列化は行レベルで起こるのであって、プロセスと共に消えるNode.jsのミューテックスで起こるのではありません。 security invokerは、呼び出し元に対してRLSを有効なまま維持します。security definerに手を伸ばすのは本当に権限昇格が必要なときだけにし、その場合は関数本体の内部でテナントチェックを改めて行ってください。definer関数は、ポリシーに空けた穴そのものです。errcodeを分けておくと、呼び出し元はエラーメッセージの文字列マッチではなく、型付きの結果に失敗をマッピングできます。
TypeScript側では、RPCの結果を判別可能なunion型にして、呼び出し元が失敗のブランチを忘れられないようにします。
export type ReserveResult =
| { ok: true; unitIds: string[] }
| { ok: false; reason: 'insufficient_stock' | 'product_not_found' };
export async function reserveStock(input: ReserveInput): Promise<ReserveResult> {
const { data, error } = await rpc('reserve_option_stock', input);
if (!error) return { ok: true, unitIds: data.map((r) => r.unit_id) };
if (error.code === 'P0001') return { ok: false, reason: 'insufficient_stock' };
if (error.code === 'P0002') return { ok: false, reason: 'product_not_found' };
throw error;
}このunion型は、あらゆる呼び出し箇所で網羅的な switch を強制します。未知のエラーは依然としてthrowされます。認識できないデータベースエラーを黙って握りつぶすことこそ、在庫のマイナス超過が見えなくなる原因です。
3. 履歴は改ざん不能でなければならず、置き換えはアトミックでなければならない
料金期間、営業時間のシーズン、アクセスポリシーには、すべて同じ要件があります。すなわち、時刻Tにおいて有効だったルールは何だったのか? もし履歴テーブルをクライアントが編集できるなら、この問いに信頼できる答えはなく、課金クレームやアクセス監査といった下流の争いはすべて解決不能になります。
履歴を信頼できるものにする制約は3つです。
- クライアントは遷移タイムスタンプを一切提供しない。 リクエストペイロードの値ではなく、データベースの
now()を使います。 - 旧期間のクローズと新期間のオープンは1つのトランザクションで行うため、ルールが適用されない隙間も、2つが同時に適用される重複も発生しません。
- データベースが構造的に重複を拒否する。 アプリケーション側の実行順序に頼りません。
alter table pricing_periods
add constraint pricing_periods_no_overlap
exclude using gist (
facility_id with =,
tstzrange(effective_from, effective_to) with &&
);
create or replace function replace_pricing_period(
p_facility_id uuid,
p_rates jsonb
) returns uuid language plpgsql as $$
declare
v_now timestamptz := now();
v_id uuid;
begin
update pricing_periods
set effective_to = v_now
where facility_id = p_facility_id and effective_to is null;
insert into pricing_periods (facility_id, rates, effective_from, effective_to)
values (p_facility_id, p_rates, v_now, null)
returning id into v_id;
return v_id;
end;
$$;排他制約(exclusion constraint)が構造を支える要です。これがあれば、将来のバグ入りマイグレーションも、手動の psql セッションも、リトライされたリクエストも、同時に有効な2つのルールを生み出すことはできません。正しさがコードレビューに依存しなくなるのです。
同じ一連の作業から得られた関連する教訓: 予約時点の価格は推論するものではなく、入力である。 チェックアウトのフローが「今日のレート」を見て価格を再導出すると、ゲストが同意した金額ではなく、現在のルールが示す金額を黙って請求してしまいます。予約の識別子をチェーン全体に引き回し、そのレコードのタイムスタンプ時点のレートを解決してください。
const quote = await priceQuote({
checkInId,
asOf: reservation.createdAt,
});4. 監査証跡がある世界で、削除は嘘である
以前は、通知ワークフローを削除すると配信履歴までカスケードで消えていました。それは便利ですが、「ゲストはチェックイン前に本当に通知されたのか?」と聞かれるまでの話です。管理者がUIを片付けたせいで、まさにその証拠が失われているのです。
ソフトデリートなら、参照履歴を保ったまま、あらゆる運用画面から行を取り除けます。
alter table notification_workflows add column deleted_at timestamptz;
create policy workflows_visible on notification_workflows
for select using (deleted_at is null or current_setting('app.include_deleted', true) = 'on');
create index on notification_workflows (facility_id) where deleted_at is null;部分インデックスが重要です。これなしのソフトデリートは、すべての一覧クエリを、蓄積された墓標(tombstone)のフルスキャンに変えてしまいます。そしてデフォルトの読み取り経路は、開発者全員が .is('deleted_at', null) を付け忘れないように祈るのではなく、ポリシーによって削除済み行を除外しなければなりません。
5. クライアントのバリデーションはUX、サーバのバリデーションは契約
フォームコンポーネントで1日24時間に上限を設けたクォータのフィールドは、あくまでヒントです。同じ上限がRPCで強制され、さらに check 制約として表現されていれば、それは保証になります。我々は、不変条件を守るあらゆる境界値について3つの表現が必要だと考えています。スキーマ制約、サーバ側バリデーション、クライアントのヒントです。最初の2つは必須です。
alter table membership_plans
add constraint daily_hours_range check (daily_hours between 0 and 24);export const membershipPlanSchema = z.object({
dailyHours: z.number().int().min(0).max(24),
name: z.string().trim().min(1).max(120),
});
export type MembershipPlanInput = z.infer<typeof membershipPlanSchema>;(両方を宣言するのではなく)スキーマからTypeScriptの型を導出すれば、バリデータと型が乖離することはあり得なくなります。同じパターンはメッセージ本文にも当てはまります。件名を必須にし、長さをサーバ側で制限することで、上限のないペイロードが下流の配信プロバイダに届くのを防げます。そこでの障害モードは、きれいな拒否ではなく部分送信になってしまうからです。
6. タイムゾーンはフォーマットの問題ではなく、正しさの問題
今回のバッチで発生した2つの別々の欠陥は、同じ根本原因から来ていました。ローカルの Date オブジェクトから日付を構築し、ランタイムのオフセットにカレンダー上の日付を決めさせていたのです。選択した日の前日を返すピッカーも、施設ではなくブラウザのタイムゾーンで描画されるチェックインウィンドウも、タイムスタンプを文字列の問題として扱ったことの症状です。
持続する原則: カレンダー上の日付は、明示的なタイムゾーンを伴う値(YYYY-MM-DD)である。Date から暗黙的に導出してはならない。
export function toFacilityDate(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(instant);
}アクセス制御システムにおいて、これは見た目の問題ではなくセキュリティ上の性質です。1日早く有効化される認証情報は、そのまま不正な入室可能時間帯を意味します。
7. 実際に書き込む経路に対するゴールデンテスト
純粋なヘルパー関数へのユニットテストは安価ですが浅いものです。本当に価値を生むテストは、apply経路、すなわち入力から永続化された結果までの全行程を、リポジトリにコミットされた期待成果物と突き合わせて検証するものです。
it('applies extracted fields and leaves unknowns to defaults', async () => {
const result = await applyExtraction(loadFixture('golden/partial-input.json'));
expect(result.applied).toMatchSnapshot();
expect(result.skipped).toEqual(['seasonRates']);
expect(result.warnings).toHaveLength(0);
});ゴールデンケースは、ここで本当に問題となるリグレッションを捕まえます。週末/ティア/シーズンの修飾子を黙って捨ててしまう価格計算ユニット、承認画面が判断できるだけの情報を得る前に切り詰めてしまう抽出処理、触るべきでないフィールドを上書きしてしまう部分適用。どれもクラッシュではなく、静かに誤った答えを返すものです。まさに、型だけでは捕まえられない種類のバグです。
8. 一度もリストアしていないバックアップは仮説にすぎない
最後に。本番のバックアップスクリプトは、backup、verify、restore の3点セットで出荷しました。verifyのステップこそチームが省略しがちであり、そして残り2つを現実のものにするステップです。
set -euo pipefail
pg_dump --format=custom --file="$DUMP" "$DATABASE_URL"
pg_restore --list "$DUMP" > /dev/null
psql "$SCRATCH_URL" -c 'drop schema if exists public cascade; create schema public;'
pg_restore --dbname="$SCRATCH_URL" --exit-on-error "$DUMP"
psql "$SCRATCH_URL" -tAc 'select count(*) from reservations' | grep -qv '^0$'set -euo pipefail と --exit-on-error は意図的なものです。部分的に成功して終了コード0で終わるリストアスクリプトは、スクリプトが存在しないより悪いのです。偽りの安心を製造するからです。
まとめ
これらすべてを貫く一本の線は同じです。不変条件を、回避できない層まで押し下げる。
| 不変条件 | 誤った層 | 正しい層 |
|---|---|---|
| テナント分離 | ハンドラのフィルタ | RLS の using + with check |
| 二重割り当ての防止 | アプリレベルのチェック | 単一RPC内の for update |
| ルールの重複禁止 | 順序付けた書き込み | exclude using gist 制約 |
| 削除後も残る監査証跡 | カスケード削除 | deleted_at + 部分インデックス |
| 値の範囲 | フォームのバリデーション | check 制約 + サーバスキーマ |
| カレンダーの正しさ | ローカルの Date |
明示的なタイムゾーンでのフォーマット |
この表の各行は、「注意深さを要するバグ」を「起こり得ないバグ」に変換しています。扉を開け、お金を動かすシステムにおいて、この変換こそが仕事のすべてです。