From c1619d95f509026eb05538249d05ed04ac657761 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 07:30:00 -0400 Subject: [PATCH] feat(auth)!: OtOpcUa canonical control-plane roles + config-DB migration (Task 1.7) Standardize the control-plane admin role VALUES on the canonical six (ZB.MOM.WW.Auth CanonicalRole). OtOpcUa uses four: ConfigViewer -> Viewer ConfigEditor -> Designer FleetAdmin -> Administrator DriverOperator -> Operator (appsettings-only string role) This is a rename, not a permission change: enforcement semantics are preserved (whoever could deploy/administer/operate before still can). - AdminRole enum members renamed (persisted as string names via HasConversion); RoleGrants.razor dropdown default updated. - EF DATA migration CanonicalizeAdminRoles rewrites existing LdapGroupRoleMapping.Role rows old->new (Up) and back (Down); schema / model snapshot byte-identical (no pending model changes). - Enforcement role STRINGS canonicalized: * Security policies keep their NAMES ("DriverOperator"/"FleetAdmin") but require canonical roles: RequireRole("Operator","Administrator") and RequireRole("Administrator"). * Deployments.razor [Authorize(Roles="Administrator,Designer")]. * DevStub now grants "Administrator"; LdapOptions/doc-comment examples canonicalized. - Data-plane authorization (NodePermissions/NodeAcl/IPermissionEvaluator/ TriePermissionEvaluator/UserAuthorizationState) UNTOUCHED. - New CanonicalAdminRolesTests pins canonical claim values end-to-end and the real registered policies; existing role-string tests updated. --- .../Enums/AdminRole.cs | 33 +- ...2112419_CanonicalizeAdminRoles.Designer.cs | 1755 +++++++++++++++++ .../20260602112419_CanonicalizeAdminRoles.cs | 39 + .../Components/Pages/Deployments.razor | 2 +- .../Components/Pages/RoleGrants.razor | 4 +- .../Ldap/LdapOptions.cs | 7 +- .../Ldap/OtOpcUaLdapAuthService.cs | 16 +- .../ServiceCollectionExtensions.cs | 15 +- .../LdapGroupRoleMappingServiceTests.cs | 28 +- .../LdapOpcUaUserAuthenticatorTests.cs | 22 +- .../TwoNodeClusterHarness.cs | 4 +- .../AuthEndpointsIntegrationTests.cs | 22 +- .../CanonicalAdminRolesTests.cs | 155 ++ .../OtOpcUaGroupRoleMapperTests.cs | 24 +- .../OtOpcUaLdapAuthServiceTests.cs | 6 +- .../RoleMapperTests.cs | 28 +- 16 files changed, 2063 insertions(+), 97 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.Designer.cs create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CanonicalAdminRolesTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs index a9baa82a..affe524b 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs @@ -7,20 +7,31 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; /// 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. +/// +/// 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. +/// +/// +/// Task 1.7 standardized the member names on the canonical control-plane role vocabulary +/// (ZB.MOM.WW.Auth CanonicalRole): ConfigViewer → Viewer, +/// ConfigEditor → Designer, FleetAdmin → Administrator. The appsettings-only +/// DriverOperator string role likewise became Operator. These members persist +/// as their string names (EF HasConversion<string>); the rename is paired with +/// a data migration (CanonicalizeAdminRoles) that rewrites existing rows. This is a +/// rename, not a permission change — enforcement semantics are preserved. +/// /// public enum AdminRole { - /// Read-only Admin UI access — can view cluster state, drafts, publish history. - ConfigViewer, + /// Read-only Admin UI access — can view cluster state, drafts, publish history. (Canonical: Viewer; was ConfigViewer.) + Viewer, - /// Can author drafts + submit for publish. - ConfigEditor, + /// Can author drafts + submit for publish. (Canonical: Designer; was ConfigEditor.) + Designer, - /// Full Admin UI privileges including publish + fleet-admin actions. - FleetAdmin, + /// Full Admin UI privileges including publish + fleet-admin actions. (Canonical: Administrator; was FleetAdmin.) + Administrator, } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.Designer.cs new file mode 100644 index 00000000..853f9386 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.Designer.cs @@ -0,0 +1,1755 @@ +// +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("20260602112419_CanonicalizeAdminRoles")] + partial class CanonicalizeAdminRoles + { + /// + 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", (string)null); + }); + + 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("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + 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.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + 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("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + 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.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (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("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_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("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("NamespaceId") + .HasDatabaseName("IX_DriverInstance_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 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("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("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("DriverInstanceId") + .HasDatabaseName("IX_Equipment_Driver"); + + b.HasIndex("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FinalisedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("RowsAccepted") + .HasColumnType("int"); + + b.Property("RowsRejected") + .HasColumnType("int"); + + b.Property("RowsStaged") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedBy", "FinalisedAtUtc") + .HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised"); + + b.ToTable("EquipmentImportBatch", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AssetLocation") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("BatchId") + .HasColumnType("uniqueidentifier"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HardwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("LineNumberInFile") + .HasColumnType("int"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Manufacturer") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RejectReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("SAPID") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SerialNumber") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SoftwareRevision") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsAreaName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("ZTag") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("BatchId") + .HasDatabaseName("IX_EquipmentImportRow_Batch"); + + b.ToTable("EquipmentImportRow", (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("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.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_Namespace_Cluster"); + + b.HasIndex("NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_NamespaceUri"); + + b.HasIndex("ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Cluster_Kind"); + + 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("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("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (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("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_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.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b => + { + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AckedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConfirmedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("EnabledState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastAckComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastAckUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastAckUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastConfirmComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastConfirmUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastConfirmUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingExpiresUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("ScriptedAlarmId"); + + b.ToTable("ScriptedAlarmState", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + }); + + 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("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("EquipmentId") + .HasDatabaseName("IX_Tag_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Driver_Device"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_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("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_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("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT 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.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch") + .WithMany("Rows") + .HasForeignKey("BatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Batch"); + }); + + 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.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + 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.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b => + { + b.Navigation("Rows"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.cs new file mode 100644 index 00000000..49ee6312 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260602112419_CanonicalizeAdminRoles.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + /// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the + /// LdapGroupRoleMapping.Role column. The column stores the AdminRole enum + /// member name as a string (EF HasConversion<string>, nvarchar(32)); + /// renaming the enum members (ConfigViewer → Viewer, ConfigEditor → Designer, + /// FleetAdmin → Administrator) therefore requires rewriting existing rows so the C# + /// enum and the stored strings stay in sync. + /// + /// + /// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged, + /// so the model snapshot is byte-identical to the prior migration. The new canonical strings + /// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing + /// nvarchar(32) column. Enforcement semantics are preserved — it is a rename only. + /// + public partial class CanonicalizeAdminRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';"); + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';"); + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';"); + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';"); + migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';"); + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor index be5ddca4..b4281789 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor @@ -9,7 +9,7 @@ @using ZB.MOM.WW.OtOpcUa.Configuration.Enums @using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations -@attribute [Authorize(Roles = "FleetAdmin,ConfigEditor")] +@attribute [Authorize(Roles = "Administrator,Designer")] @inject IDbContextFactory DbFactory @inject IAdminOperationsClient AdminOps diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor index af926189..8d32e74d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -108,7 +108,7 @@ private LdapOptions? _options; private IReadOnlyList _rows = []; private string _newGroup = ""; - private AdminRole _newRole = AdminRole.ConfigViewer; + private AdminRole _newRole = AdminRole.Viewer; private string? _error; private bool _busy; @@ -134,7 +134,7 @@ LdapGroup = _newGroup.Trim(), Role = _newRole, IsSystemWide = true, ClusterId = null, }, default); _newGroup = ""; - _newRole = AdminRole.ConfigViewer; + _newRole = AdminRole.Viewer; await ReloadAsync(); } catch (Exception ex) { _error = ex.Message; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs index f68339ad..50afcb4d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs @@ -44,7 +44,7 @@ public sealed class LdapOptions /// /// Dev-only stub: when true, bypasses the real LDAP - /// bind and accepts any non-empty username/password, returning a single FleetAdmin role + /// bind and accepts any non-empty username/password, returning a single Administrator role /// so the operator can navigate the full Admin UI. MUST be false in production. /// public bool DevStubMode { get; set; } @@ -76,8 +76,9 @@ public sealed class LdapOptions /// /// Maps LDAP group name → Admin role. Group match is case-insensitive. A user gets every - /// role whose source group is in their membership list. Example dev mapping: - /// "ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin" + /// role whose source group is in their membership list. Values are the canonical control-plane + /// roles (Task 1.7). Example dev mapping: + /// "ReadOnly":"Viewer","ReadWrite":"Designer","AlarmAck":"Administrator" /// public Dictionary GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs index 9bfcb22d..0739a372 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs @@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; /// library deliberately does not model: /// /// the master switch (disabled ⇒ deny, no bind); and -/// — the dev bypass that grants a FleetAdmin +/// — the dev bypass that grants an Administrator /// session WITHOUT touching the network, so an operator can navigate the full Admin UI /// against a machine with no directory. /// @@ -24,12 +24,13 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; /// both the login endpoint and the OPC UA data-plane authenticator call with the returned /// . The only path that pre-populates /// is the DevStub success; consumers union that pre-resolved -/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper. +/// set with the mapper output so the dev Administrator grant survives the move to the mapper. /// /// /// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The -/// DevStub result mirrors the legacy bespoke service exactly (group "dev", role -/// "FleetAdmin") so behaviour is preserved bit-for-bit on dev nodes. +/// DevStub result grants the canonical "Administrator" control-plane role (group +/// "dev") so the dev session can navigate the full Admin UI (Task 1.7 renamed the prior +/// "FleetAdmin" to the canonical "Administrator"). /// public sealed class OtOpcUaLdapAuthService : ILdapAuthService { @@ -77,12 +78,13 @@ public sealed class OtOpcUaLdapAuthService : ILdapAuthService if (_options.DevStubMode) { - // Dev bypass: accept any non-empty credentials and grant FleetAdmin WITHOUT a real bind. + // Dev bypass: accept any non-empty credentials and grant Administrator WITHOUT a real bind. // Pre-populated Roles are unioned with the mapper output by both consumers, so the grant - // survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly. + // survives the move to IGroupRoleMapper. (Task 1.7 canonicalized the role string from the + // prior "FleetAdmin" to "Administrator".) _logger.LogWarning( "OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username); - return new(true, username, username, ["dev"], ["FleetAdmin"], null); + return new(true, username, username, ["dev"], ["Administrator"], null); } // Fail closed on a plaintext transport unless explicitly opted in. The bespoke service diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 269a9880..953a286b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -103,14 +103,17 @@ public static class ServiceCollectionExtensions .RequireAuthenticatedUser() .Build(); - // DriverOperator: may issue Reconnect/Restart commands against live driver instances - // from the Admin UI DriverStatusPanel. Map LDAP group → role via GroupToRole in - // appsettings (e.g. "ot-driver-operator": "DriverOperator"). + // DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against + // live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires + // are the canonical control-plane roles (Task 1.7): Operator (was DriverOperator) and + // Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings + // (e.g. "ot-driver-operator": "Operator"). o.AddPolicy("DriverOperator", policy => - policy.RequireRole("DriverOperator", "FleetAdmin")); + policy.RequireRole("Operator", "Administrator")); - // FleetAdmin: full administrative access; gates fleet-wide pages such as RoleGrants. - o.AddPolicy("FleetAdmin", policy => policy.RequireRole("FleetAdmin")); + // FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages + // such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin). + o.AddPolicy("FleetAdmin", policy => policy.RequireRole("Administrator")); }); return services; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs index 39908756..c9ce8e21 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LdapGroupRoleMappingServiceTests.cs @@ -38,7 +38,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable public async Task Create_SetsId_AndCreatedAtUtc() { var svc = new LdapGroupRoleMappingService(_db); - var row = Make("cn=fleet,dc=x", AdminRole.FleetAdmin); + var row = Make("cn=fleet,dc=x", AdminRole.Administrator); var saved = await svc.CreateAsync(row, CancellationToken.None); @@ -51,7 +51,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable public async Task Create_Rejects_EmptyLdapGroup() { var svc = new LdapGroupRoleMappingService(_db); - var row = Make("", AdminRole.FleetAdmin); + var row = Make("", AdminRole.Administrator); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); @@ -62,7 +62,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable public async Task Create_Rejects_SystemWide_With_ClusterId() { var svc = new LdapGroupRoleMappingService(_db); - var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: "c1", isSystemWide: true); + var row = Make("cn=g", AdminRole.Viewer, clusterId: "c1", isSystemWide: true); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); @@ -73,7 +73,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable public async Task Create_Rejects_NonSystemWide_WithoutClusterId() { var svc = new LdapGroupRoleMappingService(_db); - var row = Make("cn=g", AdminRole.ConfigViewer, clusterId: null, isSystemWide: false); + var row = Make("cn=g", AdminRole.Viewer, clusterId: null, isSystemWide: false); await Should.ThrowAsync( () => svc.CreateAsync(row, CancellationToken.None)); @@ -84,15 +84,15 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable 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); + await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); + await svc.CreateAsync(Make("cn=editor,dc=x", AdminRole.Designer), CancellationToken.None); + await svc.CreateAsync(Make("cn=viewer,dc=x", AdminRole.Viewer), 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); + results.Select(r => r.Role).ShouldBe([AdminRole.Administrator, AdminRole.Viewer], ignoreOrder: true); } /// Verifies that GetByGroups returns empty when input is empty. @@ -100,7 +100,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable public async Task GetByGroups_Empty_Input_ReturnsEmpty() { var svc = new LdapGroupRoleMappingService(_db); - await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.FleetAdmin), CancellationToken.None); + await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); var results = await svc.GetByGroupsAsync([], CancellationToken.None); @@ -112,9 +112,9 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable 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); + await svc.CreateAsync(Make("cn=b,dc=x", AdminRole.Administrator), CancellationToken.None); + await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c2", isSystemWide: false), CancellationToken.None); + await svc.CreateAsync(Make("cn=a,dc=x", AdminRole.Designer, clusterId: "c1", isSystemWide: false), CancellationToken.None); var results = await svc.ListAllAsync(CancellationToken.None); @@ -129,7 +129,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable 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); + var saved = await svc.CreateAsync(Make("cn=fleet,dc=x", AdminRole.Administrator), CancellationToken.None); await svc.DeleteAsync(saved.Id, CancellationToken.None); @@ -153,7 +153,7 @@ public sealed class LdapGroupRoleMappingServiceTests : IDisposable { var svc = new LdapGroupRoleMappingService(_db); var saved = await svc.CreateAsync( - Make("cn=sysadmins,dc=x", AdminRole.FleetAdmin, clusterId: null, isSystemWide: true), + Make("cn=sysadmins,dc=x", AdminRole.Administrator, clusterId: null, isSystemWide: true), CancellationToken.None); saved.IsSystemWide.ShouldBeTrue(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs index 0287f9dc..8f859751 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// Verifies translates app /// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups /// through the shared seam (Task 1.2), unions any pre-resolved -/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather +/// roles (the DevStub Administrator grant) in, and turns LDAP backend exceptions into a denial rather /// than letting them escape into the SDK. /// public sealed class LdapOpcUaUserAuthenticatorTests @@ -23,33 +23,33 @@ public sealed class LdapOpcUaUserAuthenticatorTests public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups() { // Library-style result: groups present, Roles empty (the real path). The mapper maps the - // group "configeditor" -> "ConfigEditor". + // group "configeditor" -> "Designer" (canonical, Task 1.7). var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty(), null)); - var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray()); + var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "Designer" : x).ToArray()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); result.Success.ShouldBeTrue(); result.DisplayName.ShouldBe("Alice"); - result.Roles.ShouldBe(new[] { "ConfigEditor" }); + result.Roles.ShouldBe(new[] { "Designer" }); } - /// The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are + /// The DevStub pre-resolved roles (Administrator) survive the move to the mapper: they are /// unioned with the mapper output so the dev grant still reaches the OPC UA session. [Fact] public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper() { - // DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to - // nothing, so the union is exactly {FleetAdmin}. - var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null)); + // DevStub-shaped result: group "dev", pre-resolved role "Administrator". Mapper maps "dev" to + // nothing, so the union is exactly {Administrator}. + var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); var mapper = new FakeMapper(_ => Array.Empty()); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "FleetAdmin" }); + result.Roles.ShouldBe(new[] { "Administrator" }); } /// A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls @@ -57,14 +57,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests [Fact] public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles() { - var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null)); + var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "Administrator" }, null)); var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down")); var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); result.Success.ShouldBeTrue(); - result.Roles.ShouldBe(new[] { "FleetAdmin" }); + result.Roles.ShouldBe(new[] { "Administrator" }); } /// Verifies that LDAP authentication failure returns Deny result with error text. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index 7f7d32e8..cd057db9 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -313,8 +313,8 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable Success: password == "valid-password", DisplayName: username, Username: username, - Groups: ["FleetAdmin"], - Roles: ["FleetAdmin"], + Groups: ["Administrator"], + Roles: ["Administrator"], Error: null)); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index 4bcb3452..5d38e025 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -64,10 +64,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime ["Security:Jwt:Issuer"] = "otopcua-test", ["Security:Jwt:Audience"] = "otopcua-test", // GroupToRole baseline bound onto LdapOptions: the production - // OtOpcUaGroupRoleMapper resolves "ConfigViewer" from the LDAP group + // OtOpcUaGroupRoleMapper resolves "Viewer" from the LDAP group // "ReadOnly". This exercises the real mapper path — the stub no longer - // pre-populates roles, so ConfigViewer can only come from the mapper. - ["Security:Ldap:GroupToRole:ReadOnly"] = "ConfigViewer", + // pre-populates roles, so Viewer can only come from the mapper. + ["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer", }).Build(); services.AddOtOpcUaAuth(configuration); services.AddSingleton(); @@ -206,13 +206,13 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime public async Task Login_merges_db_role_grant_into_claims() { // StubLdapAuthService returns Groups ["ReadOnly"] with empty Roles (the real production - // shape). The mapper resolves the appsettings baseline "ReadOnly" → ConfigViewer, then a - // system-wide DB row maps "ReadOnly" → FleetAdmin, so the merged set is both. + // shape). The mapper resolves the appsettings baseline "ReadOnly" → Viewer, then a + // system-wide DB row maps "ReadOnly" → Administrator, so the merged set is both. _roleMappings.Rows.Add(new LdapGroupRoleMapping { Id = Guid.NewGuid(), LdapGroup = "ReadOnly", - Role = AdminRole.FleetAdmin, + Role = AdminRole.Administrator, IsSystemWide = true, ClusterId = null, }); @@ -229,8 +229,8 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime var payload = await tokenResp.Content.ReadFromJsonAsync(Ct); var roles = JwtRoleClaims(payload.GetProperty("token").GetString()!); - roles.ShouldContain("ConfigViewer"); // appsettings baseline preserved - roles.ShouldContain("FleetAdmin"); // DB grant merged in + roles.ShouldContain("Viewer"); // appsettings baseline preserved + roles.ShouldContain("Administrator"); // DB grant merged in } /// Fail-closed (review I3): when the role mapper throws on the real production path @@ -315,7 +315,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime { Id = Guid.NewGuid(), LdapGroup = "ReadOnly", - Role = AdminRole.FleetAdmin, + Role = AdminRole.Administrator, IsSystemWide = true, ClusterId = null, }); @@ -370,7 +370,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime [Fact] public async Task Token_payload_uses_canonical_zb_claim_keys() { - // Arrange — the appsettings baseline maps group "ReadOnly" → role "ConfigViewer", so alice + // Arrange — the appsettings baseline maps group "ReadOnly" → role "Viewer", so alice // (whose groups are ["ReadOnly"]) will carry at least one role in the issued JWT. // No extra DB rows needed — the appsettings GroupToRole entry is always active. var client = NewClient(); @@ -401,7 +401,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime // Role claim(s) must be carried under JwtTokenService.RoleClaimType (= "Role"). // This pins the role-key contract: any future rename of RoleClaimType will be caught here. - // The appsettings "ReadOnly" → "ConfigViewer" mapping guarantees alice has ≥1 role. + // The appsettings "ReadOnly" → "Viewer" mapping guarantees alice has ≥1 role. payloadJson.TryGetProperty(JwtTokenService.RoleClaimType, out var roleEl).ShouldBeTrue( $"JWT payload must carry at least one role under JwtTokenService.RoleClaimType " + $"(\"{JwtTokenService.RoleClaimType}\")"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CanonicalAdminRolesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CanonicalAdminRolesTests.cs new file mode 100644 index 00000000..854b1739 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CanonicalAdminRolesTests.cs @@ -0,0 +1,155 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Auth.AspNetCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +/// +/// Task 1.7 — control-plane admin roles are standardized on the canonical six +/// (Viewer / Operator / Engineer / Designer / Deployer / Administrator). OtOpcUa +/// uses four of them: ConfigViewer→Viewer, ConfigEditor→Designer, FleetAdmin→Administrator, +/// and the appsettings-only DriverOperator→Operator. These tests pin the canonical role +/// VALUES end-to-end (mapper output claims + the real registered authorization policies) and +/// prove enforcement semantics are preserved (whoever could deploy/administer/operate before +/// still can — it is a rename, not a permission change). +/// +public sealed class CanonicalAdminRolesTests +{ + // --- (a) the mapper mints the CANONICAL role claim for each native group ---------------- + + [Theory] + [InlineData("Viewer")] // was ConfigViewer + [InlineData("Designer")] // was ConfigEditor + [InlineData("Administrator")] // was FleetAdmin + [InlineData("Operator")] // was DriverOperator (appsettings-only string role) + public async Task Mapper_yields_canonical_role_for_native_group(string canonicalRole) + { + // appsettings GroupToRole baseline carries the canonical value verbatim. + var mapper = BuildMapper(new Dictionary { ["the-group"] = canonicalRole }); + + var result = await mapper.MapAsync(["the-group"], CancellationToken.None); + + result.Roles.ShouldContain(canonicalRole); + } + + [Theory] + [InlineData(AdminRole.Viewer, "Viewer")] + [InlineData(AdminRole.Designer, "Designer")] + [InlineData(AdminRole.Administrator, "Administrator")] + public async Task System_wide_db_row_role_renders_as_canonical_string(AdminRole role, string expected) + { + // The DB path stringifies the enum member name (row.Role.ToString()); renaming the enum + // members is what makes the persisted/emitted string canonical. + var mapper = BuildMapper( + new Dictionary(), + new LdapGroupRoleMapping { LdapGroup = "g", Role = role, IsSystemWide = true }); + + var result = await mapper.MapAsync(["g"], CancellationToken.None); + + result.Roles.ShouldContain(expected); + } + + // --- (b)/(c) the REAL registered authorization policies enforce on the canonical values --- + + [Fact] + public async Task Deployments_role_check_authorizes_Designer_and_Administrator() + { + // Deployments.razor uses [Authorize(Roles="Administrator,Designer")] — a direct role-string + // check (not a named policy). Reproduce it via RequireRole and prove both still pass. + var policy = new AuthorizationPolicyBuilder() + .RequireRole("Administrator", "Designer") + .Build(); + var authz = BuildAuthorizationService(); + + (await authz.AuthorizeAsync(UserInRole("Designer"), policy)).Succeeded.ShouldBeTrue(); + (await authz.AuthorizeAsync(UserInRole("Administrator"), policy)).Succeeded.ShouldBeTrue(); + (await authz.AuthorizeAsync(UserInRole("Viewer"), policy)).Succeeded.ShouldBeFalse(); + } + + [Fact] + public async Task FleetAdmin_policy_authorizes_only_Administrator() + { + var authz = BuildAuthorizationService(); + + // RoleGrants.razor is gated by the "FleetAdmin" named policy → RequireRole("Administrator"). + (await authz.AuthorizeAsync(UserInRole("Administrator"), "FleetAdmin")).Succeeded.ShouldBeTrue(); + (await authz.AuthorizeAsync(UserInRole("Designer"), "FleetAdmin")).Succeeded.ShouldBeFalse(); + (await authz.AuthorizeAsync(UserInRole("Operator"), "FleetAdmin")).Succeeded.ShouldBeFalse(); + (await authz.AuthorizeAsync(UserInRole("Viewer"), "FleetAdmin")).Succeeded.ShouldBeFalse(); + } + + [Fact] + public async Task DriverOperator_policy_authorizes_Operator_and_Administrator() + { + var authz = BuildAuthorizationService(); + + // DriverStatusPanel/pickers gate on the "DriverOperator" named policy → + // RequireRole("Operator","Administrator"). Operator (was DriverOperator) and Administrator + // (was FleetAdmin) both pass; a plain Viewer does not. + (await authz.AuthorizeAsync(UserInRole("Operator"), "DriverOperator")).Succeeded.ShouldBeTrue(); + (await authz.AuthorizeAsync(UserInRole("Administrator"), "DriverOperator")).Succeeded.ShouldBeTrue(); + (await authz.AuthorizeAsync(UserInRole("Viewer"), "DriverOperator")).Succeeded.ShouldBeFalse(); + } + + // --- helpers ---------------------------------------------------------------------------- + + private static ClaimsPrincipal UserInRole(string role) + { + // ZbClaimTypes.Role aliases ClaimTypes.Role, the default role-claim type, so RequireRole / + // IsInRole resolve against it. + var identity = new ClaimsIdentity( + [new Claim(ZbClaimTypes.Role, role)], authenticationType: "Test"); + return new ClaimsPrincipal(identity); + } + + private static IAuthorizationService BuildAuthorizationService() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(new ConfigurationBuilder().Build()); + // Use the REAL policy registrations from AddOtOpcUaAuth; it needs the ConfigDbContext for + // DataProtection key persistence, so register an in-memory one. + services.AddDbContextFactory(o => o.UseInMemoryDatabase("authz-test")); + services.AddDbContext(o => o.UseInMemoryDatabase("authz-test")); + services.AddOtOpcUaAuth(new ConfigurationBuilder().Build()); + + return services.BuildServiceProvider().GetRequiredService(); + } + + private static OtOpcUaGroupRoleMapper BuildMapper( + IDictionary groupToRole, + params LdapGroupRoleMapping[] dbRows) + { + var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions + { + GroupToRole = new Dictionary(groupToRole, StringComparer.OrdinalIgnoreCase), + }); + return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows)); + } + + private sealed class FakeMappingService(IReadOnlyList rows) : ILdapGroupRoleMappingService + { + public Task> GetByGroupsAsync( + IEnumerable ldapGroups, CancellationToken cancellationToken) + => Task.FromResult(rows); + + public Task> ListAllAsync(CancellationToken cancellationToken) + => Task.FromResult(rows); + + public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs index 705dfb19..bd6559b7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs @@ -31,11 +31,11 @@ public sealed class OtOpcUaGroupRoleMapperTests [Fact] public async Task Maps_config_group_and_drops_unmapped_group() { - var mapper = Build(new Dictionary { ["AdminGroup"] = "FleetAdmin" }); + var mapper = Build(new Dictionary { ["AdminGroup"] = "Administrator" }); var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None); - result.Roles.ShouldBe(["FleetAdmin"]); + result.Roles.ShouldBe(["Administrator"]); result.Scope.ShouldBeNull(); } @@ -43,13 +43,13 @@ public sealed class OtOpcUaGroupRoleMapperTests public async Task System_wide_db_row_adds_role_on_top_of_config_baseline() { var mapper = Build( - new Dictionary { ["viewers"] = "ConfigViewer" }, - new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true }); + new Dictionary { ["viewers"] = "Viewer" }, + new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true }); var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None); - result.Roles.ShouldContain("ConfigViewer"); - result.Roles.ShouldContain("FleetAdmin"); + result.Roles.ShouldContain("Viewer"); + result.Roles.ShouldContain("Administrator"); result.Scope.ShouldBeNull(); } @@ -61,14 +61,14 @@ public sealed class OtOpcUaGroupRoleMapperTests new LdapGroupRoleMapping { LdapGroup = "site-a-editors", - Role = AdminRole.ConfigEditor, + Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A", }); var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None); - result.Roles.ShouldNotContain("ConfigEditor"); + result.Roles.ShouldNotContain("Designer"); result.Roles.ShouldBeEmpty(); } @@ -77,13 +77,13 @@ public sealed class OtOpcUaGroupRoleMapperTests { var groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["viewers"] = "ConfigViewer", - ["editors"] = "ConfigEditor", + ["viewers"] = "Viewer", + ["editors"] = "Designer", }; var dbRows = new[] { - new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true }, - new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" }, + new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.Administrator, IsSystemWide = true }, + new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" }, }; var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs index d6c30137..d0b9354b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs @@ -21,9 +21,9 @@ public sealed class OtOpcUaLdapAuthServiceTests private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) => new(options, inner, NullLogger.Instance); - /// DevStubMode on → stub FleetAdmin success WITHOUT hitting the library. + /// DevStubMode on → stub Administrator success WITHOUT hitting the library. [Fact] - public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library() + public async Task DevStubMode_grants_Administrator_without_calling_the_library() { var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner); @@ -33,7 +33,7 @@ public sealed class OtOpcUaLdapAuthServiceTests result.Success.ShouldBeTrue(); result.Username.ShouldBe("anyone"); result.Groups.ShouldBe(new[] { "dev" }); - result.Roles.ShouldBe(new[] { "FleetAdmin" }); + result.Roles.ShouldBe(new[] { "Administrator" }); inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client"); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs index b94a445e..20ae93d0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs @@ -26,8 +26,8 @@ public sealed class RoleMapperTests { RoleMapper.Map( new[] { "AdminGroup" }, - new Dictionary { ["AdminGroup"] = "FleetAdmin" }) - .ShouldBe(new[] { "FleetAdmin" }); + new Dictionary { ["AdminGroup"] = "Administrator" }) + .ShouldBe(new[] { "Administrator" }); } /// @@ -40,9 +40,9 @@ public sealed class RoleMapperTests new[] { "admingroup" }, new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AdminGroup"] = "FleetAdmin", + ["AdminGroup"] = "Administrator", }) - .ShouldBe(new[] { "FleetAdmin" }); + .ShouldBe(new[] { "Administrator" }); } /// @@ -55,11 +55,11 @@ public sealed class RoleMapperTests new[] { "AdminGroup", "AlsoAdmin" }, new Dictionary { - ["AdminGroup"] = "FleetAdmin", - ["AlsoAdmin"] = "FleetAdmin", + ["AdminGroup"] = "Administrator", + ["AlsoAdmin"] = "Administrator", }); - roles.ShouldBe(new[] { "FleetAdmin" }); + roles.ShouldBe(new[] { "Administrator" }); } [Fact] @@ -67,16 +67,16 @@ public sealed class RoleMapperTests { var rows = new[] { - new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true }, - new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" }, + new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.Administrator, IsSystemWide = true }, + new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.Designer, IsSystemWide = false, ClusterId = "SITE-A" }, }; - var result = RoleMapper.Merge(["ConfigViewer"], rows); - result.ShouldContain("ConfigViewer"); - result.ShouldContain("FleetAdmin"); - result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only) + var result = RoleMapper.Merge(["Viewer"], rows); + result.ShouldContain("Viewer"); + result.ShouldContain("Administrator"); + result.ShouldNotContain("Designer"); // cluster-scoped row ignored (global-only) } [Fact] public void Merge_with_no_db_rows_returns_baseline() - => RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]); + => RoleMapper.Merge(["Administrator"], []).ShouldBe(["Administrator"]); }