From 12e8fcd8a83c8a5e5649eed1c3937755f59ad2a3 Mon Sep 17 00:00:00 2001 From: maxiar Date: Wed, 9 Sep 2026 22:46:31 -0300 Subject: [PATCH] feat(security): add a database-backed Data Protection key store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEPENDS ON #1372 AND #1374 — see the PR description. It does not build against main on its own, and that is expected. The framework only persists Data Protection keys to Redis. That breaks down two ways. The DbMigrator is normally run standalone, outside the AppHost wiring that injects a Redis connection string, so anything its seed encrypts can become permanently undecryptable by the API — "CryptographicException: key {guid} not found in the key ring" — no matter how consistently the application name is pinned. And Redis configured as a cache can evict a key, taking every session and pending reset token with it. DataProtection:Store now selects Redis (default, unchanged) or Database, backed by DataProtectionKeysDbContext in Persistence with migrations for both providers. It follows the framework's existing shape for a framework-owned context — AddHeroDbContext plus an IDbInitializer, exactly like EventingDbContext — so the table is created by the API host, the DbMigrator and the integration-test harness alike. Registering the context WITHOUT its initializer is the failure this shape prevents: the table then exists only where someone migrated it by hand, and every flow that protects a payload fails elsewhere with "Invalid object name 'DataProtectionKeys'". That is how this was found, in a downstream project that wired it by hand and lost 123 of 747 integration tests to it, identically on both providers. The DbMigrator additionally creates the table BEFORE it starts its host. Data Protection resolves its key ring eagerly during StartAsync, well before the migrator's own Step 0/1/2 flow, so the initializer alone is too late there: against an empty database that logged six query failures with stack traces and still exited 0 — a key created while the table is missing cannot be persisted. Silent, not loud. The integration suite now runs against the database store. It has always run without Redis, so the key ring was ephemeral and neither store was exercised; pointing it at the database means all 747 tests also prove the key table is migrated and writable. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/rules/security.md | 42 ++++++++++++++++ src/BuildingBlocks/Caching/Extensions.cs | 19 ++++++-- .../DataProtection/DataProtectionStores.cs | 24 ++++++++++ .../DataProtectionKeysDbContext.cs | 35 ++++++++++++++ .../DataProtectionKeysDbInitializer.cs | 46 ++++++++++++++++++ .../DataProtection/DataProtectionSchema.cs | 47 ++++++++++++++++++ .../Persistence/Persistence.csproj | 1 + src/BuildingBlocks/Web/Extensions.cs | 32 +++++++++++-- src/Directory.Packages.props | 1 + src/Host/FSH.Starter.Api/appsettings.json | 7 +++ src/Host/FSH.Starter.DbMigrator/Program.cs | 32 +++++++++++++ ...09230643_InitialDataProtection.Designer.cs | 48 +++++++++++++++++++ .../20260909230643_InitialDataProtection.cs | 35 ++++++++++++++ ...ataProtectionKeysDbContextModelSnapshot.cs | 45 +++++++++++++++++ ...09230633_InitialDataProtection.Designer.cs | 48 +++++++++++++++++++ .../20260909230633_InitialDataProtection.cs | 36 ++++++++++++++ ...ataProtectionKeysDbContextModelSnapshot.cs | 45 +++++++++++++++++ .../FshWebApplicationFactory.cs | 6 +++ 18 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs create mode 100644 src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs create mode 100644 src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs create mode 100644 src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs create mode 100644 src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs create mode 100644 src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs diff --git a/.agents/rules/security.md b/.agents/rules/security.md index b3fb38404b..1ee2f2d7f8 100644 --- a/.agents/rules/security.md +++ b/.agents/rules/security.md @@ -24,3 +24,45 @@ Opt-in per endpoint with **`.WithIdempotency()`**. Reads the `Idempotency-Key` h `QuotaEnforcementMiddleware` charges 1 `ApiCalls` unit per request via `CheckAndRecordAsync`; over-limit → 429 + ProblemDetails + `Retry-After`, and sets `HttpContext.Items[QuotaRejected]` so auditing can tag it. Resources: `ApiCalls` (counter), `StorageBytes`, `Users`, `ActiveFeatureFlags` (gauges). Skips health/metrics, unresolved tenants, and the root tenant. **Pipeline:** runs after auth (needs tenant) and after the rate limiter. Inject `TimeProvider` (not `DateTimeOffset.UtcNow`) for any time math here — the subsystem is `TimeProvider`-based. `IQuotaService`: `CheckAsync` (no mutation), `RecordAsync` (increment), `CheckAndRecordAsync` (atomic — won't increment past the limit). Store: Redis (`RedisQuotaService`) or per-process `InMemoryQuotaService` (dev/test). `NoopQuotaService` when disabled. + +## Data Protection keys (`Caching/`, `Persistence/DataProtection/`) + +Data Protection encrypts auth cookies, password-reset and confirmation tokens, and antiforgery +tokens. Two settings decide whether that survives. + +**`DataProtection:ApplicationName`** is what keys are isolated by, so it MUST be unique per +application. It is read from configuration, never hard-coded: the framework also ships as compiled +`FSH.Framework.*` packages, where the template's token substitution cannot reach a literal, so a +constant would make every project on those packages share one key ring — and two of them against +the same store could decrypt each other's cookies and tokens. `appsettings.json` is scaffolded +source, so the value there is renamed per project in both distribution modes. Unconfigured, it +falls back to the entry assembly name, which errs towards isolation. + +**`DataProtection:Store`** is `Redis` (default) or `Database`. + +| | Redis | Database | +|---|---|---| +| Wired in | `AddHeroCaching` | `AddHeroPlatform` | +| Needs | a configured Redis | `DataProtectionKeysDbContext` + its migrations | +| Choose when | Redis is durable and shared by every host | hosts do not reliably share Redis, or Redis is a cache with eviction | + +Pick `Database` when the DbMigrator is run standalone — outside the AppHost wiring that injects a +Redis connection string — since anything its seed encrypts otherwise becomes undecryptable by the +API (`CryptographicException: key {guid} not found in the key ring`). Redis eviction has the same +effect on a live system: losing a key takes every session and pending reset token with it. + +The DbMigrator additionally creates the key table **before** it starts its host +(`DataProtectionSchema.EnsureAsync`). Data Protection resolves its key ring eagerly during +`StartAsync`, long before the migrator's own Step 0/1/2 flow, so the `IDbInitializer` alone is too +late there: the first run against an empty database logs a query failure with a stack trace and — +worse — does not fail, because a key created while the table is missing cannot be persisted, and +anything encrypted in that window is undecryptable afterwards. The initializer still covers the API +and the test harness, which migrate before they serve. + +`DataProtectionKeysDbContext` is a plain `DbContext`, not `BaseDbContext` — keys are global +framework infrastructure, not tenant data. It registers a `DataProtectionKeysDbInitializer` like +every other framework context, which is what makes the table appear in the API, the DbMigrator and +the integration-test harness alike. **Wiring the context without its `IDbInitializer` is the +failure mode to avoid**: the table then only exists wherever someone migrated it by hand, and every +flow that protects a payload fails with `Invalid object name 'DataProtectionKeys'` everywhere else. + diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs index bdfd138781..1cab90cf38 100644 --- a/src/BuildingBlocks/Caching/Extensions.cs +++ b/src/BuildingBlocks/Caching/Extensions.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Core.DataProtection; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Configuration; @@ -60,9 +61,21 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services // Persist Data Protection keys (auth cookies, reset/confirmation tokens, antiforgery) to // Redis so multi-instance hosts share a key ring and tokens survive rolling restarts. - services.AddDataProtection() - .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys") - .SetApplicationName("FSH.Starter"); + // Skipped when the database store is selected: AddHeroPlatform wires the key ring to + // DataProtectionKeysDbContext instead. Configuring both would leave the last + // PersistKeysTo call silently deciding where keys actually land. + // + // The application name comes from configuration (see #1372): a literal here would ship + // inside the compiled FSH.Framework.Caching package, where the template cannot rename + // it, making every project on that package share one key ring. + if (!DataProtectionStores.UsesDatabase(configuration[DataProtectionStores.ConfigurationKey])) + { + services.AddDataProtection() + .PersistKeysToStackExchangeRedis(sharedMultiplexer, "DataProtection-Keys") + .SetApplicationName( + DataProtectionApplicationName.Resolve( + configuration[DataProtectionApplicationName.ConfigurationKey])); + } } // HybridCache auto-composes with whatever IDistributedCache is registered above. diff --git a/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs b/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs new file mode 100644 index 0000000000..e170a34fba --- /dev/null +++ b/src/BuildingBlocks/Core/DataProtection/DataProtectionStores.cs @@ -0,0 +1,24 @@ +namespace FSH.Framework.Core.DataProtection; + +/// +/// Where ASP.NET Core Data Protection keys are persisted, selected by +/// DataProtection:Store in configuration. +/// +/// Lives in Core because both Caching (which owns the Redis store) and Web (which owns the +/// database store) have to agree on the value, and Caching cannot reference Persistence. +/// +public static class DataProtectionStores +{ + /// Configuration key selecting the store. + public const string ConfigurationKey = "DataProtection:Store"; + + /// Redis, when configured. The default, and what the framework has always used. + public const string Redis = "REDIS"; + + /// The application database, via the framework's Data Protection keys context. + public const string Database = "DATABASE"; + + /// True when configuration selects the database store. + public static bool UsesDatabase(string? configuredValue) => + string.Equals(configuredValue?.Trim(), Database, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs new file mode 100644 index 0000000000..13a5bf1ec0 --- /dev/null +++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbContext.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Persistence.DataProtection; + +/// +/// Persists ASP.NET Core Data Protection keys to the application database. +/// +/// +/// +/// The alternative store is Redis, which stays the default. Database-backed keys matter when the +/// hosts that protect and unprotect data do not reliably share a Redis instance: the DbMigrator is +/// normally run standalone, outside the AppHost wiring that injects a Redis connection string, so +/// anything its seed encrypts can become permanently undecryptable by the API — a +/// CryptographicException: key {guid} not found in the key ring that no amount of pinning +/// the application name will fix, because the two hosts simply have different key rings. Redis +/// eviction has the same effect on a live system: keys stored in a cache can be evicted, taking +/// every session and pending reset token with them. +/// +/// +/// Deliberately a plain rather than BaseDbContext: Data Protection +/// keys are global framework infrastructure, not tenant data, so they are neither tenant-filtered +/// nor duplicated into a tenant's dedicated database. +/// +/// +/// Provider-neutral. It is wired through AddHeroDbContext like every other context, so it +/// follows DatabaseOptions:Provider, and each provider's migrations project carries its own +/// DataProtection/ folder for it. +/// +/// +public sealed class DataProtectionKeysDbContext(DbContextOptions options) + : DbContext(options), IDataProtectionKeyContext +{ + public DbSet DataProtectionKeys => Set(); +} diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs new file mode 100644 index 0000000000..306d502906 --- /dev/null +++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionKeysDbInitializer.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FSH.Framework.Persistence.DataProtection; + +/// +/// Migrates the Data Protection key schema. +/// +/// +/// Registered as an so the table is created by whatever brings the +/// schema up — the API host, the DbMigrator, and the integration-test harness alike. Wiring the +/// context without an initializer is the failure mode this exists to prevent: the table then only +/// appears wherever someone remembered to migrate it by hand, and every flow that protects a +/// payload (registration, password reset, two-factor) fails with a +/// CryptographicException everywhere else. +/// +/// The context is not tenant-aware, so this targets the root connection on every pass; after the +/// first it is a no-op, which is why running inside the per-tenant loop is harmless. +/// +public sealed partial class DataProtectionKeysDbInitializer : IDbInitializer +{ + private readonly DataProtectionKeysDbContext _context; + private readonly ILogger _logger; + + public DataProtectionKeysDbInitializer( + DataProtectionKeysDbContext context, + ILogger logger) + { + _context = context; + _logger = logger; + } + + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if ((await _context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await _context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + LogMigrated(); + } + } + + public Task SeedAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage(Level = LogLevel.Information, Message = "applied database migrations for the Data Protection key schema")] + private partial void LogMigrated(); +} diff --git a/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs new file mode 100644 index 0000000000..7307288997 --- /dev/null +++ b/src/BuildingBlocks/Persistence/DataProtection/DataProtectionSchema.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; + +namespace FSH.Framework.Persistence.DataProtection; + +/// +/// Creates the Data Protection key table before anything can read the key ring. +/// +/// +/// +/// covers hosts that migrate before they serve — the +/// API and the integration-test harness. It is not enough for the DbMigrator, which builds and +/// STARTS a full host before running its own migration flow: Data Protection resolves its key ring +/// eagerly during that start, queries the table, and logs a failure with a stack trace on every +/// first run against an empty database. Nothing crashes, which is worse than crashing — a key +/// created while the table is missing cannot be persisted, so anything encrypted in that window is +/// undecryptable afterwards. +/// +/// +/// Deliberately standalone: it builds its own context rather than resolving one from the host, +/// because the whole point is to run before the host exists. +/// +/// +public static class DataProtectionSchema +{ + /// Applies pending migrations for the Data Protection key schema. + public static async Task EnsureAsync( + string dbProvider, + string connectionString, + string migrationsAssembly, + bool isDevelopment, + CancellationToken cancellationToken = default) + { + var options = new DbContextOptionsBuilder(); + + // ConfigureHeroDatabase rather than a bare UseNpgsql/UseSqlServer: it is the one call that + // picks the provider, points at that provider's migrations assembly and applies the + // provider-specific options, so this follows DatabaseOptions:Provider like every context. + options.ConfigureHeroDatabase(dbProvider, connectionString, migrationsAssembly, isDevelopment); + + await using var context = new DataProtectionKeysDbContext(options.Options); + + if ((await context.Database.GetPendingMigrationsAsync(cancellationToken).ConfigureAwait(false)).Any()) + { + await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/BuildingBlocks/Persistence/Persistence.csproj b/src/BuildingBlocks/Persistence/Persistence.csproj index a01f48bfc8..66a397cc78 100644 --- a/src/BuildingBlocks/Persistence/Persistence.csproj +++ b/src/BuildingBlocks/Persistence/Persistence.csproj @@ -7,6 +7,7 @@ + diff --git a/src/BuildingBlocks/Web/Extensions.cs b/src/BuildingBlocks/Web/Extensions.cs index 50c6568fda..a43664ba2b 100644 --- a/src/BuildingBlocks/Web/Extensions.cs +++ b/src/BuildingBlocks/Web/Extensions.cs @@ -1,16 +1,17 @@ using FSH.Framework.Caching; +using FSH.Framework.Core.DataProtection; using FSH.Framework.Jobs; using FSH.Framework.Mailing; using FSH.Framework.Persistence; +using FSH.Framework.Persistence.DataProtection; using FSH.Framework.Quota; using FSH.Framework.Shared.Constants; using FSH.Framework.Web.Auth; using FSH.Framework.Web.Cors; using FSH.Framework.Web.Exceptions; using FSH.Framework.Web.FeatureFlags; -using FSH.Framework.Web.Idempotency; -using FSH.Framework.Web.Sse; using FSH.Framework.Web.Health; +using FSH.Framework.Web.Idempotency; using FSH.Framework.Web.Mediator.Behaviors; using FSH.Framework.Web.Modules; using FSH.Framework.Web.Observability.Logging.Serilog; @@ -20,15 +21,19 @@ using FSH.Framework.Web.RateLimiting; using FSH.Framework.Web.Realtime; using FSH.Framework.Web.Security; +using FSH.Framework.Web.Sse; using FSH.Framework.Web.Versioning; +using Mediator; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; -using Mediator; namespace FSH.Framework.Web; @@ -104,6 +109,27 @@ public static IHostApplicationBuilder AddHeroPlatform(this IHostApplicationBuild } } + // Data Protection keys in the database rather than Redis. Wired here because this is the + // only place that sees both Caching (which owns the Redis store) and Persistence (which + // owns the context); Caching skips its own wiring when this store is selected. + // + // Worth choosing when the hosts that protect and unprotect data do not reliably share a + // Redis instance - the DbMigrator is normally run standalone, outside the AppHost wiring + // that injects a Redis connection string - or when Redis is a cache with eviction, where + // losing a key evicts every session and pending reset token with it. + if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey])) + { + builder.Services.AddHeroDbContext(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + + builder.Services.AddDataProtection() + .PersistKeysToDbContext() + .SetApplicationName( + DataProtectionApplicationName.Resolve( + builder.Configuration[DataProtectionApplicationName.ConfigurationKey])); + } + if (options.EnableFeatureFlags) { builder.Services.AddHeroFeatureFlags(builder.Configuration); diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0d38b28190..1b7696005c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -79,6 +79,7 @@ + diff --git a/src/Host/FSH.Starter.Api/appsettings.json b/src/Host/FSH.Starter.Api/appsettings.json index 293fdfebb6..eb80e9e9d7 100644 --- a/src/Host/FSH.Starter.Api/appsettings.json +++ b/src/Host/FSH.Starter.Api/appsettings.json @@ -64,6 +64,13 @@ "OriginOptions": { "OriginUrl": "https://localhost:7030" }, + "DataProtection": { + // "Redis" (default) or "Database". Choose Database when the hosts that protect and unprotect + // data do not reliably share a Redis instance - the DbMigrator is normally run standalone, + // outside the AppHost wiring that injects a Redis connection string - or when Redis is a cache + // with eviction, where losing a key takes every session and pending reset token with it. + "Store": "Redis" + }, "CachingOptions": { "Redis": "" }, diff --git a/src/Host/FSH.Starter.DbMigrator/Program.cs b/src/Host/FSH.Starter.DbMigrator/Program.cs index dbdd12f345..2207d229bb 100644 --- a/src/Host/FSH.Starter.DbMigrator/Program.cs +++ b/src/Host/FSH.Starter.DbMigrator/Program.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Reflection; +using FSH.Framework.Core.DataProtection; using FSH.Framework.Eventing; +using FSH.Framework.Persistence.DataProtection; using FSH.Framework.Shared.Multitenancy; using FSH.Framework.Web; using FSH.Framework.Web.Modules; @@ -161,6 +163,36 @@ await Console.Error.WriteLineAsync( // the DI graph is satisfied; the verb dispatch below decides whether to call it. builder.Services.AddScoped(); +// The Data Protection key table has to exist BEFORE the host starts, when keys live in the +// database. Data Protection resolves its key ring eagerly during StartAsync, well before this +// file's own Step 0/1/2 flow runs, so leaving it to the IDbInitializer means the first run against +// an empty database logs a query failure with a stack trace - and, worse, does not fail: a key +// created while the table is missing cannot be persisted, so anything encrypted in that window is +// undecryptable afterwards. The initializer still covers the API and the test harness, which +// migrate before they serve. +// +// This waits for the database itself, because Step 0's wait is also after StartAsync. +if (DataProtectionStores.UsesDatabase(builder.Configuration[DataProtectionStores.ConfigurationKey])) +{ + using var bootstrapLoggerFactory = LoggerFactory.Create(b => b.AddConsole()); + var bootstrapLogger = bootstrapLoggerFactory.CreateLogger("DataProtectionSchema"); + + var bootstrapProvider = builder.Configuration["DatabaseOptions:Provider"] ?? DbProviders.PostgreSQL; + var bootstrapConnectionString = builder.Configuration["DatabaseOptions:ConnectionString"] + ?? throw new InvalidOperationException("DatabaseOptions:ConnectionString is not configured."); + + await MigratorLockFactory.Create(bootstrapProvider) + .WaitForDatabaseAsync(bootstrapConnectionString, bootstrapLogger, CancellationToken.None) + .ConfigureAwait(false); + + await DataProtectionSchema.EnsureAsync( + bootstrapProvider, + bootstrapConnectionString, + builder.Configuration["DatabaseOptions:MigrationsAssembly"] + ?? throw new InvalidOperationException("DatabaseOptions:MigrationsAssembly is not configured."), + builder.Environment.IsDevelopment()).ConfigureAwait(false); +} + using var host = builder.Build(); var logger = host.Services.GetRequiredService>(); diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs new file mode 100644 index 0000000000..44424ef66a --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.Designer.cs @@ -0,0 +1,48 @@ +// +using FSH.Framework.Persistence.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.DataProtection +{ + [DbContext(typeof(DataProtectionKeysDbContext))] + [Migration("20260909230643_InitialDataProtection")] + partial class InitialDataProtection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs new file mode 100644 index 0000000000..cf2e6d8ea8 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/20260909230643_InitialDataProtection.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.DataProtection +{ + /// + public partial class InitialDataProtection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + FriendlyName = table.Column(type: "nvarchar(max)", nullable: true), + Xml = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs new file mode 100644 index 0000000000..d9dae2c160 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.MSSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using FSH.Framework.Persistence.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FSH.Starter.Migrations.MSSQL.DataProtection +{ + [DbContext(typeof(DataProtectionKeysDbContext))] + partial class DataProtectionKeysDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs new file mode 100644 index 0000000000..c8b3a1ef36 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.Designer.cs @@ -0,0 +1,48 @@ +// +using FSH.Framework.Persistence.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.DataProtection +{ + [DbContext(typeof(DataProtectionKeysDbContext))] + [Migration("20260909230633_InitialDataProtection")] + partial class InitialDataProtection + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs new file mode 100644 index 0000000000..d24e27c226 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/20260909230633_InitialDataProtection.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.DataProtection +{ + /// + public partial class InitialDataProtection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DataProtectionKeys", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FriendlyName = table.Column(type: "text", nullable: true), + Xml = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DataProtectionKeys"); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs new file mode 100644 index 0000000000..4e72f3134e --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/DataProtection/DataProtectionKeysDbContextModelSnapshot.cs @@ -0,0 +1,45 @@ +// +using FSH.Framework.Persistence.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.DataProtection +{ + [DbContext(typeof(DataProtectionKeysDbContext))] + partial class DataProtectionKeysDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs index ab8cfe3c65..166f47c977 100644 --- a/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs +++ b/src/Tests/Integration.Tests/Infrastructure/FshWebApplicationFactory.cs @@ -117,6 +117,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["DatabaseOptions:ConnectionString"] = _postgres.GetConnectionString(), ["DatabaseOptions:MigrationsAssembly"] = "FSH.Starter.Migrations.PostgreSQL", ["CachingOptions:Redis"] = "", + // The suite runs without Redis, so the framework's default key ring would be + // ephemeral and in-process - neither store exercised. Pointing Data Protection at + // the database instead means every test that registers a user, resets a password + // or enrols in two-factor also proves the key table is actually migrated and + // writable. + ["DataProtection:Store"] = "Database", ["JwtOptions:Issuer"] = TestConstants.JwtIssuer, ["JwtOptions:Audience"] = TestConstants.JwtAudience, ["JwtOptions:SigningKey"] = TestConstants.JwtSigningKey,