Amazon Web Services ブログ

PostgreSQL の行レベルのセキュリティを備えたマルチテナントデータの分離

Software as a Service (SaaS) プロバイダーには、基本的にテナントデータの分離を適用する責任があります。テナントの 1 つが別のテナントのデータにアクセスした場合、信頼はなくなり、ビジネスのブランドに永久的な損害を与える可能性があるだけでなく、さらにひどい場合には、ビジネスを失う可能性があります。

リスクが非常に大きいため、効果的なデータの分離を計画することが重要です。マルチテナントアーキテクチャは、各テナントのリソースをレプリケートするのではなく、すべてのテナントのデータストレージリソースを共有することで、俊敏性と運用コストを節約します。しかし、共有モデルで分離を適用することは難しいため、マルチテナントデータモデルで妥協して、テナントごとにコストがかかるデータベースのオプションに戻すことが可能です。

多くの場合、共有データベースモデルはソフトウェア開発者に依存しているため、記述してあるすべての SQL ステートメントで適切なチェックを実装することが唯一の選択となります。他のセキュリティ上の懸念事項と同様に、日常的なソースコードの変動性にあまり依存しないより一元的な方法で、テナントデータの分離ポリシーを適用するのがよいでしょう。

この投稿は SaaS アーキテクトと開発者を対象としており、テナントの共有データベースの利点を享受しながら一元型分離の適用を実現する方法について解説しています。

データパーティション分割のオプション

マルチテナントシステムで使用する一般的なデータパーティション分割モデルには、サイロ、ブリッジ、プールの 3 つがあります。各モデルの分離方法には、それぞれ長所と短所があります。

  • サイロ – テナントごとに個別のデータベースインスタンスを使用すると、分離はたいていインフラストラクチャコストが高くなるだけでなく、テナントのセットアップがより複雑になります。これは、SaaS のサービスにオンボードする各テナントに、新しいデータベースインスタンスを作成および管理する必要があるためです。
  • ブリッジ – テナントデータをパーティション分割する 2 つ目のアプローチは、同じデータベースインスタンスを共有しながら、テナントごとに異なるスキーマを使用する方法です。リソースを共有することでモデルのコストを削減できますが、メンテナンスとテナントのセットアップはかなり複雑になる可能性があります。
  • プール – 3 つ目のパーティション分割モデルは、共有データベースインスタンス名前空間の両方を使用するものです。この設計ではすべてのテナントデータを並べて表示しますが、各テーブルまたはビューには、データのフィルターに使用するパーティション分割キー (通常はテナント ID) が含まれています。

プールモデルは運用コストが最も節約でき、インフラストラクチャコードとメンテナンスのオーバーヘッドを削減します。ただし、このモデルはデータアクセスポリシーを適用するのが難しい場合があり、通常は、すべての SQL ステートメントに正しい WHERE 句を実装することが望まれます。

マルチテナントデータのパーティション分割の詳細については、次の AWS SaaS Factory ホワイトペーパーをご参照ください。

行レベルのセキュリティ

RDBMS 分離ポリシーの適用をデータベースレベルで一元化することにより、ソフトウェア開発者の負担を軽減できます。このため、プールモデルの利点を活用し、かつテナント間のデータアクセスのリスクを軽減できます。

PostgreSQL 9.5 以降には、行レベルセキュリティ (RLS) と呼ばれる機能が含まれています。テーブルにセキュリティポリシーを定義すると、これらのポリシーが、SELECT クエリによって返されるそのテーブルの行、または INSERTUPDATEDELETE コマンドの影響を受ける行を制限します。Amazon Relational Database Service (RDS) は、Amazon Aurora for PostgreSQL と RDS for PostgreSQL エンジンの両方で RLS をサポートしています。詳細については、PostgreSQL ウェブサイトの「Row Security Policies」をご覧ください。

RLS ポリシーには名前があり、ALTER ステートメントを使用してテーブルに適用されたり、テーブルから削除されたりします。ポリシーは、ブール値を返す USING 句で定義されます。このブール値はテーブル内の特定の行を処理するかどうかを示します。複数のポリシーをテーブルに同時に適用して、複雑なセキュリティ体制を実現できます。さらに、ポリシーはすべてのステートメントタイプ (SELECT, INSERTUPDATEDELETE) にも対応します。または、読み込み用とは異なる変更用のポリシーを持つことができます。変更に対する SELECT に異なるポリシーを使用する場合、ポリシー定義に必要なものWITH CHECK 句です。

RLS ポリシーを、データベースエンジンが管理する自動化した WHERE 句と考えることができます。

コード例

この投稿は、GitHub の「AWS サンプル」セクションでホストされている完全に機能する例を補足するものです。詳細については、AWS SaaS Factory GitHub リポジトリをご覧ください。この投稿のコード例はリポジトリから取得したものであり、個別に実行することを意図したものではありません。

テーブル定義の一部として RLS ポリシーを作成するには、次のコードをご参照ください。

-- Create a table for our tenants with indexes on the primary key and the tenant’s name
CREATE TABLE tenant (
    tenant_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    name VARCHAR(255) UNIQUE,
    status VARCHAR(64) CHECK (status IN ('active', 'suspended', 'disabled')),
    tier VARCHAR(64) CHECK (tier IN ('gold', 'silver', 'bronze'))
);

-- Create a table for users of a tenant
CREATE TABLE tenant_user (
    user_id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenant (tenant_id) ON DELETE RESTRICT,
    email VARCHAR(255) NOT NULL UNIQUE,
    given_name VARCHAR(255) NOT NULL CHECK (given_name <> ''),
    family_name VARCHAR(255) NOT NULL CHECK (family_name <> '')
);

-- Turn on RLS
ALTER TABLE tenant ENABLE ROW LEVEL SECURITY;

-- Restrict read and write actions so tenants can only see their rows
-- Cast the UUID value in tenant_id to match the type current_user returns
-- This policy implies a WITH CHECK that matches the USING clause
CREATE POLICY tenant_isolation_policy ON tenant
USING (tenant_id::TEXT = current_user);

-- And do the same for the tenant users
ALTER TABLE tenant_user ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_user_isolation_policy ON tenant_user
USING (tenant_id::TEXT = current_user);

プライマリキーにランダムな UUID 値ではなく数値シーケンスを使用し、大規模なデータセットのスケーラビリティとパフォーマンスを向上させることを検討してください。詳細については、PostgreSQL ウェブサイトの「UUID-OSSP」をご覧ください。

RLS の使用

これらのテーブルとポリシーの定義を前提として、SaaS プロバイダーである皆さんがデータベースのプロビジョニングと新しいテナントのオンボードに使用する、共有システムレベルのロール (ユーザー) があると仮定します。以下は、PostgreSQL のコマンドラインクライアント psql を使用した例です。

テナントをオンボーディングした SaaS プロバイダーのシステムレベルのロールでログインすると、すべてのテナントレコードを表示できます。これは、デフォルトでテーブルの所有者がセキュリティポリシーによって制限されないためです (テーブルが FORCE ROW LEVEL SECURITY で変更されない限り)。次のコードをご参照ください。

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
 69ad9212-f5ef-456d-a724-dd8ea3c80d61 | Tenant 2 | active | gold
(2 rows)

すべてのテナントユーザーを表示することもできます。

rls_multi_tenant=> SELECT tenant_id, user_id, given_name || ' ' || family_name AS name FROM tenant_user;
              tenant_id               |               user_id                |      name       
--------------------------------------+--------------------------------------+-----------------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | d9f7d636-69a0-40d4-96d9-d429d1e1cee3 | User 1 Tenant 1
 69ad9212-f5ef-456d-a724-dd8ea3c80d61 | eb7a503a-a7c6-44c0-9916-8df68dd96815 | User 1 Tenant 2
(2 rows)

非システムユーザーの Tenant 1 ロールでデータベースにログインすると、行レベルのセキュリティポリシーの動作を確認できます。まず、Tenant 1 としてログインしていることを確認します。次のコードをご参照ください。

rls_multi_tenant=> SELECT current_user;
             current_user             
--------------------------------------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c
(1 row)

セキュリティポリシーの適用による SELECT ステートメントのエラーやメッセージはありません。ポリシーの USING ステートメントに一致しない行は、結果セットに存在しません。次のコードをご参照ください。

rls_multi_tenant=> SELECT * FROM tenant;
              tenant_id               |    name  | status | tier 
--------------------------------------+----------+--------+------
 1cf1cc14-dd34-4a7b-b87d-adf79b2c255c | Tenant 1 | active | gold
(1 row)

別のテナントの情報へのブルートフォースアクセスを試みても、ポリシーによって保護されます。次のコードをご参照ください。

rls_multi_tenant=> SELECT * FROM tenant WHERE tenant_id = '69ad9212-f5ef-456d-a724-dd8ea3c80d61'::UUID;
 tenant_id | name | status | tier 
-----------+------+--------+------
(0 rows)

アクションに一致する行が返されないため、UPDATE および DELETE ステートメントのポリシーを同じように適用します。次のコードをご参照ください。

rls_multi_tenant=> UPDATE tenant_user SET given_name = 'Cross Tenant Access' WHERE user_id = 'eb7a503a-a7c6-44c0-9916-8df68dd96815'::UUID;
UPDATE 0

rls_multi_tenant=> DELETE FROM tenant WHERE tenant_id = '69ad9212-f5ef-456d-a724-dd8ea3c80d61'::UUID;
DELETE 0

しかし、セキュリティポリシーに失敗した INSERT ステートメントはエラーを返します。

rls_multi_tenant=> INSERT INTO tenant (name) VALUES ('Tenant 3');
ERROR:  new row violates row-level security policy for table "tenant"

Tenant 1 は tenant_id 列の値 (この使用例では自動生成されています) が ID と一致しないので、新しいレコードを挿入できません。挿入の発行中に独自の ID を指定すると、一意のキー違反が発生します。

RLS を使用する際の考慮事項

PostgreSQL のスーパーユーザーと BYPASSRLS 属性を使用して作成したロールには、テーブルポリシーが適用されません。また、デフォルトでは、テーブルが FORCE ROW LEVEL SECURITY で変更されない限り、テーブルの所有者は RLS ポリシーをバイパスします。これが、tenant および tenant_user テーブルを作成したシステムレベルのロールが、前の例のすべての行にアクセスできる理由です。

アプリケーションコードがテーブル所有者と同じ PostgreSQL ロール (後で変更されない限り、通常は CREATE TABLE ステートメントを発行したユーザー) としてデータベースに接続する場合、セキュリティポリシーはデフォルトでは有効になりません

そういった理由やその他のセキュリティおよびモニタリング上の理由から、データベースオブジェクトの所有者以外のユーザーとしてアプリケーションをデータベースに接続する必要があります。

対処すべき 2 つ目の項目は、USING 句の定義方法です。前の例では、tenant_id = current_user を使用しました。そのため、現在接続している PostgreSQL ロール名は、処理する行の tenant_id 列の値と一致する必要があります。このメカニズムを使用する場合、すべてのテナントに対して PostgreSQL ロールを作成する必要があります。これは管理が容易でなく、拡張性も低くなります。

代替アプローチ

テナントごとに PostgreSQL ユーザーを作成して管理しない場合でも、アプリケーションで共有の PostgreSQL ログインを使用できます。ただし、ランタイムパラメーターを定義して、アプリケーションの現在のテナントコンテキストを保持する必要があります。ログインがテーブルの所有者ではないこと、または BYPASSRLS で定義されていないことを確認してください。非常にスケーラブルなこの代替方法は、次のコードのようになります。

CREATE POLICY tenant_isolation_policy ON tenant
USING (tenant_id = current_setting('app.current_tenant')::UUID);

現在ログインしている PostgreSQL ユーザーを tenant_id 列と比較する代わりに、ビルトイン current_setting 関数を使用して、app.current_tenant という名前の設定変数の値を読み込みます (さらにテキスト値を UUID 値にキャストします。これはその値が tenant_id 列の定義済みタイプであるためです)。変数は、プレフィックスドット変数の形式である必要があります。プレフィックスを含まない変数は postgresql.conf ファイルで定義されているため、RDS インスタンスでアクセスすることはできません。

app.current_tenant の値を定義するときは、ビルトイン set_config 関数または SQL コマンド SET を使用して、現在のデータベース接続セッションを範囲とするランタイムパラメータを宣言できます。この宣言は、データベース接続を作成するとき、またはアプリケーション接続プールから既存の接続を取得するときに、アプリケーションコードが実行する必要があります。PostgreSQL はこれらの変数の範囲を現在のセッションに限定しているため、マルチ接続アプリケーションで使用しても安全です。すべての接続には個別の変数のコピーがあり、他の接続のランタイムパラメータにアクセスしたり変更したりすることはできません。

セッション変数の使用は、pgBouncer などのサーバー側の接続プールと互換性がない場合があります。あらゆる接続プール戦略の影響を確認し、セッション状態を共有しているかどうかをテストしてください。

実装例

次のコード例は、ランタイムパラメータを設定する 1 つの方法です。このコードは Java プログラミング言語を使用していますが、メカニズムはご自身が選択した言語でも同じようなものです。

Java データベース接続 (JDBC) では、コードは javax.sql.DataSource インスタンスを使用して getConnection() メソッドを上書きするため、アプリケーション (または接続プールライブラリ) がデータベースへの接続を取得するたびに、適切なテナントコンテキストを設定し、テーブルの RLS ポリシーがテナントの分離を適用します。そうすると、次のコードようになります。

// Every time the app asks the data source for a connection
// set the PostgreSQL session variable to the current tenant
// to enforce data isolation.
@Override
public Connection getConnection() throws SQLException {
    Connection connection = super.getConnection();
    try (Statement sql = connection.createStatement()) {
        sql.execute("SET app.current_tenant = '" + TenantContext.getTenant() + "'");
    }
    return connection;
}

コマンドラインの psql クライアントと同様に、PostgreSQL の JDBC ドライバーは RLS ポリシーのトリガーを例外として扱いません。クエリがポリシーの USING 句を満たさない場合は、テーブルに行が存在しないかのようになり、空の結果セットが返されます。

データベースレベルでのこのセキュリティ保護は、テナントコンテキストに関係なく、開発者が作成するすべての SQL ステートメントが同じように見えることを意味し、PostgreSQL は分離を適用します。開発者は、ビジネスユースケースに適した WHERE 句を記述するだけでよく、共有のマルチテナントデータベースでの操作について心配する必要がなくなります。

関数、手順、表示、複雑なネスト済みクエリを徹底的にテストして、ポリシーの定義によって起こる意図しない制限やアクセス許可がないことを確認してください。

まとめ

PostgreSQL の行レベルでのセキュリティ機能を利用することで、プールモデルを使ってデータベースリソースを共有する SaaS アプリケーションを作成し、分離ポリシーを適用するリスクとオーバーヘッドを削減できます。RLS を使用すれば、開発者が日々コーディングに勤しむことなく、PostgreSQL バックエンドの作業の中で分離の適用に集中できるようになります。

プールモデルでは、各テナントの複製リソースにかかる高いコストと、それらのコピーのセットアップや管理に必要な専用インフラストラクチャコードを避けることが可能となります。皆さんはリソースが限られており、すべてのテナントが 1 か所に集まっているため、プラットフォームの単一の運用表示を実装する方が簡単です。このモデルでは可動部分が少ないため、データベースのバックアップや復元処理を簡略化することもできます。

現在、テナントデータの分離にサイロモデルまたはブリッジモデルを使用しているのであれば、より俊敏性とコスト効率が高いアプローチとして RLS を検討するときかもしれません。

この投稿の例の詳細については、AWS SaaS Factory GitHub リポジトリをご参照ください。リポジトリには、AWS アカウントで必要なインフラストラクチャを自動的にプロビジョニングするためのサンプルアプリケーションと AWS CloudFormation リソースが含まれています。

AWS での SaaS ソリューションの構築と配信の詳細については、AWS SaaS Factory および AWS SaaS Factory ブログをご覧ください。

 


著者について

 

Michael Beardsley はアマゾン ウェブ サービスのシニアパートナーソリューションアーキテクトです。