diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs
new file mode 100644
index 0000000..4029edf
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs
@@ -0,0 +1,56 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+///
+/// Maps an LDAP group to an for Admin UI access. Optionally scoped
+/// to one ; when is true, the grant
+/// applies fleet-wide.
+///
+///
+/// Per docs/v2/plan.md decisions #105 and #150 — this entity is control-plane
+/// only. The OPC UA data-path evaluator does not read these rows; it reads
+/// joined directly against the session's resolved LDAP group
+/// memberships. Collapsing the two would let a user inherit tag permissions via an
+/// admin-role claim path never intended as a data-path grant.
+///
+/// Uniqueness: (LdapGroup, ClusterId) — the same LDAP group may hold
+/// different roles on different clusters, but only one row per cluster. A system-wide row
+/// (IsSystemWide = true, ClusterId = null) stacks additively with any
+/// cluster-scoped rows for the same group.
+///
+public sealed class LdapGroupRoleMapping
+{
+ /// Surrogate primary key.
+ public Guid Id { get; set; }
+
+ ///
+ /// LDAP group DN the membership query returns (e.g. cn=fleet-admin,ou=groups,dc=corp,dc=example).
+ /// Comparison is case-insensitive per LDAP conventions.
+ ///
+ public required string LdapGroup { get; set; }
+
+ /// Admin role this group grants.
+ public required AdminRole Role { get; set; }
+
+ ///
+ /// Cluster the grant applies to; null when is true.
+ /// Foreign key to .
+ ///
+ public string? ClusterId { get; set; }
+
+ ///
+ /// true = grant applies across every cluster in the fleet; ClusterId must be null.
+ /// false = grant is cluster-scoped; ClusterId must be populated.
+ ///
+ public required bool IsSystemWide { get; set; }
+
+ /// Row creation timestamp (UTC).
+ public DateTime CreatedAtUtc { get; set; }
+
+ /// Optional human-readable note (e.g. "added 2026-04-19 for Warsaw fleet admin handoff").
+ public string? Notes { get; set; }
+
+ /// Navigation for EF core when the row is cluster-scoped.
+ public ServerCluster? Cluster { get; set; }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs
new file mode 100644
index 0000000..a9baa82
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs
@@ -0,0 +1,26 @@
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+///
+/// Admin UI roles per admin-ui.md §"Admin Roles" and Phase 6.2 Stream A.
+/// These govern Admin UI capabilities (cluster CRUD, draft → publish, fleet-wide admin
+/// actions) — they do NOT govern OPC UA data-path authorization, which reads
+/// joined against LDAP group memberships directly.
+///
+///
+/// Per docs/v2/plan.md decision #150 the two concerns share zero runtime code path:
+/// the control plane (Admin UI) consumes ; the
+/// data plane consumes rows directly. Having them in one
+/// table would collapse the distinction + let a user inherit tag permissions via their
+/// admin-role claim path.
+///
+public enum AdminRole
+{
+ /// Read-only Admin UI access — can view cluster state, drafts, publish history.
+ ConfigViewer,
+
+ /// Can author drafts + submit for publish.
+ ConfigEditor,
+
+ /// Full Admin UI privileges including publish + fleet-admin actions.
+ FleetAdmin,
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.Designer.cs
new file mode 100644
index 0000000..a82dbda
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.Designer.cs
@@ -0,0 +1,1342 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+
+#nullable disable
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
+{
+ [DbContext(typeof(OtOpcUaConfigDbContext))]
+ [Migration("20260419131444_AddLdapGroupRoleMapping")]
+ partial class AddLdapGroupRoleMapping
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ApplicationUri")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("DashboardPort")
+ .HasColumnType("int");
+
+ b.Property("DriverConfigOverridesJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Host")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("nvarchar(255)");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("OpcUaPort")
+ .HasColumnType("int");
+
+ b.Property("RedundancyRole")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("ServiceLevelBase")
+ .HasColumnType("tinyint");
+
+ b.HasKey("NodeId");
+
+ b.HasIndex("ApplicationUri")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNode_ApplicationUri");
+
+ b.HasIndex("ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster")
+ .HasFilter("[RedundancyRole] = 'Primary'");
+
+ b.ToTable("ClusterNode", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b =>
+ {
+ b.Property("CredentialId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("NodeId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("RotatedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.HasKey("CredentialId");
+
+ b.HasIndex("Kind", "Value")
+ .IsUnique()
+ .HasDatabaseName("UX_ClusterNodeCredential_Value")
+ .HasFilter("[Enabled] = 1");
+
+ b.HasIndex("NodeId", "Enabled")
+ .HasDatabaseName("IX_ClusterNodeCredential_NodeId");
+
+ b.ToTable("ClusterNodeCredential", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CurrentGenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("LastAppliedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastAppliedError")
+ .HasMaxLength(2048)
+ .HasColumnType("nvarchar(2048)");
+
+ b.Property("LastAppliedStatus")
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("NodeId");
+
+ b.HasIndex("CurrentGenerationId")
+ .HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
+
+ b.ToTable("ClusterNodeGenerationState", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b =>
+ {
+ b.Property("AuditId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId"));
+
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DetailsJson")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("EventType")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Principal")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Timestamp")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.HasKey("AuditId");
+
+ b.HasIndex("GenerationId")
+ .HasDatabaseName("IX_ConfigAuditLog_Generation")
+ .HasFilter("[GenerationId] IS NOT NULL");
+
+ b.HasIndex("ClusterId", "Timestamp")
+ .IsDescending(false, true)
+ .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
+
+ b.ToTable("ConfigAuditLog", null, t =>
+ {
+ t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b =>
+ {
+ b.Property("GenerationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId"));
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("ParentGenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("PublishedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("PublishedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.HasKey("GenerationId");
+
+ b.HasIndex("ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster")
+ .HasFilter("[Status] = 'Draft'");
+
+ b.HasIndex("ParentGenerationId");
+
+ b.HasIndex("ClusterId", "Status", "GenerationId")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
+
+ SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" });
+
+ b.ToTable("ConfigGeneration", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b =>
+ {
+ b.Property("DeviceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DeviceConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DeviceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.HasKey("DeviceRowId");
+
+ b.HasIndex("GenerationId", "DeviceId")
+ .IsUnique()
+ .HasDatabaseName("UX_Device_Generation_LogicalId")
+ .HasFilter("[DeviceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_Device_Generation_Driver");
+
+ b.ToTable("Device", null, t =>
+ {
+ t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b =>
+ {
+ b.Property("NodeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("HostName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Detail")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("LastSeenUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("State")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("StateChangedUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("NodeId", "DriverInstanceId", "HostName");
+
+ b.HasIndex("LastSeenUtc")
+ .HasDatabaseName("IX_DriverHostStatus_LastSeen");
+
+ b.HasIndex("NodeId")
+ .HasDatabaseName("IX_DriverHostStatus_Node");
+
+ b.ToTable("DriverHostStatus", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
+ {
+ b.Property("DriverInstanceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("NamespaceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("DriverInstanceRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_DriverInstance_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .IsUnique()
+ .HasDatabaseName("UX_DriverInstance_Generation_LogicalId")
+ .HasFilter("[DriverInstanceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "NamespaceId")
+ .HasDatabaseName("IX_DriverInstance_Generation_Namespace");
+
+ b.ToTable("DriverInstance", null, t =>
+ {
+ t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b =>
+ {
+ b.Property("DriverInstanceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("HostName")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("BaselineFootprintBytes")
+ .HasColumnType("bigint");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("int");
+
+ b.Property("CurrentBulkheadDepth")
+ .HasColumnType("int");
+
+ b.Property("CurrentFootprintBytes")
+ .HasColumnType("bigint");
+
+ b.Property("LastCircuitBreakerOpenUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastRecycleUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("LastSampledUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.HasKey("DriverInstanceId", "HostName");
+
+ b.HasIndex("LastSampledUtc")
+ .HasDatabaseName("IX_DriverResilience_LastSampled");
+
+ b.ToTable("DriverInstanceResilienceStatus", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b =>
+ {
+ b.Property("EquipmentRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("AssetLocation")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("DeviceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DeviceManualUri")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("EquipmentClassRef")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("EquipmentId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentUuid")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("HardwareRevision")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("MachineCode")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Manufacturer")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ManufacturerUri")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Model")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("SAPID")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("SerialNumber")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("SoftwareRevision")
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("UnsLineId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("YearOfConstruction")
+ .HasColumnType("smallint");
+
+ b.Property("ZTag")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("EquipmentRowId");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_Equipment_Generation_Driver");
+
+ b.HasIndex("GenerationId", "EquipmentId")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_LogicalId")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "EquipmentUuid")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_Uuid");
+
+ b.HasIndex("GenerationId", "MachineCode")
+ .HasDatabaseName("IX_Equipment_Generation_MachineCode");
+
+ b.HasIndex("GenerationId", "SAPID")
+ .HasDatabaseName("IX_Equipment_Generation_SAPID")
+ .HasFilter("[SAPID] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsLineId")
+ .HasDatabaseName("IX_Equipment_Generation_Line");
+
+ b.HasIndex("GenerationId", "ZTag")
+ .HasDatabaseName("IX_Equipment_Generation_ZTag")
+ .HasFilter("[ZTag] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsLineId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Equipment_Generation_LinePath");
+
+ b.ToTable("Equipment", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
+ {
+ b.Property("ReservationId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentUuid")
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("FirstPublishedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("FirstPublishedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("LastPublishedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("ReleaseReason")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("ReleasedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("ReleasedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("ReservationId");
+
+ b.HasIndex("EquipmentUuid")
+ .HasDatabaseName("IX_ExternalIdReservation_Equipment");
+
+ b.HasIndex("Kind", "Value")
+ .IsUnique()
+ .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active")
+ .HasFilter("[ReleasedAt] IS NULL");
+
+ b.ToTable("ExternalIdReservation", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("IsSystemWide")
+ .HasColumnType("bit");
+
+ b.Property("LdapGroup")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("LdapGroup")
+ .HasDatabaseName("IX_LdapGroupRoleMapping_Group");
+
+ b.HasIndex("LdapGroup", "ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster")
+ .HasFilter("[ClusterId] IS NOT NULL");
+
+ b.ToTable("LdapGroupRoleMapping", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
+ {
+ b.Property("NamespaceRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("NamespaceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("NamespaceUri")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.HasKey("NamespaceRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_Namespace_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "NamespaceId")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_LogicalId")
+ .HasFilter("[NamespaceId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "NamespaceUri")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
+
+ b.HasIndex("GenerationId", "ClusterId", "Kind")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
+
+ b.HasIndex("GenerationId", "NamespaceId", "ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster")
+ .HasFilter("[NamespaceId] IS NOT NULL");
+
+ b.ToTable("Namespace", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
+ {
+ b.Property("NodeAclRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("LdapGroup")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("NodeAclId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("PermissionFlags")
+ .HasColumnType("int");
+
+ b.Property("ScopeId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("ScopeKind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.HasKey("NodeAclRowId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_NodeAcl_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "LdapGroup")
+ .HasDatabaseName("IX_NodeAcl_Generation_Group");
+
+ b.HasIndex("GenerationId", "NodeAclId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_Generation_LogicalId")
+ .HasFilter("[NodeAclId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ScopeKind", "ScopeId")
+ .HasDatabaseName("IX_NodeAcl_Generation_Scope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId")
+ .IsUnique()
+ .HasDatabaseName("UX_NodeAcl_Generation_GroupScope")
+ .HasFilter("[ScopeId] IS NOT NULL");
+
+ b.ToTable("NodeAcl", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b =>
+ {
+ b.Property("PollGroupRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("IntervalMs")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("PollGroupRowId");
+
+ b.HasIndex("GenerationId", "DriverInstanceId")
+ .HasDatabaseName("IX_PollGroup_Generation_Driver");
+
+ b.HasIndex("GenerationId", "PollGroupId")
+ .IsUnique()
+ .HasDatabaseName("UX_PollGroup_Generation_LogicalId")
+ .HasFilter("[PollGroupId] IS NOT NULL");
+
+ b.ToTable("PollGroup", null, t =>
+ {
+ t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
+ {
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2(3)")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("CreatedBy")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Enabled")
+ .HasColumnType("bit");
+
+ b.Property("Enterprise")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("ModifiedBy")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("NodeCount")
+ .HasColumnType("tinyint");
+
+ b.Property("Notes")
+ .HasMaxLength(1024)
+ .HasColumnType("nvarchar(1024)");
+
+ b.Property("RedundancyMode")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("Site")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("ClusterId");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("UX_ServerCluster_Name");
+
+ b.HasIndex("Site")
+ .HasDatabaseName("IX_ServerCluster_Site");
+
+ b.ToTable("ServerCluster", null, t =>
+ {
+ t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
+ {
+ b.Property("TagRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("AccessLevel")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("nvarchar(16)");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("DeviceId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("DriverInstanceId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("EquipmentId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("FolderPath")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("PollGroupId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("TagConfig")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("TagId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("WriteIdempotent")
+ .HasColumnType("bit");
+
+ b.HasKey("TagRowId");
+
+ b.HasIndex("GenerationId", "EquipmentId")
+ .HasDatabaseName("IX_Tag_Generation_Equipment")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "TagId")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_LogicalId")
+ .HasFilter("[TagId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId")
+ .HasDatabaseName("IX_Tag_Generation_Driver_Device");
+
+ b.HasIndex("GenerationId", "EquipmentId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_EquipmentPath")
+ .HasFilter("[EquipmentId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_Tag_Generation_FolderPath")
+ .HasFilter("[EquipmentId] IS NULL");
+
+ b.ToTable("Tag", null, t =>
+ {
+ t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
+ });
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
+ {
+ b.Property("UnsAreaRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("ClusterId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UnsAreaId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsAreaRowId");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("GenerationId", "ClusterId")
+ .HasDatabaseName("IX_UnsArea_Generation_Cluster");
+
+ b.HasIndex("GenerationId", "UnsAreaId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_Generation_LogicalId")
+ .HasFilter("[UnsAreaId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "ClusterId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsArea_Generation_ClusterName");
+
+ b.ToTable("UnsArea", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b =>
+ {
+ b.Property("UnsLineRowId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier")
+ .HasDefaultValueSql("NEWSEQUENTIALID()");
+
+ b.Property("GenerationId")
+ .HasColumnType("bigint");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("UnsAreaId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("UnsLineId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.HasKey("UnsLineRowId");
+
+ b.HasIndex("GenerationId", "UnsAreaId")
+ .HasDatabaseName("IX_UnsLine_Generation_Area");
+
+ b.HasIndex("GenerationId", "UnsLineId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_Generation_LogicalId")
+ .HasFilter("[UnsLineId] IS NOT NULL");
+
+ b.HasIndex("GenerationId", "UnsAreaId", "Name")
+ .IsUnique()
+ .HasDatabaseName("UX_UnsLine_Generation_AreaName");
+
+ b.ToTable("UnsLine", (string)null);
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Nodes")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node")
+ .WithMany("Credentials")
+ .HasForeignKey("NodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Node");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration")
+ .WithMany()
+ .HasForeignKey("CurrentGenerationId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node")
+ .WithOne("GenerationState")
+ .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("CurrentGeneration");
+
+ b.Navigation("Node");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Generations")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent")
+ .WithMany()
+ .HasForeignKey("ParentGenerationId")
+ .OnDelete(DeleteBehavior.Restrict);
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Cluster");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany("Namespaces")
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Cluster");
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
+ .WithMany()
+ .HasForeignKey("GenerationId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Generation");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
+ {
+ b.Navigation("Credentials");
+
+ b.Navigation("GenerationState");
+ });
+
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
+ {
+ b.Navigation("Generations");
+
+ b.Navigation("Namespaces");
+
+ b.Navigation("Nodes");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.cs
new file mode 100644
index 0000000..58eaa7f
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.cs
@@ -0,0 +1,62 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
+{
+ ///
+ public partial class AddLdapGroupRoleMapping : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "LdapGroupRoleMapping",
+ columns: table => new
+ {
+ Id = table.Column(type: "uniqueidentifier", nullable: false),
+ LdapGroup = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false),
+ Role = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false),
+ ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true),
+ IsSystemWide = table.Column(type: "bit", nullable: false),
+ CreatedAtUtc = table.Column(type: "datetime2(3)", nullable: false),
+ Notes = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_LdapGroupRoleMapping", x => x.Id);
+ table.ForeignKey(
+ name: "FK_LdapGroupRoleMapping_ServerCluster_ClusterId",
+ column: x => x.ClusterId,
+ principalTable: "ServerCluster",
+ principalColumn: "ClusterId",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LdapGroupRoleMapping_ClusterId",
+ table: "LdapGroupRoleMapping",
+ column: "ClusterId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_LdapGroupRoleMapping_Group",
+ table: "LdapGroupRoleMapping",
+ column: "LdapGroup");
+
+ migrationBuilder.CreateIndex(
+ name: "UX_LdapGroupRoleMapping_Group_Cluster",
+ table: "LdapGroupRoleMapping",
+ columns: new[] { "LdapGroup", "ClusterId" },
+ unique: true,
+ filter: "[ClusterId] IS NOT NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "LdapGroupRoleMapping");
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
index e634faf..0b413d1 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs
@@ -663,6 +663,51 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("ExternalIdReservation", (string)null);
});
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uniqueidentifier");
+
+ b.Property("ClusterId")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("datetime2(3)");
+
+ b.Property("IsSystemWide")
+ .HasColumnType("bit");
+
+ b.Property("LdapGroup")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Notes")
+ .HasMaxLength(512)
+ .HasColumnType("nvarchar(512)");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("nvarchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClusterId");
+
+ b.HasIndex("LdapGroup")
+ .HasDatabaseName("IX_LdapGroupRoleMapping_Group");
+
+ b.HasIndex("LdapGroup", "ClusterId")
+ .IsUnique()
+ .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster")
+ .HasFilter("[ClusterId] IS NOT NULL");
+
+ b.ToTable("LdapGroupRoleMapping", (string)null);
+ });
+
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.Property("NamespaceRowId")
@@ -1181,6 +1226,16 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Generation");
});
+ modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
+ {
+ b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
+ .WithMany()
+ .HasForeignKey("ClusterId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("Cluster");
+ });
+
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
index 19414bc..3c5f360 100644
--- a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
@@ -29,6 +29,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions ExternalIdReservations => Set();
public DbSet DriverHostStatuses => Set();
public DbSet DriverInstanceResilienceStatuses => Set();
+ public DbSet LdapGroupRoleMappings => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -51,6 +52,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions x.LastSampledUtc).HasDatabaseName("IX_DriverResilience_LastSampled");
});
}
+
+ private static void ConfigureLdapGroupRoleMapping(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(e =>
+ {
+ e.ToTable("LdapGroupRoleMapping");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.LdapGroup).HasMaxLength(512).IsRequired();
+ e.Property(x => x.Role).HasConversion().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");
+ });
+ }
}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs
new file mode 100644
index 0000000..7cc6a0b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs
@@ -0,0 +1,47 @@
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
+
+///
+/// CRUD surface for — the control-plane mapping from
+/// LDAP groups to Admin UI roles. Consumed only by Admin UI code paths; the OPC UA
+/// data-path evaluator MUST NOT depend on this interface (see decision #150 and the
+/// Phase 6.2 compliance check on control/data-plane separation).
+///
+///
+/// Per Phase 6.2 Stream A.2 this service is expected to run behind the Phase 6.1
+/// ResilientConfigReader pipeline (timeout → retry → fallback-to-cache) so a
+/// transient DB outage during sign-in falls back to the sealed snapshot rather than
+/// denying every login.
+///
+public interface ILdapGroupRoleMappingService
+{
+ /// List every mapping whose LDAP group matches one of .
+ ///
+ /// Hot path — fires on every sign-in. The default EF implementation relies on the
+ /// IX_LdapGroupRoleMapping_Group index. Case-insensitive per LDAP conventions.
+ ///
+ Task> GetByGroupsAsync(
+ IEnumerable ldapGroups, CancellationToken cancellationToken);
+
+ /// Enumerate every mapping; Admin UI listing only.
+ Task> ListAllAsync(CancellationToken cancellationToken);
+
+ /// Create a new grant.
+ ///
+ /// Thrown when the proposed row violates an invariant (IsSystemWide inconsistent with
+ /// ClusterId, duplicate (group, cluster) pair, etc.) — ValidatedLdapGroupRoleMappingService
+ /// is the write surface that enforces these; the raw service here surfaces DB-level violations.
+ ///
+ Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
+
+ /// Delete a mapping by its surrogate key.
+ Task DeleteAsync(Guid id, CancellationToken cancellationToken);
+}
+
+/// Thrown when authoring violates an invariant.
+public sealed class InvalidLdapGroupRoleMappingException : Exception
+{
+ public InvalidLdapGroupRoleMappingException(string message) : base(message) { }
+}
diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs
new file mode 100644
index 0000000..38f2e7b
--- /dev/null
+++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs
@@ -0,0 +1,69 @@
+using Microsoft.EntityFrameworkCore;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
+
+///
+/// EF Core implementation of . Enforces the
+/// "exactly one of (ClusterId, IsSystemWide)" invariant at the write surface so a
+/// malformed row can't land in the DB.
+///
+public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
+{
+ public async Task> GetByGroupsAsync(
+ IEnumerable ldapGroups, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(ldapGroups);
+ var groupSet = ldapGroups.ToList();
+ if (groupSet.Count == 0) return [];
+
+ return await db.LdapGroupRoleMappings
+ .AsNoTracking()
+ .Where(m => groupSet.Contains(m.LdapGroup))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async Task> ListAllAsync(CancellationToken cancellationToken)
+ => await db.LdapGroupRoleMappings
+ .AsNoTracking()
+ .OrderBy(m => m.LdapGroup)
+ .ThenBy(m => m.ClusterId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ public async Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(row);
+ ValidateInvariants(row);
+
+ if (row.Id == Guid.Empty) row.Id = Guid.NewGuid();
+ if (row.CreatedAtUtc == default) row.CreatedAtUtc = DateTime.UtcNow;
+
+ db.LdapGroupRoleMappings.Add(row);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return row;
+ }
+
+ public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
+ {
+ var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
+ if (existing is null) return;
+ db.LdapGroupRoleMappings.Remove(existing);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private static void ValidateInvariants(LdapGroupRoleMapping row)
+ {
+ if (string.IsNullOrWhiteSpace(row.LdapGroup))
+ throw new InvalidLdapGroupRoleMappingException("LdapGroup must not be empty.");
+
+ if (row.IsSystemWide && !string.IsNullOrEmpty(row.ClusterId))
+ throw new InvalidLdapGroupRoleMappingException(
+ "IsSystemWide=true requires ClusterId to be null. A fleet-wide grant cannot also be cluster-scoped.");
+
+ if (!row.IsSystemWide && string.IsNullOrEmpty(row.ClusterId))
+ throw new InvalidLdapGroupRoleMappingException(
+ "IsSystemWide=false requires a populated ClusterId. A cluster-scoped grant needs its target cluster.");
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs
new file mode 100644
index 0000000..4ed5b5d
--- /dev/null
+++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs
@@ -0,0 +1,138 @@
+using Microsoft.EntityFrameworkCore;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
+using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
+using ZB.MOM.WW.OtOpcUa.Configuration.Services;
+
+namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
+
+[Trait("Category", "Unit")]
+public sealed class LdapGroupRoleMappingServiceTests : IDisposable
+{
+ private readonly OtOpcUaConfigDbContext _db;
+
+ public LdapGroupRoleMappingServiceTests()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"ldap-grm-{Guid.NewGuid():N}")
+ .Options;
+ _db = new OtOpcUaConfigDbContext(options);
+ }
+
+ public void Dispose() => _db.Dispose();
+
+ private LdapGroupRoleMapping Make(string group, AdminRole role, string? clusterId = null, bool? isSystemWide = null) =>
+ new()
+ {
+ LdapGroup = group,
+ Role = role,
+ ClusterId = clusterId,
+ IsSystemWide = isSystemWide ?? (clusterId is null),
+ };
+
+ [Fact]
+ public async Task Create_SetsId_AndCreatedAtUtc()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin);
+
+ var saved = await svc.CreateAsync(row, CancellationToken.None);
+
+ saved.Id.ShouldNotBe(Guid.Empty);
+ saved.CreatedAtUtc.ShouldBeGreaterThan(DateTime.UtcNow.AddMinutes(-1));
+ }
+
+ [Fact]
+ public async Task Create_Rejects_EmptyLdapGroup()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ var row = Make("", AdminRole.FleetAdmin);
+
+ await Should.ThrowAsync(
+ () => svc.CreateAsync(row, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Create_Rejects_SystemWide_With_ClusterId()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true);
+
+ await Should.ThrowAsync(
+ () => svc.CreateAsync(row, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Create_Rejects_NonSystemWide_WithoutClusterId()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false);
+
+ await Should.ThrowAsync(
+ () => svc.CreateAsync(row, CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task GetByGroups_Returns_MatchingGrants_Only()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
+ await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.ConfigEditor), CancellationToken.None);
+ await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.ConfigViewer), CancellationToken.None);
+
+ var results = await svc.GetByGroupsAsync(
+ ["cn=fleet,dc=x", "cn=viewer,dc=x"], CancellationToken.None);
+
+ results.Count.ShouldBe(2);
+ results.Select(r => r.Role).ShouldBe([AdminRole.FleetAdmin, AdminRole.ConfigViewer], ignoreOrder: true);
+ }
+
+ [Fact]
+ public async Task GetByGroups_Empty_Input_ReturnsEmpty()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
+
+ var results = await svc.GetByGroupsAsync([], CancellationToken.None);
+
+ results.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task ListAll_Orders_ByGroupThenCluster()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
+ await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c2", isSystemWide: false), CancellationToken.None);
+ await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.ConfigEditor, clusterId: "c1", isSystemWide: false), CancellationToken.None);
+
+ var results = await svc.ListAllAsync(CancellationToken.None);
+
+ results[0].LdapGroup.ShouldBe("cn=a,dc=x");
+ results[0].ClusterId.ShouldBe("c1");
+ results[1].ClusterId.ShouldBe("c2");
+ results[2].LdapGroup.ShouldBe("cn=b,dc=x");
+ }
+
+ [Fact]
+ public async Task Delete_Removes_Matching_Row()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+ var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None);
+
+ await svc.DeleteAsync(saved.Id, CancellationToken.None);
+
+ var after = await svc.ListAllAsync(CancellationToken.None);
+ after.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Delete_Unknown_Id_IsNoOp()
+ {
+ var svc = new LdapGroupRoleMappingService(_db);
+
+ await svc.DeleteAsync(Guid.NewGuid(), CancellationToken.None);
+ // no exception
+ }
+}
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
index d05c437..cc8ae95 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
+++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs
@@ -30,6 +30,7 @@ public sealed class SchemaComplianceTests
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
"DriverInstanceResilienceStatus",
+ "LdapGroupRoleMapping",
};
var actual = QueryStrings(@"
diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj
index 79b7a2b..4c0186f 100644
--- a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj
+++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj
@@ -14,6 +14,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive