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,