From b104760b3af2c36b4d01848c16083c830d533575 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 08:00:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(auth)!:=20ScadaBridge=20canonical=20roles?= =?UTF-8?q?=20+=20SoD=20collapse=20(Audit=E2=86=92Administrator,=20AuditRe?= =?UTF-8?q?adOnly=E2=86=92Viewer)=20+=20config-DB=20migration=20(Task=201.?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize role string VALUES on the canonical vocabulary (Administrator/Designer/Deployer/Viewer; Operator/Engineer unused here): Admin -> Administrator Design -> Designer Deployment -> Deployer Audit -> Administrator (COLLAPSE; accepted privilege escalation) AuditReadOnly-> Viewer (COLLAPSE; keeps audit-read, no export) SoD: OperationalAuditRoles = { Administrator, Viewer }, AuditExportRoles = { Administrator } so Viewer reads the audit log + nav but cannot bulk-export, while Administrator does both + holds the full admin surface (the documented, accepted auditor/admin SoD collapse). Atomic move across every enforcement site: - Roles constants; AuthorizationPolicies (RequireClaim values + SoD arrays + honest XML-doc); RoleMapper Deployer check. - ManagementActor.GetRequiredRole switch + the hard-coded site-scope admin-bypass (now Roles.Administrator at all 6 sites). Site-scoping logic is otherwise unchanged. - DebugStreamHub Administrator/Deployer gates (Deployer kept case-sensitive). - CentralUI BrowseService/BindingTester Designer guards; LdapMappingForm dropdown now offers canonical values (incl. Viewer). - Config-DB seed (LdapGroupMappings Id 1-4) + EF migration CanonicalizeRoles: Id-keyed UpdateData for seed rows + idempotent raw catch-all UPDATEs for operator-added rows. Down is lossy on the collapse (documented in-file). No pending model changes. Tests reworked to the collapsed model across Security/CentralUI/ ManagementService/ConfigurationDatabase/Integration suites, incl. explicit Viewer-reads-not-exports and former-Audit-now-Administrator-escalation cases. CHANGELOG: BREAKING security note documenting the canonicalization + SoD collapse. --- CHANGELOG.md | 47 + .../Pages/Admin/LdapMappingForm.razor | 9 +- .../Services/BindingTester.cs | 4 +- .../Services/BrowseService.cs | 4 +- .../Configurations/SecurityConfiguration.cs | 13 +- ...260602113822_CanonicalizeRoles.Designer.cs | 1740 +++++++++++++++++ .../20260602113822_CanonicalizeRoles.cs | 133 ++ .../ScadaBridgeDbContextModelSnapshot.cs | 8 +- .../AuditEndpoints.cs | 9 +- .../DebugStreamHub.cs | 12 +- .../ManagementActor.cs | 37 +- .../AuthorizationPolicies.cs | 73 +- .../RoleMapper.cs | 2 +- src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs | 29 +- .../Admin/ApiKeyFormAuditDrillinTests.cs | 2 +- .../Admin/ApiKeysListPageTests.cs | 2 +- .../Admin/SiteFormAuditDrillinTests.cs | 2 +- .../Audit/AuditExportEndpointsTests.cs | 4 +- .../Auth/SiteScopeServiceTests.cs | 12 +- .../DataConnectionFormTests.cs | 2 +- .../DataConnectionsPageTests.cs | 2 +- .../InstanceConfigureAuditDrillinTests.cs | 2 +- .../ExternalSystemFormAuditDrillinTests.cs | 2 +- .../Layout/NavMenuTests.cs | 12 +- .../Pages/AuditLogPagePermissionTests.cs | 56 +- .../Pages/AuditLogPageScaffoldTests.cs | 26 +- .../Pages/Design/TransportExportPageTests.cs | 6 +- .../Pages/Design/TransportImportPageTests.cs | 4 +- .../Pages/ExecutionTreePageTests.cs | 10 +- .../Pages/HealthPageTests.cs | 2 +- .../Pages/NotificationKpisPageTests.cs | 2 +- .../Pages/NotificationListsPageTests.cs | 2 +- .../NotificationReportDetailModalTests.cs | 2 +- .../Pages/NotificationReportPageTests.cs | 2 +- .../Pages/QueryStringDrillInTests.cs | 4 +- .../Pages/SiteCallsReportPageTests.cs | 4 +- .../Pages/SmtpConfigurationPageTests.cs | 2 +- .../TemplatesPageTests.cs | 2 +- .../TopologyPageTests.cs | 4 +- .../RepositoryTests.cs | 32 +- .../SeedDataTests.cs | 3 +- .../UnitTest1.cs | 2 +- .../AuditTransactionTests.cs | 6 +- .../AuthFlowTests.cs | 8 +- .../CentralFailoverTests.cs | 10 +- .../SecurityHardeningTests.cs | 10 +- .../ApiKeyCreationTests.cs | 32 +- .../AuditEndpointsTests.cs | 52 +- .../DebugStreamHubTests.cs | 12 +- .../ManagementActorTests.cs | 144 +- .../ScadaBridgeGroupRoleMapperTests.cs | 16 +- .../SecurityTests.cs | 173 +- 52 files changed, 2388 insertions(+), 402 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.Designer.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0de866b6..0d54c18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed — BREAKING: canonical role names + audit separation-of-duties collapse (Task 1.7) + +Role string VALUES are standardized onto the canonical vocabulary +(`Administrator`/`Designer`/`Deployer`/`Viewer`; `Operator`/`Engineer` are unused +by ScadaBridge). The legacy ScadaBridge role names were renamed and two were +**collapsed**: + +| Legacy role | Canonical role | Notes | +|-----------------|-----------------|-------| +| `Admin` | `Administrator` | rename | +| `Design` | `Designer` | rename | +| `Deployment` | `Deployer` | rename | +| `Audit` | `Administrator` | **COLLAPSE** | +| `AuditReadOnly` | `Viewer` | **COLLAPSE** | + +- **SECURITY — privilege escalation (accepted).** The former `Audit` role + collapses into `Administrator`. This is a real escalation: a former audit-only + user now holds the **entire admin surface** (create/update/delete sites, manage + LDAP group→role mappings and API keys, preview/import transport bundles), not + just audit read+export. This loss of auditor/admin separation-of-duties is a + deliberate, accepted trade-off of the canonicalization. +- **SECURITY — half-SoD preserved.** The former `AuditReadOnly` role collapses + into `Viewer`, which **keeps audit READ** (Audit Log page, Configuration Audit + Log page, audit nav group) but **cannot bulk-export**. The audit policy sets are + now `OperationalAuditRoles = { Administrator, Viewer }` and + `AuditExportRoles = { Administrator }`, so a `Viewer` reads the audit log but the + Export-CSV button / `/api/audit/export` endpoint correctly refuses it. +- **Enforcement.** Every enforcement site moved together: the role-claim values, + the authorization policies (`RequireAdmin`/`RequireDesign`/`RequireDeployment` + policy *names* are unchanged; only the role *values* inside them changed), the + `ManagementActor.GetRequiredRole` switch, the hard-coded site-scope admin-bypass + (`Roles.Administrator` everywhere), the `DebugStreamHub` Administrator/Deployer + gates, and the CentralUI `BrowseService`/`BindingTester` Designer guards. + **Site-scoping logic is otherwise unchanged** — only the admin-bypass *value* + moved from `"Admin"` to `Roles.Administrator`. +- **Config-DB migration `CanonicalizeRoles`.** Updates the four seeded + `LdapGroupMappings` rows (Id 1-4) to the canonical role values and adds raw + idempotent catch-all `UPDATE`s for operator-added rows + (`Admin`/`Audit`→`Administrator`, `Design`→`Designer`, `Deployment`→`Deployer`, + `AuditReadOnly`→`Viewer`). The Down migration is **lossy** for the collapse: it + best-effort maps `Administrator`→`Admin` and `Viewer`→`AuditReadOnly` but cannot + recover the original `Audit`/`Admin` or `Viewer`/`AuditReadOnly` distinction. +- **Operator action.** Any LDAP group→role mappings created with the legacy role + strings are migrated automatically by `CanonicalizeRoles`. New mappings created + via the CentralUI LDAP-mappings form now offer the canonical role values + (including a `Viewer` option for audit-read-only delegation). + ### Changed — BREAKING: inbound API authentication Inbound API authentication has migrated off the SQL Server `X-API-Key` scheme and diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor index 758040da..47c9b76e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/LdapMappingForm.razor @@ -30,11 +30,12 @@ -
Deployment role: configure site scope below after saving.
+
Deployer role: configure site scope below after saving.
@if (_formError != null) { diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs index e8e1c7d5..8f9d6b3c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingTester.cs @@ -36,11 +36,11 @@ public sealed class BindingTester : IBindingTester CancellationToken ct = default) { // CentralUI-side role guard — sites don't enforce envelope-level - // roles, so the Design check must happen here before any cross-cluster + // roles, so the Designer check must happen here before any cross-cluster // traffic. Use HasClaim against JwtTokenService.RoleClaimType (not // IsInRole, per c1e16cf). var state = await _auth.GetAuthenticationStateAsync(); - if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer)) { return new ReadTagValuesResult( Array.Empty(), diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs index b27f4298..968df7f6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BrowseService.cs @@ -43,9 +43,9 @@ public sealed class BrowseService : IBrowseService CancellationToken cancellationToken = default) { // CentralUI-side role guard — sites don't enforce envelope-level roles, - // so the Design check must happen here before any cross-cluster traffic. + // so the Designer check must happen here before any cross-cluster traffic. var state = await _auth.GetAuthenticationStateAsync(); - if (!state.User.HasClaim(JwtTokenService.RoleClaimType, "Design")) + if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer)) { return new BrowseNodeResult( Array.Empty(), diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SecurityConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SecurityConfiguration.cs index 912d268e..109b5142 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SecurityConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/SecurityConfiguration.cs @@ -25,12 +25,15 @@ public class LdapGroupMappingConfiguration : IEntityTypeConfiguration m.LdapGroupName).IsUnique(); - // Seed default group mappings matching GLAuth test users + // Seed default group mappings matching GLAuth test users. + // Role VALUES are the canonical six (Task 1.7): Administrator/Designer/ + // Deployer. The LDAP group NAMES (SCADA-Admins etc.) are unchanged — + // only the role each group maps to was canonicalized. builder.HasData( - new LdapGroupMapping("SCADA-Admins", "Admin") { Id = 1 }, - new LdapGroupMapping("SCADA-Designers", "Design") { Id = 2 }, - new LdapGroupMapping("SCADA-Deploy-All", "Deployment") { Id = 3 }, - new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployment") { Id = 4 }); + new LdapGroupMapping("SCADA-Admins", "Administrator") { Id = 1 }, + new LdapGroupMapping("SCADA-Designers", "Designer") { Id = 2 }, + new LdapGroupMapping("SCADA-Deploy-All", "Deployer") { Id = 3 }, + new LdapGroupMapping("SCADA-Deploy-SiteA", "Deployer") { Id = 4 }); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.Designer.cs new file mode 100644 index 00000000..b013f343 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.Designer.cs @@ -0,0 +1,1740 @@ +// +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.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260602113822_CanonicalizeRoles")] + partial class CanonicalizeRoles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Administrator" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Designer" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployer" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployer" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.cs new file mode 100644 index 00000000..f8846683 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602113822_CanonicalizeRoles.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + /// Task 1.7 — canonicalizes ScadaBridge role VALUES onto the canonical six + /// (only Administrator/Designer/Deployer/Viewer are used here) and collapses + /// the former audit roles: + /// + /// Admin → Administrator + /// Design → Designer + /// Deployment → Deployer + /// Audit → Administrator (COLLAPSE — accepted SoD loss / + /// privilege escalation) + /// AuditReadOnly → Viewer (COLLAPSE — keeps audit-read, + /// never had export) + /// + /// The UpdateData calls below canonicalize the four seeded rows + /// (Id 1-4). The raw Sql catch-alls then canonicalize ANY + /// operator-added (non-seed) rows whose Role still holds a legacy + /// value — LdapGroupMappings.Role is free-text nvarchar, so operators + /// may have created rows with Admin/Design/Deployment/Audit/AuditReadOnly. + /// The catch-alls are value-keyed (WHERE Role IN (...)), so they are + /// idempotent and safe whether or not the seed UpdateData already ran + /// (after the seed update the seed rows no longer match a legacy value, so + /// they are simply skipped). + /// + /// + /// LOSSY DOWN: the Audit/AuditReadOnly → Administrator/Viewer collapse is not + /// perfectly reversible. Down is a best-effort that maps Administrator→Admin + /// and Viewer→AuditReadOnly. It CANNOT recover the original Audit vs Admin + /// distinction (both became Administrator) nor distinguish a genuine Viewer + /// from a former AuditReadOnly (both became Viewer). The four seeded rows are + /// restored exactly by the Id-keyed UpdateData calls; operator rows + /// are reverted by the value-keyed catch-alls under this caveat. + /// + public partial class CanonicalizeRoles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1) Canonicalize the four seeded rows by Id (EF-scaffolded). + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 1, + column: "Role", + value: "Administrator"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 2, + column: "Role", + value: "Designer"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 3, + column: "Role", + value: "Deployer"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 4, + column: "Role", + value: "Deployer"); + + // 2) Catch-all for operator-added (non-seed) rows. Value-keyed and + // idempotent. Audit collapses INTO Administrator; AuditReadOnly + // collapses INTO Viewer. + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Administrator' WHERE [Role] IN (N'Admin', N'Audit');"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Designer' WHERE [Role] = N'Design';"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Deployer' WHERE [Role] = N'Deployment';"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Viewer' WHERE [Role] = N'AuditReadOnly';"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Restore the four seeded rows exactly (Id-keyed). Down is lossy for + // the collapsed audit roles — see the class . + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 1, + column: "Role", + value: "Admin"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 2, + column: "Role", + value: "Design"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 3, + column: "Role", + value: "Deployment"); + + migrationBuilder.UpdateData( + table: "LdapGroupMappings", + keyColumn: "Id", + keyValue: 4, + column: "Role", + value: "Deployment"); + + // Best-effort reverse for operator-added (non-seed) rows. LOSSY: + // Administrator→Admin and Viewer→AuditReadOnly cannot recover the + // original Audit/Admin or Viewer/AuditReadOnly distinction (the + // forward collapse merged those). Value-keyed and idempotent. + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Admin' WHERE [Role] = N'Administrator';"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Design' WHERE [Role] = N'Designer';"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'Deployment' WHERE [Role] = N'Deployer';"); + migrationBuilder.Sql( + "UPDATE [LdapGroupMappings] SET [Role] = N'AuditReadOnly' WHERE [Role] = N'Viewer';"); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 9b8347ec..4eb19ad8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -1045,25 +1045,25 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations { Id = 1, LdapGroupName = "SCADA-Admins", - Role = "Admin" + Role = "Administrator" }, new { Id = 2, LdapGroupName = "SCADA-Designers", - Role = "Design" + Role = "Designer" }, new { Id = 3, LdapGroupName = "SCADA-Deploy-All", - Role = "Deployment" + Role = "Deployer" }, new { Id = 4, LdapGroupName = "SCADA-Deploy-SiteA", - Role = "Deployment" + Role = "Deployer" }); }); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs index b5476482..98ff808c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/AuditEndpoints.cs @@ -402,10 +402,11 @@ public static class AuditEndpoints /// public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user) { - // Empty PermittedSiteIds is the system-wide signal (Admin, system-wide - // Deployment). System-wide audit roles also fall here — the design treats - // Audit/AuditReadOnly as non-site-scoped unless an operator attaches scope - // rules to the LDAP mapping; if they do, this helper enforces them. + // Empty PermittedSiteIds is the system-wide signal (Administrator, + // system-wide Deployer). System-wide audit roles also fall here — the + // design treats Administrator/Viewer (the post-Task-1.7 audit roles) as + // non-site-scoped unless an operator attaches scope rules to the LDAP + // mapping; if they do, this helper enforces them. if (user.PermittedSiteIds.Length == 0) { return filter; diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs index 7f6542b2..754639bd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/DebugStreamHub.cs @@ -26,8 +26,8 @@ public class DebugStreamHub : Hub /// Pure site-scope authorization check for a debug-stream subscription. /// Returns true when the caller may subscribe to a debug stream for an instance /// belonging to . - /// Admin role, or an empty (system-wide - /// Deployment), grants access to any site; otherwise the instance's site must be + /// Administrator role, or an empty (system-wide + /// Deployer), grants access to any site; otherwise the instance's site must be /// in the permitted set. /// /// Roles held by the connected user. @@ -38,7 +38,7 @@ public class DebugStreamHub : Hub IReadOnlyCollection permittedSiteIds, int instanceSiteId) { - if (roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return true; + if (roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return true; if (permittedSiteIds.Count == 0) return true; // system-wide deployment return permittedSiteIds.Contains(instanceSiteId.ToString()); } @@ -109,13 +109,13 @@ public class DebugStreamHub : Hub return; } - // Role check — Deployment role required + // Role check — Deployer role required var roleMapper = httpContext.RequestServices.GetRequiredService(); var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, Context.ConnectionAborted); - if (!mappingResult.Roles.Contains("Deployment")) + if (!mappingResult.Roles.Contains(Roles.Deployer)) { - _logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployment role", username); + _logger.LogWarning("DebugStreamHub connection rejected: {Username} lacks Deployer role", username); Context.Abort(); return; } diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index bd2e7251..811bd156 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -153,7 +153,7 @@ public class ManagementActor : ReceiveActor private static string? GetRequiredRole(object command) => command switch { - // Admin operations + // Administrator operations CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand or ListRoleMappingsCommand or CreateRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand @@ -167,9 +167,9 @@ public class ManagementActor : ReceiveActor // should use the REST endpoint; this command is retained for // backward compatibility with the CentralUI Configuration Audit // Log page (Management-018). - or QueryAuditLogCommand => "Admin", + or QueryAuditLogCommand => Roles.Administrator, - // Design operations + // Designer operations CreateAreaCommand or DeleteAreaCommand or CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand or ValidateTemplateCommand @@ -194,14 +194,15 @@ public class ManagementActor : ReceiveActor or CreateTemplateFolderCommand or RenameTemplateFolderCommand or MoveTemplateFolderCommand or DeleteTemplateFolderCommand or MoveTemplateToFolderCommand - or ExportBundleCommand => "Design", + or ExportBundleCommand => Roles.Designer, - // Transport import operations (mirror the Central UI gating: Admin - // for inbound bundle handling because they mutate cross-cutting - // configuration; Export stays Design because it only reads). - PreviewBundleCommand or ImportBundleCommand => "Admin", + // Transport import operations (mirror the Central UI gating: + // Administrator for inbound bundle handling because they mutate + // cross-cutting configuration; Export stays Designer because it only + // reads). + PreviewBundleCommand or ImportBundleCommand => Roles.Administrator, - // Deployment operations + // Deployer operations CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand @@ -211,7 +212,7 @@ public class ManagementActor : ReceiveActor or MgmtDeployArtifactsCommand or QueryDeploymentsCommand or RetryParkedMessageCommand or DiscardParkedMessageCommand - or DebugSnapshotCommand => "Deployment", + or DebugSnapshotCommand => Roles.Deployer, // Read-only queries -- any authenticated user _ => null @@ -385,15 +386,15 @@ public class ManagementActor : ReceiveActor // ======================================================================== /// - /// Throws SiteScopeViolationException if the user has site-scoped Deployment + /// Throws SiteScopeViolationException if the user has site-scoped Deployer /// and the target site is not in their permitted sites. - /// Users with Admin or Design roles, or system-wide Deployment, are not restricted. + /// Users with the Administrator role, or system-wide Deployer, are not restricted. /// private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId) { if (targetSiteId == null) return; if (user.PermittedSiteIds.Length == 0) return; // system-wide access - if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return; + if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return; if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString())) { @@ -409,7 +410,7 @@ public class ManagementActor : ReceiveActor private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId) { if (user.PermittedSiteIds.Length == 0) return; - if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return; + if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return; var repo = sp.GetRequiredService(); var instance = await repo.GetInstanceByIdAsync(instanceId); @@ -424,7 +425,7 @@ public class ManagementActor : ReceiveActor private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier) { if (user.PermittedSiteIds.Length == 0) return; - if (user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) return; + if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return; var repo = sp.GetRequiredService(); var site = await repo.GetSiteByIdentifierAsync(siteIdentifier); @@ -617,7 +618,7 @@ public class ManagementActor : ReceiveActor var repo = sp.GetRequiredService(); var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm); // Filter by permitted sites for site-scoped users - if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) { var permittedIds = new HashSet(user.PermittedSiteIds); instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList(); @@ -864,7 +865,7 @@ public class ManagementActor : ReceiveActor { var repo = sp.GetRequiredService(); var sites = await repo.GetAllSitesAsync(); - if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) { var permittedIds = new HashSet(user.PermittedSiteIds); sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList(); @@ -1372,7 +1373,7 @@ public class ManagementActor : ReceiveActor // SiteId, so resolve each record's instance to its site and filter // (mirrors the HandleListInstances / HandleListSites filter pattern). var records = await repo.GetAllDeploymentRecordsAsync(); - if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains("Admin", StringComparer.OrdinalIgnoreCase)) + if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return records; var permittedIds = new HashSet(user.PermittedSiteIds); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs index 421018d5..4d5710ad 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs @@ -18,44 +18,52 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// /// /// -/// Default role → permission mapping (#23 M7-T15 / Bundle G): +/// Default role → permission mapping (#23 M7-T15 / Bundle G), post Task 1.7 +/// canonicalization + SoD collapse: /// /// /// Role /// Policies granted /// /// -/// Admin +/// Administrator /// , /// , — admins hold -/// every permission by convention so an Admin-only user never loses +/// every permission by convention so an Administrator-only user never loses /// access to a new surface. /// /// -/// Design +/// Designer /// /// /// -/// Deployment +/// Deployer /// /// /// -/// Audit -/// , -/// — the full audit surface (read + bulk -/// export) per Component-AuditLog.md §"Authorization". -/// -/// -/// AuditReadOnly -/// only — operators who -/// should see the Audit Log + drill in to incidents but not pull bulk -/// CSV exports. Use this when delegating triage without granting -/// forensic-export capability. +/// Viewer +/// only — read access to the +/// Audit Log + nav, but NOT . This preserves the +/// half-SoD that the legacy AuditReadOnly role provided (read-not- +/// export) after AuditReadOnly was collapsed into +/// Viewer. /// /// +/// +/// SoD collapse (Task 1.7): the legacy distinct audit roles were removed. The +/// former Audit role (full audit surface = read + bulk export) was +/// collapsed into Administrator — a deliberate, accepted privilege +/// escalation (former audit-only users gain the entire admin surface: create +/// sites, manage LDAP mappings/API keys, import bundles). The former +/// AuditReadOnly role (read-only audit) was collapsed into +/// Viewer, which keeps audit-read but correctly LACKS export. The net +/// effect on the audit policies: is granted to +/// {Administrator, Viewer} and only to +/// {Administrator}. +/// /// LDAP group → role mapping is configured via the central UI Admin → LDAP /// Mappings page (rows in LdapGroupMappings); the same code path -/// reads them whether the role is one of the four built-ins above or any +/// reads them whether the role is one of the built-ins above or any /// future addition. Adding a role here means adding the LDAP mapping row in /// the deployment; no schema migration is needed. /// @@ -69,16 +77,16 @@ public static class AuthorizationPolicies /// /// Read access to the Audit Log #23 surface (Audit Log page, /// Configuration Audit Log page, Audit nav group). Granted to the - /// Audit role, the AuditReadOnly role, and the - /// Admin role. + /// Administrator role and the Viewer role (the latter being + /// the post-Task-1.7 home of the former AuditReadOnly role). /// public const string OperationalAudit = "OperationalAudit"; /// /// Permission to pull a bulk CSV export of the Audit Log. Separate from - /// so a triage operator can read the + /// so a Viewer can read the /// table without being able to exfiltrate it in bulk. Granted to the - /// Audit role and the Admin role. + /// Administrator role only. /// public const string AuditExport = "AuditExport"; @@ -91,20 +99,23 @@ public static class AuthorizationPolicies /// /api/audit/* routes with a manual Basic-Auth + LDAP role check /// rather than the ASP.NET authorization-policy pipeline — can reuse the /// exact same role set the policy enforces. + /// Task 1.7: {Administrator, Viewer} (was {Admin, Audit, + /// AuditReadOnly} — the audit roles collapsed into Administrator/Viewer). /// - public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly }; + public static readonly string[] OperationalAuditRoles = { Roles.Administrator, Roles.Viewer }; /// /// Roles that satisfy . A strict subset of /// — read access does NOT imply - /// export permission. + /// export permission, so Viewer can read but not export. /// /// /// Public for the same reason as — /// the ManagementService /api/audit/export route checks roles - /// against this set directly. + /// against this set directly. Task 1.7: {Administrator} (was + /// {Admin, Audit}). /// - public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit }; + public static readonly string[] AuditExportRoles = { Roles.Administrator }; /// /// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport). @@ -115,21 +126,21 @@ public static class AuthorizationPolicies services.AddAuthorization(options => { options.AddPolicy(RequireAdmin, policy => - policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin)); + policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Administrator)); options.AddPolicy(RequireDesign, policy => - policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design)); + policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Designer)); options.AddPolicy(RequireDeployment, policy => - policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment)); + policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployer)); // Multi-role permission policies — the policy succeeds when the // principal holds ANY of the mapped roles. RequireClaim with // multiple allowed values is the right primitive: it checks // whether *any* role claim's value is in the allowed set, so a - // user with role=Admin (and nothing else) satisfies the - // OperationalAudit policy without needing a separate Audit - // role claim. + // user with role=Administrator (and nothing else) satisfies the + // OperationalAudit policy, and a user with role=Viewer satisfies + // OperationalAudit but not AuditExport. options.AddPolicy(OperationalAudit, policy => policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles)); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs index 314ff1a7..34bf2b88 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs @@ -39,7 +39,7 @@ public class RoleMapper matchedRoles.Add(mapping.Role); - if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase)) + if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase)) { hasDeploymentRole = true; diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs index 77b8156c..2e1f4ccc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs @@ -5,18 +5,37 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// Security module and downstream authorization checks. /// /// +/// /// Role names appear in three independent contexts: /// (LDAP-group → role resolution), /// (policy RequireClaim values + the audit role arrays), and at LDAP /// mapping rows configured by an operator. Holding the literals here means a /// rename either succeeds everywhere or fails to compile, eliminating the /// "string drift" class that Security-018 documented. +/// +/// +/// Task 1.7 canonicalization (auth normalization): role VALUES were +/// standardized onto the canonical six (Viewer/Operator/Engineer/Designer/ +/// Deployer/Administrator; only four are used by ScadaBridge). The legacy +/// ScadaBridge role names were renamed/collapsed as follows: +/// +/// AdminAdministrator +/// DesignDesigner +/// DeploymentDeployer +/// AuditAdministrator (COLLAPSE — accepted +/// separation-of-duties loss; a former audit-only user gains the full admin +/// surface) +/// AuditReadOnlyViewer (COLLAPSE — keeps +/// audit-read + nav, loses bulk export, which it never had) +/// +/// Operator and Engineer exist in the canonical vocabulary but are +/// unused by ScadaBridge, so they are intentionally not declared here. +/// /// public static class Roles { - public const string Admin = "Admin"; - public const string Design = "Design"; - public const string Deployment = "Deployment"; - public const string Audit = "Audit"; - public const string AuditReadOnly = "AuditReadOnly"; + public const string Administrator = "Administrator"; + public const string Designer = "Designer"; + public const string Deployer = "Deployer"; + public const string Viewer = "Viewer"; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs index e9d6797b..ffd21018 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs @@ -37,7 +37,7 @@ public class ApiKeyFormAuditDrillinTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "admin"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs index 19b5a80a..83b3d8ef 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/ApiKeysListPageTests.cs @@ -27,7 +27,7 @@ public class ApiKeysListPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "admin"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs index 44b59358..c03de360 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs @@ -36,7 +36,7 @@ public class SiteFormAuditDrillinTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "admin"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs index 804f85a3..f21bce0e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Audit/AuditExportEndpointsTests.cs @@ -81,7 +81,7 @@ public class AuditExportEndpointsTests // Use the real production policy wiring so the endpoint's // updated AuditExport gate (#23 M7-T15 Bundle G) is what // the tests exercise. The fake principal carries the - // "Admin" role, which AuditExportRoles permits. + // "Administrator" role, which AuditExportRoles permits. services.AddScadaBridgeAuthorization(); services.AddSingleton(repo); services.AddScoped(); @@ -288,7 +288,7 @@ public class AuditExportEndpointsTests var claims = new[] { new Claim(ClaimTypes.Name, "test-admin"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var identity = new ClaimsIdentity(claims, SchemeName); var principal = new ClaimsPrincipal(identity); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs index f0dd8a61..eab55fb4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/SiteScopeServiceTests.cs @@ -37,7 +37,7 @@ public class SiteScopeServiceTests [Fact] public async Task DeploymentUserWithNoSiteClaims_IsSystemWide() { - var svc = ForUser(Role("Deployment")); + var svc = ForUser(Role("Deployer")); Assert.True(await svc.IsSystemWideAsync()); Assert.Empty(await svc.PermittedSiteIdsAsync()); @@ -46,7 +46,7 @@ public class SiteScopeServiceTests [Fact] public async Task SystemWideUser_FilterSites_ReturnsAllSites() { - var svc = ForUser(Role("Deployment")); + var svc = ForUser(Role("Deployer")); var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3)); @@ -57,7 +57,7 @@ public class SiteScopeServiceTests public async Task ScopedUser_FilterSites_ReturnsOnlyPermittedSites() { // Regression: a Deployment user scoped to sites 1 and 3 must NOT see site 2. - var svc = ForUser(Role("Deployment"), SiteClaim(1), SiteClaim(3)); + var svc = ForUser(Role("Deployer"), SiteClaim(1), SiteClaim(3)); var filtered = await svc.FilterSitesAsync(Sites(1, 2, 3, 4)); @@ -67,7 +67,7 @@ public class SiteScopeServiceTests [Fact] public async Task ScopedUser_IsSiteAllowed_OnlyForGrantedSites() { - var svc = ForUser(Role("Deployment"), SiteClaim(5)); + var svc = ForUser(Role("Deployer"), SiteClaim(5)); Assert.True(await svc.IsSiteAllowedAsync(5)); Assert.False(await svc.IsSiteAllowedAsync(6)); @@ -76,7 +76,7 @@ public class SiteScopeServiceTests [Fact] public async Task ScopedUser_IsNotSystemWide_AndReportsItsPermittedIds() { - var svc = ForUser(Role("Deployment"), SiteClaim(7), SiteClaim(9)); + var svc = ForUser(Role("Deployer"), SiteClaim(7), SiteClaim(9)); Assert.False(await svc.IsSystemWideAsync()); Assert.Equal(new[] { 7, 9 }, (await svc.PermittedSiteIdsAsync()).OrderBy(x => x)); @@ -85,7 +85,7 @@ public class SiteScopeServiceTests [Fact] public async Task SystemWideUser_IsSiteAllowed_ForAnySite() { - var svc = ForUser(Role("Deployment")); + var svc = ForUser(Role("Deployer")); Assert.True(await svc.IsSiteAllowedAsync(1)); Assert.True(await svc.IsSiteAllowedAsync(999)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs index cdfc7b6f..9a2bc496 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionFormTests.cs @@ -30,7 +30,7 @@ public class DataConnectionFormTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Admin") + new Claim(JwtTokenService.RoleClaimType, "Administrator") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs index 0a5414aa..6f8213e4 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/DataConnectionsPageTests.cs @@ -37,7 +37,7 @@ public class DataConnectionsPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Admin") + new Claim(JwtTokenService.RoleClaimType, "Administrator") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs index 63bc824a..12124d3a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs @@ -54,7 +54,7 @@ public class InstanceConfigureAuditDrillinTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "deployer"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); var authProvider = new TestAuthStateProvider(user); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs index 81464f00..b2e78746 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs @@ -28,7 +28,7 @@ public class ExternalSystemFormAuditDrillinTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Design"), + new Claim(JwtTokenService.RoleClaimType, "Designer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs index 8b5ca4dc..c3c80f5c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/NavMenuTests.cs @@ -76,7 +76,7 @@ public class NavMenuTests : BunitContext [Fact] public void Sections_AreCollapsedByDefault() { - var cut = RenderWithRoles("Admin", "Design", "Deployment"); + var cut = RenderWithRoles("Administrator", "Designer", "Deployer"); cut.WaitForAssertion(() => { @@ -92,7 +92,7 @@ public class NavMenuTests : BunitContext [Fact] public void TogglingSection_RevealsItsItems() { - var cut = RenderWithRoles("Deployment"); + var cut = RenderWithRoles("Deployer"); Assert.DoesNotContain("/deployment/topology", cut.Markup); ExpandSection(cut, "Deployment"); @@ -105,7 +105,7 @@ public class NavMenuTests : BunitContext [Fact] public void TogglingSection_PersistsStateToCookie() { - var cut = RenderWithRoles("Deployment"); + var cut = RenderWithRoles("Deployer"); ExpandSection(cut, "Deployment"); @@ -117,7 +117,7 @@ public class NavMenuTests : BunitContext [Fact] public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() { - var cut = RenderWithRoles("Admin", "Design", "Deployment"); + var cut = RenderWithRoles("Administrator", "Designer", "Deployer"); ExpandSection(cut, "Notifications"); cut.WaitForAssertion(() => @@ -133,7 +133,7 @@ public class NavMenuTests : BunitContext [Fact] public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp() { - var cut = RenderWithRoles("Admin"); + var cut = RenderWithRoles("Administrator"); ExpandSection(cut, "Notifications"); cut.WaitForAssertion(() => @@ -148,7 +148,7 @@ public class NavMenuTests : BunitContext [Fact] public void OldRoutes_AreNoLongerLinked() { - var cut = RenderWithRoles("Admin", "Design", "Deployment"); + var cut = RenderWithRoles("Administrator", "Designer", "Deployer"); cut.WaitForAssertion(() => { diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs index 0fe6f7e3..b618d9a3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPagePermissionTests.cs @@ -38,10 +38,12 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Pages; /// AuditExport — additional gate on the Export-CSV button and /// the streaming export endpoint. /// -/// Both policies are satisfied by the Audit role and (defence in depth) -/// the Admin role — admins see everything by convention in this -/// codebase. The tests pin both the page-level + endpoint-level enforcement, -/// and the Export-button visibility split. +/// Post Task 1.7: AuditExport is satisfied only by the +/// Administrator role (which absorbed the former Audit role); +/// OperationalAudit is additionally satisfied by the Viewer role +/// (the home of the former AuditReadOnly role) — Viewer reads but cannot +/// export. The tests pin both the page-level + endpoint-level enforcement, and +/// the Export-button visibility split. /// /// public class AuditLogPagePermissionTests : BunitContext @@ -106,15 +108,15 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public async Task WithoutOperationalAudit_PolicyDenies() { - // A Design-only user (no Audit, no Admin) must NOT satisfy the - // OperationalAudit policy. + // A Designer-only user (not Administrator, not Viewer) must NOT satisfy + // the OperationalAudit policy. var services = new ServiceCollection(); services.AddLogging(); services.AddScadaBridgeAuthorization(); using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); - var principal = BuildPrincipal("Design"); + var principal = BuildPrincipal("Designer"); var result = await authService.AuthorizeAsync( principal, null, AuthorizationPolicies.OperationalAudit); @@ -156,11 +158,12 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public void WithOperationalAudit_NoAuditExport_PageRenders_ExportButtonHidden() { - // The "Audit" role grants OperationalAudit + AuditExport in the - // default mapping, so we test the split by handing the user ONLY - // an extra-narrow role that we map ONLY to OperationalAudit: a - // fresh "AuditReadOnly" role (see AuthorizationPolicies). - var cut = RenderAuditLogPage("AuditReadOnly"); + // The "Administrator" role grants OperationalAudit + AuditExport, so we + // test the split by handing the user ONLY the "Viewer" role, which maps + // to OperationalAudit (read) but NOT AuditExport — the preserved + // half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse + // (see AuthorizationPolicies). + var cut = RenderAuditLogPage("Viewer"); cut.WaitForAssertion(() => { @@ -174,7 +177,7 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public void WithOperationalAudit_AndAuditExport_PageRenders_ExportButtonVisible() { - var cut = RenderAuditLogPage("Audit"); + var cut = RenderAuditLogPage("Administrator"); cut.WaitForAssertion(() => { @@ -186,9 +189,9 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public void AdminUser_SeesPage_AndExportButton() { - // Admin holds every permission by convention — both policies must - // succeed for a plain Admin user. - var cut = RenderAuditLogPage("Admin"); + // Administrator holds every permission by convention — both policies must + // succeed for a plain Administrator user. + var cut = RenderAuditLogPage("Administrator"); cut.WaitForAssertion(() => { @@ -204,9 +207,9 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public async Task AuditExportEndpoint_WithoutAuditExport_Returns403() { - // A user holding only Design must NOT be able to call the export + // A user holding only Designer must NOT be able to call the export // endpoint. Live wiring re-uses AuthorizationPolicies.AuditExport. - var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Design" }); + var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Designer" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); @@ -217,7 +220,7 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public async Task AuditExportEndpoint_WithAuditExport_Returns200() { - var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Audit" }); + var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); @@ -228,8 +231,9 @@ public class AuditLogPagePermissionTests : BunitContext [Fact] public async Task AuditExportEndpoint_AdminAlone_Returns200() { - // Admin alone (no Audit role) must still pass — defence in depth. - var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Admin" }); + // Administrator alone must pass — it absorbed the former Audit role and + // holds AuditExport by convention (defence in depth). + var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); @@ -238,12 +242,12 @@ public class AuditLogPagePermissionTests : BunitContext } [Fact] - public async Task AuditExportEndpoint_AuditReadOnly_Returns403() + public async Task AuditExportEndpoint_Viewer_Returns403() { - // AuditReadOnly grants OperationalAudit but NOT AuditExport, so the - // endpoint must refuse — the page is readable but the bulk export - // path is gated separately. - var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "AuditReadOnly" }); + // Viewer (former AuditReadOnly) grants OperationalAudit but NOT + // AuditExport, so the endpoint must refuse — the page is readable but + // the bulk export path is gated separately (preserved half-SoD). + var (client, _, host) = await BuildEndpointHostAsync(roles: new[] { "Viewer" }); using (host) { var response = await client.GetAsync("/api/centralui/audit/export"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs index da9220d3..6b04d668 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/AuditLogPageScaffoldTests.cs @@ -118,7 +118,7 @@ public class AuditLogPageScaffoldTests : BunitContext [Fact] public void AuditLogPage_Renders_PageHeading() { - var cut = RenderAuditLogPage("Admin"); + var cut = RenderAuditLogPage("Administrator"); cut.WaitForAssertion(() => { @@ -132,7 +132,7 @@ public class AuditLogPageScaffoldTests : BunitContext [Fact] public void NavMenu_Contains_AuditGroup_With_AuditLog_Link() { - var cut = RenderNavMenu("Admin", "Design", "Deployment"); + var cut = RenderNavMenu("Administrator", "Designer", "Deployer"); ExpandNavSection(cut, "Audit"); cut.WaitForAssertion(() => @@ -145,7 +145,7 @@ public class AuditLogPageScaffoldTests : BunitContext [Fact] public void NavMenu_Contains_ConfigurationAuditLog_Link_UnderAuditGroup() { - var cut = RenderNavMenu("Admin", "Design", "Deployment"); + var cut = RenderNavMenu("Administrator", "Designer", "Deployer"); ExpandNavSection(cut, "Audit"); cut.WaitForAssertion(() => @@ -178,7 +178,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Admin"); + var cut = RenderAuditLogPageWithQuery($"correlationId={corr}", "Administrator"); cut.WaitForAssertion(() => { @@ -201,7 +201,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Admin"); + var cut = RenderAuditLogPageWithQuery($"executionId={executionId}", "Administrator"); cut.WaitForAssertion(() => { @@ -217,7 +217,7 @@ public class AuditLogPageScaffoldTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Admin"); + var cut = RenderAuditLogPageWithQuery("executionId=not-a-guid", "Administrator"); // An unparseable executionId leaves ExecutionId null. With no other filter // params present the page renders but does NOT call the query service. @@ -239,7 +239,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Admin"); + var cut = RenderAuditLogPageWithQuery($"parentExecutionId={parentExecutionId}", "Administrator"); cut.WaitForAssertion(() => { @@ -255,7 +255,7 @@ public class AuditLogPageScaffoldTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Admin"); + var cut = RenderAuditLogPageWithQuery("parentExecutionId=not-a-guid", "Administrator"); // An unparseable parentExecutionId leaves ParentExecutionId null. With no // other filter params present the page renders but does NOT call the query @@ -274,7 +274,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Admin"); + var cut = RenderAuditLogPageWithQuery("target=ExternalSystem-Alpha", "Administrator"); cut.WaitForAssertion(() => { @@ -292,7 +292,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery("site=plant-a", "Admin"); + var cut = RenderAuditLogPageWithQuery("site=plant-a", "Administrator"); cut.WaitForAssertion(() => { @@ -314,7 +314,7 @@ public class AuditLogPageScaffoldTests : BunitContext _queryService.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(new List())); - var cut = RenderAuditLogPageWithQuery("status=Failed", "Admin"); + var cut = RenderAuditLogPageWithQuery("status=Failed", "Administrator"); cut.WaitForAssertion(() => { @@ -331,7 +331,7 @@ public class AuditLogPageScaffoldTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Admin"); + var cut = RenderAuditLogPageWithQuery("status=NotARealStatus", "Administrator"); // An unparseable status value leaves Status null. With no other filter // params present the page renders but does NOT call the query service @@ -348,7 +348,7 @@ public class AuditLogPageScaffoldTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderAuditLogPage("Admin"); + var cut = RenderAuditLogPage("Administrator"); // The grid is in "no filter" state — the page heading renders, but the // query service must NOT be hit because nothing told us to load. diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs index a5bb1ac2..e9de7248 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs @@ -88,7 +88,7 @@ public class TransportExportPageTests : BunitContext SourceEnvironment = "test-cluster", })); - var principal = BuildPrincipal("alice", "Design"); + var principal = BuildPrincipal("alice", "Designer"); Services.AddSingleton(new TestAuthStateProvider(principal)); Services.AddAuthorizationCore(); } @@ -304,8 +304,8 @@ public class TransportExportPageTests : BunitContext using var provider = services.BuildServiceProvider(); var authService = provider.GetRequiredService(); - // Audit-only user — has a role but it isn't Design. - var principal = BuildPrincipal("bob", "Audit"); + // Administrator user — has a role but it isn't Designer. + var principal = BuildPrincipal("bob", "Administrator"); var result = await authService.AuthorizeAsync( principal, null, AuthorizationPolicies.RequireDesign); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs index 113a0531..d338f89e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportImportPageTests.cs @@ -62,7 +62,7 @@ public class TransportImportPageTests : BunitContext dbContext.Database.EnsureCreated(); Services.AddSingleton(dbContext); - var principal = BuildPrincipal("alice", "Admin"); + var principal = BuildPrincipal("alice", "Administrator"); Services.AddSingleton(new TestAuthStateProvider(principal)); Services.AddAuthorizationCore(); } @@ -299,7 +299,7 @@ public class TransportImportPageTests : BunitContext var authService = provider.GetRequiredService(); // Design-only user — has a role but it isn't Admin. - var principal = BuildPrincipal("bob", "Design"); + var principal = BuildPrincipal("bob", "Designer"); var result = await authService.AuthorizeAsync( principal, null, AuthorizationPolicies.RequireAdmin); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs index b268459f..e7399a0a 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/ExecutionTreePageTests.cs @@ -81,7 +81,7 @@ public class ExecutionTreePageTests : BunitContext Node(child, root), })); - var cut = RenderPage($"executionId={child}", "Admin"); + var cut = RenderPage($"executionId={child}", "Administrator"); cut.WaitForAssertion(() => { @@ -96,7 +96,7 @@ public class ExecutionTreePageTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderPage(query: null, "Admin"); + var cut = RenderPage(query: null, "Administrator"); cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); @@ -107,7 +107,7 @@ public class ExecutionTreePageTests : BunitContext { _queryService = Substitute.For(); - var cut = RenderPage("executionId=not-a-guid", "Admin"); + var cut = RenderPage("executionId=not-a-guid", "Administrator"); cut.WaitForAssertion(() => Assert.Contains("Execution Chain", cut.Markup)); _queryService.DidNotReceive().GetExecutionTreeAsync(Arg.Any(), Arg.Any()); @@ -131,7 +131,7 @@ public class ExecutionTreePageTests : BunitContext // AuditEventDetail (reachable from the modal) owns a clipboard interop call. JSInterop.Mode = JSRuntimeMode.Loose; - var cut = RenderPage($"executionId={child}", "Admin"); + var cut = RenderPage($"executionId={child}", "Administrator"); // The modal is absent until a node is activated. Assert.Empty(cut.FindAll("[data-test=\"execution-detail-modal\"]")); @@ -163,7 +163,7 @@ public class ExecutionTreePageTests : BunitContext .Returns(Task.FromResult>(Array.Empty())); JSInterop.Mode = JSRuntimeMode.Loose; - var cut = RenderPage($"executionId={child}", "Admin"); + var cut = RenderPage($"executionId={child}", "Administrator"); cut.Find($"[data-test=\"tree-node-{child}\"] .execution-tree-body").DoubleClick(); cut.WaitForAssertion(() => diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index a82f116e..0946d183 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -82,7 +82,7 @@ public class HealthPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs index 780aedf8..ffc1c01b 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationKpisPageTests.cs @@ -69,7 +69,7 @@ public class NotificationKpisPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs index b542a350..84be4c45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationListsPageTests.cs @@ -23,7 +23,7 @@ public class NotificationListsPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Design"), + new Claim(JwtTokenService.RoleClaimType, "Designer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs index b8038477..7f7a5682 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportDetailModalTests.cs @@ -89,7 +89,7 @@ public class NotificationReportDetailModalTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs index c6408bca..8bc4ef69 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs @@ -74,7 +74,7 @@ public class NotificationReportPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs index ed8e7a3a..24cc2cf0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs @@ -173,7 +173,7 @@ public sealed class QueryStringDrillInTests var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); @@ -241,7 +241,7 @@ public sealed class QueryStringDrillInTests var claims = new List { new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.UsernameClaimType, "alice"), - new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Admin"), + new(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Administrator"), }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(principal)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs index 4ed2cc65..45315eff 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs @@ -89,7 +89,7 @@ public class SiteCallsReportPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); @@ -494,7 +494,7 @@ public class SiteCallsReportPageTests : BunitContext var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.UsernameClaimType, "scoped"), - new Claim(JwtTokenService.RoleClaimType, "Deployment"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), new Claim(JwtTokenService.SiteIdClaimType, "1"), // Plant A only }, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(scopedUser)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs index 26a5d9e9..2fb67658 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SmtpConfigurationPageTests.cs @@ -21,7 +21,7 @@ public class SmtpConfigurationPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Admin"), + new Claim(JwtTokenService.RoleClaimType, "Administrator"), }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs index 3e6cd23b..30b36fdf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TemplatesPageTests.cs @@ -52,7 +52,7 @@ public class TemplatesPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Design") + new Claim(JwtTokenService.RoleClaimType, "Designer") }; var identity = new ClaimsIdentity(claims, "TestAuth"); var user = new ClaimsPrincipal(identity); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs index ff8577cb..0fb3ea89 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs @@ -88,7 +88,7 @@ public class TopologyPageTests : BunitContext var claims = new[] { new Claim(JwtTokenService.UsernameClaimType, "tester"), - new Claim(JwtTokenService.RoleClaimType, "Deployment") + new Claim(JwtTokenService.RoleClaimType, "Deployer") }; var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); @@ -217,7 +217,7 @@ public class TopologyPageTests : BunitContext var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(JwtTokenService.UsernameClaimType, "scoped-tester"), - new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployment"), + new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployer"), // Permitted on site 1 only. new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"), }, "TestAuth")); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs index ebf36fc4..0defd9f0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/RepositoryTests.cs @@ -38,21 +38,21 @@ public class SecurityRepositoryTests : IDisposable [Fact] public async Task AddMapping_AndGetById_ReturnsMapping() { - var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Admin"); + var mapping = new LdapGroupMapping("CN=Admins,DC=test", "Administrator"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); var loaded = await _repository.GetMappingByIdAsync(mapping.Id); Assert.NotNull(loaded); Assert.Equal("CN=Admins,DC=test", loaded.LdapGroupName); - Assert.Equal("Admin", loaded.Role); + Assert.Equal("Administrator", loaded.Role); } [Fact] public async Task GetAllMappings_ReturnsAll() { - await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Admin")); - await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Design")); + await _repository.AddMappingAsync(new LdapGroupMapping("Group1", "Administrator")); + await _repository.AddMappingAsync(new LdapGroupMapping("Group2", "Designer")); await _repository.SaveChangesAsync(); // +1 for seed data @@ -63,12 +63,12 @@ public class SecurityRepositoryTests : IDisposable [Fact] public async Task GetMappingsByRole_FiltersCorrectly() { - await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Design")); - await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployment")); + await _repository.AddMappingAsync(new LdapGroupMapping("Designers", "Designer")); + await _repository.AddMappingAsync(new LdapGroupMapping("Deployers", "Deployer")); await _repository.SaveChangesAsync(); - var designMappings = await _repository.GetMappingsByRoleAsync("Design"); - // Seed data includes "SCADA-Designers" with role "Design", plus the one we added + var designMappings = await _repository.GetMappingsByRoleAsync("Designer"); + // Seed data includes "SCADA-Designers" with role "Designer", plus the one we added Assert.Equal(2, designMappings.Count); Assert.Contains(designMappings, m => m.LdapGroupName == "Designers"); } @@ -76,23 +76,23 @@ public class SecurityRepositoryTests : IDisposable [Fact] public async Task UpdateMapping_PersistsChange() { - var mapping = new LdapGroupMapping("OldGroup", "Admin"); + var mapping = new LdapGroupMapping("OldGroup", "Administrator"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); - mapping.Role = "Design"; + mapping.Role = "Designer"; await _repository.UpdateMappingAsync(mapping); await _repository.SaveChangesAsync(); _context.ChangeTracker.Clear(); var loaded = await _repository.GetMappingByIdAsync(mapping.Id); - Assert.Equal("Design", loaded!.Role); + Assert.Equal("Designer", loaded!.Role); } [Fact] public async Task DeleteMapping_RemovesEntity() { - var mapping = new LdapGroupMapping("ToDelete", "Admin"); + var mapping = new LdapGroupMapping("ToDelete", "Administrator"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); @@ -108,7 +108,7 @@ public class SecurityRepositoryTests : IDisposable { var site = new Site("Site1", "SITE-001"); _context.Sites.Add(site); - var mapping = new LdapGroupMapping("Deployers", "Deployment"); + var mapping = new LdapGroupMapping("Deployers", "Deployer"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); @@ -126,7 +126,7 @@ public class SecurityRepositoryTests : IDisposable { var site = new Site("Site1", "SITE-001"); _context.Sites.Add(site); - var mapping = new LdapGroupMapping("Group", "Deployment"); + var mapping = new LdapGroupMapping("Group", "Deployer"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); @@ -145,7 +145,7 @@ public class SecurityRepositoryTests : IDisposable var site1 = new Site("Site1", "SITE-001"); var site2 = new Site("Site2", "SITE-002"); _context.Sites.AddRange(site1, site2); - var mapping = new LdapGroupMapping("Group", "Deployment"); + var mapping = new LdapGroupMapping("Group", "Deployer"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); @@ -167,7 +167,7 @@ public class SecurityRepositoryTests : IDisposable { var site = new Site("Site1", "SITE-001"); _context.Sites.Add(site); - var mapping = new LdapGroupMapping("Group", "Deployment"); + var mapping = new LdapGroupMapping("Group", "Deployer"); await _repository.AddMappingAsync(mapping); await _repository.SaveChangesAsync(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs index 9e563446..8f7286ca 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SeedDataTests.cs @@ -31,7 +31,8 @@ public class SeedDataTests : IDisposable .SingleOrDefaultAsync(m => m.LdapGroupName == "SCADA-Admins"); Assert.NotNull(adminMapping); - Assert.Equal("Admin", adminMapping.Role); + // Role VALUE canonicalized to "Administrator" (Task 1.7); group NAME unchanged. + Assert.Equal("Administrator", adminMapping.Role); Assert.Equal(1, adminMapping.Id); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs index 445a54e1..e1c5b0bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs @@ -253,7 +253,7 @@ public class DbContextTests : IDisposable _context.Sites.Add(site); _context.SaveChanges(); - var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Admin"); + var mapping = new LdapGroupMapping("CN=Admins,DC=example,DC=com", "Administrator"); _context.LdapGroupMappings.Add(mapping); _context.SaveChanges(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs index ec403e0f..57bdb6ab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuditTransactionTests.cs @@ -28,7 +28,7 @@ public class AuditTransactionTests : IClassFixture(); // Add a mapping and an audit log entry in the same unit of work - var mapping = new LdapGroupMapping("test-group-audit", "Admin"); + var mapping = new LdapGroupMapping("test-group-audit", "Administrator"); await securityRepo.AddMappingAsync(mapping); await auditService.LogAsync( @@ -37,7 +37,7 @@ public class AuditTransactionTests : IClassFixture e.State == EntityState.Added); @@ -63,7 +63,7 @@ public class AuditTransactionTests : IClassFixture(); // Add entity + audit but do NOT call SaveChangesAsync - var mapping = new LdapGroupMapping("orphan-group", "Design"); + var mapping = new LdapGroupMapping("orphan-group", "Designer"); await securityRepo.AddMappingAsync(mapping); await auditService.LogAsync("test", "Create", "LdapGroupMapping", "0", "orphan-group", null); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs index 9c4b0b4a..dfb4dc54 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs @@ -64,7 +64,7 @@ public class AuthFlowTests : IClassFixture var token = jwtService.GenerateToken( displayName: "Test User", username: "testuser", - roles: new[] { "Admin", "Design" }, + roles: new[] { "Administrator", "Designer" }, permittedSiteIds: null); Assert.NotNull(token); @@ -78,8 +78,8 @@ public class AuthFlowTests : IClassFixture Assert.Equal("Test User", displayName); Assert.Equal("testuser", username); - Assert.Contains("Admin", roles); - Assert.Contains("Design", roles); + Assert.Contains("Administrator", roles); + Assert.Contains("Designer", roles); } [Fact] @@ -91,7 +91,7 @@ public class AuthFlowTests : IClassFixture var token = jwtService.GenerateToken( displayName: "Deployer", username: "deployer1", - roles: new[] { "Deployment" }, + roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "3" }); var principal = jwtService.ValidateToken(token); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs index 2673e1be..8f2e741f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs @@ -33,7 +33,7 @@ public class CentralFailoverTests var token = jwtServiceA.GenerateToken( displayName: "Failover User", username: "failover_test", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); // Validate with a second instance (same signing key = simulated failover) @@ -54,7 +54,7 @@ public class CentralFailoverTests var token = jwtService.GenerateToken( displayName: "Scoped User", username: "scoped_user", - roles: new[] { "Deployment" }, + roles: new[] { "Deployer" }, permittedSiteIds: new[] { "site-1", "site-2", "site-5" }); var principal = jwtService.ValidateToken(token); @@ -77,7 +77,7 @@ public class CentralFailoverTests var token = jwtServiceA.GenerateToken( displayName: "User", username: "user", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); // Token from A should NOT validate on B (different key) @@ -109,7 +109,7 @@ public class CentralFailoverTests var token = jwtService.GenerateToken( displayName: "Expired User", username: "expired_user", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); var principal = jwtService.ValidateToken(token); @@ -145,7 +145,7 @@ public class CentralFailoverTests var token = jwtService.GenerateToken( displayName: "User", username: "user", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); var principal = jwtService.ValidateToken(token); diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs index 01c5f365..65157191 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs @@ -48,7 +48,7 @@ public class SecurityHardeningTests var token = jwtService.GenerateToken( displayName: "Test", username: "test", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); Assert.NotNull(token); @@ -96,7 +96,7 @@ public class SecurityHardeningTests var token = jwtService.GenerateToken( displayName: "Test", username: "test", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); // JWT tokens are base64-encoded; the signing key should not appear in the payload @@ -121,7 +121,7 @@ public class SecurityHardeningTests var token = jwtService.GenerateToken( displayName: "User", username: "user", - roles: new[] { "Admin" }, + roles: new[] { "Administrator" }, permittedSiteIds: null); // Tamper with the token payload (second segment) @@ -150,7 +150,7 @@ public class SecurityHardeningTests var originalToken = jwtService.GenerateToken( displayName: "Original User", username: "orig_user", - roles: new[] { "Admin", "Design" }, + roles: new[] { "Administrator", "Designer" }, permittedSiteIds: new[] { "site-1" }); var principal = jwtService.ValidateToken(originalToken); @@ -159,7 +159,7 @@ public class SecurityHardeningTests // Refresh the token var refreshedToken = jwtService.RefreshToken( principal!, - new[] { "Admin", "Design" }, + new[] { "Administrator", "Designer" }, new[] { "site-1" }); Assert.NotNull(refreshedToken); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs index c994a189..7fa6de45 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs @@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// the seam (the real LibraryInboundApiKeyAdmin + SQLite mapping is covered end-to-end /// by the Security project's LibraryInboundApiKeyAdminTests). They verify the actor's /// dispatch, response shapes (string keyId, one-time token, methods), the preserved ScadaBridge -/// management-audit calls, and that the "Admin" role gate still applies to all five commands. +/// management-audit calls, and that the "Administrator" role gate still applies to all five commands. /// public class ApiKeyCreationTests : TestKit, IDisposable { @@ -48,7 +48,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void CreateApiKey_ReturnsKeyIdAndOneTimeToken() { var actor = CreateActor(); - actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Admin")); + actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -73,7 +73,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void CreateApiKey_AuditsTheCreate() { var actor = CreateActor(); - actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin")); + actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator")); ExpectMsg(TimeSpan.FromSeconds(5)); _auditService.Received(1).LogAsync( @@ -84,7 +84,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void CreateApiKey_ResponseDoesNotEchoAHash() { var actor = CreateActor(); - actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin")); + actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -100,7 +100,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable _admin.Seed("key-2", "Service B", enabled: false, "M3"); var actor = CreateActor(); - actor.Tell(Envelope(new ListApiKeysCommand(), "Admin")); + actor.Tell(Envelope(new ListApiKeysCommand(), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -125,7 +125,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); - actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Admin")); + actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -145,7 +145,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); - actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Admin")); + actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -162,7 +162,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable _admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2"); var actor = CreateActor(); - actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Admin")); + actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -188,7 +188,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable { // No keys seeded — "key-unknown" does not exist. var actor = CreateActor(); - actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Admin")); + actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -202,7 +202,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { var actor = CreateActor(); - actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Admin")); + actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -215,7 +215,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit() { var actor = CreateActor(); - actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Admin")); + actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -232,7 +232,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void CreateApiKey_EmptyMethods_ReturnsManagementError() { var actor = CreateActor(); - actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty()), "Admin")); + actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty()), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -249,7 +249,7 @@ public class ApiKeyCreationTests : TestKit, IDisposable _admin.Seed("key-1", "Service A", enabled: true, "M1"); var actor = CreateActor(); - actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty()), "Admin")); + actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty()), "Administrator")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); @@ -265,11 +265,11 @@ public class ApiKeyCreationTests : TestKit, IDisposable public void EveryApiKeyCommand_RequiresAdminRole(object command) { var actor = CreateActor(); - // A Design-role caller (not Admin) must be rejected for every API-key command. - actor.Tell(Envelope(command, "Design")); + // A Designer-role caller (not Administrator) must be rejected for every API-key command. + actor.Tell(Envelope(command, "Designer")); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } public static IEnumerable AllApiKeyCommands() => new[] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs index c41fa64b..ccce0dee 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/AuditEndpointsTests.cs @@ -125,7 +125,7 @@ public class AuditEndpointsTests public async Task Query_ValidParams_ReturnsJsonPage() { var (client, _, host) = await BuildHostAsync( - roles: new[] { "Audit" }, + roles: new[] { "Administrator" }, queryPages: new[] { (IReadOnlyList)new[] { SampleEvent() } }); using (host) { @@ -160,7 +160,7 @@ public class AuditEndpointsTests new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)), }; var (client, repo, host) = await BuildHostAsync( - roles: new[] { "Audit" }, + roles: new[] { "Administrator" }, queryPages: new[] { pageOne }); using (host) { @@ -193,9 +193,9 @@ public class AuditEndpointsTests [Fact] public async Task Query_WithoutOperationalAudit_Returns403() { - // A user whose only role is Design holds neither OperationalAudit nor + // A user whose only role is Designer holds neither OperationalAudit nor // AuditExport — the query endpoint must 403. - var (client, _, host) = await BuildHostAsync(roles: new[] { "Design" }); + var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query")); @@ -206,7 +206,7 @@ public class AuditEndpointsTests [Fact] public async Task Query_WithoutCredentials_Returns401() { - var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query", credential: "")); @@ -215,10 +215,11 @@ public class AuditEndpointsTests } [Fact] - public async Task Query_AuditReadOnlyRole_IsAllowed() + public async Task Query_ViewerRole_IsAllowed() { - // AuditReadOnly satisfies OperationalAudit (read) — query must succeed. - var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); + // Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies + // OperationalAudit (read) — query must succeed. + var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); using (host) { var response = await client.SendAsync(Get("/api/audit/query")); @@ -234,7 +235,7 @@ public class AuditEndpointsTests public async Task Export_Csv_StreamsContent_WithCsvContentType() { var (client, _, host) = await BuildHostAsync( - roles: new[] { "Audit" }, + roles: new[] { "Administrator" }, queryPages: new[] { (IReadOnlyList)new[] { SampleEvent() }, @@ -263,7 +264,7 @@ public class AuditEndpointsTests { // No format= param → csv default. var (client, _, host) = await BuildHostAsync( - roles: new[] { "Audit" }, + roles: new[] { "Administrator" }, queryPages: new[] { (IReadOnlyList)Array.Empty() }); using (host) { @@ -277,7 +278,7 @@ public class AuditEndpointsTests public async Task Export_Jsonl_StreamsOnePerLine() { var (client, _, host) = await BuildHostAsync( - roles: new[] { "Audit" }, + roles: new[] { "Administrator" }, queryPages: new[] { (IReadOnlyList)new[] @@ -313,7 +314,7 @@ public class AuditEndpointsTests { // Parquet archival is deferred to v1.x (Component-AuditLog.md) — no // library is referenced, so the endpoint returns 501 with guidance. - var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=parquet")); @@ -327,9 +328,10 @@ public class AuditEndpointsTests [Fact] public async Task Export_WithoutAuditExport_Returns403() { - // AuditReadOnly grants read (OperationalAudit) but NOT bulk export - // (AuditExport) — the export endpoint must 403. - var (client, _, host) = await BuildHostAsync(roles: new[] { "AuditReadOnly" }); + // Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT + // bulk export (AuditExport) — the export endpoint must 403. This is the + // preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse. + var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=csv")); @@ -340,7 +342,7 @@ public class AuditEndpointsTests [Fact] public async Task Export_UnsupportedFormat_Returns400() { - var (client, _, host) = await BuildHostAsync(roles: new[] { "Audit" }); + var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get("/api/audit/export?format=xml")); @@ -496,7 +498,7 @@ public class AuditEndpointsTests { // End-to-end: a repeated channel= query param must surface at the // repository as a two-element Channels list. - var (client, repo, host) = await BuildHostAsync(roles: new[] { "Audit" }); + var (client, repo, host) = await BuildHostAsync(roles: new[] { "Administrator" }); using (host) { var response = await client.SendAsync(Get( @@ -537,11 +539,11 @@ public class AuditEndpointsTests [Fact] public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged() { - // Empty PermittedSiteIds is the system-wide signal (Admin, system-wide - // Deployment, audit roles with no scope rules attached). The filter - // should pass through with no restriction added. + // Empty PermittedSiteIds is the system-wide signal (Administrator, + // system-wide Deployer, audit roles with no scope rules attached). The + // filter should pass through with no restriction added. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( - "alice", "Alice", new[] { "Admin" }, Array.Empty()); + "alice", "Alice", new[] { "Administrator" }, Array.Empty()); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var result = AuditEndpoints.ApplySiteScope(filter, user); @@ -557,7 +559,7 @@ public class AuditEndpointsTests // the query to the user's permitted set, otherwise a site-scoped audit // user could read every site's rows. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( - "alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" }); + "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" }); var filter = new AuditLogQueryFilter(); var result = AuditEndpoints.ApplySiteScope(filter, user); @@ -571,7 +573,7 @@ public class AuditEndpointsTests public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim() { var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( - "alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a", "plant-b" }); + "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" }); var result = AuditEndpoints.ApplySiteScope(filter, user); @@ -586,7 +588,7 @@ public class AuditEndpointsTests // Caller explicitly asked for a site they cannot see — the helper signals // "403" by returning null rather than silently producing an empty page. var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( - "alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" }); + "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" }); var result = AuditEndpoints.ApplySiteScope(filter, user); @@ -598,7 +600,7 @@ public class AuditEndpointsTests public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly() { var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser( - "alice", "Alice", new[] { "AuditReadOnly" }, new[] { "plant-a" }); + "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" }); var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" }); var result = AuditEndpoints.ApplySiteScope(filter, user); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs index 8d5c04c3..e9e0236d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/DebugStreamHubTests.cs @@ -12,7 +12,7 @@ public class DebugStreamHubTests public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( - roles: new[] { "Deployment" }, + roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 2); @@ -23,7 +23,7 @@ public class DebugStreamHubTests public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( - roles: new[] { "Deployment" }, + roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 99); @@ -33,9 +33,9 @@ public class DebugStreamHubTests [Fact] public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed() { - // Empty permitted set == system-wide Deployment. + // Empty permitted set == system-wide Deployer. var allowed = DebugStreamHub.IsInstanceAccessAllowed( - roles: new[] { "Deployment" }, + roles: new[] { "Deployer" }, permittedSiteIds: Array.Empty(), instanceSiteId: 99); @@ -46,7 +46,7 @@ public class DebugStreamHubTests public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( - roles: new[] { "Admin", "Deployment" }, + roles: new[] { "Administrator", "Deployer" }, permittedSiteIds: new[] { "1" }, instanceSiteId: 99); @@ -57,7 +57,7 @@ public class DebugStreamHubTests public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( - roles: new[] { "admin" }, + roles: new[] { "administrator" }, permittedSiteIds: new[] { "1" }, instanceSiteId: 99); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 45fcbc46..770fa3dc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -61,12 +61,12 @@ public class ManagementActorTests : TestKit, IDisposable public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Design"); + var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); Assert.Equal(envelope.CorrelationId, response.CorrelationId); } @@ -79,19 +79,19 @@ public class ManagementActorTests : TestKit, IDisposable actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Design"); + var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Deployment", response.Message); + Assert.Contains("Deployer", response.Message); } [Fact] @@ -109,19 +109,19 @@ public class ManagementActorTests : TestKit, IDisposable actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployment"); + var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } // ======================================================================== @@ -154,7 +154,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => siteRepo); var actor = CreateActor(); - var envelope = Envelope(new ListSitesCommand(), "Design"); + var envelope = Envelope(new ListSitesCommand(), "Designer"); actor.Tell(envelope); @@ -220,7 +220,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new CreateInstanceCommand("Pump1", 1, 1), - "Deployment"); + "Deployer"); actor.Tell(envelope); @@ -264,7 +264,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new CreateInstanceCommand("BadInst", 99, 1), - "Deployment"); + "Deployer"); actor.Tell(envelope); @@ -280,16 +280,16 @@ public class ManagementActorTests : TestKit, IDisposable [Fact] public void DesignCommand_WithAdminRole_ReturnsUnauthorized() { - // CreateTemplateCommand requires "Design" role, "Admin" alone is insufficient + // CreateTemplateCommand requires "Designer" role, "Administrator" alone is insufficient var actor = CreateActor(); var envelope = Envelope( new CreateTemplateCommand("T1", null, null), - "Admin"); + "Administrator"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] @@ -305,7 +305,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new CreateSiteCommand("NewSite", "NS1", "Desc"), - "Admin"); + "Administrator"); actor.Tell(envelope); @@ -324,10 +324,10 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => siteRepo); var actor = CreateActor(); - // "admin" lowercase should still match "Admin" requirement + // "administrator" lowercase should still match "Administrator" requirement var envelope = Envelope( new CreateSiteCommand("Site2", "S2", null), - "admin"); + "administrator"); actor.Tell(envelope); @@ -343,84 +343,84 @@ public class ManagementActorTests : TestKit, IDisposable public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Admin"); + var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Administrator"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployment"); + var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Admin"); + var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Administrator"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployment"); + var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design"); + var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] public void AddScopeRule_WithDesignRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Design"); + var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] public void UpdateArea_WithAdminRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Admin"); + var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } // ======================================================================== @@ -486,7 +486,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => secRepo); var actor = CreateActor(); - var envelope = Envelope(new ListScopeRulesCommand(1), "Admin"); + var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator"); actor.Tell(envelope); @@ -545,7 +545,7 @@ public class ManagementActorTests : TestKit, IDisposable .Returns(new Instance("Pump7") { Id = 7, SiteId = 2 }); var actor = CreateActor(); - var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -560,7 +560,7 @@ public class ManagementActorTests : TestKit, IDisposable .Returns(new Instance("Pump7") { Id = 7, SiteId = 1 }); var actor = CreateActor(); - var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -573,7 +573,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -591,7 +591,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => uiRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -608,7 +608,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => siteRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -623,7 +623,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Admin"); + var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Administrator"); actor.Tell(envelope); @@ -637,7 +637,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -651,7 +651,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -665,7 +665,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -679,7 +679,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -696,7 +696,7 @@ public class ManagementActorTests : TestKit, IDisposable AddSiteRepoWithSite(2, "SITE2"); var actor = CreateActor(); - var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -808,12 +808,12 @@ public class ManagementActorTests : TestKit, IDisposable public void QueryDeployments_WithDesignRole_ReturnsUnauthorized() { var actor = CreateActor(); - var envelope = Envelope(new QueryDeploymentsCommand(), "Design"); + var envelope = Envelope(new QueryDeploymentsCommand(), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Deployment", response.Message); + Assert.Contains("Deployer", response.Message); } [Fact] @@ -828,7 +828,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = Envelope(new QueryDeploymentsCommand(), "Deployment"); + var envelope = Envelope(new QueryDeploymentsCommand(), "Deployer"); actor.Tell(envelope); @@ -846,7 +846,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployment"); + var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployer"); actor.Tell(envelope); @@ -864,7 +864,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -885,7 +885,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -914,7 +914,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -951,7 +951,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployment"); + var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer"); actor.Tell(envelope); @@ -974,7 +974,7 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => deployRepo); var actor = CreateActor(); - var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Admin", "Deployment"); + var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Administrator", "Deployer"); actor.Tell(envelope); @@ -1006,7 +1006,7 @@ public class ManagementActorTests : TestKit, IDisposable // "Good" is valid, "Bogus" is not — the whole command must fail with // nothing written. var overrides = new Dictionary { ["Good"] = "1", ["Bogus"] = "2" }; - var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployment"); + var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployer"); actor.Tell(envelope); @@ -1036,7 +1036,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var overrides = new Dictionary { ["A"] = "1", ["B"] = "2" }; - var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployment"); + var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployer"); actor.Tell(envelope); @@ -1095,7 +1095,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"), - "Design"); + "Designer"); actor.Tell(envelope); @@ -1125,7 +1125,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"), - "Design"); + "Designer"); actor.Tell(envelope); @@ -1148,7 +1148,7 @@ public class ManagementActorTests : TestKit, IDisposable .Returns((Template?)null); var actor = CreateActor(); - var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployment"); + var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployer"); actor.Tell(envelope); @@ -1228,12 +1228,12 @@ public class ManagementActorTests : TestKit, IDisposable // ExportBundle requires the Design role; an Admin-only caller is rejected. AddBundleSubstitutes(); var actor = CreateActor(); - var envelope = Envelope(AllExportCommand(), "Admin"); + var envelope = Envelope(AllExportCommand(), "Administrator"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] @@ -1244,12 +1244,12 @@ public class ManagementActorTests : TestKit, IDisposable // configuration). AddBundleSubstitutes(); var actor = CreateActor(); - var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Design"); + var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] @@ -1257,12 +1257,12 @@ public class ManagementActorTests : TestKit, IDisposable { AddBundleSubstitutes(); var actor = CreateActor(); - var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Design"); + var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Admin", response.Message); + Assert.Contains("Administrator", response.Message); } [Fact] @@ -1286,7 +1286,7 @@ public class ManagementActorTests : TestKit, IDisposable SourceEnvironment: "test-env"); var actor = CreateActor(); - var envelope = Envelope(cmd, "Design"); + var envelope = Envelope(cmd, "Designer"); actor.Tell(envelope); @@ -1331,7 +1331,7 @@ public class ManagementActorTests : TestKit, IDisposable // base64 check before reaching the importer. var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 }); var actor = CreateActor(); - var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Admin"); + var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator"); actor.Tell(envelope); @@ -1399,7 +1399,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); // "overwrite" policy so the final (Identical) row would otherwise differ // from the Modified row's action — proves the last-write-wins semantics. - var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Admin"); + var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Administrator"); actor.Tell(envelope); @@ -1425,7 +1425,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false), - "Design"); + "Designer"); actor.Tell(envelope); @@ -1440,12 +1440,12 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false), - "Deployment"); + "Deployer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Design", response.Message); + Assert.Contains("Designer", response.Message); } [Fact] @@ -1473,7 +1473,7 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null), - "Deployment"); + "Deployer"); actor.Tell(envelope); @@ -1488,11 +1488,11 @@ public class ManagementActorTests : TestKit, IDisposable var actor = CreateActor(); var envelope = Envelope( new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null), - "Design"); + "Designer"); actor.Tell(envelope); var response = ExpectMsg(TimeSpan.FromSeconds(5)); - Assert.Contains("Deployment", response.Message); + Assert.Contains("Deployer", response.Message); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs index eb7b8a21..a5983483 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs @@ -68,8 +68,8 @@ public class ScadaBridgeGroupRoleMapperTests // Two matched mappings: an Admin group and a site-scoped Deployment group. var mappings = new List { - Mapping(1, "SCADA-Admins", Roles.Admin), - Mapping(2, "SiteDeployers", Roles.Deployment), + Mapping(1, "SCADA-Admins", Roles.Administrator), + Mapping(2, "SiteDeployers", Roles.Deployer), }; var scopeRules = new Dictionary> { @@ -91,8 +91,8 @@ public class ScadaBridgeGroupRoleMapperTests // Roles: same set as RoleMapper. Assert.Equal(expected.Roles.OrderBy(r => r), mapping.Roles.OrderBy(r => r)); - Assert.Contains(Roles.Admin, mapping.Roles); - Assert.Contains(Roles.Deployment, mapping.Roles); + Assert.Contains(Roles.Administrator, mapping.Roles); + Assert.Contains(Roles.Deployer, mapping.Roles); // Scope: carries the full RoleMappingResult (no site-scope info lost). var scope = Assert.IsType(mapping.Scope); @@ -109,7 +109,7 @@ public class ScadaBridgeGroupRoleMapperTests // Unscoped Deployment mapping -> system-wide, empty PermittedSiteIds. var mappings = new List { - Mapping(1, "GlobalDeployers", Roles.Deployment), + Mapping(1, "GlobalDeployers", Roles.Deployer), }; var repo = new FakeSecurityRepository(mappings, new Dictionary>()); var roleMapper = new RoleMapper(repo); @@ -117,7 +117,7 @@ public class ScadaBridgeGroupRoleMapperTests var mapping = await sut.MapAsync(new[] { "GlobalDeployers" }, CancellationToken.None); - Assert.Contains(Roles.Deployment, mapping.Roles); + Assert.Contains(Roles.Deployer, mapping.Roles); var scope = Assert.IsType(mapping.Scope); Assert.True(scope.IsSystemWideDeployment); Assert.Empty(scope.PermittedSiteIds); @@ -134,7 +134,7 @@ public class ScadaBridgeGroupRoleMapperTests // shared LDAP service fail-closes a zero-GROUP LDAP result before it ever reaches // the mapper. var repo = new FakeSecurityRepository( - new List { Mapping(1, "SCADA-Admins", Roles.Admin) }, + new List { Mapping(1, "SCADA-Admins", Roles.Administrator) }, new Dictionary>()); var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo)); @@ -154,7 +154,7 @@ public class ScadaBridgeGroupRoleMapperTests // yields zero roles (not an error) — the mapper is the authoritative empty-roles // boundary now that the LDAP service no longer admits zero-group successes. var repo = new FakeSecurityRepository( - new List { Mapping(1, "SCADA-Admins", Roles.Admin) }, + new List { Mapping(1, "SCADA-Admins", Roles.Administrator) }, new Dictionary>()); var sut = new ScadaBridgeGroupRoleMapper(new RoleMapper(repo)); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index a8873204..d4e305a7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -116,7 +116,7 @@ public class JwtTokenServiceTests var service = CreateService(); var token = service.GenerateToken( "John Doe", "johnd", - new[] { "Admin", "Design" }, + new[] { "Administrator", "Designer" }, new[] { "1", "2" }); var principal = service.ValidateToken(token); @@ -126,8 +126,8 @@ public class JwtTokenServiceTests Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); - Assert.Contains("Admin", roles); - Assert.Contains("Design", roles); + Assert.Contains("Administrator", roles); + Assert.Contains("Designer", roles); var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList(); Assert.Contains("1", siteIds); @@ -140,7 +140,7 @@ public class JwtTokenServiceTests public void GenerateToken_NullSiteIds_NoSiteIdClaims() { var service = CreateService(); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.NotNull(principal); @@ -159,7 +159,7 @@ public class JwtTokenServiceTests public void ValidateToken_WrongKey_ReturnsNull() { var service1 = CreateService(); - var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service1.GenerateToken("User", "user", new[] { "Administrator" }, null); var service2 = CreateService(new SecurityOptions { @@ -176,7 +176,7 @@ public class JwtTokenServiceTests public void ValidateToken_UsesHmacSha256() { var service = CreateService(); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); // Decode header to verify algorithm var parts = token.Split('.'); @@ -192,7 +192,7 @@ public class JwtTokenServiceTests options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min var service = CreateService(options); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.True(service.ShouldRefresh(principal!)); @@ -202,7 +202,7 @@ public class JwtTokenServiceTests public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() { var service = CreateService(); // 15 min expiry, 5 min threshold - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.False(service.ShouldRefresh(principal!)); @@ -212,7 +212,7 @@ public class JwtTokenServiceTests public void IsIdleTimedOut_RecentActivity_ReturnsFalse() { var service = CreateService(); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(token); Assert.False(service.IsIdleTimedOut(principal!)); @@ -234,15 +234,15 @@ public class JwtTokenServiceTests public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() { var service = CreateService(); - var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var principal = service.ValidateToken(originalToken); - var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" }); + var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" }); Assert.NotNull(newToken); var newPrincipal = service.ValidateToken(newToken!); var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); - Assert.Contains("Design", roles); + Assert.Contains("Designer", roles); } [Fact] @@ -251,7 +251,7 @@ public class JwtTokenServiceTests var service = CreateService(); var principal = new ClaimsPrincipal(new ClaimsIdentity()); - var result = service.RefreshToken(principal, new[] { "Admin" }, null); + var result = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(result); } } @@ -288,16 +288,16 @@ public class RoleMapperTests : IDisposable [Fact] public async Task MapGroupsToRoles_MultiRoleExtraction() { - // Add mappings (note: seed data adds SCADA-Admins -> Admin) - _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design")); - _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment")); + // Add mappings (note: seed data adds SCADA-Admins -> Administrator) + _context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", Roles.Designer)); + _context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", Roles.Deployer)); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" }); - Assert.Contains("Admin", result.Roles); - Assert.Contains("Design", result.Roles); - Assert.DoesNotContain("Deployment", result.Roles); + Assert.Contains(Roles.Administrator, result.Roles); + Assert.Contains(Roles.Designer, result.Roles); + Assert.DoesNotContain(Roles.Deployer, result.Roles); } [Fact] @@ -306,7 +306,7 @@ public class RoleMapperTests : IDisposable var site1 = new Site("Site1", "S-001"); var site2 = new Site("Site2", "S-002"); _context.Sites.AddRange(site1, site2); - _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment")); + _context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", Roles.Deployer)); await _context.SaveChangesAsync(); var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers"); @@ -317,7 +317,7 @@ public class RoleMapperTests : IDisposable var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" }); - Assert.Contains("Deployment", result.Roles); + Assert.Contains(Roles.Deployer, result.Roles); Assert.False(result.IsSystemWideDeployment); Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds); Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds); @@ -326,8 +326,8 @@ public class RoleMapperTests : IDisposable [Fact] public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide() { - // Security-016: a user in BOTH an unscoped Deployment mapping - // (SCADA-Deploy-All, Id=3) AND a scoped Deployment mapping + // Security-016: a user in BOTH an unscoped Deployer mapping + // (SCADA-Deploy-All, Id=3) AND a scoped Deployer mapping // (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A // grant. The union semantics now preserve the broader grant: the // unscoped mapping wins, PermittedSiteIds is empty, system-wide. @@ -341,7 +341,7 @@ public class RoleMapperTests : IDisposable var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" }); - Assert.Contains("Deployment", result.Roles); + Assert.Contains(Roles.Deployer, result.Roles); Assert.True(result.IsSystemWideDeployment); Assert.Empty(result.PermittedSiteIds); } @@ -349,12 +349,12 @@ public class RoleMapperTests : IDisposable [Fact] public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules() { - _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment")); + _context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", Roles.Deployer)); await _context.SaveChangesAsync(); var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" }); - Assert.Contains("Deployment", result.Roles); + Assert.Contains(Roles.Deployer, result.Roles); Assert.True(result.IsSystemWideDeployment); Assert.Empty(result.PermittedSiteIds); } @@ -380,10 +380,10 @@ public class RoleMapperTests : IDisposable [Fact] public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch() { - // "SCADA-Admins" is seeded + // "SCADA-Admins" is seeded (role canonicalized to Administrator, Task 1.7) var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" }); - Assert.Contains("Admin", result.Roles); + Assert.Contains(Roles.Administrator, result.Roles); } } @@ -430,7 +430,7 @@ public class SecurityReviewRegressionTests { var key = new string('k', 32); var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger.Instance); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); Assert.False(string.IsNullOrEmpty(token)); } @@ -610,7 +610,7 @@ public class SecurityReviewRegressionTests2 public void GenerateToken_SetsIssuerAndAudience() { var service = CreateJwtService(); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token); Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer); @@ -642,7 +642,7 @@ public class SecurityReviewRegressionTests2 public void ValidateToken_AcceptsOwnIssuerAndAudience() { var service = CreateJwtService(); - var token = service.GenerateToken("User", "user", new[] { "Admin" }, null); + var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); Assert.NotNull(service.ValidateToken(token)); } @@ -663,7 +663,7 @@ public class SecurityReviewRegressionTests2 new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); - var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.NotNull(refreshed); var refreshedPrincipal = service.ValidateToken(refreshed!); @@ -690,7 +690,7 @@ public class SecurityReviewRegressionTests2 new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); - var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); var refreshedPrincipal = service.ValidateToken(refreshed!); // Still 25 min idle after refresh — not reset to 0. @@ -715,7 +715,7 @@ public class SecurityReviewRegressionTests2 new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) }, "test")); - var touched = service.RecordActivity(principal, new[] { "Admin" }, null); + var touched = service.RecordActivity(principal, new[] { "Administrator" }, null); Assert.NotNull(touched); var touchedPrincipal = service.ValidateToken(touched!); @@ -834,7 +834,7 @@ public class SecurityReviewRegressionTests4 new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o")) }, "test")); - var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(refreshed); } @@ -853,7 +853,7 @@ public class SecurityReviewRegressionTests4 new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o")) }, "test")); - var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.NotNull(refreshed); } @@ -870,7 +870,7 @@ public class SecurityReviewRegressionTests4 new Claim(JwtTokenService.UsernameClaimType, "user") }, "test")); - var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null); + var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); Assert.Null(refreshed); } @@ -979,33 +979,33 @@ public class Security012GroupLookupFailureTests public class AuthorizationPolicyTests { [Fact] - public async Task AdminPolicy_AdminRole_Succeeds() + public async Task AdminPolicy_AdministratorRole_Succeeds() { - var principal = CreatePrincipal(new[] { "Admin" }); + var principal = CreatePrincipal(new[] { Roles.Administrator }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.True(result); } [Fact] - public async Task AdminPolicy_DesignRole_Fails() + public async Task AdminPolicy_DesignerRole_Fails() { - var principal = CreatePrincipal(new[] { "Design" }); + var principal = CreatePrincipal(new[] { Roles.Designer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal); Assert.False(result); } [Fact] - public async Task DesignPolicy_DesignRole_Succeeds() + public async Task DesignPolicy_DesignerRole_Succeeds() { - var principal = CreatePrincipal(new[] { "Design" }); + var principal = CreatePrincipal(new[] { Roles.Designer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal); Assert.True(result); } [Fact] - public async Task DeploymentPolicy_DeploymentRole_Succeeds() + public async Task DeploymentPolicy_DeployerRole_Succeeds() { - var principal = CreatePrincipal(new[] { "Deployment" }); + var principal = CreatePrincipal(new[] { Roles.Deployer }); var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal); Assert.True(result); } @@ -1022,19 +1022,21 @@ public class AuthorizationPolicyTests } // ───────────────────────────────────────────────────────────────────── - // Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15). - // Default mapping (see AuthorizationPolicies XML doc): - // Admin → OperationalAudit + AuditExport - // Audit → OperationalAudit + AuditExport - // AuditReadOnly → OperationalAudit only - // Design → neither - // Deployment → neither + // Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15), + // post Task 1.7 canonicalization + SoD collapse. Default mapping + // (see AuthorizationPolicies XML doc): + // Administrator → OperationalAudit + AuditExport + // Viewer → OperationalAudit only (former AuditReadOnly home) + // Designer → neither + // Deployer → neither + // The former distinct Audit/AuditReadOnly roles no longer exist: + // Audit → collapsed into Administrator + // AuditReadOnly → collapsed into Viewer // ───────────────────────────────────────────────────────────────────── [Theory] - [InlineData("Admin")] - [InlineData("Audit")] - [InlineData("AuditReadOnly")] + [InlineData("Administrator")] + [InlineData("Viewer")] public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role) { var principal = CreatePrincipal(new[] { role }); @@ -1042,8 +1044,8 @@ public class AuthorizationPolicyTests } [Theory] - [InlineData("Design")] - [InlineData("Deployment")] + [InlineData("Designer")] + [InlineData("Deployer")] public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role) { var principal = CreatePrincipal(new[] { role }); @@ -1051,8 +1053,7 @@ public class AuthorizationPolicyTests } [Theory] - [InlineData("Admin")] - [InlineData("Audit")] + [InlineData("Administrator")] public async Task AuditExportPolicy_GrantedRoles_Succeed(string role) { var principal = CreatePrincipal(new[] { role }); @@ -1060,18 +1061,38 @@ public class AuthorizationPolicyTests } [Theory] - [InlineData("AuditReadOnly")] - [InlineData("Design")] - [InlineData("Deployment")] + [InlineData("Viewer")] + [InlineData("Designer")] + [InlineData("Deployer")] public async Task AuditExportPolicy_UngrantedRoles_Fail(string role) { - // AuditReadOnly is the load-bearing case: it grants OperationalAudit - // (read) but NOT AuditExport (bulk export) — the split that lets a - // triage operator drill in without exfiltrating the table. var principal = CreatePrincipal(new[] { role }); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); } + [Fact] + public async Task Viewer_ReadsAudit_ButCannotExport_PreservedHalfSoD() + { + // The load-bearing preserved-SoD case after the AuditReadOnly→Viewer + // collapse: a Viewer satisfies OperationalAudit (read the log + nav) + // but NOT AuditExport (bulk CSV exfiltration). + var principal = CreatePrincipal(new[] { Roles.Viewer }); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); + } + + [Fact] + public async Task FormerAuditUser_NowAdministrator_GainsExportAndFullAdmin_DocumentedEscalation() + { + // The documented privilege escalation: the former Audit role collapsed + // into Administrator, so a former audit-only user now passes AuditExport + // AND RequireAdmin (the full admin surface). + var principal = CreatePrincipal(new[] { Roles.Administrator }); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal)); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal)); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal)); + } + private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) { var claims = new List(); @@ -1253,7 +1274,7 @@ public class CanonicalClaimVocabularyTests var service = CreateService(); var token = service.GenerateToken( "Jane Roe", "janer", - new[] { Roles.Audit }, + new[] { Roles.Administrator }, new[] { "7" }); var principal = service.ValidateToken(token); @@ -1263,28 +1284,30 @@ public class CanonicalClaimVocabularyTests // (MapInboundClaims=false / cleared outbound map guarantee this). Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value); Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value); - Assert.Equal(Roles.Audit, principal.FindFirst(ZbClaimTypes.Role)?.Value); + Assert.Equal(Roles.Administrator, principal.FindFirst(ZbClaimTypes.Role)?.Value); Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value); } [Fact] public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy() { - // The load-bearing round-trip: a JWT minted with RoleClaimType=Audit must satisfy + // The load-bearing round-trip: a JWT minted with RoleClaimType=Administrator + // (post Task 1.7, the home of the former full-audit Audit role) must satisfy // a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation. var service = CreateService(); - var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Audit }, null); + var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Administrator }, null); var principal = service.ValidateToken(token); Assert.NotNull(principal); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!)); - // Audit does NOT grant AuditExport via a different vocabulary by accident: + // Administrator grants AuditExport too (it absorbed the former Audit role): Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!)); - // AuditReadOnly is read-only — separate assertion that the role VALUE semantics - // are untouched by the type migration. + // Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only — + // separate assertion that the read-not-export half-SoD survives the type + // migration AND the role collapse. var roPrincipal = service.ValidateToken( - service.GenerateToken("RO", "ro", new[] { Roles.AuditReadOnly }, null)); + service.GenerateToken("RO", "ro", new[] { Roles.Viewer }, null)); Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!)); Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!)); } @@ -1299,7 +1322,7 @@ public class CanonicalClaimVocabularyTests new(ClaimTypes.Name, "janer"), new(JwtTokenService.DisplayNameClaimType, "Jane Roe"), new(JwtTokenService.UsernameClaimType, "janer"), - new(JwtTokenService.RoleClaimType, Roles.Admin), + new(JwtTokenService.RoleClaimType, Roles.Administrator), new(JwtTokenService.SiteIdClaimType, "3"), }; var identity = new ClaimsIdentity( @@ -1315,7 +1338,7 @@ public class CanonicalClaimVocabularyTests Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name // roleType wiring => IsInRole resolves against the canonical role claim. - Assert.True(principal.IsInRole(Roles.Admin)); + Assert.True(principal.IsInRole(Roles.Administrator)); // Admin holds every permission by convention. Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));