コンテンツへスキップ
すべての記事

Postgres RLS とワークスペース認証によるマルチテナント SaaS のデータ分離

サーバーサイドのリアルタイムなワークスペースメンバーシップ検証とインデックス付き Postgres Row-Level Security ポリシーを組み合わせ、Web・モバイル・バックグラウンドジョブ全体でテナントデータを確実に分離します。

supastack チーム約 3 分で読めます

Should I use Postgres Row-Level Security or application-level workspace checks?

It depends on how your application connects to Postgres. If browser or mobile clients query the database directly through PostgREST, Row-Level Security (RLS) is mandatory. If all traffic flows through a server-side TypeScript boundary, explicit membership checks on every query are your primary defense, and RLS serves as defense-in-depth against forgotten WHERE clauses. Combine live session-to-membership resolution with indexed workspace_id policies. supastack enforces server-side workspace guards across web, mobile, and desktop, and provides a free RLS policy generator for Supabase tables.

アプリケーションガードとデータベースポリシーは異なる障害を防ぐ

アプリケーション層の認可と Postgres の Row-Level Security (RLS) は二者択一だと捉えられがちですが、実際には異なるバグを捕捉します。サーバーサイドのガードはビジネスロジックが動く前にロール階層、エンタイトルメント、監査ログを強制し、RLS は WHERE workspace_id = ... の書き忘れによって全テナントの行が漏洩する事態を確実に防ぎます。

Supabase を使う場合でも、Drizzle とセルフホスト Postgres を使う場合でも、テナント所有のすべてのテーブルには非 NULL の workspace_id 外部キーを持たせるべきです。ワークスペース境界の記事では、なぜそのスコープを常にサーバー側で導出しなければならないのかを解説しています。

  • アプリケーションガードはセッションの有効性、メンバーロール (owner, admin, member)、機能エンタイトルメントを検証する。
  • データベースの RLS ポリシーは、クライアントからの直接クエリや生 SQL ヘルパーが使われた際の最終防衛線として機能する。
  • 両方のレイヤーが単一の信頼できる情報源である workspace_members 中間テーブルに依存する。

古い Cookie を信用せずリアルタイムのメンバーシップを解決する

ブラウザの Cookie やクライアントヘッダーに currentWorkspaceId を保存するのは画面遷移には便利ですが、Cookie は単なる表示設定であり権限ではありません。管理者がチームメイトをワークスペースから削除しても、そのユーザーのブラウザには古いワークスペース Cookie が残っています。

すべてのリクエストで、アクティブなセッションと workspace_members を結合して確認してください。メンバーシップ行が存在しない場合は、JWT やセッション Cookie の有効期限が切れるのを待たずに即座にリクエストを拒否します。ログインフォームの先にある認証の記事では、ID がどのようにこのチェックへ繋がるかを追っています。

export async function requireWorkspaceViewer(requestedWorkspaceId?: string) {
  const session = await requireAuthenticatedSession();
  const membership = await db.query.workspaceMembers.findFirst({
    where: and(
      eq(workspaceMembers.userId, session.user.id),
      requestedWorkspaceId
        ? eq(workspaceMembers.workspaceId, requestedWorkspaceId)
        : undefined,
    ),
    with: { workspace: true },
  });

  if (!membership) throw new ForbiddenError("Not a member of this workspace");
  return { user: session.user, workspace: membership.workspace, role: membership.role };
}

高速でインデックスが効く Row-Level Security ポリシーを書く

各行で auth.uid() を直接呼び出す素朴な RLS ポリシーは、タプルごとに再評価を強いられます。(select auth.uid()) のようにスカラサブクエリで囲むとクエリプランナーが値を initPlan としてキャッシュでき、さらに (workspace_id, user_id) の複合インデックスを張ることでメンバーシップ検索をサブミリ秒のインデックススキャンに保てます。

無料の Supabase RLS ポリシージェネレーターを使えば、定型的な SQL を手書きすることなく、マルチテナントテーブル向けのすぐ実行できる SELECT・INSERT・UPDATE・DELETE ポリシーを生成できます。

CREATE INDEX IF NOT EXISTS idx_workspace_members_lookup
  ON workspace_members (workspace_id, user_id);

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY "workspace_members_manage_documents"
ON documents FOR ALL TO authenticated
USING (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
  )
)
WITH CHECK (
  workspace_id IN (
    SELECT wm.workspace_id FROM workspace_members wm
    WHERE wm.user_id = (SELECT auth.uid())
      AND wm.role IN ('owner', 'admin', 'member')
  )
);

RLS とワークスペース認証に関するよくある質問

マルチテナント Postgres を構築するチームは、サービスロール、バックグラウンドジョブ、パフォーマンスに関して同じエッジケースに直面します。

  • Drizzle ORM や Prisma は RLS をバイパスしますか? — はい、サーバーが postgres スーパーユーザーや Supabase の service_role キーで接続している場合はバイパスします。だからこそ、サーバーアクションや API ルートでは必ず requireWorkspaceViewer() を呼び出し、すべてのクエリを workspaceId で絞り込む必要があります。
  • RLS ポリシー内で (select auth.uid()) を括弧で囲むのはなぜですか? — Postgres オプティマイザーに、スキャンした行ごとではなくステートメントごとに 1 回だけ auth.uid() を評価させるためで、大規模テーブルでのシーケンシャルスキャンによる遅延を防ぎます。
  • バックグラウンドジョブはテナントデータにどうアクセスすべきですか? — 認証済みサーバールートからジョブペイロードに検証済みの workspaceId を渡し、ワーカーの読み書きをその workspaceId に限定します。
  • テナント分離が本当に機能していることをどう検証しますか? — ユーザー A が Web・モバイル・API ルート経由でユーザー B のレコード ID を取得または変更しようとする自動ネガティブテストを作成し、変更行数がゼロのまま 403 / 404 が返ることを検証します。

よくある質問

14 件の質問

supastack とは何ですか?

supastack は AI SaaS 製品を構築するためのモジュラー基盤です。共通インフラをまとめて提供するので、製品固有の機能に集中できます。

Web とモバイルに対応していますか?

はい。supastack には Web とモバイル体験の出発点として Next.js アプリと Expo アプリが含まれています。

どの認証が含まれていますか?

Better Auth がメール/パスワード、OTP、ソーシャルログインをカバーします。個人ワークスペースはアカウントと一緒にプロビジョニングされ、初日からレコードが境界内に収まります。

AI はどうサポートされていますか?

supastack には設定可能なマルチプロバイダー AI、使用量追跡、予算、暗号化された自前キー(BYOK)サポートが含まれています。

サブスクリプションはどう管理されますか?

このキット上に構築された製品では、Stripe 課金・サブスクリプション・権限が有料アクセスの基盤になります。プロバイダーの秘密はサーバー側に留まります。

製品固有のコードはどこに置きますか?

製品固有の機能は別のコードベースに置き、製品側が supastack の基本モジュールを設定・統合します。

supastack の価格は?

キットは生涯アップデート付きの一回払いです:シングルスタック 249 ドル(Next.js(Web)、Expo(iOS・Android)、Tauri(macOS・Windows デスクトップ)の各リポジトリから1つ選択)、デュアルスタック 398 ドル(任意の2リポジトリ)、またはフルバンドル 399 ドル(3つのリポジトリすべてと完全なモノレポ)、追加開発者シート 1 席 75 ドル。キットのサブスクリプションはありません。このサイトのチェックアウトはライセンスキーをメールで配信し、GitHub 招待でリポジトリアクセスを提供します。

ワークスペースはどうデータを囲いますか?

ユーザーレコードは workspaceId でスコープされます。認証、権限、AI 使用量、課金イベント、監査エントリは個人ワークスペースの境界内に留まります。

バックグラウンドジョブはどう機能しますか?

Trigger.dev が明示的なジョブランタイムです。JobRunner コントラクトは enqueue・status・cancel のみを約束し、ワークフロー意味論は Trigger.dev 固有で明示的です。

メールとアウトリーチの違いは?

トランザクションメールはタスク完了通知などの製品ワークフローをカバーします。コールドアウトリーチやキャンペーンは製品自身に属し、このキットのメールモジュールの外です。

モバイルの購入はどのように機能しますか?

モバイルでは RevenueCat によるネイティブ購入と復元に対応し、アクセス権をサーバーで検証します。公開前にストアアカウントを設定し、実機テストを完了してください。Web 課金は別に管理します。

AI プロバイダーのキーはどこにありますか?

BYOK 認証情報は暗号化され、サーバー側に留まります。キーはログでサニタイズされ、使用量と予算はワークスペースレベルで可視です。

チェックアウトで使える支払い方法は?

Stripe ホストのチェックアウトはカードを受け付け、Stripe アカウントでウォレットを有効にすれば Alipay と WeChat Pay も提供できます——中国の購入者に便利です。ウォレット認可は非同期で確定するため、決済完了と同時にライセンスページが更新されます。

返金を受けることはできますか?

リポジトリへのアクセス権を取得した後は、supastack は永久にあなたのものとなるため、返金はできません。ですがご安心ください。生涯アップデートを含む完全なソースコードを入手し、ご自身やクライアントのために無制限にプロダクトを構築・公開できます。コードの転売や、リポジトリをスターターキットやテンプレートとして再配布することのみ禁止されています。例外としてローンチキャッシュバックがあります。supastack で作ったプロダクトを実際に公開して共有し、購入から 60 日以内にオーガニックで 5,000 回表示またはオーガニックないいね 50 件に達すると、支払額の 50% が戻ります。詳しくは利用規約をご覧ください。

JA