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が返ることを検証します。