forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Reflection;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Common;
|
||||
@@ -14,8 +16,26 @@ using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence;
|
||||
|
||||
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
|
||||
public sealed class TikuDbContext(
|
||||
DbContextOptions<TikuDbContext> options,
|
||||
ITenantContext tenantContext) : DbContext(options)
|
||||
{
|
||||
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;
|
||||
|
||||
private static ITenantContext CreateToolingTenantContext()
|
||||
{
|
||||
var context = new TenantContext();
|
||||
context.InitializeSystem(null, "Direct DbContext construction for model tooling");
|
||||
return context;
|
||||
}
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
|
||||
@@ -23,6 +43,7 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<TenantDomain> TenantDomains => Set<TenantDomain>();
|
||||
public DbSet<TenantBranding> TenantBrandings => Set<TenantBranding>();
|
||||
public DbSet<TenantSettings> TenantSettings => Set<TenantSettings>();
|
||||
public DbSet<TenantFrontendConfig> TenantFrontendConfigs => Set<TenantFrontendConfig>();
|
||||
public DbSet<TenantAuthProvider> TenantAuthProviders => Set<TenantAuthProvider>();
|
||||
public DbSet<TenantSecret> TenantSecrets => Set<TenantSecret>();
|
||||
public DbSet<SmsVerificationCode> SmsVerificationCodes => Set<SmsVerificationCode>();
|
||||
@@ -42,6 +63,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<Major> Majors => Set<Major>();
|
||||
public DbSet<Subject> Subjects => Set<Subject>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<TaxonomyNode> TaxonomyNodes => Set<TaxonomyNode>();
|
||||
public DbSet<QuestionTaxonomyAssignment> QuestionTaxonomyAssignments => Set<QuestionTaxonomyAssignment>();
|
||||
public DbSet<ScorelineField> ScorelineFields => Set<ScorelineField>();
|
||||
public DbSet<ScorelineRecord> ScorelineRecords => Set<ScorelineRecord>();
|
||||
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
|
||||
@@ -71,10 +94,11 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<AppAsset> AppAssets => Set<AppAsset>();
|
||||
public DbSet<VideoExplanation> VideoExplanations => Set<VideoExplanation>();
|
||||
public DbSet<QuestionVideo> QuestionVideos => Set<QuestionVideo>();
|
||||
public DbSet<QuestionBankGrant> QuestionBankGrants => Set<QuestionBankGrant>();
|
||||
public DbSet<TenantQuestionBankAdoption> TenantQuestionBankAdoptions => Set<TenantQuestionBankAdoption>();
|
||||
public DbSet<TenantQuestionBankPreference> TenantQuestionBankPreferences => Set<TenantQuestionBankPreference>();
|
||||
public DbSet<TenantQuestionReference> TenantQuestionReferences => Set<TenantQuestionReference>();
|
||||
public DbSet<AiRecommendationReport> AiRecommendationReports => Set<AiRecommendationReport>();
|
||||
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
|
||||
public DbSet<PracticeSessionQuestion> PracticeSessionQuestions => Set<PracticeSessionQuestion>();
|
||||
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
|
||||
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
|
||||
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
|
||||
@@ -155,9 +179,97 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
modelBuilder.HasPostgresExtension("citext");
|
||||
modelBuilder.HasPostgresExtension("ltree");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user