Fix Galaxy projection and constraint review findings

This commit is contained in:
Joseph Doherty
2026-04-29 14:24:11 -04:00
parent b995c174eb
commit cf20142634
13 changed files with 527 additions and 165 deletions
@@ -1,4 +1,6 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Galaxy;
@@ -21,8 +23,12 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
private readonly ILogger<DashboardSnapshotService> _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
@@ -31,7 +37,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
IGalaxyHierarchyCache galaxyHierarchyCache,
IApiKeyAdminStore apiKeyAdminStore,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
ILogger<DashboardSnapshotService>? logger = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
@@ -45,6 +52,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
}
public DashboardSnapshot GetSnapshot()
@@ -73,7 +81,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
ApiKeys: CreateApiKeySummaries(),
ApiKeys: Volatile.Read(ref _apiKeySummaries),
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
@@ -86,6 +94,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
@@ -106,6 +115,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
}
}
@@ -197,13 +207,19 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
.ToArray();
}
private IReadOnlyList<DashboardApiKeySummary> CreateApiKeySummaries()
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
{
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
return _apiKeyAdminStore.ListAsync(CancellationToken.None)
.GetAwaiter()
.GetResult()
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
.ConfigureAwait(false))
.Select(key => new DashboardApiKeySummary(
KeyId: key.KeyId,
DisplayName: key.DisplayName,
@@ -213,10 +229,26 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc))
.ToArray();
Volatile.Write(ref _apiKeySummaries, summaries);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Timed out refreshing dashboard API key summaries after {Timeout}.",
_apiKeySummaryRefreshTimeout);
}
catch (Exception)
{
return Array.Empty<DashboardApiKeySummary>();
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
}
finally
{
_apiKeySummaryRefreshGate.Release();
}
}
@@ -120,6 +120,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result;
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
int areaCount = hierarchy.Count(row => row.IsArea);
int historized = attributes.Count(row => row.IsHistorized);
@@ -146,6 +147,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
LastDeployTime: deployTime,
LastError: null,
Objects: objects,
Index: index,
DashboardSummary: dashboardSummary,
ObjectCount: hierarchy.Count,
AreaCount: areaCount,
@@ -16,6 +16,7 @@ public sealed record GalaxyHierarchyCacheEntry(
DateTimeOffset? LastDeployTime,
string? LastError,
IReadOnlyList<GalaxyObject> Objects,
GalaxyHierarchyIndex Index,
DashboardGalaxySummary DashboardSummary,
int ObjectCount,
int AreaCount,
@@ -31,6 +32,7 @@ public sealed record GalaxyHierarchyCacheEntry(
LastDeployTime: null,
LastError: null,
Objects: Array.Empty<GalaxyObject>(),
Index: GalaxyHierarchyIndex.Empty,
DashboardSummary: DashboardGalaxySummary.Unknown,
ObjectCount: 0,
AreaCount: 0,
@@ -0,0 +1,106 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed class GalaxyHierarchyIndex
{
private GalaxyHierarchyIndex(
IReadOnlyList<GalaxyObjectView> objectViews,
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
{
ObjectViews = objectViews;
ObjectViewsById = objectViewsById;
TagsByAddress = tagsByAddress;
}
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
{
if (objects.Count == 0)
{
return Empty;
}
Dictionary<int, GalaxyObject> objectsById = new();
foreach (GalaxyObject obj in objects)
{
objectsById.TryAdd(obj.GobjectId, obj);
}
List<GalaxyObjectView> views = new(objects.Count);
Dictionary<int, GalaxyObjectView> viewsById = new();
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
foreach (GalaxyObject obj in objects)
{
string path = BuildContainedPath(obj, objectsById);
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
GalaxyObjectView view = new(obj, path, depth);
views.Add(view);
viewsById.TryAdd(obj.GobjectId, view);
if (!string.IsNullOrWhiteSpace(obj.TagName))
{
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
}
foreach (GalaxyAttribute attribute in obj.Attributes)
{
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
{
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
}
}
}
return new GalaxyHierarchyIndex(
views,
viewsById,
tagsByAddress);
}
private static string BuildContainedPath(
GalaxyObject obj,
IReadOnlyDictionary<int, GalaxyObject> objectsById)
{
Stack<string> names = new();
HashSet<int> seen = [];
GalaxyObject? current = obj;
while (current is not null && seen.Add(current.GobjectId))
{
names.Push(ResolvePathSegment(current));
current = current.ParentGobjectId != 0
&& objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
? parent
: null;
}
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
}
private static string ResolvePathSegment(GalaxyObject obj)
{
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
{
return obj.ContainedName;
}
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
{
return obj.BrowseName;
}
return obj.TagName;
}
}
@@ -11,12 +11,36 @@ public static class GalaxyHierarchyProjector
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs = null)
{
return Project(
entry,
request,
browseSubtreeGlobs,
offset: 0,
pageSize: int.MaxValue);
}
public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs,
int offset,
int pageSize)
{
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(request);
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
}
IReadOnlyList<ObjectView> views = BuildViews(entry.Objects);
ObjectView? root = ResolveRoot(request, views);
if (pageSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
}
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
GalaxyObjectView? root = ResolveRoot(request, views);
int? maxDepth = request.MaxDepth;
if (maxDepth < 0)
{
@@ -25,8 +49,10 @@ public static class GalaxyHierarchyProjector
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
}
List<GalaxyObject> filtered = [];
foreach (ObjectView view in views)
List<GalaxyObject> page = [];
int matchedCount = 0;
bool includeAttributes = IncludeAttributes(request);
foreach (GalaxyObjectView view in views)
{
if (!MatchesRoot(view, root, maxDepth)
|| !MatchesBrowseSubtrees(view, browseSubtreeGlobs)
@@ -35,12 +61,17 @@ public static class GalaxyHierarchyProjector
continue;
}
filtered.Add(CloneObject(view.Object, IncludeAttributes(request)));
if (matchedCount >= offset && page.Count < pageSize)
{
page.Add(CloneObject(view.Object, includeAttributes));
}
matchedCount++;
}
return new GalaxyHierarchyQueryResult(
filtered,
filtered.Count,
page,
matchedCount,
ComputeFilterSignature(request, browseSubtreeGlobs));
}
@@ -53,23 +84,9 @@ public static class GalaxyHierarchyProjector
return null;
}
foreach (GalaxyObject obj in entry.Objects)
{
if (string.Equals(obj.TagName, tagAddress, StringComparison.OrdinalIgnoreCase))
{
return obj;
}
foreach (GalaxyAttribute attribute in obj.Attributes)
{
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
{
return obj;
}
}
}
return null;
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
? lookup.Object
: null;
}
public static GalaxyAttribute? FindAttributeForTag(
@@ -81,81 +98,25 @@ public static class GalaxyHierarchyProjector
return null;
}
foreach (GalaxyObject obj in entry.Objects)
{
foreach (GalaxyAttribute attribute in obj.Attributes)
{
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
{
return attribute;
}
}
}
return null;
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
? lookup.Attribute
: null;
}
public static string GetContainedPath(
GalaxyHierarchyCacheEntry entry,
int gobjectId)
{
return BuildViews(entry.Objects)
.FirstOrDefault(view => view.Object.GobjectId == gobjectId)
?.ContainedPath ?? string.Empty;
return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
? view.ContainedPath
: string.Empty;
}
private static IReadOnlyList<ObjectView> BuildViews(IReadOnlyList<GalaxyObject> objects)
{
Dictionary<int, GalaxyObject> byId = objects.ToDictionary(obj => obj.GobjectId);
List<ObjectView> views = new(objects.Count);
foreach (GalaxyObject obj in objects)
{
string path = BuildContainedPath(obj, byId);
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
views.Add(new ObjectView(obj, path, depth));
}
return views;
}
private static string BuildContainedPath(
GalaxyObject obj,
IReadOnlyDictionary<int, GalaxyObject> byId)
{
Stack<string> names = new();
HashSet<int> seen = [];
GalaxyObject? current = obj;
while (current is not null && seen.Add(current.GobjectId))
{
names.Push(ResolvePathSegment(current));
current = current.ParentGobjectId != 0 && byId.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
? parent
: null;
}
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
}
private static string ResolvePathSegment(GalaxyObject obj)
{
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
{
return obj.ContainedName;
}
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
{
return obj.BrowseName;
}
return obj.TagName;
}
private static ObjectView? ResolveRoot(
private static GalaxyObjectView? ResolveRoot(
DiscoverHierarchyRequest request,
IReadOnlyList<ObjectView> views)
IReadOnlyList<GalaxyObjectView> views)
{
ObjectView? root = request.RootCase switch
GalaxyObjectView? root = request.RootCase switch
{
DiscoverHierarchyRequest.RootOneofCase.None => null,
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
@@ -176,8 +137,8 @@ public static class GalaxyHierarchyProjector
}
private static bool MatchesRoot(
ObjectView view,
ObjectView? root,
GalaxyObjectView view,
GalaxyObjectView? root,
int? maxDepth)
{
if (root is null)
@@ -196,7 +157,7 @@ public static class GalaxyHierarchyProjector
}
private static bool MatchesBrowseSubtrees(
ObjectView view,
GalaxyObjectView view,
IReadOnlyList<string>? browseSubtreeGlobs)
{
return browseSubtreeGlobs is null
@@ -256,7 +217,7 @@ public static class GalaxyHierarchyProjector
return clone;
}
private static string ComputeFilterSignature(
public static string ComputeFilterSignature(
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs)
{
@@ -282,6 +243,4 @@ public static class GalaxyHierarchyProjector
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash, 0, 12);
}
private sealed record ObjectView(GalaxyObject Object, string ContainedPath, int Depth);
}
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyObjectView(
GalaxyObject Object,
string ContainedPath,
int Depth);
@@ -0,0 +1,8 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
public sealed record GalaxyTagLookup(
GalaxyObject Object,
GalaxyAttribute? Attribute,
string ContainedPath);
@@ -71,34 +71,32 @@ public sealed class GalaxyRepositoryGrpcService(
ResolveUnavailableMessage(entry)));
}
int pageSize = ResolvePageSize(request.PageSize);
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
entry,
request,
browseSubtrees);
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, query.FilterSignature);
browseSubtrees,
pageToken.Offset,
pageSize);
int offset = pageToken.Offset;
if (offset > query.Objects.Count)
if (offset > query.TotalObjectCount)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is outside the current hierarchy."));
}
int pageSize = ResolvePageSize(request.PageSize);
int take = Math.Min(pageSize, query.Objects.Count - offset);
DiscoverHierarchyReply reply = new()
{
TotalObjectCount = query.TotalObjectCount,
};
for (int index = offset; index < offset + take; index++)
{
reply.Objects.Add(query.Objects[index]);
}
reply.Objects.Add(query.Objects);
int nextOffset = offset + take;
if (nextOffset < query.Objects.Count)
int nextOffset = offset + query.Objects.Count;
if (nextOffset < query.TotalObjectCount)
{
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
}
@@ -62,22 +62,20 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
}
GalaxyHierarchyCacheEntry entry = cache.Current;
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, registration.TagAddress);
if (obj is null)
GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
if (target is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
}
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId);
if (!MatchesPathOrTag(containedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
}
if (constraints.MaxWriteClassification is { } maxClassification)
{
GalaxyAttribute? attribute = GalaxyHierarchyProjector.FindAttributeForTag(entry, registration.TagAddress);
GalaxyAttribute? attribute = target.Attribute;
if (attribute is null)
{
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
@@ -115,32 +113,39 @@ public sealed class ConstraintEnforcer(
ApiKeyConstraints constraints,
string tagAddress)
{
GalaxyHierarchyCacheEntry entry = cache.Current;
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, tagAddress);
if (obj is null)
GalaxyTagLookup? target = ResolveTarget(tagAddress);
if (target is null)
{
return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.");
}
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId);
if (!MatchesPathOrTag(containedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
{
return new ConstraintFailure("read_scope", "Tag is outside the API key read scope.");
}
if (constraints.ReadAlarmOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true })
{
return new ConstraintFailure("read_alarm_only", "Object has no alarm-bearing attributes.");
return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute.");
}
if (constraints.ReadHistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true })
{
return new ConstraintFailure("read_historized_only", "Object has no historized attributes.");
return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute.");
}
return null;
}
private GalaxyTagLookup? ResolveTarget(string tagAddress)
{
GalaxyHierarchyCacheEntry entry = cache.Current;
return !string.IsNullOrWhiteSpace(tagAddress)
&& entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
? lookup
: null;
}
private static bool MatchesPathOrTag(
string containedPath,
string tagAddress,
@@ -1,4 +1,5 @@
using MxGateway.Server.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Tests.Galaxy;
@@ -52,6 +53,53 @@ public sealed class GalaxyHierarchyCacheTests
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
[Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{
GalaxyObject root = new()
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
};
GalaxyObject duplicate = new()
{
GobjectId = 1,
TagName = "DuplicateArea",
ContainedName = "DuplicateArea",
};
GalaxyObject child = new()
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Pump_001",
ContainedName = "Pump",
Attributes =
{
new GalaxyAttribute
{
FullTagReference = "Pump_001.PV",
IsHistorized = true,
},
},
};
GalaxyObject orphan = new()
{
GobjectId = 3,
ParentGobjectId = 99,
TagName = "Orphan_001",
ContainedName = "Orphan",
};
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);
Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
Assert.Same(root, index.ObjectViewsById[1].Object);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{
GalaxyRepositoryOptions options = new()
@@ -59,7 +107,7 @@ public sealed class GalaxyHierarchyCacheTests
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
GalaxyRepository repository = new(options);
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
}
@@ -5,6 +5,7 @@ using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -228,6 +229,101 @@ public sealed class DashboardSnapshotServiceTests
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
[Fact]
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Empty(snapshot.ApiKeys);
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
},
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot snapshot = enumerator.Current;
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
Assert.Equal("operator01", key.KeyId);
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
new GatewayOptions
{
Dashboard = new DashboardOptions
{
SnapshotIntervalMilliseconds = 1,
},
},
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot first = enumerator.Current;
apiKeyAdminStore.FailNext = true;
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot second = enumerator.Current;
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -258,7 +354,8 @@ public sealed class DashboardSnapshotServiceTests
SessionRegistry registry,
GatewayMetrics metrics,
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
IApiKeyAdminStore? apiKeyAdminStore = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -274,7 +371,7 @@ public sealed class DashboardSnapshotServiceTests
metrics,
configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
new FakeApiKeyAdminStore(),
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
Options.Create(resolvedOptions));
}
@@ -287,14 +384,14 @@ public sealed class DashboardSnapshotServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
@@ -317,6 +414,34 @@ public sealed class DashboardSnapshotServiceTests
}
}
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
public int ListCount { get; protected set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
}
}
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{
public bool FailNext { get; set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
{
FailNext = false;
ListCount++;
throw new InvalidOperationException("Simulated SQLite failure.");
}
return base.ListAsync(cancellationToken);
}
}
private static GatewaySession CreateSession(
string sessionId,
string? clientIdentity,
@@ -217,6 +217,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
Sequence = 7,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
@@ -70,6 +70,60 @@ public sealed class ConstraintEnforcerTests
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();
@@ -105,49 +159,63 @@ public sealed class ConstraintEnforcerTests
private static GalaxyHierarchyCacheEntry CreateEntry()
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Objects =
[
new GalaxyObject
IReadOnlyList<GalaxyObject> objects =
[
new GalaxyObject
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Pump_001",
ContainedName = "Pump",
ParentGobjectId = 1,
Attributes =
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
},
new GalaxyObject
{
GobjectId = 2,
TagName = "Pump_001",
ContainedName = "Pump",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Pump_001.PV",
SecurityClassification = 2,
IsHistorized = true,
},
},
},
new GalaxyObject
{
GobjectId = 3,
TagName = "Other_001",
ContainedName = "Other",
Attributes =
{
new GalaxyAttribute
{
AttributeName = "PV",
FullTagReference = "Other_001.PV",
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,
};
}