fix(configuration-database): resolve ConfigurationDatabase-002..007 — remove hardcoded sa creds, fail-fast no-arg DI, encrypt secret columns, resilient audit serialization

This commit is contained in:
Joseph Doherty
2026-05-16 21:11:24 -04:00
parent 8fc04d43c2
commit 0c82ffcbe6
17 changed files with 2029 additions and 40 deletions

View File

@@ -22,8 +22,10 @@ public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration<Ex
.IsRequired()
.HasMaxLength(50);
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
// the plaintext, so the column is sized generously to avoid truncation.
builder.Property(e => e.AuthConfiguration)
.HasMaxLength(4000);
.HasMaxLength(8000);
builder.HasMany<ExternalSystemMethod>()
.WithOne()
@@ -72,9 +74,11 @@ public class DatabaseConnectionDefinitionConfiguration : IEntityTypeConfiguratio
.IsRequired()
.HasMaxLength(200);
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
// the plaintext, so the column is sized generously to avoid truncation.
builder.Property(d => d.ConnectionString)
.IsRequired()
.HasMaxLength(4000);
.HasMaxLength(8000);
builder.HasIndex(d => d.Name).IsUnique();
}

View File

@@ -53,8 +53,10 @@ public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfi
.IsRequired()
.HasMaxLength(50);
// Stored encrypted at rest (EncryptedStringConverter). Ciphertext is larger than
// the plaintext, so the column is sized generously to avoid truncation.
builder.Property(s => s.Credentials)
.HasMaxLength(4000);
.HasMaxLength(8000);
builder.Property(s => s.TlsMode)
.HasMaxLength(50);

View File

@@ -6,20 +6,50 @@ namespace ScadaLink.ConfigurationDatabase;
/// <summary>
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
/// Reads connection string from Host's appsettings.Central.json.
/// Resolves the connection string from the Host's appsettings files, or — for environments
/// where those files are not present — from the
/// <c>SCADALINK_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
/// </summary>
/// <remarks>
/// There is deliberately no hardcoded fallback connection string. A credential literal in
/// source is committed to version control, encourages copy-paste of <c>sa</c> /
/// <c>TrustServerCertificate=True</c> into real environments, and can silently point
/// <c>dotnet ef</c> tooling at an unintended database. If no connection string can be
/// resolved, this factory fails loudly with an actionable message.
/// </remarks>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaLinkDbContext>
{
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
private const string ConfigurationKey = "ScadaLink:Database:ConfigurationDb";
public ScadaLinkDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Path.Combine(Directory.GetCurrentDirectory(), "..", "ScadaLink.Host"))
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Central.json", optional: true)
.Build();
var configurationBuilder = new ConfigurationBuilder();
var connectionString = configuration["ScadaLink:Database:ConfigurationDb"]
?? "Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True";
// The Host's appsettings files are an optional source — only wire them up when the
// Host directory actually exists, otherwise SetBasePath throws DirectoryNotFoundException
// (e.g. when this factory is exercised from a test runner with no sibling Host folder).
var hostDirectory = Path.Combine(Directory.GetCurrentDirectory(), "..", "ScadaLink.Host");
if (Directory.Exists(hostDirectory))
{
configurationBuilder
.SetBasePath(hostDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Central.json", optional: true);
}
var configuration = configurationBuilder.Build();
var connectionString = configuration[ConfigurationKey]
?? Environment.GetEnvironmentVariable(EnvironmentVariableName);
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new InvalidOperationException(
"No design-time database connection string was found. Set the configuration " +
$"key '{ConfigurationKey}' in the Host's appsettings file, or set the " +
$"'{EnvironmentVariableName}' environment variable, before running dotnet ef tooling.");
}
var optionsBuilder = new DbContextOptionsBuilder<ScadaLinkDbContext>();
optionsBuilder.UseSqlServer(connectionString);

View File

@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ScadaLink.ConfigurationDatabase;
/// <summary>
/// EF Core value converter that encrypts a string column at rest using ASP.NET
/// Data Protection. Plaintext is protected when written to the database and
/// transparently unprotected when read back, so secret-bearing columns
/// (SMTP credentials, external-system auth config, database connection strings)
/// are never persisted verbatim.
/// </summary>
/// <remarks>
/// The protector is purpose-scoped so ciphertext from one column cannot be
/// unprotected as another. Data Protection keys are persisted to the
/// configuration database itself (see <see cref="ScadaLinkDbContext"/> implementing
/// <c>IDataProtectionKeyContext</c>), so all central nodes share the same key ring
/// and can decrypt each other's writes.
/// </remarks>
public sealed class EncryptedStringConverter : ValueConverter<string?, string?>
{
/// <summary>The Data Protection purpose string shared by all encrypted configuration columns.</summary>
public const string ProtectorPurpose = "ScadaLink.ConfigurationDatabase.EncryptedColumn";
public EncryptedStringConverter(IDataProtector protector)
: base(
plaintext => plaintext == null ? null : protector.Protect(plaintext),
ciphertext => ciphertext == null ? null : Unprotect(protector, ciphertext))
{
}
private static string Unprotect(IDataProtector protector, string ciphertext)
{
// A row that predates encryption (or test fixtures inserting raw text) is not valid
// protected payload. Unprotect throws CryptographicException in that case; surface a
// clearer message rather than a bare crypto failure.
try
{
return protector.Unprotect(ciphertext);
}
catch (System.Security.Cryptography.CryptographicException ex)
{
throw new InvalidOperationException(
"Failed to decrypt an encrypted configuration column. The Data Protection key " +
"ring may be unavailable, or the stored value was not written by this system.",
ex);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class EncryptSecretColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Credentials",
table: "SmtpConfigurations",
type: "nvarchar(max)",
maxLength: 8000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(4000)",
oldMaxLength: 4000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "AuthConfiguration",
table: "ExternalSystemDefinitions",
type: "nvarchar(max)",
maxLength: 8000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(4000)",
oldMaxLength: 4000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ConnectionString",
table: "DatabaseConnectionDefinitions",
type: "nvarchar(max)",
maxLength: 8000,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(4000)",
oldMaxLength: 4000);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Credentials",
table: "SmtpConfigurations",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldMaxLength: 8000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "AuthConfiguration",
table: "ExternalSystemDefinitions",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldMaxLength: 8000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ConnectionString",
table: "DatabaseConnectionDefinitions",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldMaxLength: 8000);
}
}
}

View File

@@ -232,8 +232,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<string>("ConnectionString")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
.HasMaxLength(8000)
.HasColumnType("nvarchar(max)");
b.Property<int>("MaxRetries")
.HasColumnType("int");
@@ -263,8 +263,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AuthConfiguration")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
.HasMaxLength(8000)
.HasColumnType("nvarchar(max)");
b.Property<string>("AuthType")
.IsRequired()
@@ -632,8 +632,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasColumnType("int");
b.Property<string>("Credentials")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
.HasMaxLength(8000)
.HasColumnType("nvarchar(max)");
b.Property<string>("FromAddress")
.IsRequired()

View File

@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" />
</ItemGroup>

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ScadaLink.Commons.Entities.Audit;
@@ -15,10 +16,24 @@ namespace ScadaLink.ConfigurationDatabase;
public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
{
private readonly IDataProtectionProvider? _dataProtectionProvider;
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> 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>
public ScadaLinkDbContext(DbContextOptions<ScadaLinkDbContext> options, IDataProtectionProvider dataProtectionProvider)
: base(options)
{
_dataProtectionProvider = dataProtectionProvider
?? throw new ArgumentNullException(nameof(dataProtectionProvider));
}
// Templates
public DbSet<Template> Templates => Set<Template>();
public DbSet<TemplateAttribute> TemplateAttributes => Set<TemplateAttribute>();
@@ -73,5 +88,38 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ScadaLinkDbContext).Assembly);
ApplySecretColumnEncryption(modelBuilder);
}
/// <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.
/// </remarks>
private void ApplySecretColumnEncryption(ModelBuilder modelBuilder)
{
IDataProtectionProvider provider = _dataProtectionProvider ?? new EphemeralDataProtectionProvider();
var converter = new EncryptedStringConverter(
provider.CreateProtector(EncryptedStringConverter.ProtectorPurpose));
modelBuilder.Entity<SmtpConfiguration>()
.Property(s => s.Credentials)
.HasConversion(converter);
modelBuilder.Entity<ExternalSystemDefinition>()
.Property(e => e.AuthConfiguration)
.HasConversion(converter);
modelBuilder.Entity<DatabaseConnectionDefinition>()
.Property(d => d.ConnectionString)
.HasConversion((Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter)converter);
}
}

View File

@@ -15,10 +15,28 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
{
services.AddDbContext<ScadaLinkDbContext>(options =>
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
// CreateProtector during model building.
services.AddDbContext<ScadaLinkDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(connectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
});
// AddDbContext registers ScadaLinkDbContext via EF's activator, which only injects
// DbContextOptions. Override that registration (last registration wins for resolution)
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
// value converter for secret columns is always wired up at runtime.
services.AddScoped(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaLinkDbContext>>();
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
return new ScadaLinkDbContext(options, protectionProvider);
});
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
@@ -38,13 +56,27 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Registers the ScadaLinkDbContext with no connection string (for backward compatibility / Phase 0 stubs).
/// This overload is a no-op placeholder; callers should migrate to the overload that accepts a connection string.
/// Obsolete parameterless overload. This previously registered nothing, which meant a
/// central node wired up with it failed late and opaquely — the first repository
/// resolution threw a DI exception far from the actual misconfiguration. Use
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
/// configured connection string.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Always thrown. The connection string is required; there is no valid no-op registration.
/// </exception>
[Obsolete(
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
error: true)]
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
{
// Retained for backward compatibility during migration.
// Site nodes do not use the configuration database, so this is intentionally a no-op.
return services;
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
// fail fast at wire-up time rather than silently registering nothing and surfacing
// an opaque DI resolution failure much later.
throw new InvalidOperationException(
"AddConfigurationDatabase() requires a connection string. Call " +
"AddConfigurationDatabase(connectionString) with the configured " +
"'ScadaLink:Database:ConfigurationDb' value.");
}
}

View File

@@ -8,6 +8,19 @@ public class AuditService : IAuditService
{
private readonly ScadaLinkDbContext _context;
/// <summary>
/// Serializer options for audit <c>afterState</c> payloads. Audit writes commit in the
/// same transaction as the change they record, so a serialization exception here would
/// roll back the entire business operation. Reference cycles (common when an EF entity
/// with loaded navigations is passed in) are ignored rather than thrown, and depth is
/// bounded so a pathological graph cannot produce an unbounded payload.
/// </summary>
private static readonly JsonSerializerOptions AuditSerializerOptions = new()
{
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles,
MaxDepth = 32
};
public AuditService(ScadaLinkDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
@@ -26,7 +39,7 @@ public class AuditService : IAuditService
{
Timestamp = DateTimeOffset.UtcNow,
AfterStateJson = afterState != null
? JsonSerializer.Serialize(afterState)
? SerializeAfterState(afterState)
: null
};
@@ -34,4 +47,27 @@ public class AuditService : IAuditService
// to ensure atomicity with the entity change.
await _context.AuditLogEntries.AddAsync(entry, cancellationToken);
}
/// <summary>
/// Serializes the caller-supplied after-state, tolerating arbitrary object shapes.
/// Reference cycles are ignored via <see cref="AuditSerializerOptions"/>. If serialization
/// still fails (e.g. <c>MaxDepth</c> exceeded), the audit entry is preserved with a
/// diagnostic placeholder rather than throwing — a serialization failure must never
/// roll back the business operation the audit entry is recording.
/// </summary>
private static string SerializeAfterState(object afterState)
{
try
{
return JsonSerializer.Serialize(afterState, AuditSerializerOptions);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
return JsonSerializer.Serialize(new
{
AuditSerializationError = ex.Message,
StateType = afterState.GetType().FullName
});
}
}
}