UnlockOS Developers
← 記事一覧に戻る
🔐

マルチテナントのアクセス制御:RLS・クレーム・冪等なWebhook

2026年6月22日2026年6月28日
9
90 commits
深度 8/10
securityauthorizationpostgrestypescripttesting

マルチテナントのアクセス制御: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_managermanagerFACILITY_MEMBERmember など)、権限チェックは黙って拒否側の分岐に落ちます。あるいはもっと悪いことに、寛容なデフォルトに落ちます。境界で正規化し、そのマッピングを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つあります。

  1. 招待トークンは ランダムなUUID であり、サーバー側で検証されること。推測可能なメールアドレス+施設の組み合わせであってはなりません。
  2. 受諾は 冪等 であること。「承諾」の二度押しで重複したメンバーシップが作られたり、ロールが昇格したりしてはいけません。
  3. 招待は 選択された施設にスコープされる こと。「作成者が現在開いている施設」であってはいけません。
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 DEFINER RPC経由での招待受諾。
  • 操作ごとのUUIDによるWebhookの紐付けと、紐付け不能なイベントを推測せず監査に回すこと。
  • 監査証跡が本当に真実であるよう、明示的なタイムゾーンでタイムスタンプをパース・保存すること。
  • 空き状況の表示と予約ゲートの背後にある、単一の共有述語。
  • 以上すべてがデグレしないよう守る、テストと最小権限のCI。

どれも単体では気の利いた工夫ではありません。しかしそれらが揃うことで、「たぶん安全だと思う」が「なぜ安全かをお見せできる」に変わるのです。

主要な発見

1
セキュリティ

WITH CHECKのないRLSのUPDATEポリシーはテナント越境を許す

USINGは対象にできる行を制御し、WITH CHECKは更新後の行を制御します。これがないとテナントはfacility_idを書き換えてレコードを別テナントへ移動できます。with_checkがNULLのUPDATE/INSERTポリシーをpg_policiesで監査しましょう。

2
セキュリティ

テナントキーをスキーマレベルでイミュータブルにする

facility_idの変更を拒否するBEFORE UPDATEトリガーは、RLSを越えた多層防御になります。さらにTypeScriptのOmitでクライアントの編集ペイロードからフィールド自体を除去すれば、改ざん可能な入力そのものがなくなります。

3
認可

ロール切り替え時だけでなく初回ログイン時にもクレームを発行する

クレームベースの認可は高速で一貫性がありますが、それはすべての入口でクレームが存在する場合に限ります。ロール選択時のみの発行では初回ユーザーの権限が空になります。同じクレーム形状をUIのゲートとデータベースのポリシーの双方で使うべきです。

4
認可

ロール名をfail-closedに正規化し、環境ドリフトを監査する

環境ごとに異なるロール表記は権限チェックを静かに壊します。未知の値にはnullを返すエイリアステーブルで境界において正規化し、環境ごとの実際のロール値を比較するドリフト監査を定期実行しましょう。

5
信頼性

Webhookは共有のロックIDではなく操作ごとのUUIDで紐付ける

発行するクレデンシャルに一意の操作UUIDを埋め込むことで、受信するハードウェアイベントを曖昧さなく紐付けられます。紐付け不能なイベントはヒューリスティックに一致させるのではなく監査ログへ記録し、後のリプレイのために相関キーを書き込み時に永続化すべきです。

6
信頼性

キャパシティと空き状況ゲートの信頼できる情報源を一本化する

「UIでは空きなのに送信すると409」という類のバグはロジックの重複から生まれます。権威あるリレーションからキャパシティを導出し、読み取りパスと書き込みパスの双方が同じ型付き述語を呼ぶことで、一致性は願望ではなくアーキテクチャの性質になります。

7
正確性

明示的なタイムゾーンでのパースが監査証跡の完全性を守る

オフセットのないベンダーのタイムスタンプを素朴なDateコンストラクタでパースすると数時間ずれ、監査記録と入室可能時間帯を壊します。明示的なベンダーゾーンでパースし、予約時刻は施設のタイムゾーンで保存・表示しましょう。

8
テスト

冪等性と最小権限CIを回帰防止のガードにする

受諾の再実行もチェックアウトの再実行も同じ状態に収束しなければならず、ユニットテストがそれを固定します。GitHub Actionsの権限を最小限(contents: read)に宣言することで、侵害されたサードパーティ製アクションの影響範囲を縮小できます。