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
+
+
+
+
+
+
+
+
+
);
}
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