fix(security): systemic IDOR safeguard via EF global query filters
Defense-in-depth tenant isolation: every user-owned entity (Email, Sender, MailThread, MailDomain, Attachment, Label, SyncState, AnalyticsAggregate, WidgetLayout, UnsubscribeItem) gets a global query filter restricting reads to the authenticated user. AppDbContext takes an optional ICurrentUser; CurrentUserId is Guid.Empty for background workers / design-time, which DISABLES the filter so sync and tooling (which already scope by an explicit userId) are unaffected. On the HTTP attack surface a forgotten manual `WHERE UserId ==` can no longer leak another tenant's rows. Phase 1 confirmed no active IDOR; this is preventive, and prioritised now because the upcoming automation engine will add many new queries. Also: moved the Npgsql-only tsvector FTS mapping out of EmailConfiguration into AppDbContext.OnModelCreating, guarded by Database.IsRelational() (Ignored otherwise), so non-relational test providers work — honouring the existing Email.SearchVector comment. Production (Npgsql) model is unchanged; no migration needed. Adds 3 cross-user tenant-isolation integration tests. All 38 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
using FluentAssertions;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Domain.Entities;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace InboxIntel.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the global query-filter IDOR safeguard on AppDbContext: an HTTP-scoped
|
||||
/// context (with a current user) sees only that user's rows even if a query forgets
|
||||
/// its manual UserId filter; a worker-scoped context (Guid.Empty) sees everything.
|
||||
/// </summary>
|
||||
public class TenantIsolationTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
public bool IsAuthenticated => UserId != Guid.Empty;
|
||||
}
|
||||
|
||||
private static DbContextOptions<AppDbContext> InMemory(string name)
|
||||
=> new DbContextOptionsBuilder<AppDbContext>().UseInMemoryDatabase(name).Options;
|
||||
|
||||
private static readonly Guid UserA = Guid.NewGuid();
|
||||
private static readonly Guid UserB = Guid.NewGuid();
|
||||
|
||||
private static async Task SeedAsync(DbContextOptions<AppDbContext> opts)
|
||||
{
|
||||
// Seed with no current user (Guid.Empty) so the filter is bypassed for writes/reads here.
|
||||
using var seed = new AppDbContext(opts, new FakeCurrentUser());
|
||||
seed.Emails.Add(new Email { UserId = UserA, GmailMessageId = "a1", Subject = "A-one" });
|
||||
seed.Emails.Add(new Email { UserId = UserA, GmailMessageId = "a2", Subject = "A-two" });
|
||||
seed.Emails.Add(new Email { UserId = UserB, GmailMessageId = "b1", Subject = "B-one" });
|
||||
await seed.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticated_context_sees_only_its_own_rows_even_without_manual_filter()
|
||||
{
|
||||
var opts = InMemory(nameof(Authenticated_context_sees_only_its_own_rows_even_without_manual_filter));
|
||||
await SeedAsync(opts);
|
||||
|
||||
// Note: NO manual .Where(e => e.UserId == ...) here — the global filter must enforce it.
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = UserA });
|
||||
var emails = await ctx.Emails.ToListAsync();
|
||||
|
||||
emails.Should().HaveCount(2);
|
||||
emails.Should().OnlyContain(e => e.UserId == UserA);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Other_users_row_is_invisible_by_id()
|
||||
{
|
||||
var opts = InMemory(nameof(Other_users_row_is_invisible_by_id));
|
||||
await SeedAsync(opts);
|
||||
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = UserA });
|
||||
// Fetch B's row by its GmailMessageId — classic IDOR attempt; must return null.
|
||||
var leaked = await ctx.Emails.FirstOrDefaultAsync(e => e.GmailMessageId == "b1");
|
||||
|
||||
leaked.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_current_user_bypasses_the_filter_for_background_workers()
|
||||
{
|
||||
var opts = InMemory(nameof(Empty_current_user_bypasses_the_filter_for_background_workers));
|
||||
await SeedAsync(opts);
|
||||
|
||||
// Guid.Empty == background/worker scope: must see all tenants' rows so sync/upsert works.
|
||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = Guid.Empty });
|
||||
var all = await ctx.Emails.ToListAsync();
|
||||
|
||||
all.Should().HaveCount(3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user