c0c1777d3f
CI / backend (push) Successful in 48s
CI / frontend (push) Successful in 13s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 53s
97 lines
5.2 KiB
C#
97 lines
5.2 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");
|
|
}
|
|
else
|
|
{
|
|
modelBuilder.Entity<Email>().Ignore(e => e.SearchVector);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|