はじめに
こんにちは、Acsim 開発チームの笹沢です。
AI 要件定義サービス Acsim はマルチテナント SaaS として提供しており、RLS(Row Level Security)によってデータベースレベルで一元的なデータ分離を実現しています。
RLS によるデータ分離の実装は AWS ブログ にあるような基本的なアプローチを採用していますが、実際にプロダクトに組み込むには「テナントコンテキストがリクエスト発生から DB に到達するまでどう流れるか」の全体設計が必要になります。
本記事では Acsim の実装を例に、HTTP リクエストから RLS ポリシー評価に至るまでの構成を概説しつつ、その過程で直面した技術的な設計判断を深掘ります。
Acsim の技術スタック
本記事に関連する Acsim の技術スタックは下記の通りです。
| カテゴリ | 技術 | バージョン |
|---|---|---|
| 言語 | TypeScript | 5.9.3 |
| Web フレームワーク | Hono | 4.11.9 |
| データベース | Aurora PostgreSQL | 16.4 |
全体像: リクエストから RLS ポリシー評価まで
Acsim では、1つの HTTP リクエストが RLS で保護されたデータにアクセスするまでに、次の流れを辿ります。
テナントコンテキスト(所属テナントやロールなどの情報)は「Middleware → Hono Context → UseCase → PostgreSQL」の順に流れます。Middleware がテナントコンテキストを取得・検証して Hono Context に格納し、UseCase がそれを取り出して PostgreSQL のトランザクション内でセッション変数に注入し、RLS ポリシーが評価します。
ここからは、この構成に至る過程で直面した設計判断を3つ掘り下げます。
設計判断 1: テナントコンテキストをどう取得するか
RLS にテナントコンテキストを渡すには、リクエストからテナントコンテキストを取得して set_config に渡す必要があります。問題はどこから取得するかです。
JWT に載せるアプローチ
よく見かけるのは、JWT のクレームにテナント ID を含めておき、ミドルウェアで取り出して set_config に渡す方法です1。
// JWT からテナント ID を取り出して set_config に渡す(擬似コード)
const tenantId = jwt.claims.tenant_id;
await tx.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, TRUE)`);
リクエストの度に DB を参照する必要がないためレイテンシや負荷の面では有利です。しかし、JWT は発行後にクレームを変更できないため、トークンが再発行されるまでテナントコンテキストは古いままになります。
Acsim が踏んだ落とし穴
Acsim でも以前はクライアントサイドにテナントコンテキストをキャッシュしていました。パフォーマンスを考慮した設計でしたが、ユーザーが新しいテナントに招待された直後にキャッシュが更新されず、招待先のデータが見えないバグが発生しました。また、顕在化はしませんでしたが、ユーザーがテナントから削除された後もデータにアクセスできてしまう状態でした。
権限の変更を即座に反映するには、再認証を強制するか、リクエストごとに DB から最新のテナントコンテキストを取得するしかありません。
Acsim の選択: リクエストごとの DB ルックアップ
Acsim はクライアントサイドでのテナントコンテキストのキャッシュを廃止しました。代わりに、認証成功後に admin 接続で DB から最新のテナントコンテキストを取得しています。
リクエストごとに DB への問い合わせが増えるデメリットはありますが、テナントコンテキストをリアルタイムに更新することでデータアクセスの正確性が向上しました。
設計判断 2: 認可を RLS で実装するか、アプリ層で実装するか
RLS は強力なので、テナント分離だけでなく認可(ロールベースのアクセス制御)もポリシーに組み込みたくなります。たとえば「viewer 権限のユーザーには UPDATE を許可しない」をポリシーで表現するとこうなります。
CREATE POLICY tenant_update_policy ON projects
FOR UPDATE
USING (
tenant_id = ANY(current_setting('app.tenant_ids')::text[])
AND current_setting('app.tenant_role') IN ('editor', 'approver')
);
問題ないように見えますが、Acsim ではこのアプローチを採用していません。理由は2つあります。
複数テナント × ロールの組み合わせ問題
Acsim ではユーザーが複数のテナントに所属でき、テナントごとに異なるロールを持ちます。たとえば、あるユーザーがテナント A では editor、テナント B では viewer という状態がありえます。
この場合、app.tenant_role を1つのセッション変数で表現できません。配列にしても app.tenant_ids との対応関係が崩れます。ポリシーの中で「このテナントにおけるロール」を正しく解決するには、ポリシー内で JOIN やサブクエリが必要になり、複雑さとコストが急激に増します。
RLS のエラーハンドリングの制約
もう1つの問題はエラーの表現力です。RLS ポリシーに違反した場合の挙動は PostgreSQL が決めます。
| 句 | 対象操作 | 違反時の挙動 |
|---|---|---|
| USING | SELECT, UPDATE, DELETE | 該当行が静かに除外される(エラーにはならない) |
| WITH CHECK | INSERT, UPDATE | エラーコード 425012(権限不足)を返す |
SELECT の場合、「データが存在しない」のか「権限がなくて見えない」のか区別できません。アプリ層であれば「このテナントへの編集権限がありません」と具体的なエラーメッセージを返せます。
Acsim の選択: テナント分離と認可の責務を分ける
Acsim ではテナント分離と認可の責務を明確に分けています3。
| レイヤー | 責務 | 問い |
|---|---|---|
| 認可ミドルウェア(アプリ層) | 操作の権限チェック | この操作を実行する権限があるか? |
| RLS ポリシー(DB 層) | データの分離 | このデータにアクセスできるか? |
認可ミドルウェアはリクエストが含む tenant_id に対して権限チェックを行うため、テナントとロールの対応関係が曖昧になりません。
万が一認可ミドルウェアにバグがあっても、RLS が最後の砦としてデータ漏洩を防ぎます。
設計判断 3: テーブルオーナーの RLS バイパスをどう防ぐか
RLS を導入したら、もう1つ見落とせない PostgreSQL の仕様があります。テーブルオーナーとスーパーユーザーは、デフォルトで RLS ポリシーをバイパスするという仕様です4。
何が起きるか
マイグレーションとアプリケーションで同じ DB ユーザーを使っている場合、CREATE TABLE を実行したユーザーがそのテーブルのオーナーになるため5、アプリケーションからの接続でも RLS ポリシーを素通りします。
-- admin ユーザーでテーブルを作成
CREATE TABLE projects (...);
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_policy ON projects USING (...);
-- admin ユーザーで SELECT すると…ポリシーが無視され全行返る
SELECT * FROM projects; -- 全テナントのデータが見える
RLS を有効にしたつもりでも、テーブルオーナーで接続していればポリシーは適用されません。
Acsim の選択: SET ROLE による RLS ポリシーの強制
Acsim では DB 接続自体は admin ユーザーで行い、RLS を適用したいクエリの実行前に SET ROLE rls_client で非オーナーロールに切り替えています6。
// トランザクション内で RLS を強制適用
await tx.execute(sql`SET ROLE rls_client`);
// ↓ ここから先のクエリには RLS が適用される
// ビジネスロジックのクエリを実行
const result = await transaction(tx);
// クリーンアップ
await tx.execute(sql`RESET ROLE`);
rls_client はテーブルのオーナーではないため、RLS ポリシーが適用されます。一方、マイグレーションやスコープ解決のような管理操作は admin ユーザーのまま実行するので、RLS に制約されません。
なお、FORCE ROW LEVEL SECURITY をテーブルに設定すればオーナーにも RLS を強制できます7が、マイグレーションや管理操作にも影響するため、SET ROLE で使い分ける運用にしています。
まとめ
RLS によるテナント分離はポリシー定義だけ見ればシンプルですが、プロダクトに組み込むと「テナントコンテキストをどうポリシーまで届けるか」の全体設計が必要になります。
本記事では Acsim の実装を通じて、3つの設計判断を紹介しました。
- テナントコンテキストの取得方法 — JWT に載せると即時反映できない。リクエストごとに DB から引くことで正確性を担保した
- 認可と RLS の責務分担 — RLS で認可もできるが、複数テナント × ロールの組み合わせやエラーハンドリングの制約からアプリ層に分離した
- テーブルオーナーの RLS バイパス —
SET ROLEで非オーナーロールに切り替えて RLS を強制適用した
これらの判断にひとつの正解があるわけではありません。重要なのは、自分たちのプロダクトの要件に照らしてどのトレードオフを選ぶかを意識的に判断することです。本記事がその判断の参考になれば幸いです。
Footnotes
-
Introducing and applying tenant context(AWS Prescriptive Guidance) では、JWT にテナントコンテキストを保持するアプローチが言及されています。実装例としては RLSではじめるマルチテナントSaaS が参考になります。 ↩
-
42501は PostgreSQL のエラーコードinsufficient_privilegeに対応します。PostgreSQL Error Codes を参照してください。 ↩ -
PostgreSQL Row-Level Security Limitations and Alternatives(Bytebase) でも、RLS はシンプルなテナント分離には有効だが複雑な認可にはアプリ層との組み合わせが必要と論じられています。 ↩
-
Row Security Policies に "Table owners normally bypass row security as well" と記載されています。 ↩
-
ALTER TABLE の
OWNER TOで変更しない限り、テーブルを作成したロールがオーナーになります。 ↩ -
単一コネクションプールでテナントごとに RLS コンテキストを切り替えるアプローチは PostgreSQL の RLS を Go で単一 DB コネクションプールにて使いこなす(HRBrain) でも採用されています。 ↩
-
ALTER TABLE の
FORCE ROW LEVEL SECURITYを参照してください。 ↩
