feat(security): audit batch C — data posture, retention, DP keys (#22)
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 11s
Deploy Staging / deploy (push) Successful in 24s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 52s
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 10s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 55s

This commit was merged in pull request #22.
This commit is contained in:
2026-07-02 10:13:07 +02:00
parent b905c93884
commit 2b19bddf7b
8 changed files with 286 additions and 2 deletions
+12 -1
View File
@@ -27,9 +27,20 @@ builder.Host.UseSerilog((ctx, cfg) => cfg
.WriteTo.Console());
// Persist Data Protection keys so encrypted refresh tokens survive restarts.
builder.Services.AddDataProtection()
// AUDIT M-3: optionally encrypt the Data Protection key ring with an X.509 certificate so
// the keys are not readable in plaintext from the /keys volume (which would otherwise let
// anyone with volume access decrypt all stored refresh tokens). Configure
// DataProtection:CertificatePath (+ CertificatePassword) to enable; without it, keys are
// persisted unprotected and a startup warning documents the residual risk.
var dp = builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(builder.Configuration["DataProtection:KeyPath"] ?? "/keys"))
.SetApplicationName("InboxIntel");
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
if (!string.IsNullOrWhiteSpace(dpCertPath))
{
dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
}
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
+7 -1
View File
@@ -6,7 +6,13 @@
"AutoMigrate": true
},
"DataProtection": {
"KeyPath": "/keys"
"KeyPath": "/keys",
"CertificatePath": "",
"CertificatePassword": ""
},
"DataRetention": {
"PurgeTrashedAfterDays": 0,
"PurgeAllAfterDays": 0
},
"GoogleOAuth": {
"ClientId": "",
@@ -72,3 +72,13 @@ public class DigestOptions
/// <summary>Hour (UTC) the background worker checks for due digests.</summary>
public int SendHourUtc { get; set; } = 8;
}
/// <summary>AUDIT H-3: opt-in local data retention. 0 = disabled (keep forever).</summary>
public class DataRetentionOptions
{
public const string SectionName = "DataRetention";
/// <summary>Purge locally stored emails flagged Trashed older than this many days.</summary>
public int PurgeTrashedAfterDays { get; set; } = 0;
/// <summary>Purge ALL locally stored emails older than this many days (local copy only).</summary>
public int PurgeAllAfterDays { get; set; } = 0;
}
@@ -60,6 +60,11 @@ public static class DependencyInjection
services.AddScoped<IDigestService, DigestService>();
services.AddHostedService<DigestWorker>();
// AUDIT H-3: opt-in local data retention (worker no-ops while disabled).
services.Configure<DataRetentionOptions>(config.GetSection(DataRetentionOptions.SectionName));
services.AddScoped<Retention.RetentionService>();
services.AddHostedService<Retention.RetentionWorker>();
// HTTP clients
// 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.
@@ -0,0 +1,63 @@
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Retention;
/// <summary>
/// AUDIT H-3 (retention): purges locally stored email data past the configured age so the
/// local mailbox copy is not kept forever by default-of-omission. This deletes ONLY the
/// local Postgres rows — the user's actual Gmail is never touched. Both knobs default to 0
/// (disabled) so existing deployments are unchanged until the operator opts in.
/// </summary>
public class RetentionService
{
private readonly AppDbContext _db;
private readonly DataRetentionOptions _options;
private readonly ILogger<RetentionService> _logger;
public RetentionService(AppDbContext db, IOptions<DataRetentionOptions> options, ILogger<RetentionService> logger)
{
_db = db;
_options = options.Value;
_logger = logger;
}
/// <summary>Runs the configured purges. Returns the number of emails removed.</summary>
public async Task<int> PurgeAsync(CancellationToken ct = default)
{
var removed = 0;
var now = DateTimeOffset.UtcNow;
if (_options.PurgeTrashedAfterDays > 0)
{
var cutoff = now.AddDays(-_options.PurgeTrashedAfterDays);
removed += await PurgeWhereAsync(e => e.IsTrashed && e.SentAtUtc < cutoff, ct);
}
if (_options.PurgeAllAfterDays > 0)
{
var cutoff = now.AddDays(-_options.PurgeAllAfterDays);
removed += await PurgeWhereAsync(e => e.SentAtUtc < cutoff, ct);
}
if (removed > 0)
_logger.LogInformation("Retention purge removed {Count} locally stored emails", removed);
return removed;
}
private async Task<int> PurgeWhereAsync(
System.Linq.Expressions.Expression<Func<Domain.Entities.Email, bool>> predicate,
CancellationToken ct)
{
// RemoveRange (not ExecuteDelete) so dependent rows (labels/attachments) cascade via
// the model on every provider, and the InMemory test provider works identically.
var doomed = await _db.Emails.Where(predicate).ToListAsync(ct);
if (doomed.Count == 0) return 0;
_db.Emails.RemoveRange(doomed);
await _db.SaveChangesAsync(ct);
return doomed.Count;
}
}
@@ -0,0 +1,53 @@
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Retention;
/// <summary>
/// Daily driver for <see cref="RetentionService"/> (AUDIT H-3). Exits immediately when both
/// retention knobs are 0 (the default) so existing deployments see no behaviour change.
/// </summary>
public class RetentionWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly DataRetentionOptions _options;
private readonly ILogger<RetentionWorker> _logger;
public RetentionWorker(IServiceScopeFactory scopeFactory, IOptions<DataRetentionOptions> options, ILogger<RetentionWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (_options.PurgeTrashedAfterDays <= 0 && _options.PurgeAllAfterDays <= 0)
{
_logger.LogDebug("RetentionWorker idle: retention is disabled (all knobs 0).");
return;
}
_logger.LogInformation("RetentionWorker started; trashed>{Trashed}d, all>{All}d",
_options.PurgeTrashedAfterDays, _options.PurgeAllAfterDays);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<RetentionService>();
await service.PurgeAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Retention purge failed; will retry next cycle.");
}
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
}
}
}