UnlockOS Developers
← 記事一覧に戻る
🔐

クレームベースRLS、フェイルクローズドとテナント分離

2026年5月25日2026年5月31日
8
62 commits
深度 8/10
securitytypescriptpostgresauthorizationtesting

クレームベースRLS、フェイルクローズドなガード、そしてテナント分離

SDK が物理的なドアロックを制御する場合、認可パス上のあらゆる曖昧さは「解錠されたままのドア」になりかねません。直近のスプリントで UnlockOS プラットフォームはセキュリティ重視の総点検を行いました。テナントテーブルで row-level security を再有効化し、権限チェックをレガシーなルックアップテーブルから署名済み JWT クレームへ移行し、SECURITY DEFINER ビューを削除し、決済関数にモジュールロード時の環境変数ガードを追加し、token manager にあった暗黙の「自動解決」コードパスを削除して明示的なパラメータに置き換えました。

これらの変更はどれも新機能を出荷したわけではありません。すべては「システムを誤用しにくくする」ための変更です。本記事では、その背後にあるパターンを、マルチテナントかつセキュリティが重要なあらゆるバックエンドに応用できる形で解説します。


1. テーブルではなくトークンを信頼する

典型的な権限チェックの実装は、テーブルを select して「このユーザーは管理者か?」をデータベースに問い合わせます。

-- Anti-pattern: privilege check depends on a readable table
create or replace function public.is_platform_admin()
returns boolean
language sql
stable
as $$
  select exists (
    select 1 from public.platform_admins
    where user_id = auth.uid()
  );
$$;

これには3つの問題があります。第一に、すべての RLS ポリシー評価に追加のテーブルスキャンが結び付いてしまいます。第二に、そのテーブル自体にも RLS ポリシーが必要であり、そのポリシーが is_platform_admin() を参照すると再帰が発生します。第三に、「誰が管理者か」の信頼できる情報源が認証プロバイダとアプリケーションデータベースの2箇所に存在することになり、両者は乖離していきます。

解決策は、署名済みトークンを唯一の信頼できる情報源にすることです。ID プロバイダがログイン時にクレームを刻印し、データベースはテーブルに触れることなくそれを読み取ります。

create or replace function public.is_platform_admin()
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select coalesce(
    (
      nullif(current_setting('request.jwt.claims', true), '')::jsonb
        -> 'app_metadata' ->> 'is_platform_admin'
    )::boolean,
    false
  );
$$;

見た目以上に重要な2つのポイントがあります。

  • current_setting('request.jwt.claims', true)missing_ok = true を使っているため、未認証やバックグラウンドのコンテキストでは例外ではなく NULL を返します。coalesce(..., false) と組み合わせることで、この関数は フェイルクローズド になります。
  • set search_path = ''SECURITY DEFINER 関数に対する search path ハイジャック(Postgres でよく知られた権限昇格の経路)を防ぎます。

app_metadata は service role のみが書き込み可能で、JWT に署名されて含まれるため、クライアントがクレームを偽造することはできません。また、この関数がテーブルを読まなくなったことで、再帰を気にせずポリシー内で自由に使えるようになります。

2. RLS を再び有効にする — ただし推論可能なポリシーとともに

「アプリケーション層で保護されている」テナントテーブルは、保護されていません。anon key が漏洩したり、.eq('organization_id', ...) フィルタを1つ書き忘れたりした時点で、分離は失われます。organizations のようなコアテーブルで RLS を再有効化しても安全なのは、ポリシーが軽量かつ非再帰的な場合だけです。これはまさにクレームベースのヘルパーが可能にするものです。

alter table public.organizations enable row level security;

create policy organizations_select_self
  on public.organizations
  for select
  to authenticated
  using (
    public.is_platform_admin()
    or id = public.current_organization_id()
  );

create policy organizations_write_admin_only
  on public.organizations
  for all
  to authenticated
  using (public.is_platform_admin())
  with check (public.is_platform_admin());

変更を伴うポリシーには、必ず usingwith check の両方を書いてください。using は「どの行を参照・対象にできるか」を制御し、with check は「書き込みに行がどのような状態であってよいか」を制御します。with check を省略すると、権限を持つユーザーが行を別のテナントへ移動できてしまいます。

ビューはデフォルトで誤ったアイデンティティを継承する

Postgres のビューは歴史的に所有者の権限で実行され、その結果、基となるテーブルの RLS を黙って迂回します。レポート用のビューをクライアントに公開するなら、invoker セマンティクスに切り替えましょう。

alter view public.v_user_transactions set (security_invoker = on);

そのうえで、本来強制したかった条件を追加します。例えば、取引履歴を閲覧できる前提としてユーザーの連絡先アドレスが検証済みであること、といった条件です。

create policy user_transactions_select_owner
  on public.user_transactions
  for select
  to authenticated
  using (
    user_id = auth.uid()
    and coalesce(
      (auth.jwt() -> 'user_metadata' ->> 'email_verified')::boolean,
      false
    )
  );

3. リクエスト時ではなくモジュールロード時にフェイルクローズドする

決済やロック資格情報を扱う Edge/serverless 関数は、通常は環境からシークレットを読み取ります。危険なのは、それを遅延的に読む実装です。

// Anti-pattern: a missing secret becomes an empty string at request time
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY') ?? '');

こうすると、設定不備のあるデプロイでも起動には成功し、チェックアウトの途中で失敗します。しかもロックの権限付与がすでに発行された後かもしれません。代わりに、モジュールロード時に環境全体を一度だけ検証し、起動を拒否しましょう。

const REQUIRED_ENV = [
  'STRIPE_SECRET_KEY',
  'STRIPE_WEBHOOK_SECRET',
  'SUPABASE_URL',
  'SUPABASE_SERVICE_ROLE_KEY',
] as const;
type RequiredEnvKey = (typeof REQUIRED_ENV)[number];
export function requireEnv(
  keys: readonly RequiredEnvKey[],
): Readonly<Record<RequiredEnvKey, string>> {
  const missing: string[] = [];
  const resolved = {} as Record<RequiredEnvKey, string>;
  for (const key of keys) {
    const value = Deno.env.get(key);
    if (!value || value.trim() === '') {
      missing.push(key);
      continue;
    }
    resolved[key] = value;
  }
  if (missing.length > 0) {
    throw new Error(`[boot] missing required environment variables: ${missing.join(', ')}`);
  }
  return Object.freeze(resolved);
}

as const のタプルと、そこから導出した RequiredEnvKey ユニオンにより、返されるレコードは完全に型付けされます。env.STRIPE_SECRET_KEYstring であり、決して string | undefined にはならず、キー名のタイプミスはコンパイルエラーになります。

実務上の注意点が1つあります。import 時に例外を投げると、単にモジュールを import するだけのユニットテストや静的解析が壊れてしまいます。ガードを弱めるのではなく、ゲートを設けましょう。

const SKIP_BOOT_GUARD = Deno.env.get('SKIP_ENV_GUARD') === 'true';
export const env = SKIP_BOOT_GUARD
  ? ({} as Readonly<Record<RequiredEnvKey, string>>)
  : requireEnv(REQUIRED_ENV);

この抜け道はオプトインであり、明示的に名前が付けられており、本番のデプロイ設定では決して設定されません。

4. テスト用と本番用の資格情報を推測しない

関連するバグの類型として、設定画面に「テストモード」トグルがあるのに、そのフラグがコンポーネントの state にしか保持されず永続化されていない、というものがあります。次のリクエストでサーバーは undefined を受け取り、本番キーにフォールバックし、リハーサル中に実際のカードへ課金してしまいます。

防御的な形は、「不明なモード」をデフォルトではなくエラーとして扱うことです。

export type PaymentMode = 'test' | 'live';
export interface PaymentCredentials {
  mode: PaymentMode;
  secretKey: string;
  publishableKey: string;
}
export function resolvePaymentCredentials(
  settings: { isTestMode?: boolean | null; testKeys?: KeyPair; liveKeys?: KeyPair },
): PaymentCredentials {
  if (settings.isTestMode === undefined || settings.isTestMode === null) {
    throw new ConfigurationError(
      'PAYMENT_MODE_UNRESOLVED',
      'isTestMode must be persisted explicitly; refusing to default to live keys',
    );
  }
  const mode: PaymentMode = settings.isTestMode ? 'test' : 'live';
  const keys = mode === 'test' ? settings.testKeys : settings.liveKeys;
  if (!keys) {
    throw new ConfigurationError('PAYMENT_KEYS_MISSING', `no ${mode} keys configured`);
  }
  return { mode, secretKey: keys.secretKey, publishableKey: keys.publishableKey };
}

このルールは一般化できます。boolean が安全な経路と危険な経路を選択する場合、undefined は危険な経路ではなくエラーでなければなりません。

5. 暗黙のコンテキスト解決を削除する

複数の施設向けに資格情報をキャッシュする token manager には、便利なオーバーロードがありました。呼び出し側が facilityId を省略すると、周囲の state から現在の施設を「自動解決」するというものです。シングルテナントのセッションではこれで動きます。しかし複数施設を扱う管理者セッションでは、誤った物件向けに発行されたトークンを渡してしまう可能性があります。親切な API 表面を持ったクロステナントの資格情報漏洩です。

修正はオーバーロードを完全に削除し、パラメータを必須にすることでした。

export class TokenManager {
  private readonly cache = new Map<string, CachedToken>();
  async getAccessToken(facilityId: string): Promise<string> {
    if (!facilityId) {
      throw new UnlockOSError(
        'FACILITY_ID_REQUIRED',
        'facilityId is required; ambient resolution was removed to prevent cross-tenant token reuse',
      );
    }
    const cached = this.cache.get(facilityId);
    if (cached && cached.expiresAt - Date.now() > REFRESH_SKEW_MS) {
      return cached.accessToken;
    }
    const issued = await this.refresh(facilityId);
    this.cache.set(facilityId, issued);
    return issued.accessToken;
  }
  invalidate(facilityId: string): void {
    this.cache.delete(facilityId);
  }
}

キャッシュがグローバルではなくテナント単位でキー付けされている点に注目してください。共有のキャッシュスロットは、同じバグが別の衣装を着ているだけです。また invalidate もテナント単位なので、ある連携を切断しても無関係なセッションを吹き飛ばすことはありません。

6. 認可: 経路を列挙し、理由を返す

認可チェックを広げる作業は、403 のバグが「うっかり直される」場所です。予約を作成したゲストしか認識しない所有者チェックのせいで、物件オペレーターがキャンセルしようとすると 403 が返っていました。安易なパッチは述語を緩めることです。より安全なパッチは、各許可経路を明示的かつ観測可能にすることです。

export type AuthzDecision =
  | { allowed: true; via: 'reservation_guest' | 'facility_operator' | 'platform_admin' }
  | { allowed: false; reason: 'not_found' | 'not_owner' | 'facility_mismatch' };
export async function verifyReservationAccess(
  ctx: RequestContext,
  reservationId: string,
): Promise<AuthzDecision> {
  const reservation = await ctx.db.findReservation(reservationId);
  if (!reservation) return { allowed: false, reason: 'not_found' };
  if (ctx.actor.kind === 'platform_admin') {
    return { allowed: true, via: 'platform_admin' };
  }
  if (ctx.actor.kind === 'guest' && reservation.guestId === ctx.actor.id) {
    return { allowed: true, via: 'reservation_guest' };
  }
  if (ctx.actor.kind === 'operator') {
    return ctx.actor.facilityIds.includes(reservation.facilityId)
      ? { allowed: true, via: 'facility_operator' }
      : { allowed: false, reason: 'facility_mismatch' };
  }
  return { allowed: false, reason: 'not_owner' };
}

判別可能なユニオン(discriminated union)は二重に元が取れます。呼び出し側は両方の分岐を扱わざるを得ず(コンパイラが強制します)、via / reason フィールドはそのまま監査ログへ流し込めるため、「誰がこの予約をキャンセルでき、どのルールに基づいていたのか?」を事後に答えられます。

const decision = await verifyReservationAccess(ctx, reservationId);
await ctx.audit.record({
  action: 'reservation.cancel',
  reservationId,
  actorId: ctx.actor.id,
  outcome: decision.allowed ? 'allowed' : 'denied',
  rule: decision.allowed ? decision.via : decision.reason,
});
if (!decision.allowed) throw new ForbiddenError(decision.reason);

オペレーターのアクセスは facilityIds によってスコープされます。物件 A のオペレーターは、物件 B に対しては(正確な理由 facility_mismatch とともに)拒否されます。ロールを広げること自体は問題ありませんが、スコープの述語なしに広げるのは問題です。

7. 識別子はデータベースに到達する前に検証する

フィーチャーフラグのオーバーライドは user_idorganization_id で解決されます。UI が非テナントのケースに対して "platform" のようなセンチネル文字列を合成していたため、オーバーライドのクエリは UUID ではない値を受け取っていました。Postgres は 22P02 invalid input syntax for type uuid で拒否し、リゾルバがそのエラーを catch し、catch ブロックの実装次第でフラグがフェイルオープンするか、ページ全体がエラーになります。

2つの修正を同時に適用しました。どちらも真似する価値があります。

const UUID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuid(value: unknown): value is string {
  return typeof value === 'string' && UUID_RE.test(value);
}
export async function resolveOverride(
  scope: { userId?: string | null; organizationId?: string | null },
): Promise<Override | null> {
  const filters: OverrideFilter[] = [];
  if (isUuid(scope.userId)) filters.push({ column: 'user_id', value: scope.userId });
  if (isUuid(scope.organizationId)) {
    filters.push({ column: 'organization_id', value: scope.organizationId });
  }
  if (filters.length === 0) return null;
  return queryOverrides(filters);
}
  1. クラッシュせずスキップする。 不正な形式のスコープ識別子は「適用されるオーバーライドはない」を意味し、フラグのデフォルトへ解決されます。例外パスではなく決定論的な結果になります。
  2. センチネルを発生源から取り除く。 「組織なし」をマジック文字列として UUID 型のフィールドに埋め込むのは、型システムに対する嘘です。null と別個の scope: 'platform' | 'organization' 判別子を使えば、型は正直なままです。

付随するテストの変更は小さいですが示唆に富みます。オーバーライド解決のテストは、"user-1" のような文字列ではなく実際の UUID を使うよう書き換えられました。本番スキーマが拒否するようなデータ形状を使うテストは、フィクションを検証しているに過ぎません。

const USER_A = '3f1c2a7e-9b4d-4c2f-8a51-0b7d6e5f4a3c';
const ORG_A = '8d2e4b1a-6c3f-4e5d-9a7b-1c2d3e4f5a6b';
it('prefers a user-scoped override over an organization-scoped one', async () => {
  await seedOverride({ flag: 'booking.grid_view', userId: USER_A, enabled: true });
  await seedOverride({ flag: 'booking.grid_view', organizationId: ORG_A, enabled: false });
  const result = await resolveFlag('booking.grid_view', { userId: USER_A, organizationId: ORG_A });
  expect(result).toEqual({ enabled: true, source: 'user_override' });
});

8. ゲートはメニューではなくルートに置く

メニュー項目をフィーチャーフラグの裏に隠すのは UX の判断です。アクセス制御ではありません。URL を直接入力すればルートには到達できます。ゲートはナビゲーションが解決される場所で強制し、さらにサーバー側でも再度強制しましょう。

export function createFlagGuard(flags: FlagResolver) {
  return async (route: RouteDefinition, ctx: RequestContext) => {
    if (!route.requiredFlag) return { proceed: true } as const;
    const { enabled } = await flags.resolve(route.requiredFlag, {
      userId: ctx.actor.id,
      organizationId: ctx.actor.organizationId,
      facilityId: ctx.facilityId,
    });
    if (enabled) return { proceed: true } as const;
    return { proceed: false, redirectTo: '/not-available' } as const;
  };
}

リゾルバは完全なスコープ(ユーザー、組織、施設)を受け取ります。これにより、施設レベルのオーバーライドが組織レベルのデフォルトによって迂回されることがなくなり、優先順位がちょうど1箇所で定義され、呼び出し側ごとに再導出されることもなくなります。

9. スキーマ変更にもコードと同じパイプラインを

最後に、地味ですが重要な項目です。データベースマイグレーションを CI/CD に移行しました。手作業で適用されるその場限りのマイグレーションこそ、ステージングには存在するのに本番には存在しないポリシーを生む原因です。RLS にとってこれは最悪の障害モードです。なぜなら、アプリケーションは動き続ける一方で、ある環境だけ分離が静かに消失するからです。

set -euo pipefail
supabase db lint --level warning
supabase db diff --linked --schema public > /tmp/drift.sql
if [ -s /tmp/drift.sql ]; then
  echo "::error::linked database has drifted from migrations"
  cat /tmp/drift.sql
  exit 1
fi
supabase migration up --linked

重要なのはドリフトチェックの行です。これは「誰かが本番を手作業で変更した」という不可視の状態を、ビルド失敗という可視の事象に変えます。ローカルの pre-commit フックにある補助的なガードは、リンクされたプロジェクト参照が現在の worktree と一致することを検証するため、複数ブランチをチェックアウトしている開発者が誤った環境へマイグレーションを向けることを防ぎます。


まとめ

これらすべての変更に通底するものは同じです。システムが推測しなければならない経路を取り除くこと。

推測 置き換え
可変なテーブルから読む管理者ステータス 署名済み JWT クレーム、coalesce(..., false)
アプリケーション層でのテナントフィルタリング using + with check を備えた RLS
ビューが所有者権限で実行される security_invoker = on
欠落したシークレットが '' になる モジュールロード時に throw
未定義のテストモードフラグが本番キーにフォールバック 未解決のモードで throw
周囲の state からの施設解決 必須の facilityId パラメータ
UUID でないセンチネルがデータベースに到達 検証してスキップし、センチネルを廃止
メニューだけのフィーチャーゲーティング ルートガード + サーバー側チェック
手作業で適用するマイグレーション ドリフト検出付きのパイプライン

どれも小さく、地味な変更です。しかし合わせると、SDK が「このドアは開いてよい」と判断したとき、その結論に至ったコードパスはちょうど1つだけであり、それが監査ログに名前付きで記録され、いかなる環境の設定ミスもそれを広げ得なかった、ということを意味します。

主要な発見

1
セキュリティ

権限はクエリ可能なテーブルではなく署名済みクレームから導出する

`is_platform_admin` を `request.jwt.claims` から読むことで、RLS の再帰的な依存関係がなくなり、認証プロバイダとデータベース間の乖離も解消されます。さらに `coalesce(..., false)` と `set search_path = ''` により、未認証コンテキストではフェイルクローズドに動作します。

2
アクセス制御

RLS ポリシーには USING と WITH CHECK の両方が必要

`USING` はどの行が可視かつ対象にできるかを、`WITH CHECK` は書き込み後の行の状態を制御します。後者を省略すると、権限を持つユーザーがレコードを別テナントへ移動できてしまいます。ビューも `security_invoker = on` に切り替えないと RLS を完全に迂回します。

3
信頼性

モジュールロード時に環境を検証し、起動を拒否する

決済や資格情報発行を行う関数は、`as const` で型付けしたキー一覧を用いて import 時に必要なシークレットをすべて一度に検証すべきです。そうすれば設定不備のデプロイは、トランザクションの途中ではなくトラフィック処理前に明確に失敗します。

4
セキュリティ

不明な値は決して危険な分岐のデフォルトにしない

boolean がテスト用と本番用の決済資格情報を選択する場合、値が存在しない・永続化されていないときは設定エラーを送出しなければなりません。`undefined` を本番キーにフォールバックさせると、UI の永続化バグが実際の課金に変わってしまいます。

5
マルチテナンシー

資格情報 API から暗黙のコンテキスト解決を取り除く

周囲の state から施設 ID を自動解決すると、複数施設のセッションで誤った物件向けのトークンを発行しかねません。明示的なパラメータを必須にし、トークンキャッシュをテナント単位でキー付けすれば、クロステナントの再利用は構造的に不可能になります。

6
認可

認可を、許可経路を名付けた判別可能ユニオンとしてモデル化する

単なる boolean ではなく `{ allowed: true; via: 'facility_operator' }` を返すことで、呼び出し側は拒否を必ず処理するようになり、新しいロールも明示的な述語でスコープされ、適用されたルールがそのまま監査ログに流れます。

7
バリデーション

不正な識別子はデータベースに届く前に拒否する

UUID 型のカラムにセンチネル文字列を渡してはいけません。境界で検証してクエリをスキップすれば、catch ブロックがフェイルオープンしかねない `22P02` 例外の代わりに、決定論的なデフォルトが得られます。

8
運用

CI でスキーマドリフトを検出しないと分離は静かに失われる

手作業で適用したマイグレーションは、アプリケーションが動き続けたまま、ある環境にだけ RLS ポリシーが存在する状況を生みます。デプロイパイプラインの `db diff` によるドリフトゲートは、その不可視の状態をビルド失敗に変えます。