本文へスキップ
株式会社オープンナレッジ
株式会社オープンナレッジ Open Knowledge Inc.
データベース隔離工学・マルチテナント安全設計 PostgreSQL 19 / FORCE RLS / security_barrier / Rust sqlx 対象:経営者様・システム管理ご担当者様・DB設計者様

PostgreSQL 19 FORCE ROW LEVEL SECURITY と security_barrier による マルチテナント分離設計(db01.ok.st 実装仕様)

監修:加藤 明生(株式会社オープンナレッジ 創業者・代表取締役CTO / データベーススペシャリスト 第DB-2013-04-00083号 / 登録情報処理安全確保支援士 第007617号)

最新正規URLのご案内: ON CONFLICT DO SELECT(0B WAL)・InitPlan 定数化・uuidv7() を統合した PostgreSQL 19 正規リファレンスを公開しています。 PostgreSQL 19 正規仕様ページを開く →
1. 基本と全体像

この記事の要点と4つの章のご案内

身近なたとえで見る仕組み:「係員が仕分ける大部屋の郵便棚」と「本人のカードキーでしか開かないホテルの客室金庫」に関するイメージ写真
身近なたとえで見る仕組み:「係員が仕分ける大部屋の郵便棚」と「本人のカードキーでしか開かないホテルの客室金庫」
現場で陥りやすい落とし穴と回避策:PostgreSQL RLS 実装の3大アンチパターンに関するイメージ写真
現場で陥りやすい落とし穴と回避策:PostgreSQL RLS 実装の3大アンチパターン
PostgreSQL 19 FORCE ROW LEVEL SECURITY と security_barrier による マルチテナント分離設計(db01.ok.st 実装仕様) の各章におけるテーマと要点一覧表
章構成 主なテーマ 要点と確認ポイント
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箇所でこの条件を書き忘れると、全社のデータが一覧に出てしまう重大事故につながります。
第1章〜第4章では、実務チェックリスト・公的制度基準・詳しい技術仕様を確認できます。
この記事のポイント
  • 01 クラウドサービスで他社のデータが見えてしまう事故の多くは、プログラマーが検索条件(WHERE tenant_id = ...)を1箇所書き忘れる人為的ミスから起きています。
  • 02 当社の db01.ok.st(PostgreSQL 19)は、データの1行ごとに専用の鍵をかける「FORCE ROW LEVEL SECURITY」を全面適用し、条件の書き忘れがあっても他社データを1行も返しません。
  • 03 さらに Rust 側のプログラムでも「テナントの鍵をセットしない限りSQLを実行する関数すら呼び出せない型設計(Typestate)」を組み合わせ、二重の安全装置で顧客の機密を守ります。
場面別の実務確認ポイント(ご自身の役割に合わせてお読みいただけます) 全訪問者対応・3段階の構成
01 経営者・士業・医療機関・取引先審査ご担当の方

「クラウドに帳簿や名簿を預けて他社に漏れないか?」というご不安に対し、人間の注意力に頼らずデータベースの仕組み自体で個室隔離する根拠がわかります。

02 Webアプリ開発者・社内SE・PMの方

単なる ENABLE RLS だけでは防げない「テーブル所有者のバイパス問題」や「コネクションプールでのテナントID残留事故」を防ぐ実装パターンを学べます。

03 データベーススペシャリスト・セキュリティ研究者の方

後半の「第3章 Monograph」にて、rowsecurity.c の security_barrier による Qual Pushdown サイドチャネル防止と、Rust 2024 Typestate 実装を詳解しています。

第1章:Overview & Visual Architecture

身近なたとえで見る仕組み:「係員が仕分ける大部屋の郵便棚」と「本人のカードキーでしか開かないホテルの客室金庫」

多くの企業向けクラウドサービス(SaaS)では、何百社ものお客様のデータを1つの大きなデータベースで管理しています。これを「マルチテナント方式」と呼びます。コストを安くできる反面、一番大切なのは「A社の画面にB社のデータが原則として出ないようにすること」です。

従来の一般的なシステムは、いわば「鍵のかかっていない大部屋の棚に全社分の書類を並べ、受付の係員(画面プログラム)が毎回『A社の棚だけ取ってきますね』と目で見て運ぶ方式」でした。係員がいつも通り作業していれば問題ありませんが、もし新しい画面を追加したときに係員が「A社の分だけ」というメモ(SQLの WHERE 条件)を1行書き忘れると、棚にある全会社の書類をまとめて抱えてきてしまいます。

これに対して、私たちが db01.ok.st(PostgreSQL 19)で一貫している「FORCE ROW LEVEL SECURITY(強制・行レベルセキュリティ)」は、「書類の1枚1枚(データの1行1行)そのものに、電子カードキーのロックが組み込まれている状態」です。たとえ係員(プログラム)がうっかり「全部の書類を持ってきて!」と命令したとしても、保管庫(データベース)側が「今差し込まれているのはA社のカードキーなので、A社の書類しか開きません。他社の書類は最初から存在しないものとして扱います」と自動で制限します。

ポイント 01 OWASP Top 10 脆弱性第1位
アクセス制御の不備

世界のWebセキュリティ調査(OWASP Top 10)において、プログラム側の検索条件漏れなどによる「アクセス制御の不備(A01)」が最も深刻なリスク第1位に挙げられています。

ポイント 02 鍵未設定時の漏洩行数(Fail-Closed)
0 行(即時遮断)

万が一プログラムがテナント鍵(app.current_tenant_id)の指定を忘れた場合、全件表示になるのではなく、その場でエラーとなり0行も外部へ出しません。

ポイント 03 ZFS × PG19 物理ブロック整合
8,192 Bytes 一致

PostgreSQL 19のヒープページ(8KB)とOpenZFSのレコードサイズ(8KB)を一致させ、テナントID先頭複合インデックスにより高速な応答を実現します。

図解:Rust Typestate トランザクションと PostgreSQL 19 FORCE RLS による二重隔離パイプライン
01 Rust 2024 型安全ゲート UnboundTx(未認証状態) この型には query() メソッド自体が 存在しない(コンパイル時拒否) TenantBoundTx<'tx> へ昇格 SET LOCAL app.tenant_id = $1 トランザクション終了時に自動消去 02 PG19 rowsecurity.c 検問層 FORCE ROW LEVEL SECURITY テーブル所有者を含む全クエリへ tenant_id = current_setting(...) を強制注入(バイパス不可) security_barrier 保護壁 RLS条件を通過する前の行に対して 外部の関数や演算子が先に実行される サイドチャネル推論を構造的に抑止 03 8KB ヒープページ(ZFS) 一致する行のみが解錠される 行 01: Tenant A(解錠・返却) 鍵が一致 → 安全に応答 行 02: Tenant B(全体不可視) 鍵不一致 → プランナ段階で除外 行 03: Tenant C(全体不可視) 更新・削除(WITH CHECK)も拒否
比較項目一般的な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を持つ書き込みを全体拒否
第2章:Practical Field Guide & Self-Check

現場で陥りやすい落とし穴と回避策:PostgreSQL RLS 実装の3大アンチパターン

落とし穴 01: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 属性を明示した専用ロールを割り当てます。

落とし穴 02:コネクションプーリング(PgBouncerやsqlx Pool)環境で 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のメモリ上から自動的に変数が破棄される構造にします。

落とし穴 03: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

クリックしてチェックを入れると、業務システムのデータベース保護レベルに応じた診断結果が表示されます。

上の3項目をクリックして、現在お使いの業務システムや開発中のDB設計を点検してみてください。
CATALOG AUDIT & PLANNER VERIFICATION

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条件)をチェックし、鍵が合わない行には外部の計算式を一切触れさせない」という順序を厳密に守ることで、このような推測攻撃すら構造的・原理的に防いでいます。

技術仕様・実装解説:Technical Architecture & Implementation — PostgreSQL 19 rowsecurity.c Qual Pushdown Prevention & Rust Typestate Isolation

PostgreSQL 19 rowsecurity.c における security_barrier 述語順序強制と Rust 2024 sqlx アフィン型セッション検証器

src/backend/rewrite/rowsecurity.c / bufpage.h
Rewrite Subsystem get_row_security_policies()
Qual Security Level security_level = 0 (Barrier)
Heap / ZFS Alignment BLCKSZ = 8192B == recordsize=8K
Session Context Scope GUC_LOCAL_IN_XACT (Auto-Purge)
1. 設計仕様・数理モデル(Relational Calculus of Restrictive RLS, Planner security_level Ordering & 8KB Page Alignment)

PostgreSQL 19 のクエリ書き換えエンジン(src/backend/rewrite/rowsecurity.c)では、対象リレーション R に対して pg_class.relrowsecurity = true かつ pg_class.relforcerowsecurity = true が設定されている場合、テーブル所有者であってもバイパスを許さず、許容ポリシー集合 Pperm(論理和 ⋁)と制限ポリシー集合 Prest(論理積 ⋀)をクエリ構文木(RangeTblEntry.securityQuals)へ合成します:

Qeffective(R, τ) = σP_user( σBarrier: (⋁ p ∈ P_perm : p(τ)) ∧ (⋀ r ∈ P_rest : r(τ))(R) )
∀ qi ∈ RestrictInfo :   security_level(qRLS) = 0 < security_level(quser) ⇒ No Pushdown of Non-LEAKPROOF Functions

オプティマイザ(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 完了と同時に以前のスタック値(未定義状態)へ強制巻き戻しされるため、コネクションプール再利用時のクロスセッション残留が物理的に発生しません。

2. パラメータ・構成仕様(PostgreSQL 19 Heap Page, Catalog & Planner Isolation Matrix) pg_class / pg_policy / bufpage.h
Component / StructField / ParameterRequired SettingIsolation Guarantee
pg_classrelrowsecurity & relforcerowsecuritytrue && trueEnforces RLS even on table owner Role
pg_rolesrolsuper / rolbypassrlsfalse / falsePrevents runtime privilege escalation bypass
pg_policypolpermissive = false (AS RESTRICTIVE)Boolean AND GuardCannot be weakened by permissive OR policies
RestrictInfosecurity_level & leakproof checkLevel 0 BarrierEliminates error-based side-channel oracle leaks
GUC Contextset_config('app.current_tenant_id', v, true)is_local = truePurged automatically at transaction boundary
PageHeaderDataBLCKSZ (8192B) + 24B Header + pd_checksumZFS recordsize=8K1:1 physical block mapping with zero RMW amplification
3. 実装コード・設定仕様(migrations/001_force_rls.sql & src/db/typestate_rls_tx.rs)
PostgreSQL 19 DDL + Rust Edition 2024 (Affine Typestate Guard)
-- ============================================================================
-- 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
    }
}
ご質問・公的制度の活用に関するお問い合わせ窓口

掲載資料に関するご質問・公的制度活用のご案内

本ページの図解や解説内容に関するご質問、経済産業省「SCS評価制度」やIPA「SECURITY ACTION・サイバーセキュリティお助け隊サービス」等の公的制度の確認方法について、落ち着いた参考窓口としてご案内しております。売り込みや営業連絡は一切行いませんので、社内勉強会やご家庭での確認資料としてご自由にご活用ください。

図解・ビジュアルの拡大表示

図表内のテキストもそのまま選択・検索可能です(ベクターSVG描画) 枠外クリックまたは ESC キーで閉じます