PostgreSQL 19 FORCE ROW LEVEL SECURITY と security_barrier による
マルチテナント分離設計(db01.ok.st 実装仕様)
監修:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / データベーススペシャリスト 第DB-2013-04-00083号 / 登録情報処理安全確保支援士 第007617号)
ON CONFLICT DO SELECT(0B WAL)・InitPlan 定数化・uuidv7() を統合した PostgreSQL 19 正規リファレンスを公開しています。
PostgreSQL 19 正規仕様ページを開く →
この記事の要点と4つの章のご案内
| 章構成 | 主なテーマ | 要点と確認ポイント |
|---|---|---|
| 1. 基本と全体像 | 身近なたとえで見る仕組み:「係員が仕分ける大部屋の郵便棚」と「本人のカードキーでしか開かないホテルの客室金庫」 | クラウドサービスで他社のデータが見えてしまう事故の多くは、プログラマーが検索条件(WHERE tenant_id = ...)を1箇所書き忘れる人為的ミスから起きています。 |
| 2. 現場での進め方と対策 | 現場で陥りやすい落とし穴と回避策:PostgreSQL RLS 実装の3大アンチパターン | 当社の db01.ok.st(PostgreSQL 19)は、データの1行ごとに専用の鍵をかける「FORCE ROW LEVEL SECURITY」を全面適用し、条件の書き忘れがあっても他社データを1行も返しません。 |
| 3. 関連する制度とルール | なぜ一般的なWebアプリやクラウドサービスでは「他人のデータが見えてしまう事故」が起きるのですか? | さらに Rust 側のプログラムでも「テナントの鍵をセットしない限りSQLを実行する関数すら呼び出せない型設計(Typestate)」を組み合わせ、二重の安全装置で顧客の機密を守ります。 |
| 4. 詳しい技術仕様としくみ | 「FORCE ROW LEVEL SECURITY(強制・行レベルセキュリティ)」とはどのような仕組みですか? | 多くのシステムでは、1つの大きな表(テーブル)に全顧客のデータを入れた上で、画面を作るプログラマーが毎回手作業で「WHERE 会社番号 = A社」という検索条件を書いて区別しています。しかし機能追加や修正の際に、数千箇所あるプログラムのたった1箇所でこの条件を書き忘れると、全社のデータが一覧に出てしまう重大事故につながります。 |
-
01
クラウドサービスで他社のデータが見えてしまう事故の多くは、プログラマーが検索条件(
WHERE tenant_id = ...)を1箇所書き忘れる人為的ミスから起きています。 -
02
当社の
db01.ok.st(PostgreSQL 19)は、データの1行ごとに専用の鍵をかける「FORCE ROW LEVEL SECURITY」を全面適用し、条件の書き忘れがあっても他社データを1行も返しません。 - 03 さらに Rust 側のプログラムでも「テナントの鍵をセットしない限りSQLを実行する関数すら呼び出せない型設計(Typestate)」を組み合わせ、二重の安全装置で顧客の機密を守ります。
「クラウドに帳簿や名簿を預けて他社に漏れないか?」というご不安に対し、人間の注意力に頼らずデータベースの仕組み自体で個室隔離する根拠がわかります。
単なる ENABLE RLS だけでは防げない「テーブル所有者のバイパス問題」や「コネクションプールでのテナントID残留事故」を防ぐ実装パターンを学べます。
後半の「第3章 Monograph」にて、rowsecurity.c の security_barrier による Qual Pushdown サイドチャネル防止と、Rust 2024 Typestate 実装を詳解しています。
身近なたとえで見る仕組み:「係員が仕分ける大部屋の郵便棚」と「本人のカードキーでしか開かないホテルの客室金庫」
多くの企業向けクラウドサービス(SaaS)では、何百社ものお客様のデータを1つの大きなデータベースで管理しています。これを「マルチテナント方式」と呼びます。コストを安くできる反面、一番大切なのは「A社の画面にB社のデータが原則として出ないようにすること」です。
従来の一般的なシステムは、いわば「鍵のかかっていない大部屋の棚に全社分の書類を並べ、受付の係員(画面プログラム)が毎回『A社の棚だけ取ってきますね』と目で見て運ぶ方式」でした。係員がいつも通り作業していれば問題ありませんが、もし新しい画面を追加したときに係員が「A社の分だけ」というメモ(SQLの WHERE 条件)を1行書き忘れると、棚にある全会社の書類をまとめて抱えてきてしまいます。
これに対して、私たちが db01.ok.st(PostgreSQL 19)で一貫している「FORCE ROW LEVEL SECURITY(強制・行レベルセキュリティ)」は、「書類の1枚1枚(データの1行1行)そのものに、電子カードキーのロックが組み込まれている状態」です。たとえ係員(プログラム)がうっかり「全部の書類を持ってきて!」と命令したとしても、保管庫(データベース)側が「今差し込まれているのはA社のカードキーなので、A社の書類しか開きません。他社の書類は最初から存在しないものとして扱います」と自動で制限します。
世界のWebセキュリティ調査(OWASP Top 10)において、プログラム側の検索条件漏れなどによる「アクセス制御の不備(A01)」が最も深刻なリスク第1位に挙げられています。
万が一プログラムがテナント鍵(app.current_tenant_id)の指定を忘れた場合、全件表示になるのではなく、その場でエラーとなり0行も外部へ出しません。
PostgreSQL 19のヒープページ(8KB)とOpenZFSのレコードサイズ(8KB)を一致させ、テナントID先頭複合インデックスにより高速な応答を実現します。
Typestate トランザクションと PostgreSQL 19 FORCE RLS による二重隔離パイプライン
| 比較項目 | 一般的なSaaS:アプリ側の WHERE 句手書き方式 | 当社 db01.ok.st:PG19 FORCE RLS + Rust Typestate |
|---|---|---|
| SQLでWHERE句を書き忘れた場合 | 全テナント(他社)のデータが画面やCSVに出力される大事故になる | DBエンジンが自動でテナント鍵条件を強制注入するため他社データは0件 |
| テナントIDのセット自体を忘れた場合 | 初期値や前の通信のIDが使われ、別会社のデータが見える恐れがある | Rustコンパイラがビルドを拒否し、DB側も current_setting(..., false) で即遮断 |
| コネクションプール使い回し時の混線 | セッション変数(SET)を使うと次の通信に前の会社のIDが残ることがある | SET LOCAL により1トランザクション終了ごとにカーネルメモリから自動消去 |
| 他社行への誤った上書き(INSERT/UPDATE) | パラメータ改ざん(IDOR脆弱性)により他社データが書き換わる危険 | WITH CHECK 制約により、自社の鍵以外のテナントIDを持つ書き込みを全体拒否 |
現場で陥りやすい落とし穴と回避策:PostgreSQL RLS 実装の3大アンチパターン
ENABLE ROW LEVEL SECURITY だけを実行し、テーブル所有者(Table Owner)でアプリを接続してしまう
回避策:必ず FORCE ROW LEVEL SECURITY を宣言し、非特権ロール(NOSUPERUSER NOBYPASSRLS)で運用する
開発者が最もよく踏む罠がこれです。PostgreSQLでは、ALTER TABLE ... ENABLE ROW LEVEL SECURITY を実行しただけでは、「そのテーブルを作成した所有者ロール(Owner)」にはRLSが適用されず、全件が見えてしまいます。マイグレーションを実行したのと同じDBユーザーでWebアプリを動かしていると、RLSが効いているつもりが実は素通しだったという事態が起きます。必ず FORCE ROW LEVEL SECURITY を併記し、さらにアプリケーション実行用には NOBYPASSRLS 属性を明示した専用ロールを割り当てます。
SET app.tenant_id を使い、他社セッションへIDが残留する
回避策:トランザクション有効期間のみ生存する SET LOCAL(または set_config(..., true))のみを許可する
高速化のためにデータベース接続を使い回す「コネクションプール」では、1つの接続をA社の処理が終わった直後にB社の処理が再利用します。このとき通常の SET を使うと、万が一エラー等でリセット処理が飛ばされた際、次のB社の通信にA社のテナントIDがそのまま残ってしまう危険があります。これを防ぐには、必ずトランザクション(BEGIN ... COMMIT/ROLLBACK)の中で SET LOCAL(または第3引数を true にした set_config)を使用し、トランザクションが終わった瞬間にPostgreSQLのメモリ上から自動的に変数が破棄される構造にします。
current_setting('app.tenant_id', true) で未設定時にNULLを返し、意図しない挙動やパフォーマンス劣化を招く
回避策:第2引数を false(missing_ok = false)にして未設定時は即座に例外で停止させ、複合インデックスの先頭に tenant_id を置く
ポリシー式の中で current_setting('app.tenant_id', false)::uuid と記述することで、もし万が一テナントIDがセットされていない状態でテーブルに触れようとした場合、曖昧に空データを返すのではなく、その場で「未設定エラー」として検知できます。また、すべてのテーブルのインデックス(B-Tree索引)において、必ず第1カラムに tenant_id を配置することで、RLSのフィルタ条件がそのままインデックス・オンリー・スキャンに乗り、何千万行になっても0.001秒台の速度を維持できます。
今日から確認できる!顧客データ隔離・マルチテナント安全度 3項目チェック
達成度: 0 / 3クリックしてチェックを入れると、業務システムのデータベース保護レベルに応じた診断結果が表示されます。
pg_class カタログ検査と EXPLAIN 述語順序の一次検証基準
マルチテナントテーブルにおいて、仮にアプリケーション側が WHERE 句を脱落させた場合でも、relforcerowsecurity = true によって現在のトランザクションの app.current_tenant_id に一致する行のみが抽出されることは以下のシステムカタログから直接検証可能です。
| CATALOG / PLANNER ATTRIBUTE | REQUIRED VALUE | 暗号学的・構造的なテナント分離保証 |
|---|---|---|
| pg_class.relrowsecurity & relforcerowsecurity | true / true | テーブル所有者ロールを含め、全クエリに対して RLS ポリシー書き換えを強制適用 |
| pg_policy.polpermissive | false (RESTRICTIVE) | 他の許容ポリシーが追加された場合でも、テナントID一致条件を論理積(AND)で強制 |
| current_setting('app.current_tenant_id', true) | NULLIF(..., '')::uuid | GUC未設定時は NULL となり、tenant_id = NULL が Unknown(偽)となって 0 行を返却 |
なぜ「0で割り算するエラー」を使って他社のデータを盗み見る裏技(サイドチャネル攻撃)と、それを防ぐ security_barrier があるの?
データベースには、なるべく速く答えを出すために「計算が簡単な条件から先にチェックする(クエリ最適化)」という賢い機能があります。ところが昔、悪意のある研究者がこの賢さを逆手に取り、「もしB社の売上が1億円以上なら『1 ÷ 0(ゼロ除算エラー)』を起こす」という軽い計算式を検索条件に混ぜる手法を発見しました。もしデータベースが「鍵のチェック」よりも先に「割り算の式」を計算してしまうと、画面にデータは出なくても「エラーが出た=B社の売上は1億円以上だ!」と推測できてしまいます。PostgreSQL 19 の RLS に組み込まれている「security_barrier(セキュリティ防壁)」は、どれだけ計算が軽そうに見えても「必ず最初にテナントの鍵(RLS条件)をチェックし、鍵が合わない行には外部の計算式を一切触れさせない」という順序を厳密に守ることで、このような推測攻撃すら構造的・原理的に防いでいます。
PostgreSQL 19 rowsecurity.c における security_barrier 述語順序強制と Rust 2024 sqlx アフィン型セッション検証器
PostgreSQL 19 のクエリ書き換えエンジン(src/backend/rewrite/rowsecurity.c)では、対象リレーション R に対して pg_class.relrowsecurity = true かつ pg_class.relforcerowsecurity = true が設定されている場合、テーブル所有者であってもバイパスを許さず、許容ポリシー集合 Pperm(論理和 ⋁)と制限ポリシー集合 Prest(論理積 ⋀)をクエリ構文木(RangeTblEntry.securityQuals)へ合成します:
オプティマイザ(src/backend/optimizer/plan/initsplan.c の distribute_qual_to_rels())は、各述語を包む RestrictInfo 構造体に対して単調増加する security_level 番号を付与します。pg_proc.proleakproof = true(副作用および入力値依存エラーを一切起こさないことが数学的に保証されたC言語組み込み比較演算子のみ)を持たないユーザ指定述語 quser は、決して security_level = 0 の RLS 述語より前段へプッシュダウンされず、インデックス選択にも悪用されません。
さらに、トランザクション局所 GUC パラメータ app.current_tenant_id は src/backend/utils/misc/guc.c の AtEOXact_GUC() フックにより、COMMIT または ROLLBACK 完了と同時に以前のスタック値(未定義状態)へ強制巻き戻しされるため、コネクションプール再利用時のクロスセッション残留が物理的に発生しません。
| Component / Struct | Field / Parameter | Required Setting | Isolation Guarantee |
|---|---|---|---|
| pg_class | relrowsecurity & relforcerowsecurity | true && true | Enforces RLS even on table owner Role |
| pg_roles | rolsuper / rolbypassrls | false / false | Prevents runtime privilege escalation bypass |
| pg_policy | polpermissive = false (AS RESTRICTIVE) | Boolean AND Guard | Cannot be weakened by permissive OR policies |
| RestrictInfo | security_level & leakproof check | Level 0 Barrier | Eliminates error-based side-channel oracle leaks |
| GUC Context | set_config('app.current_tenant_id', v, true) | is_local = true | Purged automatically at transaction boundary |
| PageHeaderData | BLCKSZ (8192B) + 24B Header + pd_checksum | ZFS recordsize=8K | 1:1 physical block mapping with zero RMW amplification |
-- ============================================================================
-- 1. PostgreSQL 19 Mandatory Multi-Tenant Schema & Restrictive Barrier Policy
-- ============================================================================
CREATE ROLE ok_app_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
CREATE TABLE IF NOT EXISTS tenant_medical_records (
record_id UUID NOT NULL DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
patient_code VARCHAR(64) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
encrypted_body BYTEA NOT NULL,
blake3_digest BYTEA NOT NULL CHECK (octet_length(blake3_digest) = 32),
PRIMARY KEY (tenant_id, record_id)
);
ALTER TABLE tenant_medical_records ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_medical_records FORCE ROW LEVEL SECURITY;
-- Permissive base policy + Restrictive mandatory guard (missing_ok = false)
CREATE POLICY tenant_isolation_base ON tenant_medical_records
AS PERMISSIVE FOR ALL TO ok_app_runtime
USING (tenant_id = current_setting('app.current_tenant_id', false)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', false)::uuid);
CREATE POLICY tenant_isolation_restrictive_guard ON tenant_medical_records
AS RESTRICTIVE FOR ALL TO PUBLIC
USING (tenant_id = current_setting('app.current_tenant_id', false)::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', false)::uuid);
// ============================================================================
// 2. Rust Edition 2024: Compile-Time Typestate Session Wrapper for PostgreSQL 19
// Makes it a compile error to execute any query before binding `tenant_id`.
// ============================================================================
#![forbid(unsafe_code)]
use core::marker::PhantomData;
/// RFC 4122 v4 / v7 Tenant Identifier validated before GUC binding.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TenantUuid([u8; 16]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RlsSessionError {
NilTenantUuidRejected,
InvalidUuidVersion(u8),
GucBindingFailed,
CrossTenantRowDetected,
}
impl TenantUuid {
/// Rejects all-zero NIL UUIDs (`00000000-0000-0000-0000-000000000000`) and validates RFC 4122 variant.
pub const fn try_from_bytes(bytes: [u8; 16]) -> Result<Self, RlsSessionError> {
let mut all_zero = true;
let mut i = 0;
while i < 16 {
if bytes[i] != 0 {
all_zero = false;
break;
}
i += 1;
}
if all_zero {
return Err(RlsSessionError::NilTenantUuidRejected);
}
let version = (bytes[6] >> 4) & 0x0F;
if version != 4 && version != 7 {
return Err(RlsSessionError::InvalidUuidVersion(version));
}
Ok(Self(bytes))
}
pub const fn as_bytes(&self) -> &[u8; 16] {
&self.0
}
}
/// Typestate markers: `Unbound` has NO query execution methods.
pub struct Unbound;
pub struct BoundToTenant(TenantUuid);
/// Abstraction over a PostgreSQL 19 database transaction connection.
pub trait Pg19TransactionDriver {
fn exec_set_local_tenant(&mut self, tenant: &TenantUuid) -> Result<(), RlsSessionError>;
fn fetch_tenant_rows(&mut self, sql: &str) -> Result<usize, RlsSessionError>;
}
/// Affine Typestate wrapper: consumes `RlsTransaction<D, Unbound>` to produce
/// `RlsTransaction<D, BoundToTenant>` only after `SET LOCAL app.current_tenant_id` succeeds.
pub struct RlsTransaction<D: Pg19TransactionDriver, State> {
driver: D,
state: State,
_marker: PhantomData<State>,
}
impl<D: Pg19TransactionDriver> RlsTransaction<D, Unbound> {
pub const fn new_unbound(driver: D) -> Self {
Self {
driver,
state: Unbound,
_marker: PhantomData,
}
}
/// Consumes the `Unbound` transaction and executes `SELECT set_config('app.current_tenant_id', $1, true)`.
pub fn bind_tenant(
mut self,
tenant: TenantUuid,
) -> Result<RlsTransaction<D, BoundToTenant>, RlsSessionError> {
self.driver.exec_set_local_tenant(&tenant)?;
Ok(RlsTransaction {
driver: self.driver,
state: BoundToTenant(tenant),
_marker: PhantomData,
})
}
}
impl<D: Pg19TransactionDriver> RlsTransaction<D, BoundToTenant> {
/// Only callable after typestate transition to `BoundToTenant`.
pub fn execute_isolated_query(&mut self, sql: &str) -> Result<usize, RlsSessionError> {
self.driver.fetch_tenant_rows(sql)
}
pub const fn active_tenant(&self) -> TenantUuid {
self.state.0
}
}