using System.Data.Common; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; namespace TenantRls; // One instance per request scope. Null means nothing resolved a tenant. public sealed class TenantContext { public Guid? TenantId { get; set; } } public sealed class Tenant { public Guid Id { get; set; } public string Name { get; set; } = ""; } public sealed class Invoice { public Guid Id { get; set; } public Guid TenantId { get; set; } public string Number { get; set; } = ""; public decimal Amount { get; set; } public string Status { get; set; } = ""; public DateTimeOffset CreatedAt { get; set; } } public sealed class AppDbContext(DbContextOptions options, TenantContext tenant) : DbContext(options) { public DbSet Tenants => Set(); public DbSet Invoices => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(b => { b.ToTable("tenants"); b.Property(t => t.Id).HasColumnName("id"); b.Property(t => t.Name).HasColumnName("name"); }); modelBuilder.Entity(b => { b.ToTable("invoices"); b.Property(i => i.Id).HasColumnName("id"); b.Property(i => i.TenantId).HasColumnName("tenant_id"); b.Property(i => i.Number).HasColumnName("number"); b.Property(i => i.Amount).HasColumnName("amount"); b.Property(i => i.Status).HasColumnName("status"); b.Property(i => i.CreatedAt).HasColumnName("created_at"); // The application layer. Row-level security is the database layer underneath it. b.HasQueryFilter(i => i.TenantId == tenant.TenantId); }); } } // Runs on every connection open, so the setting is in place before EF sends the first command, // no matter which pooled physical connection EF got. public sealed class TenantConnectionInterceptor(TenantContext tenant) : DbConnectionInterceptor { private const string Sql = "SELECT set_config('app.tenant_id', @tenant, false)"; public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData) { using var command = Build(connection); command.ExecuteNonQuery(); } public override async Task ConnectionOpenedAsync( DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default) { await using var command = Build(connection); await command.ExecuteNonQueryAsync(cancellationToken); } private DbCommand Build(DbConnection connection) { var command = connection.CreateCommand(); command.CommandText = Sql; var parameter = command.CreateParameter(); parameter.ParameterName = "tenant"; parameter.Value = tenant.TenantId?.ToString() ?? ""; command.Parameters.Add(parameter); return command; } }