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?

【第2回】`SET LOCAL` でトランザクションスコープのセッション変数を使う

0
Posted at

目次

この記事で扱うこと

この記事では、次を扱います。

  • SETSET LOCAL の違い
  • コネクションプールで SET が危険な理由
  • BEGIN → SET LOCAL 相当の設定 → クエリ → COMMIT の基本形
  • withTenantClient ラッパーの実装
  • UUID 形式を事前に検証する理由
  • pool.query() 直接呼び出しを避ける理由

SET LOCAL とは何か

PostgreSQL では、接続ごとに設定値を持てます。

アプリケーション独自の設定値も、次のように名前を付けて使えます。

SET app.tenant_id = '00000000-0000-0000-0000-000000000001';

RLS ポリシー側では、これを次のように読みます。

current_setting('app.tenant_id', TRUE)

ただし、ここで普通の SET を使うのは危険です。

Web アプリケーションでは、DB 接続をリクエストごとに新しく作るのではなく、コネクションプールで使い回すことが多いためです。

SETSET LOCAL の違い

SETSET LOCAL の違いは、値がいつ消えるかです。

SET:
  接続が切れるまで値が残る

SET LOCAL:
  現在のトランザクションが COMMIT / ROLLBACK されたら値が消える

表にするとこうです。

命令 値が消えるタイミング
SET app.tenant_id = '...' 接続が切れるまで残る
SET LOCAL app.tenant_id = '...' 現在のトランザクション終了時に消える

この違いは、コネクションプールを使うと大きな差になります。

SET が危険な理由

SET を使った場合を時系列で見ます。

1. リクエスト R1 が来る。tenant_id = A
2. プールから接続 X を借りる
3. SET app.tenant_id = 'A' を実行する
4. テナント A のクエリを実行する
5. 接続 X をプールに返す

6. リクエスト R2 が来る。tenant_id = B
7. たまたま同じ接続 X を借りる
8. ある処理で SET app.tenant_id = 'B' を書き忘れたまま SELECT が走る
9. 接続 X には app.tenant_id = 'A' が残っている
10. テナント B のリクエストが、テナント A の値で RLS を通ってしまう

問題は、SET の値が接続に残ることです。

アプリケーション側で「必ずリクエスト冒頭で SET する」と決めていても、1 か所でも漏れると事故になります。

SET LOCAL を使えば、この経路を構造的に避けられます。

SET LOCAL ならトランザクション終了時に消える

SET LOCAL は、現在のトランザクション内だけ有効です。

BEGIN;
SET LOCAL app.tenant_id = '00000000-0000-0000-0000-000000000001';

SELECT *
FROM correction_overrides;

COMMIT;

COMMIT した瞬間に、app.tenant_id は自動的に消えます。

時系列で見るとこうです。

1. リクエスト R1 が来る。tenant_id = A
2. プールから接続 X を借りる
3. BEGIN
4. SET LOCAL app.tenant_id = 'A'
5. テナント A のクエリを実行する
6. COMMIT
7. app.tenant_id は消える
8. 接続 X をプールに返す

9. リクエスト R2 が来る。tenant_id = B
10. 同じ接続 X を借りる
11. app.tenant_id は未設定
12. もし SET LOCAL を忘れて SELECT しても、RLS により 0 件になる

未設定なら、前回説明した RLS のフェイルセーフが働きます。

app.tenant_id 未設定
  ↓
current_setting(..., TRUE) が NULL
  ↓
tenant_id = NULL は true にならない
  ↓
全行が見えない

データが漏れるのではなく、0 件返って機能が失敗する方向に倒れます。

基本形: BEGIN → SET LOCAL 相当の設定 → クエリ → COMMIT

アプリケーション側では、1 リクエストの DB 操作を次の形に寄せます。

BEGIN
  SET LOCAL 相当の設定
  通常のクエリ
COMMIT

例外が起きたら ROLLBACK します。

この処理を毎回手で書くと漏れやすいので、ラッパー関数にします。

ここで 1 点だけ、実装上の注意があります。SQL の考え方としては SET LOCAL app.tenant_id = '...' で問題ありませんが、Node.js の pg では、SET LOCAL app.tenant_id = $1 のような書き方より、SELECT set_config('app.tenant_id', $1, true) を使うほうがパラメータバインディングしやすくなります。

set_config(name, value, true) の第 3 引数 true は、トランザクションローカルに設定する、つまり SET LOCAL 相当の意味です。

withTenantClient ラッパーを作る

Node.js の pg を例にします。

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

export async function withTenantClient<T>(
  pool: Pool,
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>,
): Promise<T> {
  assertValidTenantId(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 (err) {
    await client.query('ROLLBACK').catch(() => {
      // 元の例外を優先するため、ROLLBACK 失敗はここでは再送出しない
    });
    throw err;
  } finally {
    client.release();
  }
}

この関数の責務は、次の 4 つです。

1. tenantId の形式を事前に検証する
2. トランザクションを開始する
3. `SET LOCAL` 相当の設定を発行する
4. COMMIT / ROLLBACK と client.release() を確実に行う

使う側は、次のように書けます。

async function handleListCorrections(req: Request, res: Response) {
  const auth = parseAuthContext(req);
  if (!auth) {
    return sendError(res, 401, 'unauthenticated');
  }

  const rows = await withTenantClient(
    pool,
    auth.tenantId,
    async (client) => {
      const result = await client.query(
        `
        SELECT *
        FROM correction_overrides
        WHERE domain_id = $1
        `,
        [req.params.domainId],
      );

      return result.rows;
    },
  );

  res.json(rows);
}

この例では、SQL に tenant_id = ? がありません。

それでも RLS が DB レベルで自動的に絞ります。

ただし第 1 回で説明したとおり、実務ではパフォーマンスやエラー応答のために WHERE tenant_id = ? を残す設計も有効です。

重要なのは、仮にアプリ側の tenant_id 条件を書き忘れても、RLS が最後の砦になることです。

UUID 形式を事前に検証する

tenantId は、DB へ渡す前に UUID 形式かどうかを検証します。

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

function assertValidTenantId(tenantId: string): void {
  if (!UUID_RE.test(tenantId)) {
    throw new InvalidTenantError(tenantId);
  }
}

なぜ必要なのでしょうか。

RLS ポリシーでは、app.tenant_iduuid にキャストしています。

current_setting('app.tenant_id', TRUE)::uuid

もし app.tenant_id に UUID ではない値が入ると、クエリ実行時にエラーになります。

SET LOCAL app.tenant_id = 'not-a-uuid';

SELECT *
FROM correction_overrides;

-- ERROR: invalid input syntax for type uuid: "not-a-uuid"

SQL インジェクション対策としては、パラメータバインディングを使っているので問題ありません。

しかし、不正な tenant ID が DB まで届くと、エラーの原因調査が複雑になります。

アプリケーション側で先に弾けば、次のような分かりやすいレスポンスにできます。

400 invalid_tenant_id

DB に渡す前に検証しておくほうが、運用上も扱いやすくなります。

既存トランザクションに組み込む場合

すでにプロジェクト内に、次のような既存パターンがあるかもしれません。

const client = await pool.connect();

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

  // 既存のクエリ群

  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK').catch(() => {});
  throw err;
} finally {
  client.release();
}

この場合、いきなりすべてを withTenantClient に置き換えるのは大変です。

その場合は、SET LOCAL 相当の設定だけを行う補助関数を用意します。

export async function setLocalTenantId(
  client: PoolClient,
  tenantId: string,
): Promise<void> {
  assertValidTenantId(tenantId);

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

この関数は、必ず BEGIN 済みのトランザクション内で呼びます。set_config(..., true) はトランザクションローカルな設定なので、トランザクション境界とセットで使う必要があります。

既存のトランザクション内に 1 行追加します。

const client = await pool.connect();

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

  await setLocalTenantId(client, auth.tenantId);

  // 既存のクエリ群

  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK').catch(() => {});
  throw err;
} finally {
  client.release();
}

段階的に移行したい場合は、この形が現実的です。

アンチパターン: pool.query() を直接呼ぶ

RLS 対象の通常アプリケーションクエリでは、pool.query() の直接呼び出しを避けます。

// 避けたい例
const result = await pool.query(
  `
  SELECT *
  FROM correction_overrides
  WHERE domain_id = $1
  `,
  [domainId],
);

pool.query() は、内部で接続を借りて、1 つのクエリを実行して、すぐ返却します。

そのため、次の流れを作れません。

BEGIN
set_config(..., true)
SELECT ...
COMMIT

RLS が有効な状態で app.tenant_id を設定せずに SELECT すると、前回説明したフェイルセーフにより 0 件になります。

これは安全ではありますが、アプリケーションとしては動きません。

そのため、通常のアプリケーションコードでは、直接 pool.query() を使わず、withTenantClient 経由に寄せます。

間違った書き方を CI で止める

人間の注意だけで pool.query() を禁止するのは限界があります。

新しいメンバーが入ったとき、急いで実装しているとき、リファクタ中などに、直接呼び出しが入り込む可能性があります。

そのため、ESLint カスタムルールや grep ベースの CI チェックで止めるのが有効です。

services/api/src 配下で pool.query( を直接呼んでいたら CI で失敗させる

例外:
  services/api/src/db/with-tenant.ts
  services/api/src/db/admin-pool.ts

設計のコツは、正しい書き方を簡単にして、間違った書き方を入りにくくすることです。

正しい経路:
  withTenantClient(pool, tenantId, fn)

避けたい経路:
  pool.query(...)

この構造にしておくと、RLS を安全に運用しやすくなります。

まとめ

第 1 回では、RLS でテナント境界を DB レベルで強制する方法を見ました。

第 2 回では、その RLS が参照する app.tenant_id を、アプリケーションから安全に渡す方法を扱いました。

要点は次のとおりです。

SET:
  接続が切れるまで値が残る
  コネクションプールでは次のリクエストに漏れる危険がある

SET LOCAL:
  現在のトランザクション内だけ有効
  COMMIT / ROLLBACK で自動的に消える

アプリケーション側では、次の形に寄せます。

BEGIN
  SET LOCAL 相当の設定を行う
  -- 実装例: SELECT set_config('app.tenant_id', $1, true)
  通常のクエリ
COMMIT

これを安全に使うために、withTenantClient のようなラッパーを用意します。

withTenantClient の責務:
  tenantId の検証
  BEGIN
  SET LOCAL 相当の設定
  COMMIT / ROLLBACK
  client.release()

また、pool.query() の直接呼び出しは、SET LOCAL を挟むトランザクション境界を作れないため避けます。

SET LOCAL と RLS を組み合わせると、アプリケーションが tenant 条件を書き忘れても、DB が最後の砦になります。

次回は、テナントごとの連番を安全に払い出すために、SELECT FOR UPDATE で並行 INSERT を直列化する方法を扱います。

参考資料

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?