diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..94caccd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +# Build + test gate. Runs on pushes to the long-lived branches and on every PR so +# the 39-test suite (and both builds) must pass before merge. +on: + push: + branches: [main, develop] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + - name: Restore + run: dotnet restore InboxIntel.sln + - name: Build + run: dotnet build InboxIntel.sln -c Release --no-restore + - name: Test + run: dotnet test InboxIntel.sln -c Release --no-build --verbosity normal + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install + run: npm ci + - name: Build + run: npm run build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7870768 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +\# Project Rules + + + +\## Code Quality + + + +\- Prefer readability over cleverness. + +\- Keep methods under \~40 lines where practical. + +\- Avoid duplicated logic. + +\- Follow SOLID principles. + +\- Keep files focused on a single responsibility. + + + +\## Safety + + + +\- Never commit secrets. + +\- Never disable tests to make them pass. + +\- Never remove functionality without explaining why. + + + +\## Testing + + + +Every change must include: + +\- Unit tests where appropriate. + +\- Integration tests for API changes. + +\- Build verification. + + + +\## Architecture + + + +Prefer: + +\- Dependency Injection + +\- Composition over inheritance + +\- Async APIs + +\- Immutable models where practical + + + +\## Documentation + + + +Update documentation whenever public behaviour changes. + diff --git a/docker-compose.yml b/docker-compose.yml index 217330c..3be41ba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,16 @@ services: environment: POSTGRES_DB: inboxintel POSTGRES_USER: inboxintel - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-inboxintel} + # V-03: require an explicit strong password (fail fast if POSTGRES_PASSWORD is unset) + # rather than silently defaulting to a guessable one. + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env} volumes: - pgdata:/var/lib/postgresql/data + # V-03: bind to loopback only so the database is reachable from the host for local + # tooling but NOT from other machines on the network. The api container reaches it + # over the internal compose network regardless of this published port. ports: - - "5432:5432" + - "127.0.0.1:5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U inboxintel"] interval: 5s @@ -22,7 +27,7 @@ services: environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 - ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=inboxintel;Username=inboxintel;Password=${POSTGRES_PASSWORD:-inboxintel}" + ConnectionStrings__Postgres: "Host=postgres;Port=5432;Database=inboxintel;Username=inboxintel;Password=${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}" DataProtection__KeyPath: /keys GoogleOAuth__ClientId: ${GOOGLE_CLIENT_ID:-} GoogleOAuth__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} @@ -37,8 +42,11 @@ services: depends_on: postgres: condition: service_healthy + # V-08: bind to loopback so the API is not directly reachable from the network + # (only via the frontend/nginx proxy over the internal compose network). This + # prevents external clients from bypassing the proxy to spoof X-Forwarded-* headers. ports: - - "8080:8080" + - "127.0.0.1:8080:8080" frontend: build: diff --git a/docs/specs/ui-overhaul.md b/docs/specs/ui-overhaul.md index 66ff40c..06025fe 100644 --- a/docs/specs/ui-overhaul.md +++ b/docs/specs/ui-overhaul.md @@ -68,6 +68,12 @@ Defined once in `src/index.css`; Tailwind theme references them so `bg-backgroun } ``` +> **Accent is swappable by design.** The accent lives in exactly one token (`--primary`, +> plus its dark variant). Changing the brand color = editing those two lines. This also +> makes a **future user-facing accent picker** cheap: store a chosen hue on the user (or +> in `localStorage`) and write `--primary`/`--ring` at runtime. Decision (2026-06-30): +> ship with **Indigo `#5b5bf0`**; leave the picker as a documented future enhancement. + ### Proposed palette (for sign-off) | Token | Light | Dark | Use | diff --git a/frontend/src/components/BulkToolbar.jsx b/frontend/src/components/BulkToolbar.jsx index ef2bcfe..97fa140 100644 --- a/frontend/src/components/BulkToolbar.jsx +++ b/frontend/src/components/BulkToolbar.jsx @@ -1,30 +1,93 @@ +import { useState } from 'react'; +import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react'; import { BulkApi } from '../api/client.js'; +import { + Button, useToast, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, +} from './ui'; -/// -/// Toolbar shown above an email list when one or more rows are selected. -/// `onDone` is called with the action key after a successful bulk call so the -/// caller can update local state (e.g. remove trashed/archived rows). -/// +/** + * Toolbar shown above an email list when rows are selected. + * + * Safety (Phase 4 / UX-Critical): the destructive Trash action now requires an + * explicit confirmation dialog, every action surfaces success/partial-failure via + * a toast, and rows are only removed from the list when the server confirms the + * whole batch succeeded (no more optimistic removal that hides failures). + * `onDone(action, ids)` is called only on full success so the caller can prune state. + */ export default function BulkToolbar({ selectedIds, onDone, onClear }) { + const { toast } = useToast(); + const [busy, setBusy] = useState(false); + const [confirmTrash, setConfirmTrash] = useState(false); const count = selectedIds.length; if (count === 0) return null; - const run = (fn, action) => async () => { - await fn(selectedIds); - onDone(action, selectedIds); - onClear(); + const apply = async (fn, action, label) => { + setBusy(true); + try { + const res = await fn(selectedIds); + // CleanupResultDto: { succeededCount, failedCount, errors } + const ok = res?.succeededCount ?? count; + const failed = res?.failedCount ?? 0; + if (failed > 0) { + toast({ + variant: 'warning', + title: `${label}: ${ok} done, ${failed} failed`, + description: 'Some items could not be updated — the list was left unchanged so you can retry.', + }); + } else { + toast({ variant: 'success', title: `${label} ${ok} email${ok === 1 ? '' : 's'}` }); + onDone(action, selectedIds); + onClear(); + } + } catch { + toast({ variant: 'danger', title: `Couldn't ${label.toLowerCase()} ${count} email${count === 1 ? '' : 's'}`, description: 'Please try again.' }); + } finally { + setBusy(false); + setConfirmTrash(false); + } }; return ( -
- {count} selected -
- - - - - - +
+ {count} selected +
+ + + + + + + + !busy && setConfirmTrash(o)}> + + + Move {count} email{count === 1 ? '' : 's'} to Trash? + + This moves the selected mail to your Gmail Trash, where it stays recoverable + for 30 days before Gmail permanently deletes it. + + + + + + + + + +
); } diff --git a/src/InboxIntel.Api/Controllers/AnalyticsController.cs b/src/InboxIntel.Api/Controllers/AnalyticsController.cs index 5487053..43563a4 100644 --- a/src/InboxIntel.Api/Controllers/AnalyticsController.cs +++ b/src/InboxIntel.Api/Controllers/AnalyticsController.cs @@ -16,11 +16,13 @@ public class AnalyticsController : ApiControllerBase [HttpGet("top-senders")] public async Task TopSenders([FromQuery] int take = 20, CancellationToken ct = default) - => Ok(await _analytics.GetTopSendersAsync(UserId, take, ct)); + // V-10: clamp to a sane bound (the SPA legitimately requests up to 5000 to list + // all senders) so a caller cannot force an unbounded scan. + => Ok(await _analytics.GetTopSendersAsync(UserId, Math.Clamp(take, 1, 5000), ct)); [HttpGet("volume")] public async Task Volume([FromQuery] int days = 90, CancellationToken ct = default) - => Ok(await _analytics.GetVolumeOverTimeAsync(UserId, days, ct)); + => Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct)); [HttpGet("heatmap")] public async Task Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct)); diff --git a/src/InboxIntel.Api/Controllers/AppInfoController.cs b/src/InboxIntel.Api/Controllers/AppInfoController.cs index 8004246..8f03d6e 100644 --- a/src/InboxIntel.Api/Controllers/AppInfoController.cs +++ b/src/InboxIntel.Api/Controllers/AppInfoController.cs @@ -28,10 +28,17 @@ public class AppInfoController : ControllerBase public IActionResult Info() { var devMode = _config.GetValue("App:DevMode") ?? _env.IsDevelopment(); + // V-13: when NOT in dev mode, disclose nothing beyond the flag to anonymous + // callers. The dev banner (the only consumer of environment/maxMessages) only + // renders when devMode is true, so this preserves the feature without leaking + // the environment name or sync cap in production. + if (!devMode) + return Ok(new { devMode = false }); + return Ok(new { + devMode = true, environment = _env.EnvironmentName, - devMode, maxMessages = _config.GetValue("GmailSync:MaxMessages") }); } diff --git a/src/InboxIntel.Api/Controllers/AuthController.cs b/src/InboxIntel.Api/Controllers/AuthController.cs index 778f034..4e16fb6 100644 --- a/src/InboxIntel.Api/Controllers/AuthController.cs +++ b/src/InboxIntel.Api/Controllers/AuthController.cs @@ -17,7 +17,12 @@ public class AuthController : ControllerBase [HttpGet("login")] [AllowAnonymous] public IActionResult Login([FromQuery] string? returnUrl = "/") - => Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, GoogleDefaults.AuthenticationScheme); + { + // V-09: only allow local post-login redirects; reject absolute/off-host targets + // so the OAuth flow can't be abused as an open redirect for phishing. + var safe = !string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl) ? returnUrl : "/app"; + return Challenge(new AuthenticationProperties { RedirectUri = safe }, GoogleDefaults.AuthenticationScheme); + } [HttpPost("logout")] [Authorize] diff --git a/src/InboxIntel.Api/Controllers/SearchController.cs b/src/InboxIntel.Api/Controllers/SearchController.cs index 5b37d57..6716518 100644 --- a/src/InboxIntel.Api/Controllers/SearchController.cs +++ b/src/InboxIntel.Api/Controllers/SearchController.cs @@ -7,19 +7,31 @@ namespace InboxIntel.Api.Controllers; public class SearchController : ApiControllerBase { + /// Hard upper bound on a user-requested page (V-10: DoS via huge pageSize). + /// Enforced HERE, at the user-facing trust boundary, so internal callers of + /// ISearchService (e.g. cleanup target resolution) can still request large pages. + private const int MaxPageSize = 200; + private readonly ISearchService _search; public SearchController(ISearchService search) => _search = search; /// Structured search via JSON body. [HttpPost] public async Task Search([FromBody] SearchRequestDto request, CancellationToken ct) - => Ok(await _search.SearchAsync(UserId, request, ct)); + { + var clamped = request with + { + Page = Math.Max(1, request.Page), + PageSize = Math.Clamp(request.PageSize, 1, MaxPageSize) + }; + return Ok(await _search.SearchAsync(UserId, clamped, ct)); + } /// Gmail-like query string search, e.g. ?q=from:github.com is:unread. [HttpGet] public async Task Query([FromQuery] string? q, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default) { - var parsed = GmailQueryParser.Parse(q, page, pageSize); + var parsed = GmailQueryParser.Parse(q, Math.Max(1, page), Math.Clamp(pageSize, 1, MaxPageSize)); return Ok(await _search.SearchAsync(UserId, parsed, ct)); } } diff --git a/src/InboxIntel.Api/Dockerfile b/src/InboxIntel.Api/Dockerfile index 65a7424..384d9e0 100644 --- a/src/InboxIntel.Api/Dockerfile +++ b/src/InboxIntel.Api/Dockerfile @@ -16,5 +16,13 @@ RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/p FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . + +# V-12: run as the non-root 'app' user shipped in the .NET 8 images. Pre-create the +# DataProtection key directory owned by that user so the (initially empty) 'keys' +# volume inherits app ownership on first mount and key persistence still works. +# NOTE: an EXISTING root-owned keys volume must be recreated for this to take effect. +RUN mkdir -p /keys && chown -R app:app /keys /app +USER app + EXPOSE 8080 ENTRYPOINT ["dotnet", "InboxIntel.Api.dll"] diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs index 5f90730..087e06d 100644 --- a/src/InboxIntel.Api/Program.cs +++ b/src/InboxIntel.Api/Program.cs @@ -48,6 +48,12 @@ builder.Services.AddAuthentication(options => { options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; + // V-04: never emit the session cookie over plain HTTP in non-dev. Behind nginx + // the forwarded proto (now only trusted from known proxies, see below) makes + // Always work; local http://localhost dev still functions via SameAsRequest. + options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() + ? CookieSecurePolicy.SameAsRequest + : CookieSecurePolicy.Always; options.Cookie.Name = "inboxintel.session"; options.ExpireTimeSpan = TimeSpan.FromDays(7); options.SlidingExpiration = true; @@ -94,6 +100,9 @@ builder.Services.AddApiVersioning(o => builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +// V-06: RFC7807 ProblemDetails so the global exception handler returns a safe, +// generic error body instead of leaking framework stack traces / internal messages. +builder.Services.AddProblemDetails(); builder.Services.AddCors(o => o.AddPolicy("frontend", p => p .WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get() ?? new[] { "http://localhost:5173" }) @@ -109,21 +118,53 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); } +// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and +// cookie Secure flags reflect the external scheme/host, not the container's. +// V-08: only trust these headers from KNOWN proxy networks (configurable). The +// default covers private/Docker ranges so the compose nginx works, while a client +// reaching the API directly can no longer spoof scheme/host/forwarded-for. +var forwardedOptions = new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost, + ForwardLimit = app.Configuration.GetValue("ForwardedHeaders:ForwardLimit") ?? 1 +}; +forwardedOptions.KnownNetworks.Clear(); +forwardedOptions.KnownProxies.Clear(); +var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() + ?? new[] { "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" }; +foreach (var cidr in trustedNetworks) +{ + var parts = cidr.Split('/'); + if (parts.Length == 2 && System.Net.IPAddress.TryParse(parts[0], out var prefix) && int.TryParse(parts[1], out var len)) + forwardedOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, len)); +} +app.UseForwardedHeaders(forwardedOptions); + if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } - -// Honor X-Forwarded-* from the nginx reverse proxy so OAuth redirect URIs and -// cookie Secure flags reflect the external scheme/host, not the container's. -var forwardedOptions = new ForwardedHeadersOptions +else { - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost -}; -forwardedOptions.KnownNetworks.Clear(); -forwardedOptions.KnownProxies.Clear(); -app.UseForwardedHeaders(forwardedOptions); + // V-06: generic ProblemDetails for unhandled exceptions (no stack traces to clients). + app.UseExceptionHandler(); + // V-11: HSTS once TLS is enforced at the proxy (forwarded proto now trustworthy). + app.UseHsts(); +} + +// V-11: baseline security response headers. CSP is report-only for now so it can be +// tuned against the SPA before enforcing (the SPA itself is also served with headers +// by its nginx). Applied to API responses here as defense-in-depth. +app.Use(async (ctx, next) => +{ + var h = ctx.Response.Headers; + h["X-Content-Type-Options"] = "nosniff"; + h["X-Frame-Options"] = "DENY"; + h["Referrer-Policy"] = "no-referrer"; + h["Cross-Origin-Opener-Policy"] = "same-origin"; + await next(); +}); app.UseSerilogRequestLogging(); app.UseCors("frontend"); diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json index 0ebdc56..7a049ac 100644 --- a/src/InboxIntel.Api/appsettings.json +++ b/src/InboxIntel.Api/appsettings.json @@ -51,6 +51,10 @@ "FrequencyDays": 7, "SendHourUtc": 8 }, + "ForwardedHeaders": { + "ForwardLimit": 1, + "KnownNetworks": [ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "::1/128" ] + }, "Cors": { "Origins": [ "http://localhost:5173" ] }, diff --git a/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs b/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs index 7112cf8..508fb45 100644 --- a/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs +++ b/src/InboxIntel.Infrastructure/Cleanup/CleanupService.cs @@ -100,8 +100,9 @@ public class CleanupService : ICleanupService catch (Exception ex) { _logger.LogError(ex, "Cleanup action {Action} failed for user {UserId}", request.Action, userId); - errors.Add(ex.Message); - return Result.Failure(ex.Message); + // V-06: do not echo internal exception detail to the client. + errors.Add("The cleanup action could not be completed."); + return Result.Failure("The cleanup action could not be completed."); } } diff --git a/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs b/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs index fa01cb1..ed62e9e 100644 --- a/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs +++ b/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs @@ -5,6 +5,7 @@ using InboxIntel.Domain.Entities; using InboxIntel.Domain.Enums; using InboxIntel.Infrastructure.Gmail; using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Security; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -120,13 +121,17 @@ public class UnsubscribeService : IUnsubscribeService switch (item.Method) { case UnsubscribeMethod.OneClickPost: - var post = await http.PostAsync(item.UnsubscribeTarget, + // V-01: the target comes from an attacker-authored email header. + // Validate against SSRF (scheme + private/metadata ranges) before fetching. + var postUri = await SafeHttpGuard.ValidateAsync(item.UnsubscribeTarget, ct); + var post = await http.PostAsync(postUri, new StringContent("List-Unsubscribe=One-Click"), ct); SetResult(item, post.IsSuccessStatusCode); if (post.IsSuccessStatusCode) ok++; else fail++; break; case UnsubscribeMethod.HttpLink: - var get = await http.GetAsync(item.UnsubscribeTarget, ct); + var getUri = await SafeHttpGuard.ValidateAsync(item.UnsubscribeTarget, ct); + var get = await http.GetAsync(getUri, ct); SetResult(item, get.IsSuccessStatusCode); if (get.IsSuccessStatusCode) ok++; else fail++; break; @@ -140,12 +145,24 @@ public class UnsubscribeService : IUnsubscribeService break; } } - catch (Exception ex) + catch (SsrfBlockedException ex) { + // Blocked target (e.g. points at an internal/metadata address). Log + // server-side; surface only a generic reason to the client. fail++; item.Status = UnsubscribeStatus.Failed; - item.ResultMessage = ex.Message; - errors.Add($"{item.UnsubscribeTarget}: {ex.Message}"); + item.ResultMessage = "Unsubscribe link was blocked for safety."; + _logger.LogWarning(ex, "Blocked unsubscribe target for user {UserId}", userId); + errors.Add("An unsubscribe link was blocked for safety."); + } + catch (Exception ex) + { + // V-06: log detail, return a generic message. + fail++; + item.Status = UnsubscribeStatus.Failed; + item.ResultMessage = "Unsubscribe request failed."; + _logger.LogWarning(ex, "Unsubscribe request failed for user {UserId}", userId); + errors.Add("An unsubscribe request failed."); } } await _db.SaveChangesAsync(ct); diff --git a/src/InboxIntel.Infrastructure/DependencyInjection.cs b/src/InboxIntel.Infrastructure/DependencyInjection.cs index f52e281..6d5c30f 100644 --- a/src/InboxIntel.Infrastructure/DependencyInjection.cs +++ b/src/InboxIntel.Infrastructure/DependencyInjection.cs @@ -60,7 +60,10 @@ public static class DependencyInjection services.AddHostedService(); // HTTP clients - services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15)); + // V-01: do NOT follow redirects — a validated external URL must not be able to + // 3xx-redirect into an internal target after SafeHttpGuard has checked it. + services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); services.AddHttpClient("ollama"); services.AddHttpClient("openai"); diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs index e4d32ec..47d390a 100644 --- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs +++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs @@ -8,7 +8,22 @@ namespace InboxIntel.Infrastructure.Persistence; public class AppDbContext : DbContext, IAppDbContext { - public AppDbContext(DbContextOptions options) : base(options) { } + private readonly ICurrentUser? _currentUser; + + public AppDbContext(DbContextOptions options, ICurrentUser? currentUser = null) : base(options) + => _currentUser = currentUser; + + /// + /// Tenant id used by the global query filters below. Resolves to the + /// authenticated user during an HTTP request. It is + /// 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 WHERE UserId == can no longer leak + /// another tenant's rows, since the filter restricts to the caller. + /// + public Guid CurrentUserId => _currentUser?.UserId ?? Guid.Empty; public DbSet Users => Set(); public DbSet Emails => Set(); @@ -26,6 +41,41 @@ public class AppDbContext : DbContext, IAppDbContext 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().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); + modelBuilder.Entity