diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs index 8cbfefe..77cf7c9 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -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 _logger; + private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1); + private IReadOnlyList _apiKeySummaries = Array.Empty(); public DashboardSnapshotService( ISessionRegistry sessionRegistry, @@ -31,7 +37,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService IGalaxyHierarchyCache galaxyHierarchyCache, IApiKeyAdminStore apiKeyAdminStore, IOptions options, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + ILogger? 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.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 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 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(); + _logger.LogWarning("Failed to refresh dashboard API key summaries."); + } + finally + { + _apiKeySummaryRefreshGate.Release(); } } diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs index 8cc4655..e5e8154 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs @@ -120,6 +120,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache List hierarchy = hierarchyTask.Result; List attributes = attributesTask.Result; IReadOnlyList 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, diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs index beafe2c..fa713f6 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyCacheEntry.cs @@ -16,6 +16,7 @@ public sealed record GalaxyHierarchyCacheEntry( DateTimeOffset? LastDeployTime, string? LastError, IReadOnlyList Objects, + GalaxyHierarchyIndex Index, DashboardGalaxySummary DashboardSummary, int ObjectCount, int AreaCount, @@ -31,6 +32,7 @@ public sealed record GalaxyHierarchyCacheEntry( LastDeployTime: null, LastError: null, Objects: Array.Empty(), + Index: GalaxyHierarchyIndex.Empty, DashboardSummary: DashboardGalaxySummary.Unknown, ObjectCount: 0, AreaCount: 0, diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs new file mode 100644 index 0000000..dc55a04 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyIndex.cs @@ -0,0 +1,106 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed class GalaxyHierarchyIndex +{ + private GalaxyHierarchyIndex( + IReadOnlyList objectViews, + IReadOnlyDictionary objectViewsById, + IReadOnlyDictionary tagsByAddress) + { + ObjectViews = objectViews; + ObjectViewsById = objectViewsById; + TagsByAddress = tagsByAddress; + } + + public static GalaxyHierarchyIndex Empty { get; } = new( + Array.Empty(), + new Dictionary(), + new Dictionary(StringComparer.OrdinalIgnoreCase)); + + public IReadOnlyList ObjectViews { get; } + + public IReadOnlyDictionary ObjectViewsById { get; } + + public IReadOnlyDictionary TagsByAddress { get; } + + public static GalaxyHierarchyIndex Build(IReadOnlyList objects) + { + if (objects.Count == 0) + { + return Empty; + } + + Dictionary objectsById = new(); + foreach (GalaxyObject obj in objects) + { + objectsById.TryAdd(obj.GobjectId, obj); + } + + List views = new(objects.Count); + Dictionary viewsById = new(); + Dictionary 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 objectsById) + { + Stack names = new(); + HashSet 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; + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs index c2f3d30..3367082 100644 --- a/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -11,12 +11,36 @@ public static class GalaxyHierarchyProjector GalaxyHierarchyCacheEntry entry, DiscoverHierarchyRequest request, IReadOnlyList? browseSubtreeGlobs = null) + { + return Project( + entry, + request, + browseSubtreeGlobs, + offset: 0, + pageSize: int.MaxValue); + } + + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? 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 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 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 filtered = []; - foreach (ObjectView view in views) + List 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 BuildViews(IReadOnlyList objects) - { - Dictionary byId = objects.ToDictionary(obj => obj.GobjectId); - List 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 byId) - { - Stack names = new(); - HashSet 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 views) + IReadOnlyList 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? 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? 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); } diff --git a/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs b/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs new file mode 100644 index 0000000..7989aa4 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyObjectView.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyObjectView( + GalaxyObject Object, + string ContainedPath, + int Depth); diff --git a/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs b/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs new file mode 100644 index 0000000..449aec0 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyTagLookup.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyTagLookup( + GalaxyObject Object, + GalaxyAttribute? Attribute, + string ContainedPath); diff --git a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs index 2fb17f0..1e2174f 100644 --- a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs @@ -71,34 +71,32 @@ public sealed class GalaxyRepositoryGrpcService( ResolveUnavailableMessage(entry))); } + int pageSize = ResolvePageSize(request.PageSize); IReadOnlyList 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); } diff --git a/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs index 6d5bfcc..11a44be 100644 --- a/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs +++ b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -62,22 +62,20 @@ public sealed class ConstraintEnforcer( return Task.FromResult(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(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(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(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, diff --git a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs index fc28cb7..688f89d 100644 --- a/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs +++ b/src/MxGateway.Tests/Galaxy/GalaxyHierarchyCacheTests.cs @@ -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); } diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index a8efd7d..5f2c203 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -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([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 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([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 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> ListAsync(CancellationToken cancellationToken) + public virtual Task> ListAsync(CancellationToken cancellationToken) { return Task.FromResult>([]); } @@ -317,6 +414,34 @@ public sealed class DashboardSnapshotServiceTests } } + private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore + { + public int ListCount { get; protected set; } + + public override Task> ListAsync(CancellationToken cancellationToken) + { + ListCount++; + return Task.FromResult>(records); + } + } + + private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record) + { + public bool FailNext { get; set; } + + public override Task> 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, diff --git a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs index a8a0fa8..4388d9d 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -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, diff --git a/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs index 8878d2b..fadc1b4 100644 --- a/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs +++ b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -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 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, }; }