😃

ASP.NET Core で実現する「マルチテナント+監査情報」設計ガイド

2024/12/30に公開

はじめに

SaaS(Software as a Service)としてサービスを提供する場合、複数の顧客(テナント)が同一アプリケーションを利用しながらも、お互いのデータが混ざらないように運用する必要があります。これを「マルチテナント対応」と呼びます。

さらに運用上、システムの変更履歴(監査情報)を追跡したいケースは多々あります。たとえば、「誰がいつ何を変更したか」を把握できるようにしたい場合です。

本記事では、ASP.NET Core を用いて
マルチテナント対応
監査情報の自動付与
を両立する設計を紹介します。

本記事のゴール

  • マルチテナント対応
    各データが「どのテナントのものか」を示すために TenantId を持つ。
    Global Query Filter を使いテナント間でデータを完全に隔離。

  • 監査情報(CreatedAt, CreatedBy, UpdatedAt, UpdatedBy)
    エンティティの追加・更新時に自動で作成/更新日時とユーザーを保存し、変更履歴を追えるようにする。

  • ASP.NET Core Identity(認証・ユーザー管理)との連携方法も例示。

システム全体像

(クライアント)
     ↓
[TenantMiddleware]  // HTTPリクエストからテナントを判定
     ↓
[Controllers / Pages] // ASP.NET Core
     ↓
[ApplicationDbContext (EF Core)]
  → Global Query Filter (TenantId)
  → SaveChanges時に監査情報を付与
     ↓
[SQL Server 等のDB]
  • TenantMiddleware でリクエストからテナント情報を解析し、ITenantProvider にセット
  • ApplicationDbContext の Global Query Filter で、TenantId == CurrentTenantId なデータのみを取得
  • SaveChanges() で CreatedAt, UpdatedAt, CreatedBy, UpdatedBy を自動付与

コード例

以下はサンプル実装です。実際のプロジェクト要件に合わせて修正・拡張してください。

IAuditableEntity.cs

namespace YourProject.Models
{
    /// <summary>
    /// 監査情報を持つエンティティが実装するインターフェース。
    /// 全てのエンティティは作成・更新日時・ユーザーを記録する。
    /// </summary>
    public interface IAuditableEntity
    {
        DateTime CreatedAt { get; set; }
        string CreatedBy { get; set; }
        DateTime UpdatedAt { get; set; }
        string UpdatedBy { get; set; }
    }
}
  • すべてのエンティティはこのインターフェースを実装し、DB保存前にCreatedAt等を自動セットします。

Tenant.cs(テナント本体)

using System.Collections.Generic;

namespace YourProject.Models
{
    /// <summary>
    /// SaaSマルチテナント用のテナントエンティティ。
    /// 各データはTenantIdで紐付けられる。
    /// </summary>
    public class Tenant : IAuditableEntity
    {
        public int Id { get; set; }

        // テナント名やドメイン名など必須項目と想定
        public required string Name { get; set; }

        // 監査情報
        public DateTime CreatedAt { get; set; }
        public required string CreatedBy { get; set; }
        public DateTime UpdatedAt { get; set; }
        public required string UpdatedBy { get; set; }

        // テナント配下にあるエンティティのナビゲーション
        public ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
        public ICollection<UserGroup> UserGroups { get; set; } = new List<UserGroup>();
        public ICollection<Schedule> Schedules { get; set; } = new List<Schedule>();
        public ICollection<ActualProgress> ActualProgresses { get; set; } = new List<ActualProgress>();
    }
}
  • Tenant はそれ自体も監査対象に。

ApplicationUser.cs(Identityユーザーにテナント&監査を付与)

using Microsoft.AspNetCore.Identity;
using System.Collections.Generic;

namespace YourProject.Models
{
    /// <summary>
    /// ASP.NET Core Identityユーザーに監査情報を付与し、TenantId でマルチテナントを管理。
    /// </summary>
    public class ApplicationUser : IdentityUser, IAuditableEntity
    {
        // テナント外部キー
        public int TenantId { get; set; }
        public required Tenant Tenant { get; set; }

        // ユーザーグループ(null許容)
        public int? UserGroupId { get; set; }
        public UserGroup? UserGroup { get; set; }

        // 監査情報
        public DateTime CreatedAt { get; set; }
        public required string CreatedBy { get; set; }
        public DateTime UpdatedAt { get; set; }
        public required string UpdatedBy { get; set; }

        // 作成/更新したSchedule, ActualProgress
        public ICollection<Schedule> CreatedSchedules { get; set; } = new List<Schedule>();
        public ICollection<Schedule> UpdatedSchedules { get; set; } = new List<Schedule>();
        public ICollection<ActualProgress> CreatedActualProgresses { get; set; } = new List<ActualProgress>();
        public ICollection<ActualProgress> UpdatedActualProgresses { get; set; } = new List<ActualProgress>();
    }
}
  • IdentityUserに加えて TenantId と IAuditableEntity を実装。

Schedule.cs(業務データの一例)

namespace YourProject.Models
{
    public class Schedule : IAuditableEntity
    {
        public int Id { get; set; }
        public required string Name { get; set; }

        public DateTime StartDate { get; set; }
        public DateTime EndDate { get; set; }

        // テナント外部キー
        public int TenantId { get; set; }
        public required Tenant Tenant { get; set; }

        // 監査情報
        public DateTime CreatedAt { get; set; }
        public required string CreatedBy { get; set; }
        public DateTime UpdatedAt { get; set; }
        public required string UpdatedBy { get; set; }

        // 作成者/更新者
        public string CreatedByUserId { get; set; } = null!;
        public ApplicationUser CreatedByUser { get; set; } = null!;
        public string UpdatedByUserId { get; set; } = null!;
        public ApplicationUser UpdatedByUser { get; set; } = null!;
    }
}
  • TenantId + Tenant でマルチテナント、CreatedByUser / UpdatedByUser で作成者・更新者を追跡。

ITenantProvider.cs / TenantProvider.cs

public interface ITenantProvider
{
    int? CurrentTenantId { get; set; }
}

public class TenantProvider : ITenantProvider
{
    public int? CurrentTenantId { get; set; }
}
  • HTTPリクエストごとにこのCurrentTenantIdを設定し、後段のDB操作でテナントを判断。

TenantMiddleware.cs(テナント判定)

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public TenantMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(
        HttpContext context,
        ITenantProvider tenantProvider,
        ApplicationDbContext db)
    {
        // サブドメインからテナント名を取り出す例
        var host = context.Request.Host.Host;
        var firstDotIndex = host.IndexOf('.');
        var tenantName = firstDotIndex > 0 ? host[..firstDotIndex] : "default";

        // DBでtenantNameを検索
        var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Name == tenantName);
        if (tenant == null)
        {
            context.Response.StatusCode = 404;
            await context.Response.WriteAsync("Tenant not found");
            return;
        }

        // テナントIDをセット
        tenantProvider.CurrentTenantId = tenant.Id;

        await _next(context);
    }
}
  • ここでは「tenantName.example.com → tenantName」をテナントとする簡易実装。

ApplicationDbContext.cs(Global Query Filter & 監査付与)

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    private readonly ITenantProvider _tenantProvider;

    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    public DbSet<Tenant> Tenants => Set<Tenant>();
    public DbSet<UserGroup> UserGroups => Set<UserGroup>();
    public DbSet<Schedule> Schedules => Set<Schedule>();
    public DbSet<ActualProgress> ActualProgresses => Set<ActualProgress>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // Global Query Filter
        builder.Entity<ApplicationUser>()
            .HasQueryFilter(u => _tenantProvider.CurrentTenantId == null || u.TenantId == _tenantProvider.CurrentTenantId);
        builder.Entity<UserGroup>()
            .HasQueryFilter(ug => _tenantProvider.CurrentTenantId == null || ug.TenantId == _tenantProvider.CurrentTenantId);
        builder.Entity<Schedule>()
            .HasQueryFilter(s => _tenantProvider.CurrentTenantId == null || s.TenantId == _tenantProvider.CurrentTenantId);
        builder.Entity<ActualProgress>()
            .HasQueryFilter(ap => _tenantProvider.CurrentTenantId == null || ap.TenantId == _tenantProvider.CurrentTenantId);

        // 以降、Tenant <-> Entities などのリレーション設定省略...
    }

    public override int SaveChanges()
    {
        ApplyAuditInformation();
        return base.SaveChanges();
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        ApplyAuditInformation();
        return await base.SaveChangesAsync(cancellationToken);
    }

    private void ApplyAuditInformation()
    {
        // 実際はUserManagerなどからログイン中のユーザーIDを取得
        var currentUserId = "system";

        var entries = ChangeTracker.Entries()
            .Where(e => e.Entity is IAuditableEntity &&
                        (e.State == EntityState.Added || e.State == EntityState.Modified));

        foreach (var entry in entries)
        {
            var auditable = (IAuditableEntity)entry.Entity;
            if (entry.State == EntityState.Added)
            {
                auditable.CreatedAt = DateTime.UtcNow;
                auditable.CreatedBy = currentUserId;
                auditable.UpdatedAt = DateTime.UtcNow;
                auditable.UpdatedBy = currentUserId;
            }
            else if (entry.State == EntityState.Modified)
            {
                auditable.UpdatedAt = DateTime.UtcNow;
                auditable.UpdatedBy = currentUserId;
                entry.Property(nameof(auditable.CreatedAt)).IsModified = false;
                entry.Property(nameof(auditable.CreatedBy)).IsModified = false;
            }
        }
    }
}

Program.cs(ミドルウェア登録)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

// ASP.NET Core Identity
builder.Services.AddDefaultIdentity<ApplicationUser>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

// マルチテナント
builder.Services.AddScoped<ITenantProvider, TenantProvider>();

builder.Services.AddRazorPages();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

// テナント判定
app.UseMiddleware<TenantMiddleware>();

app.MapRazorPages();
app.Run();

ここで押さえるポイント

TenantId

すべてのエンティティに TenantId を付与して「誰のデータか」を明確化。
テナント毎にDBを分割する方法もあるが、同じDBでTenantIdを持つ設計が一般的。

Global Query Filter

EF Coreの機能で「TenantId == CurrentTenantId」という条件を自動的に付与。
開発者が意図的にwhere句を書かなくてもデータ分離を保証。
監査情報の自動付与

IAuditableEntityを実装し、DbContext.SaveChanges() 時に ChangeTracker からエンティティの状態を調べ、CreatedAt / CreatedBy / UpdatedAt / UpdatedBy を自動更新。

TenantMiddleware

リクエストのサブドメインやヘッダからテナントを判定し、ITenantProvider.CurrentTenantId に設定。
以後のDBアクセスはGlobal Query Filterによるデータ分離が行われる。

ASP.NET Core Identity

ApplicationUser にも TenantId / Tenant を持たせることでユーザー自体もテナント内の1ユーザーとして扱える。
複数テナント所属を想定する場合はさらに中間テーブルが必要など、要件に応じて工夫。

追加で検討すべきこと

  • テナント新規登録フロー
    サービス利用開始時にテナントをどう作成し、管理者ユーザーをどう作るか。

  • テナント削除・プラン切り替え
    SaaSの運用において、テナントの契約更新や解約時のデータ処理を考慮。

  • 詳細な監査ログ
    変更前/変更後の値も追いたい場合は、差分を記録するAuditLogテーブルを作るなど、より高度な実装が必要。

まとめ

ASP.NET CoreでマルチテナントSaaSを構築する際、

  • Tenantエンティティを用意し、すべてのデータに TenantId を持たせる
  • Global Query Filter によりテナント外データへのアクセスを防止
  • SaveChanges時に監査情報を自動付与 して、誰がいつ更新したか追跡
    というアプローチにより、データの安全な分離と監査履歴の両方をシンプルに実現できます。
    さらにASP.NET Core Identityを導入すれば、ユーザーログイン管理もひとまとめにできるので、認証・テナント管理・監査情報の3点を統合的に扱うことが可能です。

実際のサービス要件(オフライン連携、請求管理、拡張的な権限管理など)に合わせて、本記事をベースに設計を発展させてみてください。

Discussion