Zenn
🧭

ASP.NET CoreとRLSで安全に開発できるマルチテナント共通基盤を実装する

に公開
3

こんにちは7110です。
現在マルチテナンシーのSaaSアプリケーションを開発しています。

今回はその開発中に出会った以下の課題とアプローチに関して社内勉強会で登壇した内容を記事にしました。

  • マルチテナント構成にRLSを活用しているためDBにアクセスする際にテナントの識別情報が必須となる
  • アプリケーション層とドメイン層はテナントに依存しないため、テナントの識別情報を渡したくない
  • 可能なら開発メンバ―全員が意識せずとも開発できるようにインフラ層にRLSの処理をカプセル化したい

環境

  • API
    • ASP.NET Core
    • EntityFramework
  • DB
    • Postgresql

アーキテクチャ

アーキテクチャは以下の4層のレイヤードアーキテクチャとなっており、API層以外はDLLとして実装しています。

レイヤー 責務
API HTTP Serverの実行モジュール
依存性注入やHTTPリクエスト/レスポンスをさばく
Application アプリケーションのビジネスロジックを担う
Domain 業務知識の集約
Infrastructure 外部サービス(DB/Cloud等)との接続をカプセル化

依存の向きはこちらです。

アプリケーションの実行プロセスはAPI -> Application -> Domain or Infrastructureとなります。

本題

さて、4層のレイヤーをそれぞれカプセル化していることで困ったことが出てきました。
PostgresqlではRLSを利用しているので、DB問い合わせ時にテナントの識別情報を渡す必要があります。
テナントの認証情報はAPI層でリクエスト時にMiddlewareを使って抜き出しているので、Application層、Domain層、Infrastructure層とバケツリレーで渡すこともできますが、Application層とDomain層はテナントに依存しないため、可能なら存在を知らせたくありませんし、Infrastructure層でのRLS割り当てが実装者に依存してしまうかもしれないと考えました。
そこでRLS適応を共通基盤化し、テナントの存在をアプリケーションのコアロジック及び、開発者が意識しなくても実装できる形を目指しました。
共通基盤実装にはASP.NET CoreのDIの仕組みとDbConnectionクラスのEventHandlerを活用しました。

1. テナントの識別情報をAPI層からInfrastructure層にダイレクトに伝える

Infrastructure層はQueryServiceとRepositoryの実装クラスとDbContextの基盤クラスがあります。
QueryServiceとRepositoryはコンストラクタでDbContextのサブクラスのインスタンスを受け取るように実装します。

PostRepository.cs
public class PostRepository : IPostRepository
{
    private readonly BaseDBContext _context;

    public PostRepository(
        BaseDBContext context
    )
    {
        this._context = context;
    }
}
PostQueryService.cs
public class PostQueryService : IPostQueryService
{
    private readonly BaseDBContext _context;

    public PostQueryService(
        /* ※QueryServiceはReadDBへの接続を渡すことでマスタ/リードDBへ操作対象を分けることができます。 */
        BaseDBContext context
    )
    {
        this._context = context;
    }
}

DbContextのサブクラスは以下のようにしておきます。

BaseDBContext.cs
public class BaseDBContext : DbContext
{
    private readonly Guid _tenantId;

    public BaseDBContext(
        Guid tenantId
    )
    {
        this._tenantId = tenantId;
    }
}

API層のProgram.csは以下のようにしておきます。

Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<BaseDBContext>(provider => {
    // 1. HttpContextを取得
    var httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;

    // 2. 認証されていなかった場合例外を発生
    if(!(httpContext?.User.Identity?.IsAuthenticated ?? false)) throw new Exception("認証エラー");

    var tenantId = /* httpContextからテナントの情報を取得 */

    return new BaseDBContext(
        tenantId: tenantId
    );
})

builder.Services.AddScoped<IPostRepository, PostRepository>();
builder.Services.AddScoped<IPostQueryService, PostQueryService>();

上記のように依存性を注入することで、同一リクエストのスコープ内でtenantIdをフィールドに保持しているBaseDBContextのインスタンス生成され、それが各Repository、QueryServiceで使いまわされます。

2. DBのセッションにRLSを適応させる

まずはテーブルのRLSポリシーを有効化し、制約を割り当てます。
テーブルへのRLSを適応は少し手間ですが、MigrationBuilderを拡張し、Migrationファイルでテーブルを作成時に対応するモデルをgenericsで渡しています。

internal static void AddRLSPolicy<M>(this MigrationBuilder migrationBuilder) where M : BaseModel
{
    // テーブル名を取得する処理(※内部的にはアトリビュートで取得している)
    var tableName = ModelHandler.GetTableName<M>();

    var rlsPolicyName = $"{tableName}_access_policy";

    // RLSの有効化
    migrationBuilder.Sql($"ALTER TABLE {APPLICATION_SCHEMA}.{tableName} ENABLE ROW LEVEL SECURITY;");

    // RLSポリシーの割り当て
    migrationBuilder.Sql($@"
        CREATE POLICY {rlsPolicyName}
        ON {APPLICATION_SCHEMA}.{tableName}
        TO {_role}
        USING (tenant_id = current_setting('app.current_tenant_id')::UUID)
    ");
}
migrationファイル
migrationBuilder.AddRLSPolicy<SampleModel>();

次に、DB接続時のセッションにRLSを適応します。
DBセッションへのRLS適応は以下の関数で実現できます。

this.Database.ExecuteSql($"select set_config('app.current_tenant_id', {this._tenantId}::TEXT, false);");

DBセッションへのRLS適応方法はいくつか考えました。

  1. 全てのクエリの前段で関数を呼ぶ処理を書く。
  2. クエリの実行時に呼ばれる共通関数を実装する。

1は分かりやすいですが、実装者に依存してしまい共通基盤実装に至った目的に反してしまいます。
2はEntityFrameworkで実行時に呼ばれる共通関数を特定する必要があり、EntityFramework依存の実装になってしまいます。実装が複雑化する可能性があったので、あまりやりたくありませんでした。

いろいろ調べた結果C#のDbConnectionクラスは接続状態の変化時にEventHandlerを仕込むことができると知りました。
DbConnection.StateChange イベント
EntityFrameworkに依存せず、実装者にも依存しないことから、EventHandlerを活用することにしました。

先ほど実装したBaseDBContext.csに手を加えていきます。

BaseDBContext.cs
public class BaseDBContext : DbContext
{
    private readonly Guid _tenantId;

    public BaseDBContext(
        Guid tenantId
    )
    {
        this._tenantId = tenantId;
        this.Database.GetDbConnection().StateChange += SetRLSPolicy;
    }

    /// <summary>
    /// DbConnectionのステート変更イベント発生時にコネクションがOpenになったらDBセッションににRLSを適応する
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="args"></param>
    private void SetRLSPolicy(
        object sender,
        StateChangeEventArgs args
    )
    {
        if (args.CurrentState is ConnectionState.Open)
        {
            // 現在のセッションにテナントIdを強制
            this.Database.ExecuteSql($"select set_config('app.current_tenant_id', {this._tenantId}::TEXT, false);");
        }
    }
}
this.Database.GetDbConnection().StateChange += SetRLSPolicy;

コンストラクタ内のこの1行により、DbConnectionがOpenした際にRLSをセッションに適応してくれます。
ビジネスロジックを達成する際に複数回呼ばれる可能性があることが気になりますが、許容できる範囲だと思います。

まとめ

今回の実装はC#とASP.NET Coreの機能に助けられたという印象が強かったです。
ASP.NET Coreが非常に成熟した開発環境を提供していることを再認識しました。
Webアプリケーション開発もやりやすい環境を提供してくれているので、新たな技術スタックを検討する際にC# x ASP.NET Coreはいかがでしょうか?

参照

3
RSI技術ブログ

Discussion

ログインするとコメントできます