diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
new file mode 100644
index 0000000..0d465d5
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.Designer.cs
@@ -0,0 +1,1553 @@
+//
+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("20260520142214_AddAuditLogTable")]
+ partial class AddAuditLogTable
+ {
+ ///
+ 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.AuditEvent", b =>
+ {
+ b.Property("EventId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Actor")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DurationMs")
+ .HasColumnType("int");
+
+ b.Property("ErrorDetail")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Extra")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ForwardState")
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("HttpStatus")
+ .HasColumnType("int");
+
+ b.Property("IngestedAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("PayloadTruncated")
+ .HasColumnType("bit");
+
+ b.Property("RequestSummary")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ResponseSummary")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("SourceInstanceId")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("SourceScript")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("SourceSiteId")
+ .HasMaxLength(64)
+ .IsUnicode(false)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("Target")
+ .HasMaxLength(256)
+ .IsUnicode(false)
+ .HasColumnType("varchar(256)");
+
+ b.HasKey("EventId", "OccurredAtUtc");
+
+ b.HasIndex("CorrelationId")
+ .HasDatabaseName("IX_AuditLog_CorrelationId")
+ .HasFilter("[CorrelationId] IS NOT NULL");
+
+ b.HasIndex("EventId")
+ .IsUnique()
+ .HasDatabaseName("UX_AuditLog_EventId");
+
+ b.HasIndex("OccurredAtUtc")
+ .IsDescending()
+ .HasDatabaseName("IX_AuditLog_OccurredAtUtc");
+
+ b.HasIndex("SourceSiteId", "OccurredAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLog_Site_Occurred");
+
+ b.HasIndex("Target", "OccurredAtUtc")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_AuditLog_Target_Occurred")
+ .HasFilter("[Target] IS NOT NULL");
+
+ b.HasIndex("Channel", "Status", "OccurredAtUtc")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred");
+
+ b.ToTable("AuditLog", (string)null);
+ });
+
+ 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("KeyHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("KeyHash")
+ .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.Notification", b =>
+ {
+ b.Property("NotificationId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("DeliveredAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("nvarchar(4000)");
+
+ b.Property("ListName")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("NextAttemptAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("ResolvedTargets")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("RetryCount")
+ .HasColumnType("int");
+
+ b.Property("SiteEnqueuedAt")
+ .HasColumnType("datetimeoffset");
+
+ b.Property("SourceInstanceId")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceScript")
+ .HasMaxLength(200)
+ .HasColumnType("nvarchar(200)");
+
+ b.Property("SourceSiteId")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("nvarchar(100)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(1000)
+ .HasColumnType("nvarchar(1000)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("TypeData")
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("NotificationId");
+
+ b.HasIndex("SourceSiteId", "CreatedAt");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("Notifications");
+ });
+
+ 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.Property("Type")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ 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")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ b.Property("GrpcNodeBAddress")
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)");
+
+ 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()
+ .HasFilter("[IsDerived] = 0");
+
+ 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/20260520142214_AddAuditLogTable.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs
new file mode 100644
index 0000000..9520efe
--- /dev/null
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/20260520142214_AddAuditLogTable.cs
@@ -0,0 +1,201 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ScadaLink.ConfigurationDatabase.Migrations
+{
+ ///
+ /// Bundle C (#23 M1): creates the centralized AuditLog table with monthly
+ /// partitioning and the two access-control roles documented in alog.md §4.
+ ///
+ /// Structure:
+ /// 1. Partition function pf_AuditLog_Month (RANGE RIGHT) with 24
+ /// monthly boundaries covering 2026-01-01 through 2027-12-01 UTC.
+ /// 2. Partition scheme ps_AuditLog_Month mapping every partition to
+ /// [PRIMARY] (dev/test parity; production may relocate via filegroups).
+ /// 3. AuditLog table created via raw SQL so it is created directly
+ /// on the partition scheme. The clustered PK is composite
+ /// {EventId, OccurredAtUtc} — required because partition-aligned PKs
+ /// must include the partition column.
+ /// 4. Five reconciliation/query indexes from alog.md §4, plus the
+ /// UX_AuditLog_EventId unique index that preserves single-column
+ /// EventId uniqueness for InsertIfNotExistsAsync (M1-T8). All
+ /// non-clustered indexes are partition-aligned on
+ /// ps_AuditLog_Month(OccurredAtUtc).
+ /// 5. Two database roles:
+ /// - scadalink_audit_writer: INSERT + SELECT on AuditLog, with
+ /// explicit DENY on UPDATE and DELETE so additive role membership
+ /// (e.g. later db_datawriter) cannot accidentally re-enable mutation.
+ /// - scadalink_audit_purger: SELECT on AuditLog and ALTER on
+ /// SCHEMA::dbo so the purger can run ALTER PARTITION FUNCTION SWITCH
+ /// and SWITCH PARTITION when sliding the retention window.
+ ///
+ public partial class AddAuditLogTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ // 1) Partition function (monthly boundaries Jan 2026 – Dec 2027 UTC).
+ // RANGE RIGHT — the boundary value belongs to the right-hand partition,
+ // matching the convention used by SQL Server partition-switch tooling.
+ migrationBuilder.Sql(@"
+CREATE PARTITION FUNCTION pf_AuditLog_Month (datetime2(7))
+AS RANGE RIGHT FOR VALUES (
+ '2026-01-01T00:00:00', '2026-02-01T00:00:00', '2026-03-01T00:00:00', '2026-04-01T00:00:00',
+ '2026-05-01T00:00:00', '2026-06-01T00:00:00', '2026-07-01T00:00:00', '2026-08-01T00:00:00',
+ '2026-09-01T00:00:00', '2026-10-01T00:00:00', '2026-11-01T00:00:00', '2026-12-01T00:00:00',
+ '2027-01-01T00:00:00', '2027-02-01T00:00:00', '2027-03-01T00:00:00', '2027-04-01T00:00:00',
+ '2027-05-01T00:00:00', '2027-06-01T00:00:00', '2027-07-01T00:00:00', '2027-08-01T00:00:00',
+ '2027-09-01T00:00:00', '2027-10-01T00:00:00', '2027-11-01T00:00:00', '2027-12-01T00:00:00'
+);");
+
+ // 2) Partition scheme mapping every partition to [PRIMARY].
+ migrationBuilder.Sql(@"
+CREATE PARTITION SCHEME ps_AuditLog_Month
+AS PARTITION pf_AuditLog_Month ALL TO ([PRIMARY]);");
+
+ // 3) Create the table directly on the partition scheme. Column shapes
+ // are copied from AuditLogEntityTypeConfiguration so the live schema
+ // matches the EF model exactly. The clustered PK is composite to
+ // satisfy SQL Server's rule that partition-aligned clustered indexes
+ // must include the partition column.
+ migrationBuilder.Sql(@"
+CREATE TABLE dbo.AuditLog (
+ EventId uniqueidentifier NOT NULL,
+ OccurredAtUtc datetime2(7) NOT NULL,
+ IngestedAtUtc datetime2(7) NULL,
+ Channel varchar(32) NOT NULL,
+ Kind varchar(32) NOT NULL,
+ CorrelationId uniqueidentifier NULL,
+ SourceSiteId varchar(64) NULL,
+ SourceInstanceId varchar(128) NULL,
+ SourceScript varchar(128) NULL,
+ Actor varchar(128) NULL,
+ Target varchar(256) NULL,
+ Status varchar(32) NOT NULL,
+ HttpStatus int NULL,
+ DurationMs int NULL,
+ ErrorMessage nvarchar(1024) NULL,
+ ErrorDetail nvarchar(max) NULL,
+ RequestSummary nvarchar(max) NULL,
+ ResponseSummary nvarchar(max) NULL,
+ PayloadTruncated bit NOT NULL,
+ Extra nvarchar(max) NULL,
+ ForwardState varchar(32) NULL,
+ CONSTRAINT PK_AuditLog PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
+ ON ps_AuditLog_Month(OccurredAtUtc)
+) ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ // 4) Reconciliation/query indexes from alog.md §4. All non-clustered
+ // indexes are partition-aligned on ps_AuditLog_Month(OccurredAtUtc)
+ // so partition-switch operations only touch a single partition. The
+ // filtered indexes carry their NOT NULL predicates as documented.
+ migrationBuilder.Sql(@"
+CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
+ON dbo.AuditLog (OccurredAtUtc DESC)
+ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ migrationBuilder.Sql(@"
+CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
+ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
+ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ migrationBuilder.Sql(@"
+CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
+ON dbo.AuditLog (CorrelationId)
+WHERE CorrelationId IS NOT NULL
+ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ migrationBuilder.Sql(@"
+CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
+ON dbo.AuditLog (Channel ASC, Status ASC, OccurredAtUtc DESC)
+ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ migrationBuilder.Sql(@"
+CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
+ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
+WHERE Target IS NOT NULL
+ON ps_AuditLog_Month(OccurredAtUtc);");
+
+ // The EventId uniqueness index supports InsertIfNotExistsAsync
+ // (M1-T8). It is INTENTIONALLY non-aligned (placed on [PRIMARY]
+ // rather than ps_AuditLog_Month).
+ //
+ // SQL Server's rule for unique partition-aligned indexes is that the
+ // partition column must be a SUBSET of the index key. Including
+ // OccurredAtUtc in the key would change the uniqueness semantics
+ // from "EventId is globally unique" to "(EventId, OccurredAtUtc)
+ // is unique", which is the same guarantee the composite PK already
+ // provides — it would not give us single-column EventId uniqueness.
+ //
+ // Trade-off: a non-aligned index disables ALTER TABLE … SWITCH
+ // PARTITION on AuditLog. The M1 purge story (M2/M3) uses an
+ // explicit rebuild path that drops and re-creates this index
+ // around the switch, so the aligned-indexes pattern is preserved
+ // for partition switching at purge time.
+ migrationBuilder.Sql(@"
+CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
+ON dbo.AuditLog (EventId)
+ON [PRIMARY];");
+
+ // 5) DB roles. Both definitions are idempotent so the migration is
+ // safe to re-apply against a database that already has the role.
+ // The DENY UPDATE / DENY DELETE on the writer role is deliberate —
+ // a future db_datawriter membership cannot quietly re-enable
+ // mutation because DENY outranks GRANT.
+ migrationBuilder.Sql(@"
+IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NULL
+ EXEC sp_executesql N'CREATE ROLE scadalink_audit_writer';
+GRANT INSERT ON dbo.AuditLog TO scadalink_audit_writer;
+GRANT SELECT ON dbo.AuditLog TO scadalink_audit_writer;
+DENY UPDATE ON dbo.AuditLog TO scadalink_audit_writer;
+DENY DELETE ON dbo.AuditLog TO scadalink_audit_writer;");
+
+ migrationBuilder.Sql(@"
+IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NULL
+ EXEC sp_executesql N'CREATE ROLE scadalink_audit_purger';
+GRANT SELECT ON dbo.AuditLog TO scadalink_audit_purger;
+GRANT ALTER ON SCHEMA::dbo TO scadalink_audit_purger;");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ // Drop in reverse dependency order so each statement's prerequisites
+ // still exist when it runs. Each DROP is guarded so a partial Up()
+ // (or a re-applied Down()) cannot fail on missing objects.
+ migrationBuilder.Sql(@"
+IF DATABASE_PRINCIPAL_ID('scadalink_audit_purger') IS NOT NULL
+ EXEC sp_executesql N'DROP ROLE scadalink_audit_purger';
+IF DATABASE_PRINCIPAL_ID('scadalink_audit_writer') IS NOT NULL
+ EXEC sp_executesql N'DROP ROLE scadalink_audit_writer';");
+
+ // Indexes are dropped implicitly when the table goes away, but
+ // dropping them explicitly first keeps the Down() statement self-
+ // describing and mirrors the Up() shape.
+ migrationBuilder.Sql(@"
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_AuditLog_EventId' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX UX_AuditLog_EventId ON dbo.AuditLog;
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Target_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX IX_AuditLog_Target_Occurred ON dbo.AuditLog;
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Channel_Status_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX IX_AuditLog_Channel_Status_Occurred ON dbo.AuditLog;
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_CorrelationId' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX IX_AuditLog_CorrelationId ON dbo.AuditLog;
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_Site_Occurred' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX IX_AuditLog_Site_Occurred ON dbo.AuditLog;
+IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditLog_OccurredAtUtc' AND object_id = OBJECT_ID('dbo.AuditLog'))
+ DROP INDEX IX_AuditLog_OccurredAtUtc ON dbo.AuditLog;");
+
+ migrationBuilder.Sql(@"
+IF OBJECT_ID('dbo.AuditLog', 'U') IS NOT NULL
+ DROP TABLE dbo.AuditLog;");
+
+ migrationBuilder.Sql(@"
+IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditLog_Month')
+ DROP PARTITION SCHEME ps_AuditLog_Month;
+IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditLog_Month')
+ DROP PARTITION FUNCTION pf_AuditLog_Month;");
+ }
+ }
+}
diff --git a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
index e67197a..02b7745 100644
--- a/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
+++ b/src/ScadaLink.ConfigurationDatabase/Migrations/ScadaLinkDbContextModelSnapshot.cs
@@ -41,6 +41,123 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("DataProtectionKeys");
});
+ modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditEvent", b =>
+ {
+ b.Property("EventId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("datetime2");
+
+ b.Property("Actor")
+ .HasMaxLength(128)
+ .IsUnicode(false)
+ .HasColumnType("varchar(128)");
+
+ b.Property("Channel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .IsUnicode(false)
+ .HasColumnType("varchar(32)");
+
+ b.Property("CorrelationId")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("DurationMs")
+ .HasColumnType("int");
+
+ b.Property("ErrorDetail")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("Extra")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property