Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 2dd2d22673 feat(ops): nightly database backups with rotation (RECOMMENDATIONS #3)
CI / backend (pull_request) Successful in 51s
CI / frontend (pull_request) Successful in 12s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m3s
Adds a compose 'backup' sidecar: daily pg_dump of the inboxintel DB into ./backups
(git-ignored), atomic write (.tmp -> rename), rotation after BACKUP_KEEP_DAYS
(default 7). Previously there were NO backups — a bad migration or volume loss
meant total data loss. Restore procedure documented in compose + SECURITY.md.

Verified: compose config parses; a live pg_dump against the staging DB produced a
valid dump over the compose network with the same image/credentials the sidecar uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:19:16 +02:00
16 changed files with 75 additions and 41 deletions
+3
View File
@@ -15,3 +15,6 @@ FRONTEND_ORIGIN=http://localhost:8081
# Set DEV_MODE=true and MAX_MESSAGES=1000 to test against a large mailbox.
DEV_MODE=false
MAX_MESSAGES=0
# Nightly DB backup rotation (days of dumps to keep in ./backups)
BACKUP_KEEP_DAYS=7
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore InboxIntel.sln
- name: Build
@@ -45,7 +45,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-version: '8.0.x'
- name: dotnet format (verify only)
run: dotnet format InboxIntel.sln --verify-no-changes
@@ -67,7 +67,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-version: '8.0.x'
- name: Wait for Postgres
run: |
for i in $(seq 1 30); do
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore InboxIntel.sln
- name: .NET vulnerable packages (fail on any)
+3
View File
@@ -21,6 +21,9 @@ frontend/.vite/
appsettings.*.local.json
secrets.json
## DB backups (never commit dumps)
backups/
## Logs
logs/
*.log
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
+3 -1
View File
@@ -29,7 +29,9 @@ reverse proxy.
mitigate (a third party reading the DB files) reduces to "someone with access to your
machine" — mitigate it at the layer that actually works:
- **Use full-disk or volume encryption** on the host (BitLocker/LUKS) — strongly recommended.
- **Encrypt backups** of the `pgdata` volume the same way.
- **Encrypt backups**: nightly `pg_dump` rotation runs via the compose `backup` service
into `./backups/` (git-ignored) — keep that directory on an encrypted disk and copy it
off-machine. Restore: `docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql`.
- Before any **multi-user** deployment, revisit per the multi-provider security design
(host admins must not be able to read members' mail — plaintext bodies break that promise).
2. **DB connection is not TLS** — Postgres is only reachable on the compose-internal network /
+31
View File
@@ -22,6 +22,37 @@ services:
timeout: 5s
retries: 10
# Nightly logical backups (RECOMMENDATIONS #3 — previously there were NONE). Dumps
# rotate after BACKUP_KEEP_DAYS. The ./backups host directory should live on an
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
backup:
image: pgvector/pgvector:pg16
entrypoint: /bin/sh
command:
- -c
- |
while true; do
ts=$$(date -u +%Y%m%d-%H%M%S)
if pg_dump -h postgres -U inboxintel -d inboxintel > /backups/inboxintel-$$ts.sql.tmp; then
mv /backups/inboxintel-$$ts.sql.tmp /backups/inboxintel-$$ts.sql
echo "backup OK: inboxintel-$$ts.sql"
else
rm -f /backups/inboxintel-$$ts.sql.tmp
echo "backup FAILED at $$ts" >&2
fi
find /backups -name 'inboxintel-*.sql' -mtime +$${BACKUP_KEEP_DAYS:-7} -delete
sleep 86400
done
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in deploy/.env}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-7}
volumes:
- ./backups:/backups
depends_on:
postgres:
condition: service_healthy
api:
build:
context: .
+2 -2
View File
@@ -1,5 +1,5 @@
# Multi-stage build for the ASP.NET Core API.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy solution + project files first for layer-cached restore.
@@ -13,7 +13,7 @@ RUN dotnet restore src/InboxIntel.Api/InboxIntel.Api.csproj
COPY src/ src/
RUN dotnet publish src/InboxIntel.Api/InboxIntel.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
+3 -3
View File
@@ -5,10 +5,10 @@
<UserSecretsId>210c6d96-c7e4-4ee9-8982-8b91424979b8</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<!-- Required on the startup project for `dotnet ef migrations` to work. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
+6 -5
View File
@@ -38,8 +38,8 @@ var dp = builder.Services.AddDataProtection()
var dpCertPath = builder.Configuration["DataProtection:CertificatePath"];
if (!string.IsNullOrWhiteSpace(dpCertPath))
{
dp.ProtectKeysWithCertificate(System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
dp.ProtectKeysWithCertificate(new System.Security.Cryptography.X509Certificates.X509Certificate2(
dpCertPath, builder.Configuration["DataProtection:CertificatePassword"]));
}
builder.Services.AddApplication();
@@ -201,14 +201,15 @@ var forwardedOptions = new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
ForwardLimit = app.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1
};
forwardedOptions.KnownIPNetworks.Clear();
forwardedOptions.KnownNetworks.Clear();
forwardedOptions.KnownProxies.Clear();
var trustedNetworks = app.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get<string[]>()
?? 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)
{
if (System.Net.IPNetwork.TryParse(cidr, out var network))
forwardedOptions.KnownIPNetworks.Add(network);
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);
@@ -6,13 +6,13 @@
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.9.2" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<!-- DbSet<> is exposed on IAppDbContext so the Application layer can query.
Pinned to 8.0.4 to match the Npgsql provider's Relational dependency. -->
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by EF Core. -->
<PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="System.Text.Json" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
@@ -6,7 +6,7 @@
<ItemGroup>
<!-- NpgsqlTypes.NpgsqlTsVector (FTS) and Pgvector.Vector (semantic search) are used as
column types on the Email entity — same pragmatic precedent for both. -->
<PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="Pgvector" Version="0.3.0" />
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="Pgvector" Version="0.2.0" />
</ItemGroup>
</Project>
@@ -4,18 +4,18 @@
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.0" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
@@ -24,9 +24,9 @@
<!-- Transitive security pins: patch known .NET 8.0.0 advisories pulled in by
EF Core / ASP.NET / DataProtection. Remove once the parent packages ship
these versions transitively. -->
<PackageReference Include="System.Text.Json" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.9" />
<PackageReference Include="System.Text.Json" Version="8.0.6" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="8.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
@@ -25,7 +25,7 @@ namespace InboxIntel.IntegrationTests;
/// endpoints (model validation, per-user rate limits) without a real Google login.</summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public new const string Scheme = "Test";
public const string Scheme = "Test";
// Stable across requests so per-user rate-limit partitions accumulate correctly.
public static readonly string Uid = Guid.NewGuid().ToString();
@@ -52,9 +52,6 @@ public class AuditTestAppFactory : WebApplicationFactory<Program>
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "false",
// Npgsql 10 eagerly validates the connection string when the DbContext is
// resolved (8.x was lazy); these tests never connect, but the string must parse.
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
["GoogleOAuth:ClientId"] = "test-client-id",
["GoogleOAuth:ClientSecret"] = "test-client-secret",
// H-2: make the auth policy trip on the 3rd request within the window.
@@ -19,9 +19,6 @@ public class TestAppFactory : WebApplicationFactory<Program>
builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "false",
// Npgsql 10 eagerly validates the connection string when the DbContext is
// resolved (8.x was lazy); these tests never connect, but the string must parse.
["ConnectionStrings:Postgres"] = "Host=localhost;Database=test;Username=test;Password=test",
// Dummy OAuth creds so the Google challenge produces a real 302 redirect
// (an empty ClientId can make the handler throw instead of redirecting).
["GoogleOAuth:ClientId"] = "test-client-id",
@@ -7,8 +7,8 @@
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\InboxIntel.Api\InboxIntel.Api.csproj" />