目次
- 第 1 回 — Row Level Security でテナント境界を DB レベルで強制する
- 第 2 回 —
SET LOCALでトランザクションスコープのセッション変数を使う - 第 3 回 —
SELECT FOR UPDATEで並行 INSERT のシーケンス重複を防ぐ - 第 4 回 —
IS DISTINCT FROMで NULL 安全に比較する - 第 5 回 — HMAC チェーンで監査ログの改ざんを検出する
この記事で扱うこと
この記事では、次を扱います。
-
SETとSET 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 接続をリクエストごとに新しく作るのではなく、コネクションプールで使い回すことが多いためです。
SET と SET LOCAL の違い
SET と SET 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_id を uuid にキャストしています。
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 を直列化する方法を扱います。