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.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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;
///
/// Initializes a new instance of the class for schema-only access (design-time).
///
/// Database context options.
public ScadaBridgeDbContext(DbContextOptions options) : base(options)
{
}
///
/// 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.
///
/// Database context options.
/// Data Protection provider for encrypting secrets at rest.
public ScadaBridgeDbContext(DbContextOptions options, IDataProtectionProvider dataProtectionProvider)
: base(options)
{
_dataProtectionProvider = dataProtectionProvider
?? throw new ArgumentNullException(nameof(dataProtectionProvider));
}
// Templates
/// Gets the set of templates.
public DbSet Templates => Set();
/// Gets the set of template attributes.
public DbSet TemplateAttributes => Set();
/// Gets the set of template alarms.
public DbSet TemplateAlarms => Set();
/// Gets the set of template scripts.
public DbSet TemplateScripts => Set();
/// Gets the set of template compositions.
public DbSet TemplateCompositions => Set();
/// Gets the set of template folders.
public DbSet TemplateFolders => Set();
/// Gets the set of template native alarm source bindings.
public DbSet TemplateNativeAlarmSources => Set();
// Instances
/// Gets the set of instances.
public DbSet Instances => Set();
/// Gets the set of instance attribute overrides.
public DbSet InstanceAttributeOverrides => Set();
/// Gets the set of instance alarm overrides.
public DbSet InstanceAlarmOverrides => Set();
/// Gets the set of instance connection bindings.
public DbSet InstanceConnectionBindings => Set();
/// Gets the set of instance native alarm source overrides.
public DbSet InstanceNativeAlarmSourceOverrides => Set();
/// Gets the set of areas.
public DbSet Areas => Set();
// Sites
/// Gets the set of sites.
public DbSet Sites => Set();
/// Gets the set of data connections.
public DbSet DataConnections => Set();
// Deployment
/// Gets the set of deployment records.
public DbSet DeploymentRecords => Set();
/// Gets the set of system artifact deployment records.
public DbSet SystemArtifactDeploymentRecords => Set();
/// Gets the set of deployed configuration snapshots.
public DbSet DeployedConfigSnapshots => Set();
// External Systems
/// Gets the set of external system definitions.
public DbSet ExternalSystemDefinitions => Set();
/// Gets the set of external system methods.
public DbSet ExternalSystemMethods => Set();
/// Gets the set of database connection definitions.
public DbSet DatabaseConnectionDefinitions => Set();
// Notifications
/// Gets the set of notification lists.
public DbSet NotificationLists => Set();
/// Gets the set of notification recipients.
public DbSet NotificationRecipients => Set();
/// Gets the set of SMTP configurations.
public DbSet SmtpConfigurations => Set();
/// Gets the set of notifications.
public DbSet Notifications => Set();
// Scripts
/// Gets the set of shared scripts.
public DbSet SharedScripts => Set();
// Security
/// Gets the set of LDAP group mappings.
public DbSet LdapGroupMappings => Set();
/// Gets the set of site scope rules.
public DbSet SiteScopeRules => Set();
// Inbound API
// Auth re-arch (C5): 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.
/// Gets the set of API methods.
public DbSet ApiMethods => Set();
// Audit
/// Gets the set of audit log entries.
public DbSet AuditLogEntries => Set();
/// Gets the set of audit log rows (central dbo.AuditLog persistence shape; mapped to/from the canonical record at the repository boundary).
public DbSet AuditLogs => Set();
/// Gets the set of site calls.
public DbSet SiteCalls => Set();
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
/// Gets the set of data protection keys.
public DbSet DataProtectionKeys => Set();
///
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();
}
///
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaBridgeDbContext).Assembly);
ApplySecretColumnEncryption(modelBuilder);
NeutralizeSqlServerComputedColumnsForNonSqlServerProviders(modelBuilder);
}
///
/// C5 (Task 2.5): the central dbo.AuditLog persisted computed columns use
/// SQL Server's JSON_VALUE expression, which only SQL Server can evaluate.
/// On a non-SQL-Server provider (the SQLite test contexts) emitting that SQL in a
/// CREATE TABLE 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 … AS JSON_VALUE(...) PERSISTED 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.
///
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);
}
}
}
}
///
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
///
public override Task SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
/// True when this context was constructed with a real Data Protection provider.
internal bool HasSecretEncryptionProvider => _dataProtectionProvider is not null;
///
/// 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
/// DbUpdateException/CryptographicException; surfacing a clear
/// here makes the misconfiguration obvious.
///
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 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).");
}
}
}
///
/// Model cache key factory that folds into the
/// cache key, so a write-capable (provider-bearing) context and a schema-only context do
/// not share a cached model.
///
private sealed class SecretAwareModelCacheKeyFactory : IModelCacheKeyFactory
{
///
/// Creates a model cache key that includes the Data Protection provider state.
///
/// The database context.
/// Whether the model is being created at design-time.
/// A cache key tuple.
public object Create(DbContext context, bool designTime)
=> (context.GetType(),
designTime,
(context as ScadaBridgeDbContext)?.HasSecretEncryptionProvider ?? false);
///
/// Creates a model cache key for run-time contexts.
///
/// The database context.
/// A cache key tuple.
public object Create(DbContext context) => Create(context, false);
}
///
/// 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.
///
///
/// When no Data Protection provider is supplied — design-time dotnet ef tooling,
/// which only emits schema and never reads or writes secret data — a schema-only
/// protector is used. It produces an identical nvarchar schema, but throws a
/// clear 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.
///
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()
.Property(s => s.Credentials)
.HasConversion(converter);
modelBuilder.Entity()
.Property(e => e.AuthConfiguration)
.HasConversion(converter);
modelBuilder.Entity()
.Property(d => d.ConnectionString)
.HasConversion(converter);
}
///
/// An 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.
///
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).";
///
/// Creates a schema-only protector that raises when called.
///
/// The protector purpose string.
/// This instance.
public IDataProtector CreateProtector(string purpose) => this;
///
/// Protects plaintext (schema-only version always throws).
///
/// The data to protect.
/// Never returns.
public byte[] Protect(byte[] plaintext) => throw new InvalidOperationException(Message);
///
/// Unprotects ciphertext (schema-only version always throws).
///
/// The protected data.
/// Never returns.
public byte[] Unprotect(byte[] protectedData) => throw new InvalidOperationException(Message);
}
}