2056548702
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 15s
Deploy Staging / deploy (push) Successful in 46s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 56s
CI / backend (pull_request) Successful in 54s
CI / frontend (pull_request) Successful in 15s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s
115 lines
6.4 KiB
C#
115 lines
6.4 KiB
C#
using InboxIntel.Application.Abstractions;
|
|
using InboxIntel.Domain.Common;
|
|
using InboxIntel.Domain.Entities;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Reflection;
|
|
|
|
namespace InboxIntel.Infrastructure.Persistence;
|
|
|
|
public class AppDbContext : DbContext, IAppDbContext
|
|
{
|
|
private readonly ICurrentUser? _currentUser;
|
|
|
|
public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUser? currentUser = null) : base(options)
|
|
=> _currentUser = currentUser;
|
|
|
|
/// <summary>
|
|
/// Tenant id used by the global query filters below. Resolves to the
|
|
/// authenticated user during an HTTP request. It is <see cref="Guid.Empty"/>
|
|
/// when there is no current user (background workers, design-time tooling,
|
|
/// startup migration) — in which case filtering is DISABLED, because those
|
|
/// paths are trusted server code that already scope their own queries by a
|
|
/// userId passed in explicitly. The security value is on the HTTP attack
|
|
/// surface: a forgotten manual <c>WHERE UserId ==</c> can no longer leak
|
|
/// another tenant's rows, since the filter restricts to the caller.
|
|
/// </summary>
|
|
public Guid CurrentUserId => _currentUser?.UserId ?? Guid.Empty;
|
|
|
|
public DbSet<User> Users => Set<User>();
|
|
public DbSet<Email> Emails => Set<Email>();
|
|
public DbSet<MailThread> Threads => Set<MailThread>();
|
|
public DbSet<Sender> Senders => Set<Sender>();
|
|
public DbSet<MailDomain> Domains => Set<MailDomain>();
|
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
|
public DbSet<Label> Labels => Set<Label>();
|
|
public DbSet<EmailLabel> EmailLabels => Set<EmailLabel>();
|
|
public DbSet<SyncState> SyncStates => Set<SyncState>();
|
|
public DbSet<AnalyticsAggregate> AnalyticsAggregates => Set<AnalyticsAggregate>();
|
|
public DbSet<WidgetLayout> WidgetLayouts => Set<WidgetLayout>();
|
|
public DbSet<UnsubscribeItem> UnsubscribeItems => Set<UnsubscribeItem>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
|
|
|
// Defense-in-depth tenant isolation (systemic IDOR safeguard). Every
|
|
// user-owned entity is filtered to the current user so a query that forgets
|
|
// its manual `WHERE UserId ==` clause cannot leak across tenants. Applied
|
|
// uniformly to all user-scoped entities so EF sees no filtered/unfiltered
|
|
// navigation mismatch. Bypassed when CurrentUserId is Guid.Empty (workers).
|
|
modelBuilder.Entity<Email>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<Sender>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<MailThread>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<MailDomain>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<Attachment>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<Label>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<SyncState>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<WidgetLayout>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
|
|
|
|
// PostgreSQL full-text search: generated tsvector over subject + body with a
|
|
// GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
|
|
// and body 'B' so ts_rank_cd ranks a subject match above a body-only mention. The
|
|
// tsvector type is Npgsql-only, so map it only for relational providers and ignore
|
|
// it otherwise (e.g. the InMemory provider used by tests).
|
|
if (Database.IsRelational())
|
|
{
|
|
// pg_trgm powers the fuzzy/typo fallback in SearchService (word_similarity).
|
|
modelBuilder.HasPostgresExtension("pg_trgm");
|
|
|
|
modelBuilder.Entity<Email>().Property(e => e.SearchVector)
|
|
.HasColumnType("tsvector")
|
|
.HasComputedColumnSql(
|
|
"setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || " +
|
|
"setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
|
|
stored: true);
|
|
modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN");
|
|
|
|
// Trigram GIN indexes so the sender/domain `.Contains()` filters in SearchService
|
|
// (which translate to `LIKE '%x%'`, non-sargable on a btree) become index-accelerated.
|
|
// Needs pg_trgm (enabled by the EnablePgTrgm migration).
|
|
modelBuilder.Entity<Sender>().HasIndex(s => s.Address)
|
|
.HasMethod("gin").HasOperators("gin_trgm_ops");
|
|
modelBuilder.Entity<Sender>().HasIndex(s => s.DisplayName)
|
|
.HasMethod("gin").HasOperators("gin_trgm_ops");
|
|
modelBuilder.Entity<MailDomain>().HasIndex(d => d.Name)
|
|
.HasMethod("gin").HasOperators("gin_trgm_ops");
|
|
|
|
// pgvector: 768-dim embedding for semantic search, with an HNSW cosine index.
|
|
// Populated by the embedding backfill worker when AI is enabled; null otherwise.
|
|
modelBuilder.HasPostgresExtension("vector");
|
|
modelBuilder.Entity<Email>().Property(e => e.Embedding).HasColumnType("vector(768)");
|
|
modelBuilder.Entity<Email>().HasIndex(e => e.Embedding)
|
|
.HasMethod("hnsw").HasOperators("vector_cosine_ops");
|
|
}
|
|
else
|
|
{
|
|
modelBuilder.Entity<Email>().Ignore(e => e.SearchVector);
|
|
modelBuilder.Entity<Email>().Ignore(e => e.Embedding);
|
|
}
|
|
|
|
base.OnModelCreating(modelBuilder);
|
|
}
|
|
|
|
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
|
|
{
|
|
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
|
|
{
|
|
if (entry.State == EntityState.Modified)
|
|
entry.Entity.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
|
}
|
|
return base.SaveChangesAsync(ct);
|
|
}
|
|
}
|