a4806576af
Owner decision (b), 2026-11-09. PendingUpdatesEvaluator re-flattened every deployed instance on each cache-TTL expiry even when nothing had changed. - Migration AddConfigGraphRowVersions: a non-concurrency rowversion column + index on the 18 configuration tables the evaluation reads (Templates + 5 member tables, Instances + 4 override/binding tables, DataConnections, DeployedConfigSnapshots, Sites, SharedScripts, ExternalSystemDefinitions, ExternalSystemMethods, DatabaseConnectionDefinitions). SQL Server only in the model; SQLite test contexts are untouched. Script: docs/plans/sql/AddConfigGraphRowVersions.sql. Joins the coordinated central-pair migration window (#107 item 4). - IConfigGraphWatermarkReader / ConfigGraphWatermarkReader: ONE query of MAX(RowVersion) + COUNT_BIG(*) per table (count catches deletes, which raise no rowversion). rowversion is engine-bumped for any writer, so a peer central node's edit moves it — not the per-process watermark MG-3 removed. - The secret store (outside the DB) gets its own stamp: SHA-256 over ListAsync(includeDeleted: true) metadata. - Evaluator re-serves the last CLEAN snapshot while watermark + secret stamp are unchanged; every PendingUpdatesFullEvaluationEvery'th (default 10, validated >= 1, 1 = off) is full regardless — the safety net for a same-KEK re-wrap or an input table missing from the set. Always full when the token is unavailable/unreadable, after an evaluation that could not compute a site's artifact hash, on Re-check, and after a deploy completion. It only re-serves a real earlier answer, never manufactures one. Tests: SQL Server (real migrated DB) — token stable without writes, moves for a peer-connection INSERT/UPDATE and a non-max DELETE, moves for a local EF write, a detached Update still saves (not a concurrency token), and a guard that every table the evaluator's repository reads touch is covered. Evaluator — reuse without flatten (re-served answer still shows the stale instance), watermark move, 4 secret changes, every-Nth, 5 no-token modes, unclean evaluation not reused, cache Re-check and deploy completion force full. Mutation-proven: token compare, clean check, cache force flag, row count, and an uncovered read each turn tests red. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
432 lines
23 KiB
C#
432 lines
23 KiB
C#
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Kpi;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Entities;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
|
|
public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
|
|
{
|
|
private readonly IDataProtectionProvider? _dataProtectionProvider;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ScadaBridgeDbContext"/> class for schema-only access (design-time).
|
|
/// </summary>
|
|
/// <param name="options">Database context options.</param>
|
|
public ScadaBridgeDbContext(DbContextOptions<ScadaBridgeDbContext> options) : base(options)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a context with an explicit Data Protection provider used to encrypt
|
|
/// secret-bearing configuration columns at rest. The runtime resolves this overload
|
|
/// via DI; design-time tooling uses the single-argument overload.
|
|
/// </summary>
|
|
/// <param name="options">Database context options.</param>
|
|
/// <param name="dataProtectionProvider">Data Protection provider for encrypting secrets at rest.</param>
|
|
public ScadaBridgeDbContext(DbContextOptions<ScadaBridgeDbContext> options, IDataProtectionProvider dataProtectionProvider)
|
|
: base(options)
|
|
{
|
|
_dataProtectionProvider = dataProtectionProvider
|
|
?? throw new ArgumentNullException(nameof(dataProtectionProvider));
|
|
}
|
|
|
|
// Templates
|
|
/// <summary>Gets the set of templates.</summary>
|
|
public DbSet<Template> Templates => Set<Template>();
|
|
/// <summary>Gets the set of template attributes.</summary>
|
|
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
|
|
/// <summary>Gets the set of template alarms.</summary>
|
|
public DbSet<TemplateAlarm> TemplateAlarms => Set<TemplateAlarm>();
|
|
/// <summary>Gets the set of template scripts.</summary>
|
|
public DbSet<TemplateScript> TemplateScripts => Set<TemplateScript>();
|
|
/// <summary>Gets the set of template compositions.</summary>
|
|
public DbSet<TemplateComposition> TemplateCompositions => Set<TemplateComposition>();
|
|
/// <summary>Gets the set of template folders.</summary>
|
|
public DbSet<TemplateFolder> TemplateFolders => Set<TemplateFolder>();
|
|
/// <summary>Gets the set of template native alarm source bindings.</summary>
|
|
public DbSet<TemplateNativeAlarmSource> TemplateNativeAlarmSources => Set<TemplateNativeAlarmSource>();
|
|
|
|
// Instances
|
|
/// <summary>Gets the set of instances.</summary>
|
|
public DbSet<Instance> Instances => Set<Instance>();
|
|
/// <summary>Gets the set of instance attribute overrides.</summary>
|
|
public DbSet<InstanceAttributeOverride> InstanceAttributeOverrides => Set<InstanceAttributeOverride>();
|
|
/// <summary>Gets the set of instance alarm overrides.</summary>
|
|
public DbSet<InstanceAlarmOverride> InstanceAlarmOverrides => Set<InstanceAlarmOverride>();
|
|
/// <summary>Gets the set of instance connection bindings.</summary>
|
|
public DbSet<InstanceConnectionBinding> InstanceConnectionBindings => Set<InstanceConnectionBinding>();
|
|
/// <summary>Gets the set of instance native alarm source overrides.</summary>
|
|
public DbSet<InstanceNativeAlarmSourceOverride> InstanceNativeAlarmSourceOverrides => Set<InstanceNativeAlarmSourceOverride>();
|
|
/// <summary>Gets the set of areas.</summary>
|
|
public DbSet<Area> Areas => Set<Area>();
|
|
|
|
// Sites
|
|
/// <summary>Gets the set of sites.</summary>
|
|
public DbSet<Site> Sites => Set<Site>();
|
|
/// <summary>Gets the set of data connections.</summary>
|
|
public DbSet<DataConnection> DataConnections => Set<DataConnection>();
|
|
|
|
// Deployment
|
|
/// <summary>Gets the set of deployment records.</summary>
|
|
public DbSet<DeploymentRecord> DeploymentRecords => Set<DeploymentRecord>();
|
|
/// <summary>Gets the set of system artifact deployment records.</summary>
|
|
public DbSet<SystemArtifactDeploymentRecord> SystemArtifactDeploymentRecords => Set<SystemArtifactDeploymentRecord>();
|
|
/// <summary>Gets the set of deployed configuration snapshots.</summary>
|
|
public DbSet<DeployedConfigSnapshot> DeployedConfigSnapshots => Set<DeployedConfigSnapshot>();
|
|
/// <summary>Gets the set of pending deployments staged for HTTP fetch by sites.</summary>
|
|
public DbSet<PendingDeployment> PendingDeployments => Set<PendingDeployment>();
|
|
|
|
// External Systems
|
|
/// <summary>Gets the set of external system definitions.</summary>
|
|
public DbSet<ExternalSystemDefinition> ExternalSystemDefinitions => Set<ExternalSystemDefinition>();
|
|
/// <summary>Gets the set of external system methods.</summary>
|
|
public DbSet<ExternalSystemMethod> ExternalSystemMethods => Set<ExternalSystemMethod>();
|
|
/// <summary>Gets the set of database connection definitions.</summary>
|
|
public DbSet<DatabaseConnectionDefinition> DatabaseConnectionDefinitions => Set<DatabaseConnectionDefinition>();
|
|
|
|
// Notifications
|
|
/// <summary>Gets the set of notification lists.</summary>
|
|
public DbSet<NotificationList> NotificationLists => Set<NotificationList>();
|
|
/// <summary>Gets the set of notification recipients.</summary>
|
|
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();
|
|
/// <summary>Gets the set of SMTP configurations.</summary>
|
|
public DbSet<SmtpConfiguration> SmtpConfigurations => Set<SmtpConfiguration>();
|
|
/// <summary>Gets the set of SMS configurations.</summary>
|
|
public DbSet<SmsConfiguration> SmsConfigurations => Set<SmsConfiguration>();
|
|
/// <summary>Gets the set of notifications.</summary>
|
|
public DbSet<Notification> Notifications => Set<Notification>();
|
|
|
|
// Scripts
|
|
/// <summary>Gets the set of shared scripts.</summary>
|
|
public DbSet<SharedScript> SharedScripts => Set<SharedScript>();
|
|
|
|
// Schemas (template-level JSON-Schema library)
|
|
/// <summary>Gets the set of shared JSON-Schema library entries.</summary>
|
|
public DbSet<SharedSchema> SharedSchemas => Set<SharedSchema>();
|
|
|
|
// Security
|
|
/// <summary>Gets the set of LDAP group mappings.</summary>
|
|
public DbSet<LdapGroupMapping> LdapGroupMappings => Set<LdapGroupMapping>();
|
|
/// <summary>Gets the set of site scope rules.</summary>
|
|
public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();
|
|
|
|
// Inbound API
|
|
// Auth re-arch: the SQL Server ApiKeys DbSet was retired — inbound API keys
|
|
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. Only the method
|
|
// catalogue remains in the configuration database.
|
|
/// <summary>Gets the set of API methods.</summary>
|
|
public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();
|
|
|
|
// Audit
|
|
/// <summary>Gets the set of audit log entries.</summary>
|
|
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
|
/// <summary>Gets the set of audit log rows (central <c>dbo.AuditLog</c> persistence shape; mapped to/from the canonical record at the repository boundary).</summary>
|
|
public DbSet<AuditLogRow> AuditLogs => Set<AuditLogRow>();
|
|
/// <summary>Gets the per-month AuditLog hash-chain heads (ScadaBridge#41).</summary>
|
|
public DbSet<AuditLogChainHead> AuditLogChainHeads => Set<AuditLogChainHead>();
|
|
/// <summary>Gets the AuditLog hash-chain purge tombstones (ScadaBridge#41).</summary>
|
|
public DbSet<AuditLogChainTombstone> AuditLogChainTombstones => Set<AuditLogChainTombstone>();
|
|
/// <summary>Gets the set of site calls.</summary>
|
|
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
|
|
|
// Secured Writes (OPC UA / MxGateway UX)
|
|
/// <summary>Gets the set of pending two-person secured writes.</summary>
|
|
public DbSet<PendingSecuredWrite> PendingSecuredWrites => Set<PendingSecuredWrite>();
|
|
|
|
// KPI History ("KPI History & Trends")
|
|
/// <summary>Gets the set of KPI samples (central tall/EAV KPI-history backbone).</summary>
|
|
public DbSet<KpiSample> KpiSamples => Set<KpiSample>();
|
|
|
|
/// <summary>Gets the set of hourly KPI rollups (pre-aggregated long-range trend backbone).</summary>
|
|
public DbSet<KpiRollupHourly> KpiRollupHourly => Set<KpiRollupHourly>();
|
|
|
|
// Transport (bundle export/import)
|
|
/// <summary>Gets the set of recorded bundle-unlock attempts — the fleet-wide sliding window behind the per-client unlock throttle (register row 52).</summary>
|
|
public DbSet<BundleUnlockAttempt> BundleUnlockAttempts => Set<BundleUnlockAttempt>();
|
|
|
|
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
|
/// <summary>Gets the set of data protection keys.</summary>
|
|
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
|
|
|
/// <inheritdoc />
|
|
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>();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly);
|
|
|
|
ApplySecretColumnEncryption(modelBuilder);
|
|
|
|
// Gitea #62: rowversion change-detection columns on the pending-updates input tables.
|
|
// SQL Server only (true for production and the design-time factory, so the migration
|
|
// and snapshot carry them); SQLite cannot generate a rowversion.
|
|
if (Database.IsSqlServer())
|
|
ConfigGraphRowVersions.Apply(modelBuilder);
|
|
|
|
NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(modelBuilder);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The central <c>dbo.AuditLog</c> persisted computed columns use
|
|
/// SQL Server's <c>JSON_VALUE</c> expression, which only SQL Server can evaluate.
|
|
/// On a non-SQL-Server provider (the SQLite test contexts) emitting that SQL in a
|
|
/// <c>CREATE TABLE</c> fails ("no such function: JSON_VALUE"). Strip the
|
|
/// computed-column SQL for any non-SQL-Server provider so those columns degrade to
|
|
/// plain (always-null on the test provider) nullable columns; SQL Server keeps the
|
|
/// real <c>… AS JSON_VALUE(...) PERSISTED</c> definitions. This is a model-shape
|
|
/// adaptation only — it never runs under the design-time SQL Server provider, so the
|
|
/// migration / model-snapshot remain the canonical SQL Server shape.
|
|
/// </summary>
|
|
private void NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(ModelBuilder modelBuilder)
|
|
{
|
|
// Database.IsSqlServer() reads the configured provider — true for production and
|
|
// for the design-time factory (UseSqlServer), false for the SQLite test contexts.
|
|
if (Database.IsSqlServer())
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
|
{
|
|
foreach (var property in entityType.GetProperties())
|
|
{
|
|
if (property.GetComputedColumnSql() is not null)
|
|
{
|
|
property.SetComputedColumnSql(null);
|
|
property.SetColumnType(null);
|
|
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
|
|
property.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Save);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
|
{
|
|
GuardSecretWritesHaveAKeyRing();
|
|
try { return base.SaveChanges(acceptAllChangesOnSuccess); }
|
|
catch (DbUpdateException ex) when (IsInstanceNameConflict(ex))
|
|
{ throw DetachInstanceNameConflict(ex); }
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public override async Task<int> SaveChangesAsync(
|
|
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
|
|
{
|
|
GuardSecretWritesHaveAKeyRing();
|
|
try { return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); }
|
|
catch (DbUpdateException ex) when (IsInstanceNameConflict(ex))
|
|
{ throw DetachInstanceNameConflict(ex); }
|
|
}
|
|
|
|
private static InstanceNameConflictException DetachInstanceNameConflict(DbUpdateException error)
|
|
{
|
|
foreach (var entry in error.Entries) entry.State = EntityState.Detached;
|
|
return new InstanceNameConflictException(error);
|
|
}
|
|
|
|
private static bool IsInstanceNameConflict(DbUpdateException error)
|
|
=> error.InnerException is Microsoft.Data.SqlClient.SqlException sql
|
|
? sql.Number is 2601 or 2627 && sql.Message.Contains("IX_Instances_UniqueName", StringComparison.Ordinal)
|
|
: error.InnerException?.GetType().Name == "SqliteException"
|
|
&& error.InnerException.Message.Contains("UNIQUE constraint failed: Instances.UniqueName", StringComparison.Ordinal);
|
|
|
|
/// <summary>Data Protection protector for data-connection secrets; throws when no provider was supplied.</summary>
|
|
internal IDataProtector DataConnectionProtector => (_dataProtectionProvider
|
|
?? throw new InvalidOperationException("Data Protection is required to migrate protocol credentials."))
|
|
.CreateProtector(DataConnectionSecretConverter.Purpose);
|
|
|
|
/// <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),
|
|
SmsConfiguration => nameof(SmsConfiguration.AuthToken),
|
|
ExternalSystemDefinition => nameof(ExternalSystemDefinition.AuthConfiguration),
|
|
DatabaseConnectionDefinition => nameof(DatabaseConnectionDefinition.ConnectionString),
|
|
DataConnection connection when connection.PrimaryConfiguration is not null => nameof(DataConnection.PrimaryConfiguration),
|
|
DataConnection => nameof(DataConnection.BackupConfiguration),
|
|
_ => null
|
|
};
|
|
if (secretProperty is null)
|
|
continue;
|
|
|
|
if (entry.Property(secretProperty).CurrentValue is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"This ScadaBridgeDbContext 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 the Data Protection provider identity into the
|
|
/// cache key, so different key rings and schema-only contexts never share converter instances.
|
|
/// </summary>
|
|
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
|
|
{
|
|
/// <summary>
|
|
/// Creates a model cache key that includes the Data Protection provider identity.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="designTime">Whether the model is being created at design-time.</param>
|
|
/// <returns>A cache key tuple.</returns>
|
|
public object Create(DbContext context, bool designTime)
|
|
=> (context.GetType(),
|
|
designTime,
|
|
(context as ScadaBridgeDbContext)?._dataProtectionProvider);
|
|
|
|
/// <summary>
|
|
/// Creates a model cache key for run-time contexts.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <returns>A cache key tuple.</returns>
|
|
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 — 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)
|
|
{
|
|
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)
|
|
.HasConversion(converter);
|
|
|
|
modelBuilder.Entity<SmsConfiguration>()
|
|
.Property(s => s.AuthToken)
|
|
.HasConversion(converter);
|
|
|
|
modelBuilder.Entity<ExternalSystemDefinition>()
|
|
.Property(e => e.AuthConfiguration)
|
|
.HasConversion(converter);
|
|
|
|
modelBuilder.Entity<DatabaseConnectionDefinition>()
|
|
.Property(d => d.ConnectionString)
|
|
.HasConversion(converter);
|
|
|
|
IDataProtector protocolProtector = _dataProtectionProvider is { } protocolProvider
|
|
? protocolProvider.CreateProtector(DataConnectionSecretConverter.Purpose)
|
|
: SchemaOnlyDataProtector.Instance;
|
|
var protocolConverter = new DataConnectionSecretConverter(protocolProtector);
|
|
modelBuilder.Entity<DataConnection>().Property(d => d.PrimaryConfiguration).HasConversion(protocolConverter);
|
|
modelBuilder.Entity<DataConnection>().Property(d => d.BackupConfiguration).HasConversion(protocolConverter);
|
|
}
|
|
|
|
/// <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 ScadaBridgeDbContext 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).";
|
|
|
|
/// <summary>
|
|
/// Creates a schema-only protector that raises when called.
|
|
/// </summary>
|
|
/// <param name="purpose">The protector purpose string.</param>
|
|
/// <returns>This instance.</returns>
|
|
public IDataProtector CreateProtector(string purpose) => this;
|
|
|
|
/// <summary>
|
|
/// Protects plaintext (schema-only version always throws).
|
|
/// </summary>
|
|
/// <param name="plaintext">The data to protect.</param>
|
|
/// <returns>Never returns.</returns>
|
|
public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);
|
|
|
|
/// <summary>
|
|
/// Unprotects ciphertext (schema-only version always throws).
|
|
/// </summary>
|
|
/// <param name="protectedData">The protected data.</param>
|
|
/// <returns>Never returns.</returns>
|
|
public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
|
|
}
|
|
}
|