Next.js + Prisma で PostgreSQL の Row Level Security を試す

Next.js + Prisma で PostgreSQL の Row Level Security を試す

近年、バイブコーディングや個人開発の現場において、Next.js と Supabase を組み合わせたアプリケーション開発が急速に広がっています。

Supabase は、PostgreSQL を基盤とした BaaS(Backend as a Service)であり、認証やストレージ、データベース操作といった機能を短時間で導入できる点が魅力です。Next.js と併用することで、フロントからバックエンドまでを一気通貫で実装できるため、特に個人開発やスタートアップにとって非常に有用な選択肢となっています。

しかし一方で、この手軽さがセキュリティ上のリスクを生むケースも少なくありません。

特に懸念されるのは、Row Level Security(RLS)を適切に設定していないことによって、アプリケーションの利用者が他のユーザーのデータにアクセスできてしまう脆弱性です。実際、海外の開発者ブログやSNS上でも、Supabase を利用したプロジェクトで「認可設定が甘く、ユーザーデータが丸見えになっていた」といった事例が報告されています。これは単純な実装ミスであると同時に、「DB レイヤーでのアクセス制御を軽視した設計」が引き起こす典型的な問題でもあります。

アプリケーションコードの中で「where 句」を書き忘れたり、認証の条件分岐が抜けてしまったりすることは、人間がコードを書く以上どうしても起こり得ます。そうしたヒューマンエラーを補完し、データの安全性を保証するために有効なのが、PostgreSQL が備える Row Level Security(RLS) です。RLS は、テーブルごとに「誰がどの行を参照・更新できるのか」をポリシーとして定義でき、アプリケーション層のバグに左右されず、データベース側で強制的に境界を守ることができます。

本記事では、Supabase の文脈で話題に上がることの多い RLS を、より基盤寄りの構成(Next.js + Prisma + Docker Compose + PostgreSQL)で実際に構築し、その有効性を確認していきます。

認証セッションや JWT といった仕組みと組み合わせることで、開発規模が大きくなっても安全性を確保できる堅牢なアプリケーション設計が可能になります。

この記事を通して読者の方に伝えたいのは、「アプリ層だけでなくデータベース層でもセキュリティ境界を確立することの重要性」です。Next.js や Supabase を利用して個人開発やスタートアップ開発を進めている方にとっても、よりセキュアな設計を実践する上で参考となるはずです。

目次

Row Level Security(RLS)とは

PostgreSQL が提供する Row Level Security(RLS) は、テーブルごとに行レベルでアクセス制御を行う仕組みです。通常はアプリケーション側で「WHERE 句」を付与してユーザーごとのデータ制限を実現しますが、この方法だとコードの書き漏らしやバグによって他人のデータにアクセスできてしまう可能性があります。RLS を使えば、データベース自身が行単位でアクセス制御を強制するため、アプリケーション層の不備を補完できるのが大きな特徴です。

どのバージョンから利用できるのか

RLS は PostgreSQL 9.5(2016年リリース) から導入されました。

その後、9.6 以降では細かな機能改善が続き、現在の最新バージョン(15, 16, 17 系列)でも標準機能として利用できます。

つまり、近年のほとんどの PostgreSQL 環境(Supabase や Cloud SQL などのマネージドサービスを含む)では、追加モジュールを導入することなくすぐに RLS を有効化できます。

仕組みの概要

  • 有効化 各テーブルごとに ENABLE ROW LEVEL SECURITY を指定すると RLS が有効になります。さらに FORCE ROW LEVEL SECURITY を付けることで、スーパーユーザーを除くすべてのクエリにポリシーが強制されます。
  • ポリシー定義 CREATE POLICY を使って「どの条件を満たす行を参照できるか/更新できるか」を定義します。 たとえば、company_id がセッション変数に一致する行だけを返すようにすれば、ユーザーは自分の会社のデータしか操作できなくなります。
  • 参照と更新の区別 ポリシーは USING(参照可能な行の条件)と WITH CHECK(挿入・更新できる行の条件)の二種類を持ちます。これにより、読み取りと書き込みの制御をきちんと分けて設定できます。

活用されるシーン

  • マルチテナント型のSaaS 1つのデータベースに複数企業のデータを格納する場合、RLS を使うことで「他社のデータを見られない」という保証をDB側で確実に実現できます。
  • 個人向けサービス 個別ユーザーごとに独立したデータを保持する場合、user_id 単位で RLS を設定すれば、本人以外はアクセスできません。
  • セキュリティ要件が厳しいシステム アプリ層のバグや抜け漏れがあっても、DB側で強制的に境界を守れることは監査や法令遵守の観点でも重要です。

なぜ注目されているのか

Supabase の普及によって、PostgreSQL 標準機能である RLS の存在が一般開発者にも広く知られるようになりました。しかし一方で、RLS を有効化していなかったりポリシーが適切でなかったために他ユーザーのデータが閲覧可能になる事故が報告されるケースも見られます。

このような背景から、個人開発やスタートアップ開発でも RLS を意識的に取り入れるべきという認識が高まっています。

動作確認の流れ

本記事で紹介するサンプルは、Next.js + Prisma + PostgreSQL(Docker Compose) という構成をベースにしています。ここでは細かいコードは割愛し、全体像を段階的に示します。

まず最初に、フロントエンドとバックエンドの統合的な実装基盤として Next.js プロジェクトを用意します。Next.js はフロントエンドフレームワークという印象が強いですが、Route Handlers や Server Actions を利用することで、バックエンド API を容易に組み込むことができます。今回は画面を構築せず、API サーバーとしての役割に集中させます。

次に、ORM として Prisma を導入します。Prisma を使うことで、データベース操作を型安全に行え、マイグレーションやクエリ管理も容易になります。Next.js との統合もしやすく、開発効率を高められる選択肢です。

データベースには PostgreSQL を採用し、ローカル環境では Docker Compose で起動します。コンテナを利用することで環境差異を減らし、CI/CD パイプラインでも再現しやすくなります。ここで重要なのは、アプリケーション接続用のデータベースユーザーを 非スーパーユーザー として作成することです。これにより、常に RLS が適用される安全な環境を構築できます。

環境が整ったら、Prisma のスキーマ定義を通じて company と user の2つのモデルを設計します。マイグレーションを実行することで実際のテーブルが作成され、RLS を適用できる状態が整います。

続いて、PostgreSQL 側で RLS を設定します。各テーブルに対して「どの会社に属するデータにアクセスできるか」をポリシーとして定義し、アプリケーション側からはセッション変数経由で company_id を渡します。これにより、アプリケーションコードの不備があってもデータベースが境界を守り続ける構成となります。

最後に、Next.js の Route Handlers で CRUD API を実装し、Postman などのツールを使って動作確認を行います。会社ごとに返却されるデータが異なることを確認できれば、RLS が正しく効いていることが証明されます。

ステップ一覧

1. Next.js プロジェクトの作成 → フロント兼バックエンドの基盤を用意
2. Prisma の導入と初期化 → ORM として採用し、DB操作の型安全性とマイグレーション管理を担保
3. Docker Compose による PostgreSQL の起動 → 非スーパーユーザー(NOBYPASSRLS付き)を用意し、安全な接続ユーザーを確保
4. Prisma スキーマの定義 → company と user モデルを記述し、マイグレーションでテーブルを生成
5. RLS の設定 → PostgreSQL 側にポリシーを定義し、行レベルでアクセス制御を強制
6. API 実装(Next.js Route Handlers) → CRUD API を構築し、セッション変数によって RLS を効かせる
7. 動作確認 → 会社ごとに返却データが異なることを確認し、RLS が有効であることを検証

プロジェクト構築と Prisma 導入

本記事では、Next.js をベースとしたプロジェクトに Prisma を導入し、PostgreSQL と接続できる状態を整えます。ここでは、実際のコマンドや設定コードを差し込む場所を示し、流れの全体像を整理していきます。

1. Next.js プロジェクトの新規作成

まずは Next.js プロジェクトを新規作成します。

ここで紹介するケースでは、画面部分は利用せず API 実装を中心とするため、Route Handlers を活用したバックエンド API サーバーとして Next.js を利用します。

> npx create-next-app@latest next-rls-prisma
[質問にはすべてデフォルトで回答]
> cd next-rls-prisma

2. Prisma の導入

次に、Prisma をプロジェクトに導入します。Prisma はモダンな ORM であり、型安全なクエリの提供やマイグレーション管理を通じて、開発効率と安全性を高めてくれます。

> npm i -D prisma
> npm i @prisma/client

3. Prisma の初期化

Prisma を導入したら、初期化を行います。この操作により.envファイルとprisma/schema.prismaファイルが生成されます。

.envは接続情報を定義する環境変数ファイル、schema.prismaはデータベーススキーマを記述する中心的な設定ファイルとなります。

> npx prisma init

ここまで完了すれば、Next.js プロジェクトと Prisma の接続準備が整い、次の章で行う Docker Compose による PostgreSQL の環境構築に進むことができます。

Docker Compose でデータベースを構築し、.env を設定する

Next.js プロジェクトと Prisma の準備ができたら、次はローカル環境で利用する PostgreSQL を Docker Compose を使って立ち上げます。コンテナを使うことで環境構築が容易になり、チーム開発や CI 環境でも再現性を担保できます。

本記事では、アプリケーション接続用に 非スーパーユーザー(RLS バイパス不可のユーザー) を作成するように初期化スクリプトを設定します。これにより、後のステップで RLS を適用した際に確実に効かせられる安全な環境を用意できます。

1. docker-compose 設定ファイルの用意

まずはcompose.yamlを作成し、PostgreSQL サービスを定義します。

ここでは、初期化スクリプトを配置するフォルダを指定しておくことで、アプリケーション用ユーザーを自動的に作成できるようにします。

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: password
      POSTGRES_DB: appdb
    ports:
      - "5432:5432"
    volumes:
      - ./initdb:/docker-entrypoint-initdb.d

2. 初期化スクリプトの配置

Docker 公式の PostgreSQL イメージは、/docker-entrypoint-initdb.d/ 配下に配置された SQL ファイルを初回起動時に実行してくれます。この仕組みを利用して、アプリケーション用のユーザー(例: app_rw)を作成し、必要な権限を与えます。

-- アプリ用:非superuser・RLSバイパス不可・migrate用にCREATEDBを付与
CREATE ROLE app_rw LOGIN PASSWORD 'app_rw_password'
  NOSUPERUSER NOBYPASSRLS CREATEDB;

-- publicスキーマの利用 + 作成を許可(← これが無いとテーブル作成できない)
GRANT USAGE, CREATE ON SCHEMA public TO app_rw;

-- 既存オブジェクトへの権限
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES    IN SCHEMA public TO app_rw;
GRANT USAGE, SELECT                  ON ALL SEQUENCES IN SCHEMA public TO app_rw;

-- これから「app_rw が作成する」オブジェクトに自動付与(明示しておく)
ALTER DEFAULT PRIVILEGES FOR ROLE app_rw IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;

ALTER DEFAULT PRIVILEGES FOR ROLE app_rw IN SCHEMA public
  GRANT USAGE, SELECT ON SEQUENCES TO app_rw;

3. .env の設定変更

次に、Prisma が利用する .env の DATABASE_URL を、先ほど作成したアプリケーション用ユーザーで接続するように変更します。

DATABASE_URL="postgresql://app_rw:app_rw_password@localhost:5432/appdb?schema=public"

このステップを終えることで、Next.js + Prisma プロジェクトから PostgreSQL に接続可能な状態が整います。次の章からは、Prisma スキーマを編集し、実際にマイグレーションを実行してテーブルを作成していきます。

company / user モデルを追加し、マイグレーションを実行する

この章では、RLS をかける前段として company と user の2モデルを Prisma スキーマに追加します。テーブル/カラム名は運用で扱いやすい snake_case に統一し、主キーは cuid(ハイフンなしの文字列ID) を採用します。

1. Prisma スキーマにモデルを追加

companyモデルとuserモデルを定義します。

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

// 企業モデル
model company {
  id   String @id @default(cuid())
  name String

  users user[]
}

// ユーザーモデル
model user {
  id         String  @id @default(cuid())
  name       String
  company_id String
  company    company @relation(fields: [company_id], references: [id])
}

注意:Prisma を初期化したときに generator client に output 行が含まれていることがあります。これは削除してください。デフォルト設定を利用すれば Prisma Client は node_modules/.prisma/client に生成され、アプリ側からは import { PrismaClient } from “@prisma/client”; で問題なく利用できます。独自の出力先を指定すると環境ごとにパスがずれて不具合を起こすため、あえて残す理由がない限り削除するのが安全です。

2. マイグレーションを作成・適用

スキーマの変更をデータベースに反映します。

> npx prisma migrate dev --name init

マイグレーションを実行すると以下が行われます。

  • prisma/migrations/<timestamp>__init/ディレクトリが生成される
  • DB にcompany / userテーブルが作成される
  • Prisma Client が自動生成され、アプリから利用できる状態になる

注意:マイグレーション時には .env の DATABASE_URL が正しく app_rw(非スーパーユーザー、NOBYPASSRLS 付き、USAGE, CREATE ON SCHEMA public 権限あり)を指していることを確認してください。これが誤っていると「permission denied for schema public」などのエラーになります。

3. テーブル作成の確認

テーブルが作成されているかを確認します。Prisma Studioを使う方法が簡単です。

> npx prisma studio

これで RLS を適用できる土台(company / user テーブル) が整いました。

次の章では、PostgreSQL 側で RLS を有効化し、ポリシーを定義する手順に進みます。

RLS を適用するマイグレーションを追加する

この章では、すでに作成した company / user テーブルに対して Row Level Security(RLS) を有効化し、会社境界(company_id)でのデータ分離をポリシーとして設定します。以降、アプリケーションからはセッション変数で会社IDを注入することで、クエリに WHERE を書かずとも DB 側で行レベルの制御が強制されるようになります。

1. RLS 用のマイグレーション雛形を作る

RLS は Prisma のスキーマ記法では表現できないため、生の SQL を含むマイグレーションを作ります。まず “空の” マイグレーションを発行します。

> npx prisma migrate dev --name add-rls-user

これでprisma/migrations/<timestamp>__add-rls-user/migration.sqlが生成されます。

2. 生成されたマイグレーションスクリプトに RLS の SQL を追記

user テーブルに対して RLS を 有効化(ENABLE)強制(FORCE) し、company_id がセッション変数に一致する行のみ許可するポリシーを定義します。

セッション変数名は名前衝突を避けるため app.company_id のようにプレフィックスを付けるのが安全です。

-- UserテーブルにRLSを設定(会社境界で制限)
ALTER TABLE "user" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "user" FORCE ROW LEVEL SECURITY;

CREATE POLICY user_by_company ON "user"
  FOR ALL
  USING      (company_id = current_setting('app.company_id', true))
  WITH CHECK (company_id = current_setting('app.company_id', true));

3. マイグレーションを適用する

追記が終わったら、DB に適用します。

> npx prisma migrate dev

もしシャドーDB作成が必要な構成で、アプリ接続ユーザーに CREATEDB を付与していない場合は、schema.prisma の datasource に shadowDatabaseUrl を設定して superuser を使う運用にしておくと安定します(この章では設定コードは割愛、前章の方針どおりでOK)。

4. RLS が適用されたかを確認する

以下は psql から確認する手順です。アプリ接続用の 非スーパーユーザー(例: app_rw) で接続して実行してください。

4.1. 接続

# 例: docker compose で起動している場合
> docker compose exec -T db psql -U app_rw -d appdb

もしスーパーユーザーで入る場合は、各セッションで先に SET row_security = on; を実行してください(superuserは既定でRLSをバイパスするため)。

4.2. RLS の有効化・強制状態を確認

-- RLSフラグ(有効/強制)の確認
SELECT relname, relrowsecurity, relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND relname = 'user';

-- 付与済みポリシーの確認
SELECT schemaname, tablename, policyname, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'user';
  • relrowsecurity = t かつ relforcerowsecurity = t であること
  • user テーブルに company_id = current_setting(‘app.company_id’, true) を条件とするポリシーが載っていること

4.3. セッション変数なしだと行が見えないことを確認

-- セッション変数未設定の状態
SELECT * FROM "user";

期待:0 行(または権限エラー)。

理由:USING (company_id = current_setting(‘app.company_id’, true)) が満たせないため。

アプリ接続ユーザーは 非スーパーユーザー(NOBYPASSRLS) を使用してください。superuser で接続する場合は SET row_security = on; を入れないと RLS が適用されません(本番運用では非superuserが原則)。

4.4. つまづかないための事前注意(簡潔に)

  • テーブル・カラム名と SQL の表記を一致させる(snake_case で統一)。
  • FORCE を付けることで、所有者や誤設定によるバイパスを防ぐ。
  • セッション変数名に app. プレフィックスを付ける(カラム名と混同しないため)。
  • 非superuser + NOBYPASSRLS のアプリユーザーで接続する(compose の init スクリプトで作成済み想定)。

バックエンド API を作る(PrismaClient 準備 → CRUD 実装)

RLS を効かせるために、API から DB へアクセスする際は トランザクション先頭で set_config(‘app.company_id’, …) を実行する方針にします。今回は検証しやすいように、認証の代わりに x-company-id ヘッダで会社IDを受け取り、その値を set_config に渡します(※本番ではセッション/JWTから注入)。

1. PrismaClient の作成(共通モジュール)

Next.js から Prisma を再利用できるよう、シングルトンの PrismaClient を用意します。

  • ファイル:/lib/prisma.ts
  • 目的:開発中のホットリロードで複数インスタンスが出来ないようにする。ログ設定などもここで。
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

2. API 仕様の方針

  • ベースURL:/api
  • リソース:/companies(管理用:RLSなし), /users(RLS対象)
  • テナント切り替え:x-company-id ヘッダ(users系のみ必須
  • 例外方針:RLSで見えない行は 404 と等価の扱いにする(更新/削除も同様)

3. ディレクトリ構成

app/
  api/
    companies/
      route.ts        # GET(list), POST(create)
      [id]/
        route.ts      # GET(read), PATCH(update), DELETE(delete)
    users/
      route.ts        # GET(list), POST(create)  ← RLS適用(要ヘッダ)
      [id]/
        route.ts      # GET, PATCH, DELETE       ← RLS適用(要ヘッダ)
lib/
  prisma.ts

4. Companies API(管理用:RLSなし)

4.1. 一覧 & 作成

  • ファイル:app/api/companies/route.ts
  • ハンドラ
    • GET /api/companies?skip=0&take=50(ページング)
    • POST /api/companies(body: { name: string })
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/companies?skip=0&take=50
export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url);
  const skip = Number(searchParams.get("skip") ?? "0");
  const take = Math.min(Number(searchParams.get("take") ?? "50"), 200);

  const [items, total] = await Promise.all([
    prisma.company.findMany({ skip, take, orderBy: { name: "asc" } }),
    prisma.company.count(),
  ]);

  return NextResponse.json({ items, total, skip, take });
}

// POST /api/companies  body: { name: string }
export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body?.name) {
    return NextResponse.json({ error: "name is required" }, { status: 400 });
  }

  const company = await prisma.company.create({ data: { name: body.name } });
  return NextResponse.json(company, { status: 201 });
}

4.2. 参照・更新・削除

  • ファイル:app/api/companies/[id]/route.ts
  • ハンドラ
    • GET /api/companies/:id
    • PATCH /api/companies/:id(body: { name?: string })
    • DELETE /api/companies/:id
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(
  _req: NextRequest,
  { params }: { params: { id: string } }
) {
  const company = await prisma.company.findUnique({ where: { id: params.id } });
  if (!company) return NextResponse.json({ error: "Not found" }, { status: 404 });
  return NextResponse.json(company);
}

export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body) return NextResponse.json({ error: "invalid json" }, { status: 400 });

  try {
    const updated = await prisma.company.update({
      where: { id: params.id },
      data: { ...(body.name ? { name: body.name } : {}) },
    });
    return NextResponse.json(updated);
  } catch {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
}

export async function DELETE(
  _req: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    await prisma.company.delete({ where: { id: params.id } });
    return NextResponse.json({ ok: true });
  } catch {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
}

5. Users API(RLS対象:x-company-id 必須)

5.1. 一覧 & 作成

  • ファイル:app/api/users/route.ts
  • ヘッダ:x-company-id: <company_id>(必須)
  • ハンドラ
    • GET /api/users?skip=0&take=50
      1. ヘッダ検証 → 2) $transaction 開始 → 3) set_config(‘app.company_id’, companyId, true) → 4) findMany と count
    • POST /api/users(body: { name: string })
      1. ヘッダ検証 → 2) $transaction + set_config → 3) create({ data: { name, company_id: companyId } }) ※ WITH CHECK が効くため、万一クライアントが別の company_id を送っても DB が拒否
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users?skip=0&take=50
export async function GET(req: NextRequest) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    const { searchParams } = new URL(req.url);
    const skip = Number(searchParams.get("skip") ?? "0");
    const take = Math.min(Number(searchParams.get("take") ?? "50"), 200);

    const [items, total] = await Promise.all([
      tx.user.findMany({ skip, take, orderBy: { name: "asc" } }),
      // RLS が効くので count も自動で同じ境界に制限される
      tx.user.count(),
    ]);

    return NextResponse.json({ items, total, skip, take });
  });
}

// POST /api/users  body: { name: string, company_id?: string }
export async function POST(req: NextRequest) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  const body = await req.json().catch(() => null) as { name?: string; company_id?: string } | null;
  if (!body?.name) {
    return NextResponse.json({ error: "name is required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    // 安全のため、API入力の company_id は無視してサーバ側で上書き
    const created = await tx.user.create({
      data: { name: body.name, company_id: companyId },
    });

    return NextResponse.json(created, { status: 201 });
  });
}

5.2. 参照・更新・削除

  • ファイル:app/api/users/[id]/route.ts
  • ハンドラ
    • GET /api/users/:id → $transaction + set_config → findUnique。RLSにより他社IDは見えない=404相当
    • PATCH /api/users/:id(body: { name?: string }) → $transaction + set_config → update。RLS条件を満たさないと対象0件=404
    • DELETE /api/users/:id → $transaction + set_config → delete。同上
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

// GET /api/users/:id
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    const user = await tx.user.findUnique({ where: { id: params.id } });
    if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
    return NextResponse.json(user);
  });
}

// PATCH /api/users/:id  body: { name?: string }
export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }
  const body = await req.json().catch(() => null) as { name?: string } | null;
  if (!body) return NextResponse.json({ error: "invalid json" }, { status: 400 });

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    try {
      const updated = await tx.user.update({
        where: { id: params.id },
        data: { ...(body.name ? { name: body.name } : {}) },
      });
      return NextResponse.json(updated);
    } catch {
      // RLSに弾かれた or 存在しない
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
  });
}

// DELETE /api/users/:id
export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const companyId = req.headers.get("x-company-id");
  if (!companyId) {
    return NextResponse.json({ error: "x-company-id header required" }, { status: 400 });
  }

  return prisma.$transaction(async (tx) => {
    await tx.$executeRaw`select set_config('app.company_id', ${companyId}, true)`;

    try {
      await tx.user.delete({ where: { id: params.id } });
      return NextResponse.json({ ok: true });
    } catch {
      return NextResponse.json({ error: "Not found" }, { status: 404 });
    }
  });
}

6. 動作確認

会社を2つ作成します。

POST http://localhost:3000/api/companies
Body: { "name": "Acme" }
→ id をメモ
POST http://localhost:3000/api/companies
Body: { "name": "Globex" }
→ id をメモ

次にそれぞれの会社にユーザーを作成します。

POST http://localhost:3000/api/users
Headers: x-company-id: <Acme社のid>
Body: { "name": "Alice" }
→ 201 Created
POST http://localhost:3000/api/users
Headers: x-company-id: <Globex社のid>
Body: { "name": "Bob" }
→ 201 Created

それぞれのユーザー一覧が取得できることを確認します。

GET http://localhost:3000/api/users
Headers: x-company-id: <Acme社のid>
→ [ { name: "Alice", company_id: <Acme社のid> } ] のみ取得できることを確認
GET http://localhost:3000/api/users
Headers: x-company-id: <Globex社のid>
→ [ { name: "Bob", company_id: <Globex社のid> } ] のみ取得できることを確認

最後に、ユーザーと企業が一致しないケースではデータが取得できないことを確認します。

GET http://localhost:3000/api/users/<Aliceのid>
Headers: x-company-id: <Acme社のid>
→ [ { name: "Alice", company_id: <Acme社のid> } ] のみ取得できることを確認
GET http://localhost:3000/api/users/<Aliceのid>
Headers: x-company-id: <Globex社のid>
→ 404 Not Foundになることを確認

7. 実際に使用する際のメモ

  • x-company-id はデモ用。本番は認証セッション/JWTから company_id を取得
  • 管理者ロールを導入する場合は set_config(‘app.is_admin’,’true’,true) を追加し、RLSポリシーに OR 条件を拡張

まとめ

本記事では、PostgreSQL の Row Level Security(RLS)を Next.js + Prisma 環境で適用する方法を、一から順を追って解説しました。

まず、RLS とは何か、その背景やどのバージョンから利用できるのかといった基礎知識を整理し、データベース側で強制的に行レベルのアクセス制御を行う重要性を確認しました。続いて、Next.js プロジェクトを新規作成し、Prisma を導入してローカル環境に PostgreSQL を Docker Compose で構築しました。さらに、company / user モデルを設計し、マイグレーションによって実際のテーブルを作成。その上で、RLS を有効化してポリシーを設定し、会社単位でデータが分離される仕組みを確認しました。

最後に、PrismaClient を使って Next.js の Route Handlers に CRUD API を実装し、x-company-id ヘッダを通じてセッション変数を注入することで、アプリケーション層の記述に依存せず DB 側で安全に境界を守る仕組みを完成させました。Postman での検証を通じて、会社ごとに結果が切り替わることや、他社データにはアクセスできないことを確認できました。

RLS は アプリ層のミスをデータベース層でカバーできる強力な仕組みです。とりわけマルチテナントの SaaS やセキュリティ要件の高いサービスでは、導入する価値が非常に大きいといえます。Supabase を利用する個人開発でも、Next.js + Prisma を利用するチーム開発でも、「RLS を前提とした設計」を意識することが、今後ますます重要になるでしょう。

これから RLS を試してみようと考えている方は、ぜひ本記事の流れを参考にして、まずはローカル環境で小さなサンプルを動かすところから始めてみてください。

参考文献

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次