0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PostgreSQL の RLS でテナント ID を設定する — `SET LOCAL` ではなく `set_config(..., true)` を使う

0
Posted at

過去に連載記事ふうにしてみた第2回「SET LOCAL でトランザクションスコープのセッション変数を使う」では、設定変数をトランザクション内だけで有効にする手段として SET LOCAL を扱いました。RLS (Row Level Security: 行単位でアクセス範囲を制限する PostgreSQL の機能) でマルチテナント分離を実装すると、その方針の延長で「現在のテナント ID」を SET LOCAL で設定したくなります。リクエストごとにトランザクションを開き、その中で app.tenant_id を設定し、RLS ポリシー側でそれを参照する、という構成です。

ところが、テナント ID をパラメーターとして渡そうとすると、次のコードは動きません。

await client.query(`SET LOCAL "app.tenant_id" = $1`, [tenantId]);

実際の PostgreSQL では構文エラーになります。

syntax error at or near "$1"

この記事では、なぜ SET LOCAL がこの形を受け付けないのか、代わりに何を使うのか、そして実装時に何を確認すればよいのかを扱います。先に方向だけ示すと、RLS のテナント変数は SELECT set_config('app.tenant_id', $1, true) で設定します。set_config は通常の関数なので、SELECT の引数として $1 を渡せるためです。

なお、ここで「SET LOCAL を使うな」と言いたいわけではありません。SET LOCAL "app.tenant_id" = 'リテラル' のように値を直接書く形なら機能します。問題が起きるのは、テナント ID のような動的な値を $1 でパラメーター化したい場面だけです。対象とする環境は、PostgreSQL (current)、node-postgres (pg)、TypeScript です。

RLS でテナント ID を参照する基本形

まず、テナント分離が成り立っている状態を最小コードで確認します。マルチテナントのテーブルには tenant_id を持たせます。

CREATE TABLE corrections (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  body TEXT NOT NULL
);

そのうえで、RLS ポリシーで「現在のテナント ID」と一致する行だけを見せるようにします。

ALTER TABLE corrections ENABLE ROW LEVEL SECURITY;

CREATE POLICY corrections_tenant ON corrections
  USING (
    tenant_id = current_setting('app.tenant_id', true)::uuid
  );

current_setting('app.tenant_id', true) は、設定変数の現在値を読む関数です。第 2 引数の true は、未設定のときにエラーではなく NULL を返す指定です。app.tenant_id のようにドット付きで定義する変数はカスタム設定変数と呼ばれ、ユーザーが任意に読み書きできます。

アプリケーション側は、リクエストごとにトランザクションを開き、その中で app.tenant_id を設定します。

const client = await pool.connect();

try {
  await client.query("BEGIN");

  // ここで app.tenant_id を設定する

  const result = await fn(client);

  await client.query("COMMIT");
  return result;
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
} finally {
  client.release();
}

論点は「ここで app.tenant_id を設定する」の 1 行をどう書くかです。RLS ポリシーは current_setting('app.tenant_id', true) を読むだけなので、テナント ID の値が正しくこの変数に入っていれば分離が成立し、入らなければ成立しません。

SET LOCAL ... = $1 は値をパラメーター化できない

連載第2回の方針をそのまま当てはめると、次のように書きたくなります。

await client.query(`SET LOCAL "app.tenant_id" = $1`, [tenantId]);

しかし、これは実行すると構文エラーになります。

syntax error at or near "$1"

これは、SET LOCAL が値パラメーター $1 を取れる文ではないために起きます。node-postgres では、値を文字列へ埋め込まずに渡す手段としてパラメーター化クエリをよく使います。

await client.query(`SELECT * FROM users WHERE id = $1`, [userId]);

この $1SELECTINSERTUPDATE のような文で使えます。一方 SET / SET LOCAL は設定変数を変更する専用の構文で、値の位置にプレースホルダを置けません。そのため、SET LOCAL を使い続けると、テナント ID を SQL 文字列へ直接埋め込む形になります。

await client.query(`SET LOCAL "app.tenant_id" = '${tenantId}'`);

文字列埋め込みが動いてしまう理由と、避けたい理由

前節の文字列埋め込みは、多くの場合そのまま動きます。テナント ID が認証済みセッション由来の UUID で、事前に厳密にバリデーションされていれば、埋め込んでも実害が出にくいケースはあります。動いてしまうので、設計上の問題として気づきにくいのが難しい点です。

避けたい理由は、SQL に値を直接埋め込む形が残ること自体にあります。今の呼び出し経路ではバリデーションが効いていても、後から別の経路が withTenantClient 相当の関数を呼ぶようになったとき、その全経路でバリデーションが維持されているかを確認し続ける必要があります。経路が増えるほど、この確認の負担は増えます。SQL 注入の余地は、特定経路のバリデーションで塞ぐより、最初からパラメーター化で持ち込まない方が、前提を保ちやすくなります。

ここでの主張は「文字列埋め込みは必ず脆弱だ」ではなく、「インジェクションの余地を最初からなくす設計の方が、経路が増えても安全を保ちやすい」です。

set_config('app.tenant_id', $1, true) でパラメーター化する

SET LOCAL の代わりに、set_config 関数を使います。

await client.query(
  `SELECT set_config('app.tenant_id', $1, true)`,
  [tenantId],
);

set_config(name, value, is_local) は、設定変数を変更する関数です。第 1 引数が変数名、第 2 引数が値、第 3 引数 is_local でトランザクションスコープにするかどうかを指定します。SET LOCAL との違いは、これが通常の関数であることです。関数は SELECT の対象として呼べるため、値を $1 で渡せます。テナント ID を SQL 文字列へ埋め込む必要がありません。

SET LOCALset_config(..., true) は、設定変数をトランザクション内だけで有効にするという目的では同じです。違いはパラメーター化できるかどうかです。

書き方 トランザクションスコープ $1 でパラメーター化
SET LOCAL "app.tenant_id" = '...' できる できない (値を埋め込む)
SELECT set_config('app.tenant_id', $1, true) できる (第 3 引数 true) できる

RLS ポリシー側は変更しません。current_setting('app.tenant_id', true) は、どちらの方法で設定した値も同じように読みます。設定の書き方を set_config に変えても、ポリシー定義はそのまま使えます。

第 3 引数 true でトランザクションスコープにする

set_config の第 3 引数 is_local は、設定の有効範囲を決めます。true を渡すと、その設定は現在のトランザクション内だけで有効になり、COMMIT または ROLLBACK の時点で自動的に破棄されます。

await client.query(
  `SELECT set_config('app.tenant_id', $1, true)`,
  [tenantId],
);

false を渡すと、設定はセッションスコープになります。

await client.query(
  `SELECT set_config('app.tenant_id', $1, false)`,
  [tenantId],
);

マルチテナント環境では、セッションスコープは避けます。コネクションプールでは、同じ物理接続が別のリクエストへ再利用されます。テナント ID がセッションスコープで残ると、次に同じ接続を使ったリクエストから、前のテナント ID が見えてしまう可能性があります。RLS でテナント分離をしている場合、これは別テナントの行が見える状態に直結します。

残留が問題になる度合いは、接続の使い回し方で変わります。リクエストごとに専有接続を割り当てる構成や、セッション単位で接続を固定するセッションプーリングでは、残留の条件はトランザクションプーラと異なります。特に問題になりやすいのは、トランザクション単位で物理接続をリクエスト間で使い回すトランザクションプーラ (PgBouncer の transaction pooling や Supavisor など) を使う場合です。トランザクションが終わるたびに接続が別のリクエストへ回るため、セッションスコープに残した値が他リクエストへ漏れる経路が生まれます。

使う:
  SELECT set_config('app.tenant_id', $1, true)

避ける:
  SELECT set_config('app.tenant_id', $1, false)
  SET app.tenant_id = '...'

実装例: withTenantClient

ここまでをまとめると、テナント ID の設定は「BEGINset_config(..., true) → 業務処理 → COMMIT / ROLLBACKrelease」という流れになります。これを 1 つの関数にまとめた実装です。

import type { Pool, PoolClient } from "pg";

const UUID_RE =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

class InvalidTenantError extends Error {
  constructor(tenantId: string) {
    super(`invalid tenant id: ${tenantId}`);
  }
}

export async function withTenantClient<T>(
  pool: Pool,
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
  if (!UUID_RE.test(tenantId)) {
    throw new InvalidTenantError(tenantId);
  }

  const client = await pool.connect();

  try {
    await client.query("BEGIN");

    await client.query(
      `SELECT set_config('app.tenant_id', $1, true)`,
      [tenantId],
    );

    const result = await fn(client);

    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK").catch(() => {});
    throw error;
  } finally {
    client.release();
  }
}

呼び出し側は withTenantClient(pool, tenantId, async (client) => { ... }) の形で、テナント ID と業務処理を渡します。トランザクションの開始・設定変数のセット・コミットとロールバック・接続の解放は、すべてこの関数の内側で完結します。ROLLBACK.catch(() => {}) で受けているのは、すでにトランザクションが終了しているなどの理由で ROLLBACK 自体が失敗しても、元の error を投げ直すことを優先するためです。

UUID バリデーションを set_config でも残す理由

set_config でパラメーター化していても、上の実装は冒頭で UUID_RE による UUID バリデーションを残しています。「パラメーター化したならインジェクションの心配はないのに、なぜ残すのか」という疑問が出る箇所なので、独立して整理します。

バリデーションの目的は、SQL インジェクション対策だけではありません。残す理由は 2 つあります。1 つは、不正な値が app.tenant_id に入ること自体を防ぐためです。パラメーター化は SQL の構文として安全に値を渡しますが、渡す値が UUID として妥当かどうかは別の問題です。RLS ポリシーは current_setting('app.tenant_id', true)::uuid で UUID へキャストするため、UUID でない値が入ると、その時点でキャストエラーになります。アプリケーション側で先に弾いておくと、失敗の場所と理由が分かりやすくなります。もう 1 つは、アプリケーション側の前提を明確にするためです。「この関数に渡るテナント ID は UUID である」という契約をコードで表しておくと、後から経路が増えても前提が崩れにくくなります。

つまり、パラメーター化はインジェクションの余地をなくすためのもの、UUID バリデーションは値の妥当性とアプリ側の前提を担保するためのものです。役割が違うので、両方を残します。

SAVEPOINT 後の ROLLBACK TO での挙動

set_config(..., true) の値はトランザクション終了時に破棄されますが、サブトランザクションを使う場合は、もう 1 つ挙動を確認しておきます。PostgreSQL では、SAVEPOINT より後に行った設定変更は ROLLBACK TO SAVEPOINT で取り消され、SAVEPOINT より前に行った分は残ります。これは set_config(..., true) にも当てはまります。

BEGIN;

SELECT set_config('app.t1', 'before-savepoint', true);

SAVEPOINT s1;

SELECT set_config('app.t2', 'after-savepoint', true);

ROLLBACK TO SAVEPOINT s1;

SELECT
  current_setting('app.t1', true) AS t1,
  current_setting('app.t2', true) AS t2;

-- t1 = 'before-savepoint'   (SAVEPOINT より前に設定したので残る)
-- t2 = NULL または ''        (SAVEPOINT より後に設定したので取り消される)
COMMIT;

app.t1 は SAVEPOINT より前に設定したので残り、app.t2 は後に設定したので ROLLBACK TO SAVEPOINT s1 で取り消されます。取り消される対象は「SAVEPOINT より後に設定した分」に限られる点が要点です。

通常のリクエスト処理では、BEGIN の直後にテナント ID を設定してから業務処理に入る形が扱いやすくなります。テナント ID の設定が SAVEPOINT より前にあれば、業務処理の途中で ROLLBACK TO SAVEPOINT をしてもテナント ID は残るため、設定タイミングを毎回意識せずに済みます。

モックテストでは検出されない

この問題が見つかりにくいのは、SET LOCAL ... = $1 がモックテストでは通ってしまうことにあります。次のコードは実際の PostgreSQL では構文エラーになります。

await client.query(`SET LOCAL "app.tenant_id" = $1`, [tenantId]);

しかし、pg をモックしているテストでは、これが成功扱いになることがあります。

expect(calls[1]?.[0]).toContain("SET LOCAL app.tenant_id");

このテストが見ているのは、query() に渡された SQL 文字列の中身だけです。PostgreSQL がその SQL を解釈できるかどうかは確認していません。モックは渡された引数を記録するだけで、構文の妥当性を判定しないためです。その結果、SET LOCAL ... = $1 の構文エラーは、実際の DB へ当てるまで検出されません。RLS やトランザクションのように DB の実挙動へ依存する部分は、モックだけで完結させない方が安全です。実際の PostgreSQL へ当てる integration test (実 DB に接続して挙動を確認するテスト) を少数置いておくと、この種のずれを早く見つけられます。

実 DB で確認する integration test

移行時に実際の PostgreSQL で確認したいのは、次の 2 点です。

1. トランザクション内では app.tenant_id が見える
2. トランザクション終了後、同じ接続に app.tenant_id が残らない

withTenantClient に対して、この 2 点を確認するテストです。

import pg from "pg";
import { withTenantClient } from "./with-tenant.js";

const TENANT = "aaaaaaaa-0000-4000-a000-000000000001";

const pool = new pg.Pool({
  // 接続情報
  max: 1,
});

// 1. トランザクション内では app.tenant_id が見える
const inside = await withTenantClient(pool, TENANT, async (client) => {
  const result = await client.query(
    `SELECT current_setting('app.tenant_id', true) AS value`,
  );
  return result.rows[0]?.value;
});

console.log(inside === TENANT); // true

// 2. トランザクション終了後、同じ接続に残らない
const after = await pool.query(
  `SELECT current_setting('app.tenant_id', true) AS value`,
);

const value = after.rows[0]?.value;
console.log(value === null || value === ""); // true

await pool.end();

max: 1 にしているのが要点です。プールサイズを 1 にすると、2 回目のクエリも 1 回目と同じ物理接続を使います。その同じ接続で app.tenant_id が残っていなければ、トランザクションスコープの設定が COMMIT 時に破棄されていることを確認できます。

2 番目の assertion が value === null || value === "" の両方を許容しているのは、未設定のカスタム設定変数の読み出し結果が、環境や状態によって NULL のことも空文字のこともあるためです。ここで確認したいのは「前のテナント ID が残っていないこと」なので、NULL と空文字のどちらでも合格とします。

まとめ

RLS でテナント ID をカスタム設定変数に入れる場合、SET LOCAL ではなく set_config(..., true) を使うと、値をパラメーター化したまま設定できます。SET LOCAL "app.tenant_id" = $1 は構文として $1 を受け付けないため、SET LOCAL を使い続けると値を SQL 文字列へ埋め込む形になります。一方 set_config は関数なので、SELECT set_config('app.tenant_id', $1, true) の形で $1 を渡せます。第 3 引数 true により設定はトランザクションスコープになり、COMMIT / ROLLBACK の後に接続へ残りません。

3 つの方式を、トランザクションスコープ可否・パラメーター化可否・接続残留リスクで対比すると次のとおりです。

方式 トランザクションスコープ $1 でパラメーター化 接続再利用時の残留
SET LOCAL "app.tenant_id" = '...' できる できない (値を埋め込む) 残らない
set_config('app.tenant_id', $1, true) できる できる 残らない
set_config('app.tenant_id', $1, false) ならない (セッション) できる 残る可能性がある

RLS のようにテナント分離へ直結する処理は、モックだけでなく実際の PostgreSQL に当てるテストも置いておくと、この種の問題を早く検出できます。SET LOCAL ... = $1 の構文エラーや、トランザクション終了後の設定値の残留は、実 DB で確認するのが確実です。

参考情報

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?