Resolve all 33 medium-severity architecture review findings

Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
Joseph Doherty
2026-09-09 22:05:30 -04:00
parent 146a13238f
commit 9a87d7a328
136 changed files with 5441 additions and 648 deletions
@@ -58,7 +58,7 @@ public class InstanceConfiguration : IEntityTypeConfiguration<Instance>
.HasForeignKey(o => o.InstanceId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(i => new { i.SiteId, i.UniqueName }).IsUnique();
builder.HasIndex(i => i.UniqueName).IsUnique();
}
}
@@ -49,11 +49,9 @@ public class DataConnectionConfiguration : IEntityTypeConfiguration<DataConnecti
.IsRequired()
.HasMaxLength(50);
builder.Property(d => d.PrimaryConfiguration)
.HasMaxLength(4000);
builder.Property(d => d.PrimaryConfiguration); // Protected payload may exceed its plaintext size.
builder.Property(d => d.BackupConfiguration)
.HasMaxLength(4000);
builder.Property(d => d.BackupConfiguration);
builder.Property(d => d.FailoverRetryCount)
.IsRequired()
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// <summary>Versioned protection of complete protocol JSON, including nested credentials.</summary>
public sealed class DataConnectionSecretConverter(IDataProtector protector) : ValueConverter<string?, string?>(
value => value == null ? null : Protect(protector, value),
value => value == null ? null : Unprotect(protector, value))
{
internal const string Prefix = "dp:protocol:v1:";
internal const string Purpose = EncryptedStringConverter.ProtectorPurpose + ".DataConnection";
internal static string Protect(IDataProtector protector, string plaintext) => Prefix + protector.Protect(plaintext);
internal static string Unprotect(IDataProtector protector, string stored)
=> stored.StartsWith(Prefix, StringComparison.Ordinal)
? protector.Unprotect(stored[Prefix.Length..])
: throw new InvalidOperationException("Protocol configuration requires the startup encryption upgrade before it can be read.");
}
@@ -0,0 +1,79 @@
using System.Data;
using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// <summary>Idempotent, bounded startup upgrade of existing plaintext protocol configuration.</summary>
public static class DataConnectionSecretUpgrade
{
/// <summary>Protects both configuration columns before repositories may materialize them.</summary>
public static async Task UpgradeAsync(ScadaBridgeDbContext db, CancellationToken cancellationToken = default)
{
var connection = db.Database.GetDbConnection();
var opened = connection.State != ConnectionState.Open;
if (opened) await connection.OpenAsync(cancellationToken);
try
{
var lastId = 0;
while (true)
{
var rows = new List<(int Id, string? Primary, string? Backup)>();
await using (var read = connection.CreateCommand())
{
var sqlServer = db.Database.IsSqlServer();
read.CommandText = sqlServer
? "SELECT TOP (256) Id, PrimaryConfiguration, BackupConfiguration FROM DataConnections WHERE Id > @last ORDER BY Id"
: "SELECT Id, PrimaryConfiguration, BackupConfiguration FROM DataConnections WHERE Id > @last ORDER BY Id LIMIT 256";
Add(read, "@last", lastId);
await using var reader = await read.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
rows.Add((reader.GetInt32(0), reader.IsDBNull(1) ? null : reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetString(2)));
}
if (rows.Count == 0) break;
foreach (var row in rows)
{
lastId = row.Id;
string? Protect(string? value)
{
if (value is null) return null;
if (value.StartsWith(DataConnectionSecretConverter.Prefix, StringComparison.Ordinal))
{
_ = DataConnectionSecretConverter.Unprotect(db.DataConnectionProtector, value);
return value;
}
return DataConnectionSecretConverter.Protect(db.DataConnectionProtector, value);
}
var primary = Protect(row.Primary);
var backup = Protect(row.Backup);
if (primary == row.Primary && backup == row.Backup) continue;
await using var update = connection.CreateCommand();
// Compare-and-swap avoids overwriting a concurrent upgrade or configuration edit.
update.CommandText = """
UPDATE DataConnections SET PrimaryConfiguration = @primary, BackupConfiguration = @backup
WHERE Id = @id
AND (PrimaryConfiguration = @oldPrimary OR (PrimaryConfiguration IS NULL AND @oldPrimary IS NULL))
AND (BackupConfiguration = @oldBackup OR (BackupConfiguration IS NULL AND @oldBackup IS NULL))
""";
if (db.Database.IsSqlServer())
update.CommandText = update.CommandText
.Replace("PrimaryConfiguration = @oldPrimary", "PrimaryConfiguration COLLATE Latin1_General_100_BIN2 = @oldPrimary COLLATE Latin1_General_100_BIN2", StringComparison.Ordinal)
.Replace("BackupConfiguration = @oldBackup", "BackupConfiguration COLLATE Latin1_General_100_BIN2 = @oldBackup COLLATE Latin1_General_100_BIN2", StringComparison.Ordinal);
Add(update, "@id", row.Id);
Add(update, "@primary", primary); Add(update, "@backup", backup);
Add(update, "@oldPrimary", row.Primary); Add(update, "@oldBackup", row.Backup);
await update.ExecuteNonQueryAsync(cancellationToken);
}
}
}
finally { if (opened) await connection.CloseAsync(); }
}
private static void Add(System.Data.Common.DbCommand command, string name, object? value)
{
var parameter = command.CreateParameter();
parameter.ParameterName = name;
parameter.Value = value ?? DBNull.Value;
if (value is not int) { parameter.DbType = DbType.String; parameter.Size = -1; }
command.Parameters.Add(parameter);
}
}
@@ -48,6 +48,8 @@ public static class MigrationHelper
}
}
await DataConnectionSecretUpgrade.UpgradeAsync(dbContext, cancellationToken);
// Safety-net normalizer: rewrite any already-persisted List attribute values from the
// old array-of-strings JSON form to the new native-typed form. Idempotent and never
// aborts startup for data reasons (per-row skip + log). Safe even if both central
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class EnforceGlobalInstanceNamesAndProtectProtocolConfiguration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (SELECT UniqueName FROM Instances GROUP BY UniqueName HAVING COUNT(*) > 1)
THROW 51002, 'Duplicate global instance names exist. Inventory Instances grouped by UniqueName and resolve ownership before applying this upgrade.', 1;
""");
migrationBuilder.DropIndex(
name: "IX_Instances_SiteId_UniqueName",
table: "Instances");
migrationBuilder.AlterColumn<string>(
name: "PrimaryConfiguration",
table: "DataConnections",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(4000)",
oldMaxLength: 4000,
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "BackupConfiguration",
table: "DataConnections",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(4000)",
oldMaxLength: 4000,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Instances_SiteId",
table: "Instances",
column: "SiteId");
migrationBuilder.CreateIndex(
name: "IX_Instances_UniqueName",
table: "Instances",
column: "UniqueName",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
IF EXISTS (SELECT 1 FROM DataConnections
WHERE PrimaryConfiguration LIKE 'dp:protocol:v1:%' OR BackupConfiguration LIKE 'dp:protocol:v1:%')
THROW 51003, 'Protocol configurations are protected. Restore a coordinated pre-upgrade backup before downgrading application and schema.', 1;
""");
migrationBuilder.DropIndex(
name: "IX_Instances_SiteId",
table: "Instances");
migrationBuilder.DropIndex(
name: "IX_Instances_UniqueName",
table: "Instances");
migrationBuilder.AlterColumn<string>(
name: "PrimaryConfiguration",
table: "DataConnections",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "BackupConfiguration",
table: "DataConnections",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Instances_SiteId_UniqueName",
table: "Instances",
columns: new[] { "SiteId", "UniqueName" },
unique: true);
}
}
}
@@ -585,9 +585,11 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.HasIndex("AreaId");
b.HasIndex("SiteId");
b.HasIndex("TemplateId");
b.HasIndex("SiteId", "UniqueName")
b.HasIndex("UniqueName")
.IsUnique();
b.ToTable("Instances");
@@ -1335,8 +1337,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("BackupConfiguration")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
.HasColumnType("nvarchar(max)");
b.Property<int>("FailoverRetryCount")
.ValueGeneratedOnAdd()
@@ -1349,8 +1350,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
.HasColumnType("nvarchar(200)");
b.Property<string>("PrimaryConfiguration")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
.HasColumnType("nvarchar(max)");
b.Property<string>("Protocol")
.IsRequired()
@@ -943,10 +943,20 @@ VALUES
/// The two walkers must stay in step on window slack, depth cap and the DISTINCT-edges trick,
/// so there is exactly one copy of each.
/// </summary>
private static string ScopedAuditSource(IReadOnlyList<string>? sites) => sites is null
? "dbo.AuditLog" : "(SELECT * FROM dbo.AuditLog WHERE SourceSiteId IS NULL OR SourceSiteId IN (SELECT [value] FROM OPENJSON(@scopeSites)))";
private static void AddSiteScopeParameter(DbCommand command, IReadOnlyList<string>? sites)
{
if (sites is null) return;
var parameter = command.CreateParameter(); parameter.ParameterName = "@scopeSites";
parameter.Value = System.Text.Json.JsonSerializer.Serialize(sites); command.Parameters.Add(parameter);
}
private static async Task<ChainAnchor> ResolveChainAnchorAsync(
DbConnection conn,
Guid executionId,
CancellationToken ct)
CancellationToken ct, IReadOnlyList<string>? permittedSiteIds = null)
{
// --- Phase 1: walk up to the root ---------------------------------
// Climb ParentExecutionId until a node has no parent (root) or the
@@ -960,13 +970,15 @@ VALUES
await using (var upCmd = conn.CreateCommand())
{
upCmd.CommandText =
"SELECT TOP 1 ParentExecutionId FROM dbo.AuditLog " +
$"SELECT TOP 1 ParentExecutionId FROM {ScopedAuditSource(permittedSiteIds)} source " +
"WHERE ExecutionId = @cur AND ParentExecutionId IS NOT NULL;";
var pCur = upCmd.CreateParameter();
pCur.ParameterName = "@cur";
pCur.Value = rootExecutionId;
upCmd.Parameters.Add(pCur);
AddSiteScopeParameter(upCmd, permittedSiteIds);
var result = await upCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
parent = result is null or DBNull ? null : (Guid)result;
}
@@ -990,12 +1002,13 @@ VALUES
await using (var winCmd = conn.CreateCommand())
{
winCmd.CommandText =
"SELECT MIN(OccurredAtUtc) FROM dbo.AuditLog WHERE ExecutionId = @root;";
$"SELECT MIN(OccurredAtUtc) FROM {ScopedAuditSource(permittedSiteIds)} source WHERE ExecutionId = @root;";
var pWinRoot = winCmd.CreateParameter();
pWinRoot.ParameterName = "@root";
pWinRoot.Value = rootExecutionId;
winCmd.Parameters.Add(pWinRoot);
AddSiteScopeParameter(winCmd, permittedSiteIds);
var winResult = await winCmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
rootFirstOccurred = winResult is null or DBNull ? null : (DateTime)winResult;
}
@@ -1026,10 +1039,10 @@ VALUES
/// ParentExecutionId of its child's edge. No separate
/// union-back CTE is needed.
/// </remarks>
private static string ChainCtePrefix(string edgesWindowPredicate) => $@"
private static string ChainCtePrefix(string edgesWindowPredicate, IReadOnlyList<string>? permittedSiteIds = null) => $@"
WITH Edges AS (
SELECT DISTINCT ExecutionId, ParentExecutionId
FROM dbo.AuditLog
FROM {ScopedAuditSource(permittedSiteIds)} scoped
WHERE ExecutionId IS NOT NULL
{edgesWindowPredicate}
),
@@ -1076,7 +1089,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
Guid executionId,
CancellationToken ct = default)
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
var conn = _context.Database.GetDbConnection();
var openedHere = false;
@@ -1089,7 +1102,7 @@ VALUES
try
{
// --- Phase 1 + window: shared with GetTraceEventsAsync (arch-review F9) ---
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
// --- Phase 2: walk down from the root via a recursive CTE ---------
// The Edges/Chain prefix is ChainCtePrefix (see its remarks for the
@@ -1103,7 +1116,7 @@ VALUES
var nodes = new List<ExecutionTreeNode>();
await using (var downCmd = conn.CreateCommand())
{
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@",
downCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@",
ChainIds AS (
SELECT ExecutionId FROM Chain
UNION
@@ -1125,21 +1138,22 @@ VALUES
MIN(a.ParentExecutionId) AS [ParentExecutionId],
COUNT(a.EventId) AS [RowCount],
(SELECT STRING_AGG(d.Category, ',')
FROM (SELECT DISTINCT a2.Category FROM dbo.AuditLog a2
FROM (SELECT DISTINCT a2.Category FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Channels],
(SELECT STRING_AGG(d.Status, ',')
FROM (SELECT DISTINCT a2.Status FROM dbo.AuditLog a2
FROM (SELECT DISTINCT a2.Status FROM {ScopedAuditSource(permittedSiteIds)} a2
WHERE a2.ExecutionId = ids.ExecutionId) d) AS [Statuses],
MIN(a.SourceSiteId) AS [SourceSiteId],
MIN(JSON_VALUE(a.DetailsJson,'$.sourceInstanceId')) AS [SourceInstanceId],
MIN(a.OccurredAtUtc) AS [FirstOccurredAtUtc],
MAX(a.OccurredAtUtc) AS [LastOccurredAtUtc]
FROM ChainIds ids
LEFT JOIN dbo.AuditLog a ON a.ExecutionId = ids.ExecutionId
LEFT JOIN {ScopedAuditSource(permittedSiteIds)} a ON a.ExecutionId = ids.ExecutionId
GROUP BY ids.ExecutionId
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(downCmd, anchor);
AddSiteScopeParameter(downCmd, permittedSiteIds);
await using var reader = await downCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
@@ -1181,7 +1195,7 @@ VALUES
/// <inheritdoc />
public async Task<IReadOnlyList<AuditEvent>> GetTraceEventsAsync(
Guid executionId,
CancellationToken ct = default)
CancellationToken ct = default, IReadOnlyList<string>? permittedSiteIds = null)
{
// Flow Debugger Replay (design 2026-09-05 §7.1): same walk as GetExecutionTreeAsync
// — up to the root, then the Edges/Chain CTE down — but projecting the chain's
@@ -1197,24 +1211,28 @@ VALUES
}
var chainIds = new List<Guid>();
var rootId = executionId;
var chainTruncated = false;
try
{
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct).ConfigureAwait(false);
var anchor = await ResolveChainAnchorAsync(conn, executionId, ct, permittedSiteIds).ConfigureAwait(false);
rootId = anchor.RootExecutionId;
await using (var idsCmd = conn.CreateCommand())
{
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate) + $@"
SELECT ExecutionId FROM Chain
idsCmd.CommandText = ChainCtePrefix(anchor.EdgesWindowPredicate, permittedSiteIds) + $@"
SELECT TOP (2049) ExecutionId FROM (SELECT ExecutionId FROM Chain
UNION
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL
SELECT ParentExecutionId FROM Chain WHERE ParentExecutionId IS NOT NULL) ids
OPTION (MAXRECURSION {ExecutionChainMaxDepth});";
AddChainParameters(idsCmd, anchor);
AddSiteScopeParameter(idsCmd, permittedSiteIds);
await using var reader = await idsCmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
if (!reader.IsDBNull(0)) chainIds.Add(reader.GetGuid(0));
if (!reader.IsDBNull(0)) { if (chainIds.Count == 2048) { chainTruncated = true; break; } chainIds.Add(reader.GetGuid(0)); }
}
}
}
@@ -1231,9 +1249,10 @@ VALUES
return Array.Empty<AuditEvent>();
}
var rows = await _context.Set<AuditLogRow>()
var query = _context.Set<AuditLogRow>()
.AsNoTracking()
.Where(r => r.ExecutionId != null && chainIds.Contains(r.ExecutionId.Value))
.Where(r => permittedSiteIds == null || r.SourceSiteId == null || permittedSiteIds.Contains(r.SourceSiteId))
// Outbound-calls plan (2026-09-06, D6): the replay set is the trace channel plus the
// three outbound channels (the script's API / DB / notification calls already sit in
// the tree under the run's execution id) plus the inbound row. SecuredWrite, Cluster
@@ -1245,11 +1264,49 @@ VALUES
|| r.Channel == AuditChannel.Notification
|| (r.Channel == AuditChannel.ApiInbound && r.Kind == AuditKind.InboundRequest))
.OrderBy(r => r.OccurredAtUtc)
.ThenBy(r => r.EventId)
.ToListAsync(ct)
.ConfigureAwait(false);
.ThenBy(r => r.EventId);
return rows.Select(RowToCanonical).ToList();
const int maxRows = 500;
const int maxBytes = 1024 * 1024 - 4096; // Reserve envelope/array metadata.
var events = new List<AuditEvent>();
var bytes = 0;
var truncated = chainTruncated;
// Small keyset pages also bound EF execution-strategy buffering. Reading
// all 501 candidate rows in one query could buffer hundreds of MiB before
// the consumer reaches its byte cutoff. Each page projects only details
// within the remaining budget; oversized events are omitted whole.
const int pageSize = 8;
DateTime? afterTime = null;
Guid afterId = default;
while (true)
{
var remaining = maxBytes - bytes;
var pageQuery = afterTime is { } cursor
? query.Where(r => r.OccurredAtUtc > cursor || (r.OccurredAtUtc == cursor && r.EventId.CompareTo(afterId) > 0))
: query;
var page = await pageQuery.OrderBy(r => r.OccurredAtUtc).ThenBy(r => r.EventId)
.Take(Math.Min(pageSize, maxRows + 1 - events.Count)).Select(r => new {
Row = new AuditLogRow {
EventId = r.EventId, OccurredAtUtc = r.OccurredAtUtc, Actor = r.Actor, Action = r.Action,
Outcome = r.Outcome, Channel = r.Channel, Target = r.Target, SourceNode = r.SourceNode,
CorrelationId = r.CorrelationId,
DetailsJson = r.DetailsJson != null && r.DetailsJson.Length <= remaining ? r.DetailsJson : null
}, DetailLength = r.DetailsJson == null ? 0 : r.DetailsJson.Length
}).ToListAsync(ct);
if (page.Count == 0) break;
foreach (var item in page)
{
if (item.DetailLength > remaining || events.Count >= maxRows)
return new TraceEventBatch(rootId, events, true);
var evt = RowToCanonical(item.Row);
var size = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(evt).Length;
if (size > maxBytes - bytes) return new TraceEventBatch(rootId, events, true);
events.Add(evt); bytes += size;
afterTime = item.Row.OccurredAtUtc; afterId = item.Row.EventId;
}
if (page.Count < pageSize) break;
}
return new TraceEventBatch(rootId, events, truncated);
}
/// <inheritdoc />
@@ -224,17 +224,37 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChanges(acceptAllChangesOnSuccess);
try { return base.SaveChanges(acceptAllChangesOnSuccess); }
catch (DbUpdateException ex) when (IsInstanceNameConflict(ex))
{ throw DetachInstanceNameConflict(ex); }
}
/// <inheritdoc />
public override Task<int> SaveChangesAsync(
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
GuardSecretWritesHaveAKeyRing();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
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);
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;
@@ -262,6 +282,8 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
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)
@@ -280,14 +302,13 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
}
/// <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.
/// 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 state.
/// 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>
@@ -295,7 +316,7 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public object Create(DbContext context, bool designTime)
=> (context.GetType(),
designTime,
(context as ScadaBridgeDbContext)?.HasSecretEncryptionProvider ?? false);
(context as ScadaBridgeDbContext)?._dataProtectionProvider);
/// <summary>
/// Creates a model cache key for run-time contexts.
@@ -350,6 +371,13 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
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>