9480033373
Implements AUDIT_REPORT.md items H-1, H-2, M-1, M-4, M-6, L-3: - H-1: enable FluentValidation auto-validation — the registered validators (incl. Confirmed-required-for-destructive) now actually execute; invalid DTOs 400 at the boundary instead of reaching services. - H-2: ASP.NET Core rate limiting — global per-user/per-IP fixed window (300/min default) + stricter 'auth' (10/min) and 'expensive' (20/min: export, unsubscribe, AI) policies; config-driven; 429 with no queue. - M-1: absolute session lifetime (30d default) — an issued-at stamp set at sign-in and checked in OnValidatePrincipal, so a stolen cookie can no longer slide-renew forever. Pre-existing sessions re-login once. - M-4: remove the guessable default DB password from appsettings; startup fails fast with a clear message when the connection string has no password (compose/staging inject the real one). - M-6: matching tenant query filter on EmailLabel (via Email navigation) — clears the long-standing EF boot warning and closes the join-row leak window. - L-3: SMTP skip-notice logging downgraded to Debug (recipient address is PII-ish). Tests: 6 new (400-on-invalid x2, 429 auth rate limit via a test auth scheme, session-lifetime x3, EmailLabel cross-user invisibility). Full suite: 48/48 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
6.8 KiB
C#
119 lines
6.8 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);
|
|
// AUDIT M-6: EmailLabel is the required end of a relationship with the filtered Email
|
|
// entity; without a matching filter EF warns on boot and joins could surface rows whose
|
|
// parent is filtered out. Filter via the Email navigation so the pair is consistent.
|
|
modelBuilder.Entity<EmailLabel>().HasQueryFilter(el => CurrentUserId == Guid.Empty || el.Email!.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);
|
|
}
|
|
}
|