ASP.NET Core で実現する「マルチテナント+監査情報」設計ガイド
はじめに
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