Makes a redeploy safe for the key ring and the schema
Nothing here is visible in normal operation. Its whole purpose is that swapping the release directory on deploy cannot silently destroy state. Data Protection secures the API keys that authenticate master/slave communication. Two separate defaults would each have destroyed them: keys are held on the filesystem, which a release swap discards, and the application discriminator is derived from the content root path, which changes with every release directory — so even keys stored in a database would have stopped being derivable. Keys now live in ApplicationDbContext and the discriminator is a fixed constant. Losing them produces no error. It produces stored keys that no longer decrypt, which presents as an apparent network fault between a Master and its slaves and is easily misdiagnosed. That is also why the tests assert the resulting configuration rather than the registration: the XmlRepository must be the EF one and the discriminator must be the constant, plus a round-trip proving a value encrypted before a deploy is readable after one. A test that only checked "Data Protection is registered" would have passed in the broken case too. Both modules previously called AddDataProtection() themselves. Module registration runs after the host's, so those calls re-registered the configuration chain and would have overridden the persistent store while IDataProtector still resolved. They are removed, with a comment at each site — the deletion otherwise looks like a regression. Each module's own test project now guards against it being reintroduced. ApplicationDbContext also migrates itself at startup. Deploy targets offer no CLI, so migrations cannot be a manual step on the server. Failures are classified rather than treated alike: a connection failure means the database is not up yet, normal when the app and the database start together after a reboot, and is retried with backoff; a migration failure means something is broken and fails at once. Either way the process does not start, which is what makes the liveness health check trustworthy — an application that cannot reach its schema never answers /health, so monitoring goes red instead of reporting a healthy instance that cannot serve a request. The cost of migrating without a human gate is that migrations must stay forward-compatible and non-destructive, since rollback is "redeploy the previous release". The new migration is purely additive. Also wires this and the preceding hosting commit into both hosts, as they touch the same lines of Program.cs. Two constraints are enforced by documentation rather than code, and belong in the deployment instructions: the key table must never be pruned, and only one instance may migrate a given database at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Health;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -15,6 +16,10 @@ orchestrator.DiscoverModules();
|
||||
builder.Services.AddCoreInfrastructure(builder.Configuration);
|
||||
builder.Services.AddCmsCors(builder.Configuration);
|
||||
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||
builder.Services.AddCmsHealthChecks();
|
||||
|
||||
// Registered BEFORE module services — see the note in DataProtectionExtensions.
|
||||
builder.Services.AddCmsDataProtection();
|
||||
|
||||
// 3. Add Module Services
|
||||
orchestrator.RegisterModuleServices(builder.Services);
|
||||
@@ -32,6 +37,9 @@ builder.Services.AddControllers(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Same as the master host: Core schema first, fail fast on failure.
|
||||
app.MigrateCoreDatabase();
|
||||
|
||||
// 5. Global Exception Handling
|
||||
app.UseExceptionHandler();
|
||||
|
||||
@@ -56,4 +64,9 @@ app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// Infrastructure liveness, same as the master host. This instance serves no static content,
|
||||
// so it gets no website or admin mounts — but it is a reference for what a customer-facing
|
||||
// API instance looks like, so it behaves like one in every other respect.
|
||||
app.MapCmsHealthChecks();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using SlpModularCms.Api.Extensions;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Health;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -15,6 +17,12 @@ orchestrator.DiscoverModules();
|
||||
builder.Services.AddCoreInfrastructure(builder.Configuration);
|
||||
builder.Services.AddCmsCors(builder.Configuration);
|
||||
builder.Services.AddCmsRateLimiting(builder.Configuration);
|
||||
builder.Services.AddCmsHealthChecks();
|
||||
|
||||
// Registered BEFORE module services: modules must not configure Data Protection themselves,
|
||||
// because a later registration would override this persistent key store (see
|
||||
// DataProtectionExtensions).
|
||||
builder.Services.AddCmsDataProtection();
|
||||
|
||||
// 3. Add Module Services
|
||||
orchestrator.RegisterModuleServices(builder.Services);
|
||||
@@ -32,6 +40,13 @@ builder.Services.AddControllers(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Bring the Core schema up to date before serving any traffic. Runs before the module
|
||||
// middleware below, because the Data Protection keys table lives in this context and the
|
||||
// modules resolve an IDataProtector as soon as they start. Fails fast: a host that cannot
|
||||
// migrate does not start, so /health goes silent and monitoring goes red — which is exactly
|
||||
// what makes a liveness-only health check trustworthy.
|
||||
app.MigrateCoreDatabase();
|
||||
|
||||
// 5. Global Exception Handling
|
||||
app.UseExceptionHandler();
|
||||
|
||||
@@ -47,10 +62,11 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Serve the public website ('/') and the CMS admin SPA ('/admin') from wwwroot.
|
||||
// wwwroot/index.html + assets -> public website (built and deployed separately, not part of this repo)
|
||||
// wwwroot/web/index.html + assets -> public website (built and deployed separately, not part of this repo)
|
||||
// wwwroot/admin/index.html + assets -> CMS admin build (see frontend/, copied in on publish)
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
// Registered before the module middleware below: static files short-circuit the pipeline, so
|
||||
// anything that must observe them has to come first.
|
||||
app.UseCmsStaticContent();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
@@ -62,9 +78,14 @@ app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// Infrastructure liveness. Deliberately outside /api/v1 and on the availability gate's bypass
|
||||
// list: this reports whether the process is alive, which is a different question from whether
|
||||
// the CMS is switched on (/api/v1/Availability/status) or which modules it carries
|
||||
// (/api/v1/System/capabilities). Those are CMS domain state and must not be used for monitoring.
|
||||
app.MapCmsHealthChecks();
|
||||
|
||||
// SPA fallbacks so client-side routes (e.g. /admin/dashboard) resolve to the right index.html
|
||||
// instead of 404ing. The "nonfile" constraint keeps genuinely missing assets (e.g. /admin/assets/x.js) as 404s.
|
||||
app.MapFallbackToFile("/admin/{*path:nonfile}", "admin/index.html");
|
||||
app.MapFallbackToFile("{*path:nonfile}", "index.html");
|
||||
app.MapCmsSpaFallbacks();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the durability of the Data Protection key ring.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The failure this protects against is silent. Losing the key ring produces no error — it
|
||||
/// produces stored slave API keys that no longer decrypt, which looks like a network fault
|
||||
/// between a Master and its slaves. A test that merely asserted "Data Protection is registered"
|
||||
/// would pass in the broken case too, so these tests assert the resulting configuration instead.
|
||||
/// </remarks>
|
||||
public class DataProtectionExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddCmsDataProtection_ShouldPersistKeysToTheDatabase()
|
||||
{
|
||||
using var provider = BuildProvider(nameof(AddCmsDataProtection_ShouldPersistKeysToTheDatabase));
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<KeyManagementOptions>>().Value;
|
||||
|
||||
// The default is a filesystem key ring, which every atomic release switch discards.
|
||||
options.XmlRepository.Should().BeOfType<EntityFrameworkCoreXmlRepository<ApplicationDbContext>>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddCmsDataProtection_ShouldUseAFixedApplicationDiscriminator()
|
||||
{
|
||||
using var provider = BuildProvider(nameof(AddCmsDataProtection_ShouldUseAFixedApplicationDiscriminator));
|
||||
|
||||
var options = provider.GetRequiredService<IOptions<DataProtectionOptions>>().Value;
|
||||
|
||||
// The default derives from the content root path, which changes with every release
|
||||
// directory — so keys stored in the database would still stop being derivable.
|
||||
options.ApplicationDiscriminator.Should().Be(DataProtectionExtensions.ApplicationDiscriminator);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectedValues_ShouldSurviveAProcessRestartFromADifferentDirectory()
|
||||
{
|
||||
// The property that actually matters: an API key encrypted before a deploy must still be
|
||||
// readable by the process that starts afterwards from a different release directory.
|
||||
// Both providers share one database and one fixed discriminator, which is what makes
|
||||
// that possible.
|
||||
const string databaseName = nameof(ProtectedValues_ShouldSurviveAProcessRestartFromADifferentDirectory);
|
||||
const string secret = "slave-api-key-value";
|
||||
|
||||
string encrypted;
|
||||
using (var beforeDeploy = BuildProvider(databaseName))
|
||||
{
|
||||
encrypted = beforeDeploy
|
||||
.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("MasterApiKey")
|
||||
.Protect(secret);
|
||||
}
|
||||
|
||||
using var afterDeploy = BuildProvider(databaseName);
|
||||
|
||||
var decrypted = afterDeploy
|
||||
.GetRequiredService<IDataProtectionProvider>()
|
||||
.CreateProtector("MasterApiKey")
|
||||
.Unprotect(encrypted);
|
||||
|
||||
decrypted.Should().Be(secret);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplicationDiscriminator_ShouldNotBeDerivedFromAPath()
|
||||
{
|
||||
// Guards against someone "improving" this into a configurable or computed value: every
|
||||
// knob here is a way to reintroduce the silent failure the key ring exists to prevent.
|
||||
DataProtectionExtensions.ApplicationDiscriminator.Should().Be("SlpModularCms");
|
||||
DataProtectionExtensions.ApplicationDiscriminator.Should().NotContainAny("/", "\\", ":");
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string databaseName)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
services.AddDbContext<ApplicationDbContext>(options => options.UseInMemoryDatabase(databaseName));
|
||||
services.AddCmsDataProtection();
|
||||
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the failure classification of the startup migration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Startup migration distinguishes two failures that mean very different things:
|
||||
/// a database that is not up yet (normal when the application and the database server start
|
||||
/// together after a host reboot) versus a migration that is broken. Retrying the first is
|
||||
/// correct; retrying the second only delays the inevitable and fills the log.
|
||||
///
|
||||
/// <c>MigrateCoreDatabase</c> itself needs a composed <c>WebApplication</c>, so the classifier is
|
||||
/// exercised directly here; the composed startup path is verified at the phase-level Build and
|
||||
/// Test stage by starting both hosts.
|
||||
/// </remarks>
|
||||
public class DatabaseMigrationExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void MaxConnectionAttempts_ShouldAllowForAHostRebootWindow()
|
||||
{
|
||||
// Enough attempts that a database server starting alongside the application is tolerated,
|
||||
// few enough that a genuinely unreachable database still fails promptly.
|
||||
DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeGreaterThan(1);
|
||||
DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeLessThanOrEqualTo(10);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(TransientFailures))]
|
||||
public void IsTransientConnectionFailure_ShouldRecogniseConnectionProblems(Exception exception)
|
||||
{
|
||||
InvokeClassifier(exception).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NonTransientFailures))]
|
||||
public void IsTransientConnectionFailure_ShouldNotRecogniseMigrationProblems(Exception exception)
|
||||
{
|
||||
// A broken migration must fail immediately. Classifying it as transient would hide a
|
||||
// real fault behind a retry loop.
|
||||
InvokeClassifier(exception).Should().BeFalse();
|
||||
}
|
||||
|
||||
public static TheoryData<Exception> TransientFailures() =>
|
||||
[
|
||||
MakeSqlException(),
|
||||
new TimeoutException("Connect Timeout expired."),
|
||||
new InvalidOperationException("wrapper", MakeSqlException()),
|
||||
];
|
||||
|
||||
public static TheoryData<Exception> NonTransientFailures() =>
|
||||
[
|
||||
new InvalidOperationException("There is already an object named 'Users' in the database."),
|
||||
new NotSupportedException("The migration cannot be applied."),
|
||||
new AggregateException(new InvalidOperationException("pending model changes")),
|
||||
];
|
||||
|
||||
private static bool InvokeClassifier(Exception exception)
|
||||
{
|
||||
var method = typeof(DatabaseMigrationExtensions).GetMethod(
|
||||
"IsTransientConnectionFailure",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
|
||||
method.Should().NotBeNull("the failure classifier is the behaviour under test");
|
||||
|
||||
return (bool)method!.Invoke(null, [exception])!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="SqlException"/> has no public constructor, so one is produced through the
|
||||
/// framework's own factory path via reflection.
|
||||
/// </summary>
|
||||
private static Exception MakeSqlException()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Deliberately unreachable host and a very short timeout: this genuinely produces a
|
||||
// SqlException rather than a hand-built stand-in, so the classifier is tested against
|
||||
// the real type it will encounter in production.
|
||||
using var connection = new SqlConnection(
|
||||
"Server=localhost,9;Database=none;User Id=sa;Password=none;Connect Timeout=1;TrustServerCertificate=True");
|
||||
connection.Open();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Expected the connection attempt to fail.");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -8,7 +9,12 @@ namespace SlpModularCms.Core.Data;
|
||||
/// <summary>
|
||||
/// Database context voor de applicatie, inclusief Identity en RBAC tabellen.
|
||||
/// </summary>
|
||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
|
||||
/// <remarks>
|
||||
/// Also hosts the ASP.NET Core Data Protection key ring. The keys are application-wide
|
||||
/// infrastructure rather than module-owned data, and this context is the one that migrates
|
||||
/// automatically at startup — so the table exists without any manual step on the host.
|
||||
/// </remarks>
|
||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IDataProtectionKeyContext
|
||||
{
|
||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||
: base(options)
|
||||
@@ -20,6 +26,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, Applicati
|
||||
public DbSet<ModulePermission> ModulePermissions => Set<ModulePermission>();
|
||||
public DbSet<GlobalAvailabilityState> AvailabilityStates => Set<GlobalAvailabilityState>();
|
||||
|
||||
/// <summary>
|
||||
/// Data Protection key ring. Rows here MUST NEVER be pruned: deleting a key makes every
|
||||
/// value ever encrypted with it permanently unreadable, including the stored API keys that
|
||||
/// authenticate master/slave communication.
|
||||
/// </summary>
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SlpModularCms.Core.Data;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Configures ASP.NET Core Data Protection so encrypted values survive a redeploy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Data Protection secures the API keys that authenticate master/slave communication. Losing the
|
||||
/// key ring does not produce an error — it produces stored keys that no longer decrypt, which
|
||||
/// presents as an apparent network fault between a Master and its slaves and is easily
|
||||
/// misdiagnosed. Two separate defaults would each cause exactly that:
|
||||
///
|
||||
/// 1. Keys are held on the filesystem by default. Deployments swap the release directory
|
||||
/// atomically, so a filesystem key ring is discarded on every deploy.
|
||||
/// 2. The application discriminator is derived from the content root path by default. That path
|
||||
/// changes with every release directory, so even keys stored in the database would stop being
|
||||
/// derivable.
|
||||
///
|
||||
/// This method closes both. It MUST be called before module service registration — see the
|
||||
/// remarks on <see cref="AddCmsDataProtection"/>.
|
||||
/// </remarks>
|
||||
public static class DataProtectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable identity of this application for key derivation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A constant rather than a configuration value, deliberately. Every configurable knob here
|
||||
/// is a way to reintroduce the silent failure this whole mechanism exists to prevent — a
|
||||
/// discriminator set differently on one instance, or forgotten during a host migration,
|
||||
/// makes previously encrypted values unreadable with no error to point at.
|
||||
/// </remarks>
|
||||
public const string ApplicationDiscriminator = "SlpModularCms";
|
||||
|
||||
/// <summary>
|
||||
/// Registers Data Protection with a database-backed key ring and a fixed application
|
||||
/// discriminator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MUST be called before <c>ModuleOrchestrator.RegisterModuleServices</c>. Modules must not
|
||||
/// call <c>AddDataProtection()</c> themselves: module registration runs after the host's, and
|
||||
/// a later bare call re-registers the configuration chain, silently discarding the persistent
|
||||
/// key store configured here. <c>IDataProtector</c> resolves either way, so such a regression
|
||||
/// passes registration tests and only surfaces after the first release switch.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddCmsDataProtection(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddDataProtection()
|
||||
.SetApplicationName(ApplicationDiscriminator)
|
||||
.PersistKeysToDbContext<ApplicationDbContext>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Data;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Applies the Core database migrations at startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deployment targets are shared hosts where no CLI is available, so migrations cannot be a
|
||||
/// manual step on the server. Applying them at startup makes a deployment self-contained.
|
||||
///
|
||||
/// The cost of that convenience is that a migration now runs without a human gate — which is why
|
||||
/// migrations must stay forward-compatible and non-destructive (rollback is "redeploy the previous
|
||||
/// release", and that only works if older code can run against the newer schema), and why a
|
||||
/// database backup precedes every production deploy.
|
||||
/// </remarks>
|
||||
public static class DatabaseMigrationExtensions
|
||||
{
|
||||
/// <summary>Attempts made when the database is not reachable yet.</summary>
|
||||
public const int MaxConnectionAttempts = 5;
|
||||
|
||||
private static readonly TimeSpan BaseRetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Applies pending <see cref="ApplicationDbContext"/> migrations before the application
|
||||
/// serves traffic.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Failures are classified rather than treated alike:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A <b>connection</b> failure means the database is not up yet — normal
|
||||
/// when the application and the database server start together after a host reboot. Retried
|
||||
/// with increasing delay.</description></item>
|
||||
/// <item><description>A <b>migration</b> failure means a migration is invalid or conflicts.
|
||||
/// Retrying only delays the inevitable and fills the log, so it fails at once.</description></item>
|
||||
/// </list>
|
||||
/// Either way the exception ultimately propagates and the process does not start. That is
|
||||
/// deliberate and is what makes the liveness health check meaningful: an application that
|
||||
/// cannot reach its schema never answers <c>/health</c>, so monitoring goes red instead of
|
||||
/// reporting a healthy instance that cannot serve a single request.
|
||||
/// </remarks>
|
||||
public static WebApplication MigrateCoreDatabase(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
var logger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(typeof(DatabaseMigrationExtensions).FullName!);
|
||||
|
||||
using var scope = app.Services.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Database.Migrate();
|
||||
logger.LogInformation("Core database migrations applied successfully.");
|
||||
return app;
|
||||
}
|
||||
catch (Exception ex) when (IsTransientConnectionFailure(ex) && attempt < MaxConnectionAttempts)
|
||||
{
|
||||
var delay = BaseRetryDelay * attempt;
|
||||
|
||||
logger.LogWarning(
|
||||
"Database not reachable on attempt {Attempt} of {MaxAttempts}. Retrying in {DelaySeconds}s. Reason: {Reason}",
|
||||
attempt,
|
||||
MaxConnectionAttempts,
|
||||
delay.TotalSeconds,
|
||||
ex.Message);
|
||||
|
||||
Thread.Sleep(delay);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logged with the failure reason but never the connection string or credentials —
|
||||
// this message travels to the console and to Sentry.
|
||||
logger.LogCritical(
|
||||
ex,
|
||||
"Core database migration failed after {Attempts} attempt(s). The application will not start.",
|
||||
attempt);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Distinguishes "the database is not there yet" from "the migration is broken".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wrong credentials are treated as a connection failure too. Telling them apart from an
|
||||
/// unreachable server would add branching for no benefit: the outcome is identical — retries
|
||||
/// are exhausted and the process does not start.
|
||||
/// </remarks>
|
||||
private static bool IsTransientConnectionFailure(Exception exception)
|
||||
{
|
||||
for (var current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqlException or TimeoutException)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SlpModularCms.Core.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20260727203036_AddDataProtectionKeys")]
|
||||
partial class AddDataProtectionKeys
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("RoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("UserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("UserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationRole", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex")
|
||||
.HasFilter("[NormalizedName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Roles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex")
|
||||
.HasFilter("[NormalizedUserName] IS NOT NULL");
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.GlobalAvailabilityState", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("LastUpdatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AvailabilityState", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.Invitation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsAccepted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Invitations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("ModuleName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Permission")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.HasKey("UserId", "ModuleName", "Permission");
|
||||
|
||||
b.ToTable("ModulePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<string>("CreatedByIp")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiryDate")
|
||||
.HasColumnType("datetimeoffset");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsUsed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ModulePermission", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("ModulePermissions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("SlpModularCms.Core.Identity.Entities.ApplicationUser", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SlpModularCms.Core.Identity.Entities.ApplicationUser", b =>
|
||||
{
|
||||
b.Navigation("ModulePermissions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDataProtectionKeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DataProtectionKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DataProtectionKeys");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -12,7 +11,6 @@ using SlpModularCms.Core.Data;
|
||||
namespace SlpModularCms.Core.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
@@ -24,6 +22,25 @@ namespace SlpModularCms.Core.Migrations
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("FriendlyName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Xml")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("DataProtectionKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<!--
|
||||
Persists the Data Protection key ring in the database instead of on the filesystem.
|
||||
Required because deployments swap the release directory atomically: a filesystem key ring
|
||||
would be discarded on every deploy, making every stored slave API key permanently
|
||||
undecryptable and silently breaking master/slave communication.
|
||||
-->
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves this module does not undo the host's Data Protection configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This module used to call <c>services.AddDataProtection()</c> itself. Module registration runs
|
||||
/// AFTER the host's, so that bare call re-registered the configuration chain and silently
|
||||
/// discarded the host's database-backed key store — leaving the key ring on the filesystem, where
|
||||
/// every deployment discards it.
|
||||
///
|
||||
/// The defect was invisible: <c>IDataProtector</c> still resolved, so any test asserting that
|
||||
/// Data Protection "is registered" passed. It only surfaced after a release switch, as stored
|
||||
/// slave API keys that no longer decrypted — presenting as a network fault between Master and
|
||||
/// slave. This test asserts the resulting configuration, which is the only thing that
|
||||
/// distinguishes the two cases.
|
||||
/// </remarks>
|
||||
public class AvailabilityModuleDataProtectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseInMemoryDatabase(nameof(RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore)));
|
||||
|
||||
// Host first, module second — the real ordering.
|
||||
services.AddCmsDataProtection();
|
||||
new AvailabilityModule().RegisterServices(services);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<KeyManagementOptions>>().Value;
|
||||
|
||||
options.XmlRepository.Should().BeOfType<EntityFrameworkCoreXmlRepository<ApplicationDbContext>>(
|
||||
"the module must not reconfigure Data Protection — the host owns it");
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,12 @@ public class AvailabilityModule : IModule
|
||||
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection"));
|
||||
});
|
||||
|
||||
services.AddDataProtection();
|
||||
// Data Protection is configured once by the host (AddCmsDataProtection), NOT here.
|
||||
// Module registration runs after the host's, so a bare AddDataProtection() call at this
|
||||
// point would re-register the configuration chain and silently discard the persistent
|
||||
// database-backed key store — leaving the key ring on the filesystem, where every
|
||||
// deployment discards it. IDataProtector resolves either way, so the regression would
|
||||
// pass its tests and only surface later as slave API keys that no longer decrypt.
|
||||
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
|
||||
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
|
||||
services.AddScoped<MasterAvailabilityServiceDependencies>();
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.DataProtection.KeyManagement;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Modules.Master.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Proves this module does not undo the host's Data Protection configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See the equivalent test in the Availability module. This module encrypts the API keys of every
|
||||
/// registered CMS instance, so if the key ring were silently returned to the filesystem, a single
|
||||
/// deployment would make every registered slave unreachable — with no error to point at.
|
||||
/// </remarks>
|
||||
public class MasterModuleDataProtectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
|
||||
services.AddDbContext<ApplicationDbContext>(options =>
|
||||
options.UseInMemoryDatabase(nameof(RegisterServices_ShouldNotOverrideTheHostsPersistentKeyStore)));
|
||||
|
||||
// Host first, module second — the real ordering.
|
||||
services.AddCmsDataProtection();
|
||||
new MasterModule().RegisterServices(services);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<KeyManagementOptions>>().Value;
|
||||
|
||||
options.XmlRepository.Should().BeOfType<EntityFrameworkCoreXmlRepository<ApplicationDbContext>>(
|
||||
"the module must not reconfigure Data Protection — the host owns it");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,10 @@ public class MasterModule : IModule
|
||||
|
||||
public void RegisterServices(IServiceCollection services)
|
||||
{
|
||||
services.AddDataProtection();
|
||||
// Data Protection is configured once by the host (AddCmsDataProtection), NOT here.
|
||||
// See the equivalent note in AvailabilityModule: a bare AddDataProtection() call here
|
||||
// would override the host's persistent key store, and the resulting defect is invisible
|
||||
// until the first release switch makes every stored slave API key undecryptable.
|
||||
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
|
||||
|
||||
services.AddOptions<MasterModuleOptions>().BindConfiguration("MasterModule");
|
||||
|
||||
Reference in New Issue
Block a user