From 0c82ffcbe65ebda14fd44d66b8d31a1aac139850 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 21:11:24 -0400 Subject: [PATCH] =?UTF-8?q?fix(configuration-database):=20resolve=20Config?= =?UTF-8?q?urationDatabase-002..007=20=E2=80=94=20remove=20hardcoded=20sa?= =?UTF-8?q?=20creds,=20fail-fast=20no-arg=20DI,=20encrypt=20secret=20colum?= =?UTF-8?q?ns,=20resilient=20audit=20serialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConfigurationDatabase/findings.md | 88 +- .../ExternalSystemConfiguration.cs | 8 +- .../NotificationConfiguration.cs | 4 +- .../DesignTimeDbContextFactory.cs | 46 +- .../EncryptedStringConverter.cs | 49 + ...517010521_EncryptSecretColumns.Designer.cs | 1348 +++++++++++++++++ .../20260517010521_EncryptSecretColumns.cs | 82 + .../ScadaLinkDbContextModelSnapshot.cs | 12 +- .../ScadaLink.ConfigurationDatabase.csproj | 1 + .../ScadaLinkDbContext.cs | 48 + .../ServiceCollectionExtensions.cs | 46 +- .../Services/AuditService.cs | 38 +- .../AuditServiceTests.cs | 31 + .../DesignTimeDbContextFactoryTests.cs | 61 + .../SecretEncryptionTests.cs | 129 ++ .../ServiceCollectionExtensionsTests.cs | 57 + .../UnitTest1.cs | 21 +- 17 files changed, 2029 insertions(+), 40 deletions(-) create mode 100644 src/ScadaLink.ConfigurationDatabase/EncryptedStringConverter.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs create mode 100644 src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/DesignTimeDbContextFactoryTests.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/SecretEncryptionTests.cs create mode 100644 tests/ScadaLink.ConfigurationDatabase.Tests/ServiceCollectionExtensionsTests.cs diff --git a/code-reviews/ConfigurationDatabase/findings.md b/code-reviews/ConfigurationDatabase/findings.md index 515ddd7..d1b1615 100644 --- a/code-reviews/ConfigurationDatabase/findings.md +++ b/code-reviews/ConfigurationDatabase/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 10 | +| Open findings | 6 | ## Summary @@ -113,7 +113,7 @@ template-aggregate contract the callers depend on. |--|--| | Severity | Medium | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs:21-22` | **Description** @@ -136,7 +136,19 @@ and never use `sa`. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the factory +fell back to a literal `User Id=sa;Password=YourPassword;...` connection string when no +configured value was found. Removed the hardcoded fallback entirely. The factory now +resolves the connection string from the Host's appsettings files or, when those are not +present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and +throws a clear `InvalidOperationException` (naming both the config key and the env var) +when neither yields a value. Also hardened `SetBasePath` to be applied only when the +`ScadaLink.Host` directory exists, so the factory degrades cleanly instead of throwing +`DirectoryNotFoundException` when run from a context without a sibling Host folder. +Regression tests added in `DesignTimeDbContextFactoryTests.cs`: +`CreateDbContext_NoConnectionStringConfigured_ThrowsClearException`, +`CreateDbContext_ConnectionStringFromEnvironmentVariable_IsUsed`, and +`DesignTimeDbContextFactory_SourceContainsNoHardcodedSaCredential`. ### ConfigurationDatabase-003 — No-arg `AddConfigurationDatabase()` silently registers nothing @@ -144,7 +156,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Error handling & resilience | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/ServiceCollectionExtensions.cs:44-49` | **Description** @@ -166,7 +178,21 @@ name (e.g. `AddConfigurationDatabaseNoOp()`), and remove the stale "Phase 0" wor **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the +parameterless `AddConfigurationDatabase()` overload returned `services` unchanged, +registering no `DbContext`, repositories, `IAuditService`, or `IInstanceLocator`. +Applied the recommendation's first option: the overload is now marked +`[Obsolete(..., error: true)]` so any source reference is a compile-time failure, and +its body throws `InvalidOperationException` with an actionable message as +defence-in-depth (covering reflection-based invocation or suppressed warnings). The +stale "Phase 0 stubs / backward compatibility" XML comment was replaced with one +explaining the obsoletion. The pre-existing +`ServiceRegistrationTests.AddConfigurationDatabase_NoArgs_DoesNotThrow` test in +`UnitTest1.cs`, which encoded the old buggy no-op contract, was updated to +`AddConfigurationDatabase_NoArgs_FailsFast` to assert the corrected behaviour. +New regression tests added in `ServiceCollectionExtensionsTests.cs`: +`AddConfigurationDatabase_NoArgOverload_FailsFastWithClearMessage` and +`AddConfigurationDatabase_NoArgOverload_IsMarkedObsoleteAsError`. ### ConfigurationDatabase-004 — Secret-bearing columns stored in plaintext with no protection @@ -174,7 +200,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs:56-57`, `src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs:25-26,75-77` | **Description** @@ -199,7 +225,35 @@ doc to state the chosen at-rest protection. **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). Root cause confirmed against source: +`SmtpConfiguration.Credentials`, `ExternalSystemDefinition.AuthConfiguration`, and +`DatabaseConnectionDefinition.ConnectionString` were mapped as ordinary `nvarchar(4000)` +columns and persisted verbatim. + +Implemented the recommendation's first option — an in-module EF Core value converter +backed by ASP.NET Data Protection, which the module already uses +(`IDataProtectionKeyContext`, `AddDataProtection().PersistKeysToDbContext`). Added +`EncryptedStringConverter` (purpose-scoped `IDataProtector`; `Protect` on write, +`Unprotect` on read; null-safe; surfaces a clear message on a `CryptographicException`). +`ScadaLinkDbContext` gained an `(options, IDataProtectionProvider)` constructor and +applies the converter to the three secret columns in `OnModelCreating`; the DI +registration in `ServiceCollectionExtensions` now constructs the context with the +registered provider. The secret columns were widened to `HasMaxLength(8000)` (EF maps +this to `nvarchar(max)` on SQL Server) so ciphertext expansion cannot truncate the +value; migration `20260517010521_EncryptSecretColumns` carries the column-type change. +Regression tests added in `SecretEncryptionTests.cs` verify the raw column value is +never the plaintext secret and that EF transparently decrypts on read, for all three +columns plus a null round-trip. + +The encryption scheme itself is fully in-module; the only remaining cross-cutting item +is a documentation gap — the design doc does not yet state encryption-at-rest for these +fields. That doc update is outside this module's editable scope (constraint: edit only +`src/ScadaLink.ConfigurationDatabase`, the tests, and this file) and is surfaced here +for a follow-up to `docs/requirements/Component-ConfigurationDatabase.md`. The audit +secret-leak concern is mitigated separately by CD-007's serializer hardening; whether +callers should additionally redact secret-bearing entities before passing them to +`IAuditService` is a caller-side concern in other modules and is also surfaced for +follow-up. The code fix in this module is complete. ### ConfigurationDatabase-005 — Audit `Id` type disagrees with the design doc @@ -264,7 +318,7 @@ _Unresolved._ |--|--| | Severity | Medium | | Category | Error handling & resilience | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.ConfigurationDatabase/Services/AuditService.cs:28-30` | **Description** @@ -289,7 +343,23 @@ and document that decision against the design doc's transactional-guarantee sect **Resolution** -_Unresolved._ +Resolved 2026-05-16 (commit pending). Root cause confirmed against source: `LogAsync` +called `JsonSerializer.Serialize(afterState)` with default options, so any `afterState` +graph containing a reference cycle threw `JsonException` — and because the audit entry +commits in the same transaction as the change it records, that exception rolled back +the entire business operation. + +Fix applied per the recommendation: `AuditService` now serializes via a static +`JsonSerializerOptions` configured with `ReferenceHandler.IgnoreCycles` and +`MaxDepth = 32`. The serialization is additionally wrapped in a `SerializeAfterState` +helper that catches a residual `JsonException`/`NotSupportedException` and substitutes a +small diagnostic placeholder JSON (`AuditSerializationError` + `StateType`) — an explicit +decision that an audit-serialization failure must **degrade gracefully** and never roll +back the audited operation. The audit entry is always recorded; the design doc's +transactional-guarantee section ("if the change succeeds, the audit entry is always +recorded") is thereby honoured even for pathological state objects. Regression test +added in `AuditServiceTests.cs`: +`LogAsync_AfterStateWithReferenceCycle_DoesNotThrow_AndDoesNotRollBackOperation`. ### ConfigurationDatabase-008 — `GetApprovedKeysForMethodAsync` CSV parsing silently drops malformed ids diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs index 9572bc1..d50c4e3 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/ExternalSystemConfiguration.cs @@ -22,8 +22,10 @@ public class ExternalSystemDefinitionConfiguration : IEntityTypeConfiguration e.AuthConfiguration) - .HasMaxLength(4000); + .HasMaxLength(8000); builder.HasMany() .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(); } diff --git a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs index 46e856a..f87e82e 100644 --- a/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs +++ b/src/ScadaLink.ConfigurationDatabase/Configurations/NotificationConfiguration.cs @@ -53,8 +53,10 @@ public class SmtpConfigurationConfiguration : IEntityTypeConfiguration s.Credentials) - .HasMaxLength(4000); + .HasMaxLength(8000); builder.Property(s => s.TlsMode) .HasMaxLength(50); diff --git a/src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs b/src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs index dc13fd1..fa58ef9 100644 --- a/src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs +++ b/src/ScadaLink.ConfigurationDatabase/DesignTimeDbContextFactory.cs @@ -6,20 +6,50 @@ namespace ScadaLink.ConfigurationDatabase; /// /// 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 +/// SCADALINK_DESIGNTIME_CONNECTIONSTRING environment variable. /// +/// +/// There is deliberately no hardcoded fallback connection string. A credential literal in +/// source is committed to version control, encourages copy-paste of sa / +/// TrustServerCertificate=True into real environments, and can silently point +/// dotnet ef tooling at an unintended database. If no connection string can be +/// resolved, this factory fails loudly with an actionable message. +/// public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory { + 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(); optionsBuilder.UseSqlServer(connectionString); diff --git a/src/ScadaLink.ConfigurationDatabase/EncryptedStringConverter.cs b/src/ScadaLink.ConfigurationDatabase/EncryptedStringConverter.cs new file mode 100644 index 0000000..0be52a8 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/EncryptedStringConverter.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ScadaLink.ConfigurationDatabase; + +/// +/// 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. +/// +/// +/// 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 implementing +/// IDataProtectionKeyContext), so all central nodes share the same key ring +/// and can decrypt each other's writes. +/// +public sealed class EncryptedStringConverter : ValueConverter +{ + /// The Data Protection purpose string shared by all encrypted configuration columns. + 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); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs new file mode 100644 index 0000000..7c9cbb1 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.Designer.cs @@ -0,0 +1,1348 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ScadaLink.ConfigurationDatabase; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaLinkDbContext))] + [Migration("20260517010521_EncryptSecretColumns")] + partial class EncryptSecretColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("KeyValue") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("KeyValue") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApprovedApiKeyIds") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("GrpcNodeBAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ScadaLink.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ScadaLink.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ScadaLink.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ScadaLink.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ScadaLink.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ScadaLink.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.cs new file mode 100644 index 0000000..b49e063 --- /dev/null +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260517010521_EncryptSecretColumns.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ScadaLink.ConfigurationDatabase.Migrations +{ + /// + public partial class EncryptSecretColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Credentials", + table: "SmtpConfigurations", + type: "nvarchar(max)", + maxLength: 8000, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(4000)", + oldMaxLength: 4000, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AuthConfiguration", + table: "ExternalSystemDefinitions", + type: "nvarchar(max)", + maxLength: 8000, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(4000)", + oldMaxLength: 4000, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ConnectionString", + table: "DatabaseConnectionDefinitions", + type: "nvarchar(max)", + maxLength: 8000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(4000)", + oldMaxLength: 4000); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Credentials", + table: "SmtpConfigurations", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 8000, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "AuthConfiguration", + table: "ExternalSystemDefinitions", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 8000, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "ConnectionString", + table: "DatabaseConnectionDefinitions", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: false, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldMaxLength: 8000); + } + } +} diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs index 2625999..a29759c 100644 --- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs +++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs @@ -232,8 +232,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations b.Property("ConnectionString") .IsRequired() - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); b.Property("MaxRetries") .HasColumnType("int"); @@ -263,8 +263,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); b.Property("AuthConfiguration") - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); b.Property("AuthType") .IsRequired() @@ -632,8 +632,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations .HasColumnType("int"); b.Property("Credentials") - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); b.Property("FromAddress") .IsRequired() diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj index 1a9d67e..88e685e 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLink.ConfigurationDatabase.csproj @@ -17,6 +17,7 @@ + diff --git a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs index 8f79494..6812c66 100644 --- a/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs +++ b/src/ScadaLink.ConfigurationDatabase/ScadaLinkDbContext.cs @@ -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 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. + /// + public ScadaLinkDbContext(DbContextOptions options, IDataProtectionProvider dataProtectionProvider) + : base(options) + { + _dataProtectionProvider = dataProtectionProvider + ?? throw new ArgumentNullException(nameof(dataProtectionProvider)); + } + // Templates public DbSet