Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
T
Joseph Doherty b7f5e887ee feat(audit): OtOpcUa ConfigAuditLog.Outcome column + migration + ClusterAudit visibility fix (Task 2.2)
Persist the canonical AuditOutcome and make structured audit rows visible.

- ConfigAuditLog gains a nullable Outcome column, stored as the AuditOutcome
  enum member name (nvarchar(16), mirroring how AdminRole is persisted). The
  AuditWriterActor flush now writes Outcome = evt.Outcome.ToString(). Nullable so
  legacy rows and the bespoke stored-procedure path (no derived outcome) write
  NULL.
- Migration 20260602135350_AddConfigAuditLogOutcome: additive nullable column,
  no backfill. Up adds the column, Down drops it. Chains after
  20260602112419_CanonicalizeAdminRoles; `dotnet ef migrations
  has-pending-model-changes` is clean.
- ClusterAudit visibility fix: the page filtered solely on ClusterId, but the
  structured AuditWriterActor path stamps NodeId (ClusterId null), so those rows
  were invisible. Extracted ClusterAuditQuery.ForClusterAsync (shared by the page
  and tests) which ORs in rows whose NodeId belongs to a node in the cluster —
  membership resolved from ClusterNode (NodeId -> ClusterId). SP-path
  ClusterId-stamped rows still match.

Tests: ControlPlane 45/45 (adds Outcome persistence + Denied-outcome asserts);
new Configuration ClusterAuditQueryTests 3/3 (both-paths visible, other-cluster
excluded, page-size cap); AdminUI 121/121. Configuration Unit suite is green on a
clean run (a pre-existing timing flake in ResilientConfigReaderTests, untouched
here, occasionally fails under parallel load and passes in isolation).
2026-06-02 09:59:22 -04:00

795 lines
41 KiB
C#

using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration;
/// <summary>
/// Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
/// </summary>
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
: DbContext(options), IDataProtectionKeyContext
{
/// <summary>Gets the DbSet of server clusters.</summary>
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
/// <summary>Gets the DbSet of cluster nodes.</summary>
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
/// <summary>Gets the DbSet of cluster node credentials.</summary>
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
/// <summary>Gets the DbSet of namespaces.</summary>
public DbSet<Namespace> Namespaces => Set<Namespace>();
/// <summary>Gets the DbSet of UNS areas.</summary>
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
/// <summary>Gets the DbSet of UNS lines.</summary>
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
/// <summary>Gets the DbSet of driver instances.</summary>
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
/// <summary>Gets the DbSet of devices.</summary>
public DbSet<Device> Devices => Set<Device>();
/// <summary>Gets the DbSet of equipment.</summary>
public DbSet<Equipment> Equipment => Set<Equipment>();
/// <summary>Gets the DbSet of tags.</summary>
public DbSet<Tag> Tags => Set<Tag>();
/// <summary>Gets the DbSet of poll groups.</summary>
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
/// <summary>Gets the DbSet of node ACLs.</summary>
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
/// <summary>Gets the DbSet of configuration audit logs.</summary>
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
/// <summary>Gets the DbSet of external ID reservations.</summary>
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
/// <summary>Gets the DbSet of driver host statuses.</summary>
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
/// <summary>Gets the DbSet of driver instance resilience statuses.</summary>
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
/// <summary>Gets the DbSet of LDAP group role mappings.</summary>
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
/// <summary>Gets the DbSet of equipment import batches.</summary>
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
/// <summary>Gets the DbSet of equipment import rows.</summary>
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
/// <summary>Gets the DbSet of scripts.</summary>
public DbSet<Script> Scripts => Set<Script>();
/// <summary>Gets the DbSet of virtual tags.</summary>
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
/// <summary>Gets the DbSet of scripted alarms.</summary>
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
/// <summary>Gets the DbSet of scripted alarm states.</summary>
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
// v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment).
/// <summary>Gets the DbSet of deployments.</summary>
public DbSet<Deployment> Deployments => Set<Deployment>();
/// <summary>Gets the DbSet of node deployment states.</summary>
public DbSet<NodeDeploymentState> NodeDeploymentStates => Set<NodeDeploymentState>();
/// <summary>Gets the DbSet of configuration edits.</summary>
public DbSet<ConfigEdit> ConfigEdits => Set<ConfigEdit>();
// ASP.NET DataProtection key ring storage (decision: keys persisted in ConfigDb so every
// admin-role node decrypts the same cookies without sharing a filesystem).
/// <summary>Gets the DbSet of data protection keys.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
/// <summary>Configures the entity model when the context is first created.</summary>
/// <param name="modelBuilder">The model builder used to configure the context.</param>
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureServerCluster(modelBuilder);
ConfigureClusterNode(modelBuilder);
ConfigureClusterNodeCredential(modelBuilder);
ConfigureNamespace(modelBuilder);
ConfigureUnsArea(modelBuilder);
ConfigureUnsLine(modelBuilder);
ConfigureDriverInstance(modelBuilder);
ConfigureDevice(modelBuilder);
ConfigureEquipment(modelBuilder);
ConfigureTag(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
ConfigureDriverHostStatus(modelBuilder);
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
ConfigureScriptedAlarmState(modelBuilder);
ConfigureDeployment(modelBuilder);
ConfigureNodeDeploymentState(modelBuilder);
ConfigureConfigEdit(modelBuilder);
ConfigureDataProtectionKey(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ServerCluster>(e =>
{
e.ToTable("ServerCluster", t =>
{
t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
"((NodeCount = 1 AND RedundancyMode = 'None') " +
"OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
});
e.HasKey(x => x.ClusterId);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.Enterprise).HasMaxLength(32);
e.Property(x => x.Site).HasMaxLength(32);
e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
e.Property(x => x.ModifiedBy).HasMaxLength(128);
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
});
}
private static void ConfigureClusterNode(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClusterNode>(e =>
{
e.ToTable("ClusterNode");
e.HasKey(x => x.NodeId);
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Host).HasMaxLength(255);
e.Property(x => x.ApplicationUri).HasMaxLength(256);
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
// Fleet-wide unique per decision #86
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
// v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole
// column it filtered on) are gone. Akka cluster leader-of-driver-role is the
// authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator,
// Task 35).
});
}
private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClusterNodeCredential>(e =>
{
e.ToTable("ClusterNodeCredential");
e.HasKey(x => x.CredentialId);
e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(512);
e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.HasOne(x => x.Node).WithMany(n => n.Credentials)
.HasForeignKey(x => x.NodeId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
.HasFilter("[Enabled] = 1")
.HasDatabaseName("UX_ClusterNodeCredential_Value");
});
}
private static void ConfigureNamespace(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Namespace>(e =>
{
e.ToTable("Namespace");
e.HasKey(x => x.NamespaceRowId);
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.NamespaceUri).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ClusterId, x.Kind }).IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
e.HasIndex(x => x.NamespaceUri).IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
e.HasIndex(x => x.NamespaceId).IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId");
e.HasIndex(x => x.ClusterId)
.HasDatabaseName("IX_Namespace_Cluster");
});
}
private static void ConfigureUnsArea(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UnsArea>(e =>
{
e.ToTable("UnsArea");
e.HasKey(x => x.UnsAreaRowId);
e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsAreaId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.Notes).HasMaxLength(512);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_UnsArea_Cluster");
e.HasIndex(x => x.UnsAreaId).IsUnique().HasDatabaseName("UX_UnsArea_LogicalId");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_ClusterName");
});
}
private static void ConfigureUnsLine(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UnsLine>(e =>
{
e.ToTable("UnsLine");
e.HasKey(x => x.UnsLineRowId);
e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.UnsAreaId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.Notes).HasMaxLength(512);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.UnsAreaId).HasDatabaseName("IX_UnsLine_Area");
e.HasIndex(x => x.UnsLineId).IsUnique().HasDatabaseName("UX_UnsLine_LogicalId");
e.HasIndex(x => new { x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_AreaName");
});
}
private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverInstance>(e =>
{
e.ToTable("DriverInstance", t =>
{
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
"ISJSON(DriverConfig) = 1");
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
"ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
});
e.HasKey(x => x.DriverInstanceRowId);
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster");
e.HasIndex(x => x.NamespaceId).HasDatabaseName("IX_DriverInstance_Namespace");
e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId");
});
}
private static void ConfigureDevice(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Device>(e =>
{
e.ToTable("Device", t =>
{
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
});
e.HasKey(x => x.DeviceRowId);
e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver");
e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId");
});
}
private static void ConfigureEquipment(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Equipment>(e =>
{
e.ToTable("Equipment");
e.HasKey(x => x.EquipmentRowId);
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.MachineCode).HasMaxLength(64);
e.Property(x => x.ZTag).HasMaxLength(64);
e.Property(x => x.SAPID).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(64);
e.Property(x => x.Model).HasMaxLength(64);
e.Property(x => x.SerialNumber).HasMaxLength(64);
e.Property(x => x.HardwareRevision).HasMaxLength(32);
e.Property(x => x.SoftwareRevision).HasMaxLength(32);
e.Property(x => x.AssetLocation).HasMaxLength(256);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Equipment_Driver");
e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line");
e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId");
e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath");
e.HasIndex(x => x.EquipmentUuid).IsUnique().HasDatabaseName("UX_Equipment_Uuid");
e.HasIndex(x => x.ZTag).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_ZTag");
e.HasIndex(x => x.SAPID).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_SAPID");
e.HasIndex(x => x.MachineCode).HasDatabaseName("IX_Equipment_MachineCode");
});
}
private static void ConfigureTag(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Tag>(e =>
{
e.ToTable("Tag", t =>
{
t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
});
e.HasKey(x => x.TagRowId);
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.FolderPath).HasMaxLength(512);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => new { x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Driver_Device");
e.HasIndex(x => x.EquipmentId)
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("IX_Tag_Equipment");
e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("UX_Tag_EquipmentPath");
e.HasIndex(x => new { x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NULL")
.HasDatabaseName("UX_Tag_FolderPath");
});
}
private static void ConfigurePollGroup(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PollGroup>(e =>
{
e.ToTable("PollGroup", t =>
{
t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
});
e.HasKey(x => x.PollGroupRowId);
e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_PollGroup_Driver");
e.HasIndex(x => x.PollGroupId).IsUnique().HasDatabaseName("UX_PollGroup_LogicalId");
});
}
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NodeAcl>(e =>
{
e.ToTable("NodeAcl");
e.HasKey(x => x.NodeAclRowId);
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NodeAclId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.LdapGroup).HasMaxLength(256);
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.ScopeId).HasMaxLength(64);
e.Property(x => x.PermissionFlags).HasConversion<int>();
e.Property(x => x.Notes).HasMaxLength(512);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster");
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group");
e.HasIndex(x => new { x.ScopeKind, x.ScopeId })
.HasFilter("[ScopeId] IS NOT NULL")
.HasDatabaseName("IX_NodeAcl_Scope");
e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId");
e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
.HasDatabaseName("UX_NodeAcl_GroupScope");
});
}
private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ConfigAuditLog>(e =>
{
e.ToTable("ConfigAuditLog", t =>
{
t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
"DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
});
e.HasKey(x => x.AuditId);
e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.Principal).HasMaxLength(128);
e.Property(x => x.EventType).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.EventId);
e.Property(x => x.CorrelationId);
// Stored as the AuditOutcome enum member name (mirrors AdminRole's string storage):
// "Success" | "Failure" | "Denied" all fit nvarchar(16). Nullable for legacy + SP-path rows.
e.Property(x => x.Outcome).HasMaxLength(16);
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
.IsDescending(false, true)
.HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
e.HasIndex(x => x.GenerationId)
.HasFilter("[GenerationId] IS NOT NULL")
.HasDatabaseName("IX_ConfigAuditLog_Generation");
// Filtered unique index gives cross-restart idempotency for the AuditWriterActor:
// a retry of an already-flushed batch will hit this constraint and the catch in
// FlushBuffer drops the duplicate insert. Nullable + filter so legacy backfill rows
// (EventId=NULL) don't collide.
e.HasIndex(x => x.EventId)
.IsUnique()
.HasFilter("[EventId] IS NOT NULL")
.HasDatabaseName("UX_ConfigAuditLog_EventId");
});
}
private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExternalIdReservation>(e =>
{
e.ToTable("ExternalIdReservation");
e.HasKey(x => x.ReservationId);
e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Value).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
e.Property(x => x.ReleasedBy).HasMaxLength(128);
e.Property(x => x.ReleaseReason).HasMaxLength(512);
// Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
// The UX_ filtered index covers active-reservation lookups; history queries over released rows
// fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
// index is declared because EF Core merges duplicate column sets into a single index, which would
// clobber the filtered-unique name.
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
.HasFilter("[ReleasedAt] IS NULL")
.HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
});
}
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverHostStatus>(e =>
{
e.ToTable("DriverHostStatus");
// Composite key — one row per (server node, driver instance, probe-reported host).
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
// 6 rows because each server node owns its own runtime view; the composite key is
// what lets both views coexist without shadowing each other.
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Detail).HasMaxLength(1024);
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
});
}
private static void ConfigureDriverInstanceResilienceStatus(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverInstanceResilienceStatus>(e =>
{
e.ToTable("DriverInstanceResilienceStatus");
e.HasKey(x => new { x.DriverInstanceId, x.HostName });
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.HostName).HasMaxLength(256);
e.Property(x => x.LastCircuitBreakerOpenUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastRecycleUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastSampledUtc).HasColumnType("datetime2(3)");
// LastSampledUtc drives the Admin UI's stale-sample filter same way DriverHostStatus's
// LastSeenUtc index does for connectivity rows.
e.HasIndex(x => x.LastSampledUtc).HasDatabaseName("IX_DriverResilience_LastSampled");
});
}
private static void ConfigureLdapGroupRoleMapping(ModelBuilder modelBuilder)
{
modelBuilder.Entity<LdapGroupRoleMapping>(e =>
{
e.ToTable("LdapGroupRoleMapping");
e.HasKey(x => x.Id);
e.Property(x => x.LdapGroup).HasMaxLength(512).IsRequired();
e.Property(x => x.Role).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.Notes).HasMaxLength(512);
// FK to ServerCluster when cluster-scoped; null for system-wide grants.
e.HasOne(x => x.Cluster)
.WithMany()
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Cascade);
// Uniqueness: one row per (LdapGroup, ClusterId). Null ClusterId is treated as its own
// "bucket" so a system-wide row coexists with cluster-scoped rows for the same group.
// SQL Server treats NULL as a distinct value in unique-index comparisons by default
// since 2008 SP1 onwards under the session setting we use — tested in SchemaCompliance.
e.HasIndex(x => new { x.LdapGroup, x.ClusterId })
.IsUnique()
.HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster");
// Hot-path lookup during cookie auth: "what grants does this user's set of LDAP
// groups carry?". Fires on every sign-in so the index earns its keep.
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group");
});
}
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentImportBatch>(e =>
{
e.ToTable("EquipmentImportBatch");
e.HasKey(x => x.Id);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
// Admin preview modal filters by user; finalise / drop both hit this index.
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
});
modelBuilder.Entity<EquipmentImportRow>(e =>
{
e.ToTable("EquipmentImportRow");
e.HasKey(x => x.Id);
e.Property(x => x.ZTag).HasMaxLength(128);
e.Property(x => x.MachineCode).HasMaxLength(128);
e.Property(x => x.SAPID).HasMaxLength(128);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.UnsAreaName).HasMaxLength(64);
e.Property(x => x.UnsLineName).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(256);
e.Property(x => x.Model).HasMaxLength(256);
e.Property(x => x.SerialNumber).HasMaxLength(256);
e.Property(x => x.HardwareRevision).HasMaxLength(64);
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
e.Property(x => x.AssetLocation).HasMaxLength(512);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.RejectReason).HasMaxLength(512);
e.HasOne(x => x.Batch)
.WithMany(b => b.Rows)
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
});
}
private static void ConfigureScript(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Script>(e =>
{
e.ToTable("Script");
e.HasKey(x => x.ScriptRowId);
e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
e.Property(x => x.SourceHash).HasMaxLength(64);
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.ScriptId).IsUnique().HasDatabaseName("UX_Script_LogicalId");
e.HasIndex(x => x.SourceHash).HasDatabaseName("IX_Script_SourceHash");
});
}
private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
{
modelBuilder.Entity<VirtualTag>(e =>
{
e.ToTable("VirtualTag", t =>
{
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
"ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
"TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
});
e.HasKey(x => x.VirtualTagRowId);
e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.VirtualTagId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.ScriptId).HasMaxLength(64);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.VirtualTagId).IsUnique().HasDatabaseName("UX_VirtualTag_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_EquipmentPath");
e.HasIndex(x => x.ScriptId).HasDatabaseName("IX_VirtualTag_Script");
});
}
private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarm>(e =>
{
e.ToTable("ScriptedAlarm", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
"AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
});
e.HasKey(x => x.ScriptedAlarmRowId);
e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.AlarmType).HasMaxLength(32);
e.Property(x => x.MessageTemplate).HasMaxLength(1024);
e.Property(x => x.PredicateScriptId).HasMaxLength(64);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.ScriptedAlarmId).IsUnique().HasDatabaseName("UX_ScriptedAlarm_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_EquipmentPath");
e.HasIndex(x => x.PredicateScriptId).HasDatabaseName("IX_ScriptedAlarm_Script");
});
}
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ScriptedAlarmState>(e =>
{
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
// stable identity across generations — Modified alarms keep their ack audit trail.
e.ToTable("ScriptedAlarmState", t =>
{
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
e.HasKey(x => x.ScriptedAlarmId);
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
e.Property(x => x.EnabledState).HasMaxLength(16);
e.Property(x => x.AckedState).HasMaxLength(16);
e.Property(x => x.ConfirmedState).HasMaxLength(16);
e.Property(x => x.ShelvingState).HasMaxLength(16);
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastAckUser).HasMaxLength(128);
e.Property(x => x.LastAckComment).HasMaxLength(1024);
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
});
}
private static void ConfigureDeployment(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Deployment>(e =>
{
e.ToTable("Deployment");
e.HasKey(x => x.DeploymentId);
e.Property(x => x.DeploymentId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.RevisionHash).HasMaxLength(64).IsRequired();
e.Property(x => x.Status).HasConversion<int>();
e.Property(x => x.CreatedBy).HasMaxLength(128).IsRequired();
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.ArtifactBlob).HasColumnType("varbinary(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.Property(x => x.FailureReason).HasMaxLength(2048);
e.Property(x => x.SealedAtUtc).HasColumnType("datetime2(3)");
e.HasIndex(x => x.Status).HasDatabaseName("IX_Deployment_Status");
e.HasIndex(x => x.CreatedAtUtc).HasDatabaseName("IX_Deployment_CreatedAt");
});
}
private static void ConfigureNodeDeploymentState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NodeDeploymentState>(e =>
{
e.ToTable("NodeDeploymentState");
e.HasKey(x => new { x.NodeId, x.DeploymentId });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.Status).HasConversion<int>();
e.Property(x => x.StartedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.AppliedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FailureReason).HasMaxLength(2048);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Node).WithMany().HasForeignKey(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Deployment).WithMany().HasForeignKey(x => x.DeploymentId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.DeploymentId).HasDatabaseName("IX_NodeDeploymentState_Deployment");
e.HasIndex(x => x.Status).HasDatabaseName("IX_NodeDeploymentState_Status");
});
}
private static void ConfigureConfigEdit(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ConfigEdit>(e =>
{
e.ToTable("ConfigEdit", t =>
{
t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1");
});
e.HasKey(x => x.EditId);
e.Property(x => x.EditId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EntityType).HasMaxLength(64).IsRequired();
e.Property(x => x.FieldsJson).HasColumnType("nvarchar(max)").IsRequired();
e.Property(x => x.EditedBy).HasMaxLength(128).IsRequired();
e.Property(x => x.EditedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.SourceNode).HasMaxLength(64).IsRequired();
// Replays of admin operations group rows by ExecutionId, then by time.
e.HasIndex(x => new { x.EntityType, x.EntityId }).HasDatabaseName("IX_ConfigEdit_Entity");
e.HasIndex(x => x.ExecutionId).HasFilter("[ExecutionId] IS NOT NULL").HasDatabaseName("IX_ConfigEdit_Execution");
e.HasIndex(x => x.EditedAtUtc).HasDatabaseName("IX_ConfigEdit_EditedAt");
});
}
private static void ConfigureDataProtectionKey(ModelBuilder modelBuilder)
{
// ASP.NET DataProtection ships its own EF mapping; override only the table name so it
// matches the rest of the schema's PascalCase convention.
modelBuilder.Entity<DataProtectionKey>(e =>
{
e.ToTable("DataProtectionKeys");
});
}
}