refactor(db): named tenant query filters (#31)
CI / backend (push) Successful in 51s
CI / frontend (push) Successful in 12s
CI / format (push) Successful in 50s
CI / db-tests (push) Successful in 52s
Deploy Staging / deploy (push) Successful in 25s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m18s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 23s
CI / format (pull_request) Successful in 1m2s
CI / db-tests (pull_request) Successful in 1m11s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m10s

This commit was merged in pull request #31.
This commit is contained in:
2026-07-02 17:24:21 +02:00
parent 5191dd010f
commit 402628a3a7
@@ -47,20 +47,20 @@ public class AppDbContext : DbContext, IAppDbContext
// 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<Email>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Sender>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailThread>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<MailDomain>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Attachment>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<Label>().HasQueryFilter("Tenant", 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);
modelBuilder.Entity<EmailLabel>().HasQueryFilter("Tenant", el => CurrentUserId == Guid.Empty || el.Email!.UserId == CurrentUserId);
modelBuilder.Entity<SyncState>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<AnalyticsAggregate>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<WidgetLayout>().HasQueryFilter("Tenant", e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter("Tenant", 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'