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
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user