diff --git a/README.md b/README.md index cbf7e30..fd1f554 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,12 @@ A PostgreSQL connection string is required, by default under | `DatabaseConnectionName` | `appfoundation-database` | Connection-string name to read | | `WolverineServiceName` | `AppFoundation` | Service name for the durable inbox/outbox | | `SecretsDirectory` | `/run/secrets` | Key-per-file secrets directory (see below) — *from 1.1.0* | +| `SchemaCreation` | env-based | Marten schema mode; `null` ⇒ `All` in Development, `CreateOrUpdate` otherwise. Set `None` for least-privilege — see [Database schema](#database-schema) | +| `KnownProxyNetworks` / `KnownProxies` | *(empty)* | Trusted reverse-proxy CIDRs / IPs for `X-Forwarded-*` — see [Running behind a reverse proxy](#running-behind-a-reverse-proxy) | +| `ConfigureForwardedHeaders` | — | Callback for full `ForwardedHeadersOptions` control | +| `DataProtectionApplicationDiscriminator` | `WolverineServiceName` | Isolates protected payloads from other apps sharing infrastructure | +| `ConfigureDataProtection` | — | Callback on the DataProtection builder (Key Vault, cert rotation, …) | +| `AllowUnprotectedKeyRing` | `false` | Accept an unencrypted key ring outside Development — see [Data protection keys](#data-protection-keys) | **`AppFoundationLayoutOptions`** (management shell branding): `BrandName`, `LogoPath`, `Copyright`, and `AdminMenu` (a Razor component type rendered as @@ -136,6 +142,89 @@ secrets: --- +## Production configuration + +Outside `Development`, a few things must be configured — the app is secure by default and +**fails fast** rather than running in an unsafe state. Checklist for a non-Development host +(`ASPNETCORE_ENVIRONMENT=Production` or `Staging`): + +1. **Connection string** — required (see [Configuration](#configuration)). +2. **Data protection keys** — required, or the app won't start (see below). +3. **Reverse proxy** — set the trusted proxy networks so client IP/scheme are honored (see below). +4. **Database schema** — defaults to non-destructive; tighten for least privilege (see below). + +### Data protection keys + +The DataProtection key ring is persisted in the same database as the data it protects, so +outside Development the foundation **refuses to start unless the keys are encrypted at rest** +— otherwise a database dump would also expose the keys guarding the SMTP password, login +tokens, and auth cookies. Provide one of: + +- **A certificate** via `DataProtection:CertificatePath` (+ `DataProtection:CertificatePassword`), + ideally supplied as Docker secrets: + + ```yaml + environment: + - DataProtection__CertificatePath=/run/secrets/dataprotection_cert + secrets: + - dataprotection_cert + - DataProtection__CertificatePassword + ``` + +- **Key Vault / KMS** via `options.ConfigureDataProtection = b => b.ProtectKeysWithAzureKeyVault(...)`. +- Or, only when the database storage is already encrypted at rest by other means, opt out with + `options.AllowUnprotectedKeyRing = true`. + +### Running behind a reverse proxy + +The app trusts the TCP peer of each request — which behind a proxy is the **proxy's** address, +not the client's. Declare the proxy so `X-Forwarded-For` / `X-Forwarded-Proto` are honored only +from it (and never spoofable by arbitrary clients). Configure via code or, since the production +CIDR is usually only known at deploy time, via configuration: + +```yaml +# docker-compose.yml — app service +environment: + - AppFoundation__KnownProxyNetworks=172.28.0.0/16 # your proxy/ingress subnet +expose: ["8080"] # not published to the host — only the proxy can reach the app +networks: [edge] +networks: + edge: + ipam: + config: [{ subnet: 172.28.0.0/16 }] +``` + +`AppFoundation__KnownProxyNetworks` (and `AppFoundation__KnownProxies` for single IPs) accept a +comma/semicolon/space-delimited list or a config array; IPv6 CIDRs are supported. With none set, +forwarded headers are trusted from any origin in Development but only from loopback otherwise +(the app logs a warning). The proxy must forward the headers — for nginx, and to keep Blazor +Server circuits alive over WebSockets: + +```nginx +location / { + proxy_pass http://app:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; # https → app sees IsHttps=true + proxy_http_version 1.1; # WebSocket upgrade for SignalR + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; +} +``` + +For multiple proxies (e.g. Cloudflare → nginx), also raise `ForwardLimit` via +`ConfigureForwardedHeaders` and trust each hop. + +### Database schema + +`SchemaCreation` defaults to `AutoCreate.All` in Development (permits destructive rebuilds) and +`AutoCreate.CreateOrUpdate` (additive only — never drops) elsewhere, so a code/database mismatch +can't destroy data at runtime. For a least-privilege deployment, set `SchemaCreation = AutoCreate.None` +and provision the schema out-of-band (a migration job / `db-apply`) with a privileged role, then +run the app with a role that has no DDL rights. + +--- + ## Repository layout ``` diff --git a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs index 9fe93bb..5faacc1 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/AppFoundationOptions.cs @@ -47,6 +47,17 @@ public sealed class AppFoundationOptions /// public Action? ConfigureDataProtection { get; set; } + /// + /// Opt in to storing the DataProtection key ring without at-rest encryption + /// outside Development. The key ring lives in the same database as the data it + /// protects, so by default the foundation refuses to start in a non-Development + /// environment unless key encryption is configured (a certificate via + /// DataProtection:CertificatePath, or Key Vault/KMS via + /// ). Set this to true only when the + /// database storage is encrypted at rest by other means (#54). + /// + public bool AllowUnprotectedKeyRing { get; set; } + /// /// Optional extension point on the Wolverine options, invoked inside the /// foundation's single UseWolverine call after its own configuration. diff --git a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs index ecef00c..5e00261 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs +++ b/src/AndreGoepel.AppFoundation.Hosting/Initialization.cs @@ -10,12 +10,14 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.AspNetCore.DataProtection.XmlEncryption; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Radzen; using Wolverine; using Wolverine.Marten; @@ -176,6 +178,16 @@ public static WebApplication UseAppFoundation(this WebApplication app) app.MapDefaultEndpoints(); var options = app.Services.GetRequiredService(); + + // Fail closed if the key ring would be persisted unencrypted in a non-local + // environment: the keys live in the same Postgres as the data they protect, + // so a database dump must not also yield the keys (#54). + EnsureKeyRingProtected( + app.Environment.IsDevelopment(), + options.AllowUnprotectedKeyRing, + app.Services.GetRequiredService>().Value.XmlEncryptor + ); + var forwardedOptions = BuildForwardedHeadersOptions( options, app.Environment.IsDevelopment() @@ -225,6 +237,37 @@ public static WebApplication UseAppFoundation(this WebApplication app) /// (local convenience) and only the framework default (loopback) elsewhere, so /// arbitrary clients cannot spoof the client IP or scheme in production (#51). /// + /// + /// Throws when the DataProtection key ring would be stored without at-rest + /// encryption outside Development, unless the host has explicitly accepted that + /// via . + /// is the resolved + /// : non-null whenever key + /// encryption is configured (certificate, Key Vault, KMS, …), so this reflects the + /// actual end state regardless of how protection was wired. + /// + internal static void EnsureKeyRingProtected( + bool isDevelopment, + bool allowUnprotectedKeyRing, + IXmlEncryptor? xmlEncryptor + ) + { + if (isDevelopment || allowUnprotectedKeyRing || xmlEncryptor is not null) + { + return; + } + + throw new InvalidOperationException( + "The DataProtection key ring would be stored unencrypted in the database, " + + "where a dump would also expose the keys that protect the SMTP password, " + + "login tokens, and auth cookies. Configure key encryption — set " + + "DataProtection:CertificatePath, or use AppFoundationOptions." + + "ConfigureDataProtection for Azure Key Vault / KMS — or set " + + "AppFoundationOptions.AllowUnprotectedKeyRing = true to accept this " + + "(e.g. when the database storage is encrypted at rest by other means)." + ); + } + /// /// Merges reverse-proxy trust configured under AppFoundation:KnownProxyNetworks /// and AppFoundation:KnownProxies into . Each key diff --git a/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs b/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs new file mode 100644 index 0000000..844aabc --- /dev/null +++ b/tests/AndreGoepel.AppFoundation.Tests/Hosting/EnsureKeyRingProtectedTests.cs @@ -0,0 +1,58 @@ +using AndreGoepel.AppFoundation.Hosting; +using Microsoft.AspNetCore.DataProtection.XmlEncryption; +using NSubstitute; + +namespace AndreGoepel.AppFoundation.Tests.Hosting; + +public class EnsureKeyRingProtectedTests +{ + [Fact] + public void NonDevelopment_WithoutEncryptor_NotAllowed_Throws() + { + // Arrange / Act / Assert + var exception = Assert.Throws(() => + Initialization.EnsureKeyRingProtected( + isDevelopment: false, + allowUnprotectedKeyRing: false, + xmlEncryptor: null + ) + ); + Assert.Contains("key ring", exception.Message); + } + + [Fact] + public void NonDevelopment_WithEncryptor_DoesNotThrow() + { + // Arrange + var encryptor = Substitute.For(); + + // Act / Assert — encryption configured (cert / Key Vault / KMS), so allowed. + Initialization.EnsureKeyRingProtected( + isDevelopment: false, + allowUnprotectedKeyRing: false, + xmlEncryptor: encryptor + ); + } + + [Fact] + public void NonDevelopment_WithoutEncryptor_ExplicitlyAllowed_DoesNotThrow() + { + // Act / Assert — host accepted an unencrypted key ring. + Initialization.EnsureKeyRingProtected( + isDevelopment: false, + allowUnprotectedKeyRing: true, + xmlEncryptor: null + ); + } + + [Fact] + public void Development_WithoutEncryptor_DoesNotThrow() + { + // Act / Assert — local development never requires key encryption. + Initialization.EnsureKeyRingProtected( + isDevelopment: true, + allowUnprotectedKeyRing: false, + xmlEncryptor: null + ); + } +}