マルチテナントのアクセス制御:RLS・クレーム・冪等なWebhook
スマートロックのプラットフォームは、その最も弱い境界と同じ程度にしか信頼できません。ホテル、コワーキングスペース、集合住宅といった多数の施設を単一のデプロイメントで運用する場合、すべてのクエリ、すべてのJWTクレーム、そしてすべての受信Webhookが、テナントをまたぐ情報漏えいの潜在的な起点になります。本記事では、認可スタック、データベース層、外部連携の境界に対して適用した一連の堅牢化パターンを、あらゆるマルチテナントシステムに応用できる汎用的なコードとともに解説します。
1. Row-Level Security には USING だけでなく WITH CHECK が必要
PostgresのRLSで非常によくある間違いが、UPDATE ポリシーを USING 句だけで記述してしまうことです。USING が制御するのは どの行を参照・対象にできるか です。WITH CHECK が制御するのは 更新後の行がどのような姿であることを許されるか です。後者がなければ、テナントは正当に所有する行をSELECTしたうえで、その facility_id を別のテナントのものに書き換えられます。これは事実上、テナント境界を越えてレコードを譲渡(あるいは窃取)できてしまうということです。
-- VULNERABLE: caller can move the row to another tenant
CREATE POLICY check_ins_update ON check_ins
FOR UPDATE
USING (facility_id = ANY (current_facility_ids()));
-- HARDENED: the post-image must also stay inside the caller's scope
DROP POLICY check_ins_update ON check_ins;
CREATE POLICY check_ins_update ON check_ins
FOR UPDATE
USING (facility_id = ANY (current_facility_ids()))
WITH CHECK (facility_id = ANY (current_facility_ids()));経験則としては、すべての FOR UPDATE および FOR INSERT ポリシーには WITH CHECK を付ける ことです。これは機械的に監査できます。
SELECT schemaname, tablename, policyname, cmd
FROM pg_policies
WHERE cmd IN ('UPDATE', 'INSERT')
AND with_check IS NULL;このクエリが行を返すなら、テナントをまたげる穴が存在します。
2. テナントキーを構造的にイミュータブルにする
RLSは実行時のガードです。多層防御の考え方に従えば、テナントキーはスキーマレベルでもイミュータブルであるべきで、そうすればservice roleのスクリプトやマイグレーション、あるいは将来のポリシーのデグレによってレコードが黙って別テナントへ移動することを防げます。
CREATE OR REPLACE FUNCTION assert_facility_id_immutable()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF NEW.facility_id IS DISTINCT FROM OLD.facility_id THEN
RAISE EXCEPTION 'facility_id is immutable (table %, id %)', TG_TABLE_NAME, OLD.id
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER check_ins_facility_id_immutable
BEFORE UPDATE ON check_ins
FOR EACH ROW EXECUTE FUNCTION assert_facility_id_immutable();これと対をなすフロントエンド側の変更も同じくらい重要です。すなわち、編集ペイロードにテナントキーをそもそも含めない ことです。クライアントが更新時に facility_id を一切送信しなければ、改ざんできるフィールドが存在せず、サーバーは認証済みセッションだけからスコープを導出します。
type CheckInEditablePayload = Omit<CheckInRow, 'id' | 'facility_id' | 'created_at'>;
export function toEditPayload(form: CheckInForm): CheckInEditablePayload {
const { facility_id: _ignored, ...editable } = form;
return editable;
}TypeScriptの Omit は、「テナントIDは送らないでください」というコードレビュー上の慣習を、コンパイル時の保証へと変えてくれます。
3. クレームベースの認可:早い段階で発行し、あらゆる場所で検証する
リクエストごとにデータベースへ問い合わせるロール判定は遅く、書き忘れも起こりがちです。施設スタッフの所属を 加算的なJWTクレーム としてエンコードすれば、単一かつ低コストな信頼できる情報源が得られます。ただしそれは、クレームが適切なタイミングで発行され、検証されるまでは常に信頼できない入力として扱われる場合に限ります。
重要なタイミングは2つあります。初回ログイン と ロール/施設の切り替え です。ありがちなバグの型として、後者の経路でしかクレームを発行しないというものがあり、この場合、初回ユーザーはクレームが空のまま、理由の分からない空のUIに直面します。
export interface FacilityStaffClaims {
facility_roles: Record<string, StaffRole>;
}
export type StaffRole = 'owner' | 'manager' | 'member';
const WRITE_ROLES: ReadonlySet<StaffRole> = new Set(['owner', 'manager']);
export function canRead(claims: FacilityStaffClaims, facilityId: string): boolean {
return Boolean(claims.facility_roles[facilityId]);
}
export function canWrite(claims: FacilityStaffClaims, facilityId: string): boolean {
const role = claims.facility_roles[facilityId];
return role !== undefined && WRITE_ROLES.has(role);
}同じクレームの形をデータベースのポリシーにも使うべきです。そうすればUIとデータ層が食い違うことはあり得なくなります。
CREATE OR REPLACE FUNCTION current_facility_ids()
RETURNS uuid[]
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(
ARRAY(
SELECT (jsonb_object_keys(
COALESCE(auth.jwt() -> 'facility_roles', '{}'::jsonb)
))::uuid
),
ARRAY[]::uuid[]
);
$$;ロールの正規化はセキュリティの問題
環境ごとにロールの表記が異なる場合(facility_manager と manager、FACILITY_MEMBER と member など)、権限チェックは黙って拒否側の分岐に落ちます。あるいはもっと悪いことに、寛容なデフォルトに落ちます。境界で正規化し、そのマッピングをADRに記録して安定させましょう。
const ROLE_ALIASES: Record<string, StaffRole> = {
owner: 'owner',
facility_owner: 'owner',
manager: 'manager',
facility_manager: 'manager',
member: 'member',
facility_member: 'member',
};
export function normalizeRole(raw: string | null | undefined): StaffRole | null {
if (!raw) return null;
return ROLE_ALIASES[raw.trim().toLowerCase()] ?? null;
}fail-closedな ?? null に注目してください。認識できないロールには何の権限も与えられません。さらに、ステージングと本番で実際に使われているロール値の集合をエイリアステーブルと突き合わせる 環境ドリフト監査 を実行しましょう。スケジュール実行のジョブで見つかるドリフトは、サポートチケットで見つかるドリフトよりはるかに安く済みます。
4. 招待:冪等な受諾と、すべてをテナントスコープに
スタッフ招待のフローは、設計上そもそも権限を発行するものであるため、テナントをまたぐ穴の典型です。譲れない性質が3つあります。
- 招待トークンは ランダムなUUID であり、サーバー側で検証されること。推測可能なメールアドレス+施設の組み合わせであってはなりません。
- 受諾は 冪等 であること。「承諾」の二度押しで重複したメンバーシップが作られたり、ロールが昇格したりしてはいけません。
- 招待は 選択された施設にスコープされる こと。「作成者が現在開いている施設」であってはいけません。
CREATE OR REPLACE FUNCTION accept_staff_invite(p_invite_id uuid)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_invite staff_invites%ROWTYPE;
BEGIN
SELECT * INTO v_invite
FROM staff_invites
WHERE id = p_invite_id
AND accepted_at IS NULL
AND expires_at > now()
AND lower(email) = lower(auth.jwt() ->> 'email')
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'invite_invalid' USING ERRCODE = '42501';
END IF;
INSERT INTO facility_staff (facility_id, user_id, role)
VALUES (v_invite.facility_id, auth.uid(), v_invite.role)
ON CONFLICT (facility_id, user_id) DO NOTHING;
UPDATE staff_invites SET accepted_at = now() WHERE id = v_invite.id;
END;
$$;search_path を固定した SECURITY DEFINER、行ロック、有効期限チェック、JWTに対する本人性チェック、そして ON CONFLICT DO NOTHING ——それぞれの行が特定の攻撃または競合状態を塞いでいます。
5. Webhookの紐付け:共有識別子を決して信用しない
外部のハードウェアはWebhookを送り返してきます。もしそれらのイベントを 共有された 識別子——ロックID、ドアID、施設ID——で紐付けているなら、同じロックに対する2つの同時操作は区別がつかなくなり、イベントが誤ったレコードに記録され得ます。解決策は、発行するクレデンシャルに 操作ごとのUUID を埋め込み、そのUUIDだけで厳密に紐付けることです。
export function buildPinTargetName(slipId: string): string {
return `slip:${slipId}`;
}
export function resolveSlipIdFromWebhook(payload: WebhookPayload): string | null {
const match = /^slip:([0-9a-f-]{36})$/i.exec(payload.targetName ?? '');
return match ? match[1] : null;
}
export async function handleDeliveryWebhook(payload: WebhookPayload) {
const slipId = resolveSlipIdFromWebhook(payload);
if (!slipId) {
await auditLog.warn('webhook.unattributed', { targetName: payload.targetName });
return;
}
await recordDelivery({
slipId,
entranceId: payload.entranceId,
occurredAt: parseVendorTimestamp(payload.createTime),
});
}盗む価値のあるポイントが2つあります。
- 大きな声で、しかし安全に失敗する。 紐付け不能なWebhookは推測せず、監査のためにログへ残します。推測こそが、イベントを誤ったテナントに着地させる原因です。
- 後で必要になる相関キーを永続化する。 配送ログと並べて
entrance_idを記録しておくことが、監査時にカメラ/録画のリプレイを可能にします。書き込み時にJOINキーを保存していなければ、監査証跡は事実上失われます。
6. タイムスタンプは正しさの境界線
9時間ずれた監査ログは、監査ログがないより悪い状態です。誤った安心感を生むからです。ベンダーのAPIはしばしば、オフセットのないローカルの壁時計時刻を返します。その文字列を素朴に new Date() でパースすると、サーバーのタイムゾーンが黙って適用されてしまいます。
import { fromZonedTime } from 'date-fns-tz';
export function parseVendorTimestamp(raw: string, vendorZone = 'Asia/Tokyo'): Date {
if (/(?:Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
return new Date(raw);
}
return fromZonedTime(raw, vendorZone);
}同じ規律はプロダクトの内部にも当てはまります。予約時刻はブラウザのタイムゾーンではなく、施設のタイムゾーンで 表示 し、かつ 保存 しなければなりません。閲覧者のロケールで表示されながらUTCにずれた形で書き込まれた予約は、1日ずれた入室可能時間帯を生み出します。誤った日に開いてしまうロックは、セキュリティインシデントそのものです。
export function toFacilityInstant(localInput: string, facilityTimeZone: string): string {
return fromZonedTime(localInput, facilityTimeZone).toISOString();
}そして、範囲の境界が包含的かどうかにも注意してください。start <= day ではなく start < day を計算する週表示は、範囲の初日ちょうどに始まる予約をすべて取りこぼします。グリッドの1列にしか影響しないため、何ヶ月も潜伏するタイプのオフバイワンです。
7. キャパシティのゲートは信頼できる情報源を1つに
「空きと表示されるのに、送信すると409が返る」というバグは、セキュリティの香りがする信頼性の失敗です。空き状況の計算と予約のゲートが別々のコードパスで、徐々に食い違ったのです。修正は構造的なものになります——権威あるリレーションからキャパシティを導出し、読み取りパスと書き込みパスの 両方 が同じ関数を呼ぶようにします。
export function resolveCapacity(roomType: RoomType, linkedRooms: Room[]): number {
return linkedRooms.reduce((sum, room) => sum + room.capacity, 0);
}
export function isSlotBookable(input: SlotInput): Result<true, BookingRejection> {
if (!input.plan.availableDays.includes(input.dayOfWeek)) {
return err({ code: 'DAY_NOT_AVAILABLE' });
}
if (input.occupied >= resolveCapacity(input.roomType, input.linkedRooms)) {
return err({ code: 'CAPACITY_EXCEEDED' });
}
return ok(true);
}グリッドのレンダラーもPOSTハンドラーも、ともに isSlotBookable を呼びます。そうすれば一致性はコードレビューでの約束ではなく、アーキテクチャの性質になります。判別可能な Result 型は拒否理由に型を付けたまま保つので、UIは汎用的な「エラーが発生しました」に逃げることなく、正確にローカライズできます。
8. テストで挙動を固定する
上記のすべてのガードには回帰テストを用意する価値があり、それを最も安く手に入れる方法は、初日からCIで動くテストハーネスを持つことです。
import { describe, expect, it } from 'vitest';
describe('accept flow guards', () => {
it('is idempotent on repeated accept', async () => {
await acceptInvite(inviteId);
await acceptInvite(inviteId);
const rows = await listStaff(facilityId);
expect(rows.filter((r) => r.userId === userId)).toHaveLength(1);
});
it('rejects an unknown role fail-closed', () => {
expect(normalizeRole('SUPER_ADMIN_X')).toBeNull();
});
it('keeps already-checked-out calls idempotent', async () => {
const first = await checkOut(reservationId);
const second = await checkOut(reservationId);
expect(first.status).toBe('checked_out');
expect(second.status).toBe('checked_out');
});
});最後に、CI自体にも最小権限を適用しましょう。GitHub Actionsのトークンは、明示的に宣言しない限り広範な書き込みスコープがデフォルトになります。
permissions:
contents: read
id-token: writeビルドとテストしか行わないワークフローに必要なのは contents: read だけで、それ以外は不要です。これは1行の変更で、サードパーティ製アクションが侵害された場合の影響範囲を縮小します。
まとめ
物理アクセスシステムへの信頼は、地味で重層的な保証の積み重ねから組み上げられます。
- すべての書き込みポリシーへの
WITH CHECKと、トリガーで強制されるイミュータブルなテナントキー。 - クライアントのペイロードからも、それを記述する型からもテナントIDを取り除くこと。
- ログイン時とロール切り替え時の 両方 でクレームを発行し、fail-closedに正規化し、UIとデータベースで共有すること。
- 有効期限と本人性のチェックを備えた、冪等な
SECURITY DEFINERRPC経由での招待受諾。 - 操作ごとのUUIDによるWebhookの紐付けと、紐付け不能なイベントを推測せず監査に回すこと。
- 監査証跡が本当に真実であるよう、明示的なタイムゾーンでタイムスタンプをパース・保存すること。
- 空き状況の表示と予約ゲートの背後にある、単一の共有述語。
- 以上すべてがデグレしないよう守る、テストと最小権限のCI。
どれも単体では気の利いた工夫ではありません。しかしそれらが揃うことで、「たぶん安全だと思う」が「なぜ安全かをお見せできる」に変わるのです。