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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user