Apply technical-light design system to the gateway dashboard

Restyles the Blazor dashboard onto a portable token-based theme so it
reads like an instrument panel: warm-paper background, hairline-ruled
panels, IBM Plex type, monospace tabular numerics, and status carried by
colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css
as a thin token-driven view layer, and swaps the Bootstrap navbar and
status badges for the design-system app bar and chips.

Also includes pending API-key management, Galaxy hierarchy projection,
and constraint-enforcement work with their tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-16 22:30:25 -04:00
parent a4ed605f74
commit 96bea1d478
42 changed files with 3529 additions and 178 deletions
@@ -0,0 +1,65 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyAuthorizationTests
{
[Fact]
public void CanManage_AuthenticatedUserWithShortRequiredGroupClaim_ReturnsTrue()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("GwAdmin");
Assert.True(authorization.CanManage(user));
}
[Fact]
public void CanManage_AuthenticatedUserWithRequiredGroupDnClaim_ReturnsTrue()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local");
Assert.True(authorization.CanManage(user));
}
[Fact]
public void CanManage_AnonymousUser_ReturnsFalse()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = new(new ClaimsIdentity());
Assert.False(authorization.CanManage(user));
}
[Fact]
public void CanManage_AuthenticatedUserWithoutRequiredGroup_ReturnsFalse()
{
DashboardApiKeyAuthorization authorization = CreateAuthorization();
ClaimsPrincipal user = CreatePrincipal("ReadOnly");
Assert.False(authorization.CanManage(user));
}
private static DashboardApiKeyAuthorization CreateAuthorization()
{
return new DashboardApiKeyAuthorization(Options.Create(new GatewayOptions
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
}));
}
private static ClaimsPrincipal CreatePrincipal(string group)
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, group)],
DashboardAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}
}
@@ -0,0 +1,237 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
{
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
DashboardApiKeyManagementResult result = await service.CreateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
CreateRequest(),
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.CreateCount);
}
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
{
FakeApiKeyAdminStore adminStore = new();
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
DashboardApiKeyManagementResult result = await service.CreateAsync(
CreateAuthorizedUser(),
CreateRequest(),
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
string secret = result.ApiKey["mxgw_operator01_".Length..];
Assert.Equal(secret, hasher.LastSecret);
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
Assert.Equal("operator01", stored.KeyId);
Assert.Equal("Operator", stored.DisplayName);
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-create-key"
&& entry.KeyId == "operator01");
}
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
"operator01",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.RevokeCount);
}
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-revoke-key"
&& entry.KeyId == "operator01"
&& entry.Details == "revoked");
}
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
DashboardApiKeyManagementResult result = await service.RotateAsync(
CreateAuthorizedUser(),
"operator01",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-rotate-key"
&& entry.KeyId == "operator01"
&& entry.Details == "rotated");
}
private static DashboardApiKeyManagementService CreateService(
FakeApiKeyAdminStore? adminStore = null,
FakeApiKeyAuditStore? auditStore = null,
FakeApiKeySecretHasher? hasher = null)
{
GatewayOptions options = new()
{
Ldap = new LdapOptions
{
RequiredGroup = "GwAdmin",
},
};
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(Options.Create(options)),
adminStore ?? new FakeApiKeyAdminStore(),
auditStore ?? new FakeApiKeyAuditStore(),
hasher ?? new FakeApiKeySecretHasher(),
new HttpContextAccessor { HttpContext = httpContext });
}
private static DashboardApiKeyManagementRequest CreateRequest()
{
return new DashboardApiKeyManagementRequest(
KeyId: "operator01",
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.SessionOpen], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
});
}
private static ClaimsPrincipal CreateAuthorizedUser()
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwAdmin")],
DashboardAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
{
public int CreateCount { get; private set; }
public int RevokeCount { get; private set; }
public bool RevokeResult { get; init; }
public bool RotateResult { get; init; }
public string? LastRevokedKeyId { get; private set; }
public byte[]? LastRotatedSecretHash { get; private set; }
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
CreateCount++;
CreatedRequests.Add(request);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
{
RevokeCount++;
LastRevokedKeyId = keyId;
return Task.FromResult(RevokeResult);
}
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
public List<ApiKeyAuditEntry> Entries { get; } = [];
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
public string? LastSecret { get; private set; }
public byte[] HashSecret(string secret)
{
LastSecret = secret;
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
}
}
}
@@ -0,0 +1,21 @@
using MxGateway.Server.Dashboard;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardConnectionStringDisplayTests
{
[Fact]
public void GalaxyRepositoryConnectionString_WithSqlCredentials_OnlyKeepsNonSecretFields()
{
string display = DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(
"Server=localhost;Database=ZB;User ID=mxuser;Password=secret;Encrypt=True;Trust Server Certificate=False;");
Assert.Contains("Data Source=localhost", display, StringComparison.Ordinal);
Assert.Contains("Initial Catalog=ZB", display, StringComparison.Ordinal);
Assert.Contains("Encrypt=True", display, StringComparison.Ordinal);
Assert.DoesNotContain("User", display, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("Password", display, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", display, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("mxuser", display, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,371 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Grpc;
public sealed class GalaxyRepositoryGrpcServiceTests
{
[Fact]
public async Task DiscoverHierarchy_ReturnsRequestedPageAndTotals()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
},
new TestServerCallContext());
Assert.Equal(2, reply.Objects.Count);
Assert.Equal("Object_001", reply.Objects[0].TagName);
Assert.Equal("Object_002", reply.Objects[1].TagName);
Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal);
Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal);
Assert.Equal(3, reply.TotalObjectCount);
}
[Fact]
public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
},
new TestServerCallContext());
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 2,
PageToken = firstPage.NextPageToken,
},
new TestServerCallContext());
GalaxyObject item = Assert.Single(reply.Objects);
Assert.Equal("Object_003", item.TagName);
Assert.Equal("", reply.NextPageToken);
Assert.Equal(3, reply.TotalObjectCount);
}
[Theory]
[InlineData("-1", 1)]
[InlineData("not-an-offset", 1)]
[InlineData("7:4", 1)]
[InlineData("6:2", 1)]
[InlineData("", -1)]
public async Task DiscoverHierarchy_WithInvalidPagingArguments_ReturnsInvalidArgument(
string pageToken,
int pageSize)
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3)));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = pageSize,
PageToken = pageToken,
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
}
[Fact]
public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootContainedPath = "Area1/Line3",
MaxDepth = 1,
PageSize = 10,
},
new TestServerCallContext());
Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName));
Assert.Equal(3, reply.TotalObjectCount);
}
[Fact]
public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootTagName = "Area1",
TagNameGlob = "Pump_*",
AlarmBearingOnly = true,
HistorizedOnly = true,
IncludeAttributes = false,
PageSize = 10,
CategoryIds = { 10 },
TemplateChainContains = { "Pump" },
},
new TestServerCallContext());
GalaxyObject obj = Assert.Single(reply.Objects);
Assert.Equal("Pump_001", obj.TagName);
Assert.Empty(obj.Attributes);
Assert.Equal(1, reply.TotalObjectCount);
}
[Fact]
public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply first = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootGobjectId = 1,
PageSize = 1,
CategoryIds = { 10 },
},
new TestServerCallContext());
DiscoverHierarchyReply second = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootGobjectId = 1,
PageSize = 1,
PageToken = first.NextPageToken,
CategoryIds = { 10 },
},
new TestServerCallContext());
GalaxyObject firstObject = Assert.Single(first.Objects);
GalaxyObject secondObject = Assert.Single(second.Objects);
Assert.Equal(2, first.TotalObjectCount);
Assert.Equal(2, second.TotalObjectCount);
Assert.NotEqual(firstObject.TagName, secondObject.TagName);
}
[Fact]
public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
DiscoverHierarchyReply first = await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 1,
CategoryIds = { 10 },
},
new TestServerCallContext());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
PageSize = 1,
PageToken = first.NextPageToken,
CategoryIds = { 11 },
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.DiscoverHierarchy(
new DiscoverHierarchyRequest
{
RootTagName = "Missing",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
}
private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
return new GalaxyRepositoryGrpcService(
new global::MxGateway.Server.Galaxy.GalaxyRepository(options),
new StubGalaxyHierarchyCache(entry),
new GalaxyDeployNotifier(),
new GatewayRequestIdentityAccessor(),
NullLogger<GalaxyRepositoryGrpcService>.Instance);
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects)
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private static IReadOnlyList<GalaxyObject> CreateObjects(int count)
{
return Enumerable.Range(1, count)
.Select(index => new GalaxyObject
{
GobjectId = index,
TagName = $"Object_{index:000}",
BrowseName = $"Object_{index:000}",
})
.ToArray();
}
private static IReadOnlyList<GalaxyObject> CreateFilterObjects()
{
return
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
BrowseName = "Area1",
IsArea = true,
CategoryId = 13,
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Line3",
ContainedName = "Line3",
BrowseName = "Line3",
ParentGobjectId = 1,
CategoryId = 10,
TemplateChain = { "$Line", "$Base" },
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Pump_001",
ContainedName = "Pump",
BrowseName = "Pump_001",
ParentGobjectId = 2,
CategoryId = 10,
TemplateChain = { "$Pump", "$Base" },
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Pump_001.PV",
IsAlarm = true,
IsHistorized = true,
SecurityClassification = 2,
},
},
},
new GalaxyObject
{
GobjectId = 4,
TagName = "Valve_001",
ContainedName = "Valve",
BrowseName = "Valve_001",
ParentGobjectId = 2,
CategoryId = 11,
TemplateChain = { "$Valve" },
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Valve_001.PV",
},
},
},
new GalaxyObject
{
GobjectId = 5,
TagName = "Other_001",
ContainedName = "Other",
BrowseName = "Other_001",
CategoryId = 10,
},
];
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
{
private readonly Metadata requestHeaders = [];
private readonly Metadata responseTrailers = [];
private readonly Dictionary<object, object> userState = [];
private Status status;
private WriteOptions? writeOptions;
protected override string MethodCore => "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy";
protected override string HostCore => "localhost";
protected override string PeerCore => "ipv4:127.0.0.1:5000";
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
protected override Metadata RequestHeadersCore => requestHeaders;
protected override CancellationToken CancellationTokenCore => cancellationToken;
protected override Metadata ResponseTrailersCore => responseTrailers;
protected override Status StatusCore
{
get => status;
set => status = value;
}
protected override WriteOptions? WriteOptionsCore
{
get => writeOptions;
set => writeOptions = value;
}
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
protected override IDictionary<object, object> UserStateCore => userState;
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options)
{
throw new NotSupportedException();
}
}
}
@@ -0,0 +1,247 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
namespace MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests
{
[Fact]
public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadSubtrees = ["Area1/*"],
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Other_001.PV",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_scope", failure.ConstraintName);
}
[Fact]
public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits()
{
ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
WriteSubtrees = ["Area1/*"],
MaxWriteClassification = 1,
});
GatewaySession session = CreateSession();
session.TrackCommandReply(
new MxCommand
{
Kind = MxCommandKind.AddItem,
AddItem = new AddItemCommand
{
ServerHandle = 12,
ItemDefinition = "Pump_001.PV",
},
},
new MxCommandReply
{
ProtocolStatus = MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(),
AddItem = new AddItemReply { ItemHandle = 42 },
});
ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync(
identity,
session,
serverHandle: 12,
itemHandle: 42,
CancellationToken.None);
Assert.NotNull(failure);
await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None);
ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries);
Assert.Equal("operator01", entry.KeyId);
Assert.Equal("constraint-denied", entry.EventType);
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
}
[Fact]
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadHistorizedOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001.NonHistorized",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_historized_only", failure.ConstraintName);
}
[Fact]
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadAlarmOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001.PV",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_alarm_only", failure.ConstraintName);
}
[Fact]
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
{
ConstraintEnforcer enforcer = CreateEnforcer(out _);
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
{
ReadHistorizedOnly = true,
});
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
identity,
"Pump_001",
CancellationToken.None);
Assert.NotNull(failure);
Assert.Equal("read_historized_only", failure.ConstraintName);
}
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
{
auditStore = new FakeAuditStore();
return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore);
}
private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints)
{
return new ApiKeyIdentity(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator",
Scopes: new HashSet<string>(StringComparer.Ordinal),
Constraints: constraints);
}
private static GatewaySession CreateSession()
{
GatewaySession session = new(
"session-1",
"mxaccess",
"pipe",
"nonce",
"operator",
"client",
"correlation",
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5),
DateTimeOffset.UtcNow);
return session;
}
private static GalaxyHierarchyCacheEntry CreateEntry()
{
IReadOnlyList<GalaxyObject> objects =
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Pump_001",
ContainedName = "Pump",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Pump_001.PV",
SecurityClassification = 2,
IsHistorized = true,
},
new GalaxyAttribute
{
AttributeName = "Alarm",
FullTagReference = "Pump_001.Alarm",
IsAlarm = true,
},
new GalaxyAttribute
{
AttributeName = "NonHistorized",
FullTagReference = "Pump_001.NonHistorized",
},
},
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Other_001",
ContainedName = "Other",
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Other_001.PV",
},
},
},
];
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown,
};
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeAuditStore : IApiKeyAuditStore
{
public List<ApiKeyAuditEntry> Entries { get; } = [];
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
}