Drizzle ORM で検証する Row Level Security

はじめに

アプリケーションサービス本部ディベロップメントサービス1課の森山です。

今回は、RDS における RLS (Row Level Security) について記事にしてみます。

前職からマルチテナント SaaS の開発に携わっており、リレーショナルデータベースにおけるテナント分離戦略の一つである、Row Level Security について検証してみます。

また、今回は最近キャッチアップ中の TypeScript の ORM である Drizzle を使って動作検証をしてみます。

orm.drizzle.team

Row Level Security とは

Row Level Security(RLS)は、PostgreSQL 9.5 以降で利用可能な機能で、テーブルの行レベルでアクセス制御を行うことができます。

従来のテーブル・カラムレベルの権限制御では、「テーブル全体にアクセスできるかどうか」しか制御できませんでしたが、RLS を使うことで「どの行にアクセスできるか」をデータベースレベルで制御できるようになります。

マルチテナント SaaS では、複数の顧客(テナント)のデータを同一のデータベースで管理するパターンがありますが、アプリケーション層でのテナント分離に加えて、データベース層でも RLS による分離を実装することで、多層防御を実現できます。

今回は、この RLS を TypeScript の ORM である Drizzle と組み合わせて実装する際のポイントや注意点について検証していきます。

動作検証

実際に動作検証できるように以下検証コードを公開しています。

github.com

実際に動作させてみたい場合はクローンしていただき、動作確認していただければと思います。

なお、このコードは検証用の簡易実装ですのでご注意ください。

テーブル作成と RLS 設定

今回は、以下のような複数テナントの注文を管理するordersテーブルを使います。

カラム名 説明
id SERIAL 主キー
tenant_id INTEGER テナント ID(分離キー)
product_name TEXT 商品名
amount INTEGER 金額

属性tenant_idをキーに RLS でテナント分離してみます。

まずは、テーブルの作成と RLS の有効化を行います。

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER,
    product_name TEXT,
    amount INTEGER
);
  

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
  

CREATE POLICY tenant_policy ON orders
FOR ALL TO app_user
USING (tenant_id = current_setting('app.current_tenant', true)::INTEGER);
  

INSERT INTO orders (tenant_id, product_name, amount) VALUES
  (1, 'Product A', 100),
  (1, 'Product B', 200),
  (1, 'Product C', 150),
  (2, 'Product D', 300),
  (2, 'Product E', 400),
  (2, 'Product F', 250),
  (3, 'Product G', 500),
  (3, 'Product H', 600);

テーブル単位で RLS を有効化し、どのように分離するかをポリシーで指定しています。

実際に作成するにはフォルダpostgresdocker-compose.ymlを格納していますので、以下コマンドで Docker を起動していただければ自動生成されるようになっています。

cd postgres
docker compose up -d

なお、PostgreSQL では、テーブル所有者やスーパーユーザーは RLS ポリシーの影響を受けません。

そのため、今回の検証ではapp_userという一般ユーザーを作成し、このユーザーでデータベースに接続することで RLS の動作を確認します。

動作確認(SQL)

まずは SQL で動作確認してみます。

-- Run-time parameterを設定してクエリ実行
SET app.current_tenant="1";
SELECT * FROM orders;

問題なく、テナントIDが1のデータだけ取得できています。

では、次にテナントIDが2の場合も確認してみます。

SET app.current_tenant="2";
SELECT * FROM orders;

問題ないです。

なお、動作確認は Drizzle Studio の SQL console で実施しています。
以下のコマンドで ローカル(https://local.drizzle.studio) 上で動作確認できます。

% pnpm db:studio

> drizzle-kit studio

...略

Drizzle Studio is up and running on https://local.drizzle.studio

コネクションプーリング利用時の注意点

これまでの検証の通り、取得したいテナントの対象はSET app.current_tenant="1";のような SET コマンドで設定します。

いくつかの条件はあるものの、このコマンドで設定された値(Run-time parameter)はセッション終了時までは維持される設定です。

If SET (or equivalently SET SESSION) is issued within a transaction that is later aborted, the effects of the SET command disappear when the transaction is rolled back. Once the surrounding transaction is committed, the effects will persist until the end of the session, unless overridden by another SET.

PostgreSQL: Documentation: 18: SET

この挙動により、1つのコネクション(=セッション)を複数のリクエストで使い回す、コネクションプーリングとの併用時には考慮すべき点がいくつかあります。

Run-time parameter 設定漏れによるテナント間参照

コネクションプーリングは、一定量のコネクションをあらかじめ作成しておき、複数のリクエストでそれらを使い回すことでコネクション作成のコストを削減する技術です。

しかし、コネクションを使い回す際には、前のリクエストで設定した Run-time parameter も次のリクエストに引き継がれてしまうという特性があります。

そのため、Run-time parameter の設定(上書き)を忘れると、前のリクエストで設定された値が残ったままになり、自テナント以外のデータが取得できてしまうセキュリティリスクがあります。

以下のコードで、擬似的に再現させてみました。

同じコネクションを使って、テナント1、2の情報を取得していますが、テナント2側のソースは SET コマンドの設定が抜けているパターンです。

import "dotenv/config";
import { db } from "./db/client.js";
import { orders } from "./db/schema.js";
import { sql } from "drizzle-orm";
  

async function withTenantSessionT>(
  tenantId: number,
  operation: () => PromiseT>
): PromiseT> {
  await db.execute(sql.raw(`SET app.current_tenant="${tenantId}"`));
  return await operation();
}
  

async function withoutTenantSessionT>(
  operation: () => PromiseT>
): PromiseT> {
  return await operation();
}
  
async function main() {
  
  console.log("\n--- Tenant 1 Orders ---");
  const tenant1Orders = await withTenantSession(1, async () => {
    return await db.select().from(orders);
  });
  console.log(tenant1Orders);
  
  
  console.log("\n--- Tenant 2 Orders ---");
  const noSessionOrders = await withoutTenantSession(async () => {
    return await db.select().from(orders);
  });
  console.log(noSessionOrders);
  
  process.exit(0);
}
  
main().catch((error) => {
  console.error("Error:", error);
  process.exit(1);
});

pnpm run devで実行できるようにしているので、実施してみます。

--- Tenant 1 Orders ---
[
  { id: 1, tenantId: 1, productName: 'Product A', amount: 100 },
  { id: 2, tenantId: 1, productName: 'Product B', amount: 200 },
  { id: 3, tenantId: 1, productName: 'Product C', amount: 150 }
]

--- Tenant 2 Orders ---
[
  { id: 1, tenantId: 1, productName: 'Product A', amount: 100 },
  { id: 2, tenantId: 1, productName: 'Product B', amount: 200 },
  { id: 3, tenantId: 1, productName: 'Product C', amount: 150 }
]

テナント 2 の情報を取得したいにも関わらず、前の処理のテナント1のデータが取得できてしまいました。

別のテナントへのデータアクセスはビジネス上、非常に大きな問題が発生する可能性があります。

ピン留め問題

次に Amazon RDS Proxy 利用時にも注意が必要です。

aws.amazon.com

RDS Proxy は、Lambda などのスケールするコンピューティングリソースと RDS の間でコネクションプーリングを提供し、データベースへの接続数を効率的に管理するサービスです。

しかし、特定の操作(SET コマンドなど)を実行すると「ピン留め」が発生し、コネクションプーリングの効果が失われる場合があります。

ピン留め問題については下記のページに詳細の記載がありますが、コネクションが利用したリクエスト専用のコネクションとして固定されてしまい、
コネクションの再利用ができなくなる問題です。

docs.aws.amazon.com

docs.aws.amazon.com

上記ページに Aurora PostgreSQL / RDS for PostgreSQLで固定が発生する条件として、SET コマンドの使用が明記されており、この点への考慮も必要です

解決法

上記の問題に対して、いくつか解決策を記載しておきます。

SET LOCALコマンドを利用する。

まずはSETよりも適用スコープが狭い、SET LOCALコマンドを使うことで解決が可能です。

SET LOCALは適用スコープがトランザクション内に限定されます。
そのため、他のリクエストが設定した Run-time parameter を使い回すことができなくなります。

また、ピン留め問題も発生しません。

以下、先ほどの検証ソースをSET LOCALに修正したソースで動作確認してみます。

async function withTenantSessionT>(
  tenantId: number,
  operation: (tx: any) => PromiseT>
): PromiseT> {
  return await db.transaction(async (tx) => {
    await tx.execute(sql.raw(`SET LOCAL app.current_tenant="${tenantId}"`));
    return await operation(tx);
  });
}
  

async function withoutTenantSessionT>(
  operation: () => PromiseT>
): PromiseT> {
  return await operation();
}
  
async function main() {
  
  console.log("\n--- Tenant 1 Orders ---");
  const tenant1Orders = await withTenantSession(1, async (tx) => {
    return await tx.select().from(orders);
  });
  console.log(tenant1Orders);
  
  
  console.log("\n--- Tenant 2 Orders ---");
  const noSessionOrders = await withoutTenantSession(async () => {
    return await db.select().from(orders);
  });
  console.log(noSessionOrders);
  
  process.exit(0);
}

pnpm run dev:set-localで実行可能なので、実施してみます。

--- Tenant 1 Orders ---
[
  { id: 1, tenantId: 1, productName: 'Product A', amount: 100 },
  { id: 2, tenantId: 1, productName: 'Product B', amount: 200 },
  { id: 3, tenantId: 1, productName: 'Product C', amount: 150 }
]

--- Tenant 2 Orders ---
Error: DrizzleQueryError: Failed query: select "id", "tenant_id", "product_name", "amount" from "orders"
params:
  ...
  query: 'select "id", "tenant_id", "product_name", "amount" from "orders"',
  params: [],
  cause: PostgresError: invalid input syntax for type integer: ""

エラーが発生しましたね。

ポリシーに指定したUSING (tenant_id = current_setting('app.current_tenant', true)::INTEGER);の評価時、app.current_tenantの値が設定されていないため、空文字から数値への変換が発生することでエラーが発生しています。

エラーは発生するものの、他のテナントのデータにアクセスできる事象を回避できます。

難点としては明示的なトランザクションが必要なことです。

明示的に Where 句を指定する

次にアプリケーション側で明示的に Where 句を指定する方針です。

RLS の仕組みはあくまで最終防壁といった考え方で、アプリケーション側でも明示的にテナント分離をするアプローチです。

async function withTenantSession>(
  tenantId: number,
  operation: () => PromiseT>
): PromiseT> {
  await db.execute(sql.raw(`SET app.current_tenant="${tenantId}"`));
  return await operation();
}
  

async function withoutTenantSession>(
  operation: () => PromiseT>
): PromiseT> {
  return await operation();
}
  
async function main() {
  
  console.log("\n--- Tenant 1 Orders ---");
  const tenant1Orders = await withTenantSession(1, async () => {
    return await db.select().from(orders).where(eq(orders.tenantId, 1));
  });
  console.log(tenant1Orders);
  
  
  console.log("\n--- Tenant 2 Orders ---");
  const tenant2Orders = await withoutTenantSession(async () => {
    return await db.select().from(orders).where(eq(orders.tenantId, 2));
  });
  console.log(tenant2Orders);
  
  process.exit(0);
}

pnpm run dev:with-whereで実行可能なので、実施してみます。

--- Tenant 1 Orders ---
[
  { id: 1, tenantId: 1, productName: 'Product A', amount: 100 },
  { id: 2, tenantId: 1, productName: 'Product B', amount: 200 },
  { id: 3, tenantId: 1, productName: 'Product C', amount: 150 }
]

--- Tenant 2 Orders ---
[]

こちらはエラーは出ませんが取得件数が0件となりました。

別テナントのデータが閲覧状態にあるものの、Where 句で除外されているためです。

まとめ

RLS の内容、コネクションプーリング併用時の注意点等をまとめてみました。

基本的にはRLSがあれば問題ない、というよりは各レイヤーでテナント分離をする、多層防壁が良さそうです。

プール型のテナント分離戦略は様々な方法があるので、検討してみたいですね!

誰かのお役に立てば幸いです。

森山 智史 (記事一覧)

アプリケーションサービス本部ディベロップメントサービス1課

2025年10月中途入社。




元の記事を確認する

関連記事