feat(auth)!: ScadaBridge canonical roles + SoD collapse (Audit→Administrator, AuditReadOnly→Viewer) + config-DB migration (Task 1.7)

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.
This commit is contained in:
Joseph Doherty
2026-06-02 08:00:47 -04:00
parent 6ae605160c
commit b104760b3a
52 changed files with 2388 additions and 402 deletions
@@ -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<AuditEvent>)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<AuditEvent>)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<AuditEvent>)Array.Empty<AuditEvent>() });
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<AuditEvent>)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<string>());
"alice", "Alice", new[] { "Administrator" }, Array.Empty<string>());
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);