fix(configuration-database): resolve ConfigurationDatabase-013,014 — fail-fast on missing key ring, single converter local; ConfigurationDatabase-012 left open (cross-module design decision)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Entities.Deployment;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
@@ -85,6 +87,19 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
base.OnConfiguring(optionsBuilder);
|
||||
|
||||
// The secret-column converter built in OnModelCreating differs depending on whether
|
||||
// a real Data Protection provider was supplied (encrypting converter) or not
|
||||
// (schema-only converter). EF Core's default model cache keys only on context type,
|
||||
// so a provider-bearing and a schema-only context sharing the same options type
|
||||
// would otherwise share one cached model — and whichever was built first would win.
|
||||
// Distinguish them so each gets its own model.
|
||||
optionsBuilder.ReplaceService<IModelCacheKeyFactory, SecretAwareModelCacheKeyFactory>();
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);
|
||||
@@ -92,23 +107,106 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
ApplySecretColumnEncryption(modelBuilder);
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
{
|
||||
GuardSecretWritesHaveAKeyRing();
|
||||
return base.SaveChanges(acceptAllChangesOnSuccess);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(
|
||||
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
||||
{
|
||||
GuardSecretWritesHaveAKeyRing();
|
||||
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>True when this context was constructed with a real Data Protection provider.</summary>
|
||||
internal bool HasSecretEncryptionProvider => _dataProtectionProvider is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Fails fast — before any database round-trip — if a context built without a real
|
||||
/// Data Protection key ring (the schema-only single-argument constructor) is about to
|
||||
/// persist a non-null secret-bearing column. Without this guard the schema-only
|
||||
/// protector would still throw, but only deep inside EF's update pipeline wrapped in a
|
||||
/// <c>DbUpdateException</c>/<c>CryptographicException</c>; surfacing a clear
|
||||
/// <see cref="InvalidOperationException"/> here makes the misconfiguration obvious.
|
||||
/// </summary>
|
||||
private void GuardSecretWritesHaveAKeyRing()
|
||||
{
|
||||
if (_dataProtectionProvider is not null)
|
||||
return;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State is not (EntityState.Added or EntityState.Modified))
|
||||
continue;
|
||||
|
||||
string? secretProperty = entry.Entity switch
|
||||
{
|
||||
SmtpConfiguration => nameof(SmtpConfiguration.Credentials),
|
||||
ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
|
||||
DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
|
||||
_ => null
|
||||
};
|
||||
if (secretProperty is null)
|
||||
continue;
|
||||
|
||||
if (entry.Property(secretProperty).CurrentValue is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"This ScadaLinkDbContext was constructed without a Data Protection key " +
|
||||
"ring (the single-argument, schema-only constructor). It cannot persist " +
|
||||
$"the secret-bearing column '{entry.Entity.GetType().Name}.{secretProperty}'. " +
|
||||
"Construct the context with the DI-registered IDataProtectionProvider " +
|
||||
"(AddConfigurationDatabase wires this up).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Model cache key factory that folds <see cref="HasSecretEncryptionProvider"/> into the
|
||||
/// cache key, so a write-capable (provider-bearing) context and a schema-only context do
|
||||
/// not share a cached model.
|
||||
/// </summary>
|
||||
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
|
||||
{
|
||||
public object Create(DbContext context, bool designTime)
|
||||
=> (context.GetType(),
|
||||
designTime,
|
||||
(context as ScadaLinkDbContext)?.HasSecretEncryptionProvider ?? false);
|
||||
|
||||
public object Create(DbContext context) => Create(context, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies encryption-at-rest to columns that hold authentication secrets
|
||||
/// (SMTP credentials, external-system auth config, database connection strings)
|
||||
/// so they are never persisted as plaintext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When no Data Protection provider is supplied (design-time <c>dotnet ef</c> tooling,
|
||||
/// which only emits schema and never reads or writes secret data), an ephemeral provider
|
||||
/// is used. The encrypted-column type is <c>nvarchar</c> either way, so the generated
|
||||
/// schema is identical regardless of which provider is in effect. The runtime path always
|
||||
/// receives the DI-registered provider whose keys are persisted to this database.
|
||||
/// When no Data Protection provider is supplied — design-time <c>dotnet ef</c> tooling,
|
||||
/// which only emits schema and never reads or writes secret data — a schema-only
|
||||
/// protector is used. It produces an identical <c>nvarchar</c> schema, but throws a
|
||||
/// clear <see cref="InvalidOperationException"/> if a secret column is ever read or
|
||||
/// written through it. This deliberately does NOT silently substitute an ephemeral
|
||||
/// (in-memory, process-lifetime) key: encrypting a runtime write with a throwaway key
|
||||
/// would persist ciphertext that becomes permanently undecryptable on the next process
|
||||
/// restart, with no error. A write-capable context must be constructed with the
|
||||
/// DI-registered provider whose keys are persisted to this database.
|
||||
/// </remarks>
|
||||
private void ApplySecretColumnEncryption(ModelBuilder modelBuilder)
|
||||
{
|
||||
IDataProtectionProvider provider = _dataProtectionProvider ?? new EphemeralDataProtectionProvider();
|
||||
var converter = new EncryptedStringConverter(
|
||||
provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose));
|
||||
IDataProtector protector = _dataProtectionProvider is { } provider
|
||||
? provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose)
|
||||
: SchemaOnlyDataProtector.Instance;
|
||||
|
||||
// Held as the non-generic ValueConverter base so all three HasConversion calls
|
||||
// read identically. The converter's CLR type is string?->string?; binding it to
|
||||
// the non-nullable DatabaseConnectionDefinition.ConnectionString property would
|
||||
// otherwise raise a CS8620 nullability mismatch — the non-generic reference is
|
||||
// the supported way to apply one converter uniformly across nullable and
|
||||
// non-nullable string columns.
|
||||
ValueConverter converter = new EncryptedStringConverter(protector);
|
||||
|
||||
modelBuilder.Entity<SmtpConfiguration>()
|
||||
.Property(s => s.Credentials)
|
||||
@@ -120,6 +218,29 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
|
||||
modelBuilder.Entity<DatabaseConnectionDefinition>()
|
||||
.Property(d => d.ConnectionString)
|
||||
.HasConversion((Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter)converter);
|
||||
.HasConversion(converter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IDataProtector"/> for contexts built without a real Data Protection
|
||||
/// provider (design-time / schema-only). It satisfies model building but fails fast
|
||||
/// with a clear message if a secret column is actually read or written, rather than
|
||||
/// silently producing throwaway ciphertext that cannot be decrypted after a restart.
|
||||
/// </summary>
|
||||
private sealed class SchemaOnlyDataProtector : IDataProtector
|
||||
{
|
||||
internal static readonly SchemaOnlyDataProtector Instance = new();
|
||||
|
||||
private const string Message =
|
||||
"This ScadaLinkDbContext was constructed without a Data Protection key ring " +
|
||||
"(the single-argument, schema-only constructor). Secret-bearing configuration " +
|
||||
"columns cannot be read or written through it. Construct the context with the " +
|
||||
"DI-registered IDataProtectionProvider (AddConfigurationDatabase wires this up).";
|
||||
|
||||
public IDataProtector CreateProtector(string purpose) => this;
|
||||
|
||||
public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);
|
||||
|
||||
public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user