148 lines
6.3 KiB
C#
148 lines
6.3 KiB
C#
using System.Reflection;
|
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Identity;
|
|
|
|
namespace Tiku.Infrastructure.Persistence;
|
|
|
|
public sealed partial class TikuDbContext(
|
|
DbContextOptions<TikuDbContext> options,
|
|
ITenantContext tenantContext) : IdentityUserContext<User, Guid>(options), IDataProtectionKeyContext
|
|
{
|
|
public TikuDbContext(DbContextOptions<TikuDbContext> options)
|
|
: this(options, CreateToolingTenantContext())
|
|
{
|
|
}
|
|
|
|
public Guid? CurrentTenantId => tenantContext.TenantId;
|
|
public Guid CurrentTenantIdOrEmpty => tenantContext.TenantId ?? Guid.Empty;
|
|
public bool IsTenantResolved => tenantContext.IsResolved;
|
|
public bool IsSystemScope => tenantContext.IsSystem;
|
|
public long SaveVersion { get; private set; }
|
|
|
|
private static ITenantContext CreateToolingTenantContext()
|
|
{
|
|
var context = new TenantContext();
|
|
context.InitializeSystem(null, "Direct DbContext construction for model tooling");
|
|
return context;
|
|
}
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
base.OnModelCreating(modelBuilder);
|
|
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
|
|
modelBuilder.Entity<IdentityUserLogin<Guid>>().ToTable("user_logins");
|
|
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
|
|
modelBuilder.HasPostgresExtension("citext");
|
|
modelBuilder.HasPostgresExtension("ltree");
|
|
modelBuilder.HasPostgresExtension("pg_trgm");
|
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
|
modelBuilder.Entity<DataProtectionKey>().ToTable("data_protection_keys");
|
|
ApplyTenantQueryFilters(modelBuilder);
|
|
ValidateTenantModel(modelBuilder);
|
|
modelBuilder.UseSnakeCaseIdentifiers();
|
|
}
|
|
|
|
private void ApplyTenantQueryFilters(ModelBuilder modelBuilder)
|
|
{
|
|
var applyMethod = typeof(TikuDbContext)
|
|
.GetMethod(nameof(ApplyTenantQueryFilter), BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
|
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
|
{
|
|
var tenantProperty = entityType.FindProperty("TenantId");
|
|
if (tenantProperty?.ClrType != typeof(Guid) || entityType.BaseType is not null) continue;
|
|
|
|
applyMethod.MakeGenericMethod(entityType.ClrType).Invoke(this, [modelBuilder]);
|
|
}
|
|
}
|
|
|
|
private void ApplyTenantQueryFilter<TEntity>(ModelBuilder modelBuilder)
|
|
where TEntity : class
|
|
{
|
|
modelBuilder.Entity<TEntity>().HasQueryFilter(entity =>
|
|
IsSystemScope ||
|
|
(IsTenantResolved &&
|
|
EF.Property<Guid>(entity, "TenantId") == CurrentTenantIdOrEmpty));
|
|
}
|
|
|
|
private static void ValidateTenantModel(ModelBuilder modelBuilder)
|
|
{
|
|
var invalidUniqueIndexes = new List<string>();
|
|
var invalidTenantForeignKeys = new List<string>();
|
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
|
{
|
|
var tenantProperty = entityType.FindProperty("TenantId");
|
|
if (tenantProperty?.ClrType != typeof(Guid)) continue;
|
|
|
|
if (!typeof(ITenantOwned).IsAssignableFrom(entityType.ClrType))
|
|
throw new InvalidOperationException(
|
|
$"Tenant entity '{entityType.ClrType.Name}' must implement {nameof(ITenantOwned)}.");
|
|
|
|
if (!entityType.GetDeclaredQueryFilters().Any())
|
|
throw new InvalidOperationException(
|
|
$"Tenant entity '{entityType.ClrType.Name}' does not have a tenant query filter.");
|
|
|
|
foreach (var index in entityType.GetDeclaredIndexes().Where(index => index.IsUnique))
|
|
if (index.Properties.All(property => property.Name != "TenantId") &&
|
|
index.FindAnnotation("Tiku:GlobalUnique")?.Value is not true)
|
|
invalidUniqueIndexes.Add(
|
|
$"{entityType.ClrType.Name}({string.Join(",", index.Properties.Select(property => property.Name))})");
|
|
|
|
|
|
foreach (var foreignKey in entityType.GetDeclaredForeignKeys().Where(foreignKey =>
|
|
typeof(ITenantOwned).IsAssignableFrom(foreignKey.PrincipalEntityType.ClrType)))
|
|
if (foreignKey.PrincipalKey.Properties.All(property => property.Name != "TenantId"))
|
|
invalidTenantForeignKeys.Add(
|
|
$"{entityType.ClrType.Name}->{foreignKey.PrincipalEntityType.ClrType.Name}");
|
|
}
|
|
|
|
if (invalidUniqueIndexes.Count > 0)
|
|
throw new InvalidOperationException(
|
|
$"Unique indexes on tenant entities must include TenantId: {string.Join("; ", invalidUniqueIndexes)}");
|
|
|
|
|
|
if (invalidTenantForeignKeys.Count > 0)
|
|
throw new InvalidOperationException(
|
|
$"Foreign keys between tenant entities must use a tenant-qualified principal key: {string.Join("; ", invalidTenantForeignKeys)}");
|
|
}
|
|
|
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
|
{
|
|
UpdateTimestamps();
|
|
var result = base.SaveChanges(acceptAllChangesOnSuccess);
|
|
SaveVersion++;
|
|
return result;
|
|
}
|
|
|
|
public override async Task<int> SaveChangesAsync(
|
|
bool acceptAllChangesOnSuccess,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
UpdateTimestamps();
|
|
var result = await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
|
SaveVersion++;
|
|
return result;
|
|
}
|
|
|
|
private void UpdateTimestamps()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
|
|
foreach (var entry in ChangeTracker.Entries<User>().Where(entry => entry.State == EntityState.Modified))
|
|
if (entry.Property(user => user.Status).IsModified ||
|
|
entry.Property(user => user.PasswordHash).IsModified)
|
|
entry.Entity.SecurityStamp = Guid.NewGuid().ToString("N");
|
|
|
|
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
|
|
{
|
|
if (entry.State == EntityState.Added) entry.Entity.CreatedAt = now;
|
|
|
|
if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.UpdatedAt = now;
|
|
}
|
|
}
|
|
} |