diff --git a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Grpc/GalaxyRepositoryGrpcService.cs b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Grpc/GalaxyRepositoryGrpcService.cs index aab17eb..b705629 100644 --- a/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Grpc/GalaxyRepositoryGrpcService.cs +++ b/ZB.MOM.WW.GalaxyRepository/src/ZB.MOM.WW.GalaxyRepository/Grpc/GalaxyRepositoryGrpcService.cs @@ -15,18 +15,24 @@ namespace ZB.MOM.WW.GalaxyRepository.Grpc; /// TestConnection remains a direct SQL probe since callers use it as a health check. /// /// -/// This service applies no per-identity browse-subtree filtering — the full -/// hierarchy is projected (null subtree globs). Authorization (including any -/// subtree scoping) is the responsibility of the hosting gateway's interceptor layer. +/// Per-identity browse-subtree filtering is delegated to the injected +/// . The default +/// returns null globs, so the full +/// hierarchy is projected and behavior is unchanged. A hosting gateway can register its +/// own provider to scope DiscoverHierarchy/BrowseChildren results — and the +/// object_count/attribute_count totals streamed by WatchDeployEvents — +/// to the caller's allowed subtrees. /// /// /// Direct SQL surface used by TestConnection. /// Shared hierarchy cache that DiscoverHierarchy/BrowseChildren/GetLastDeployTime serve from. /// Deploy-event source streamed by WatchDeployEvents. +/// Resolves the per-caller browse-subtree globs applied to browse/discover results. public sealed class GalaxyRepositoryGrpcService( IGalaxyRepository repository, IGalaxyHierarchyCache cache, - IGalaxyDeployNotifier notifier) : ProtoGalaxyRepository.GalaxyRepositoryBase + IGalaxyDeployNotifier notifier, + IGalaxyBrowseScopeProvider scope) : ProtoGalaxyRepository.GalaxyRepositoryBase { private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); private const int DefaultDiscoverPageSize = 1000; @@ -82,14 +88,13 @@ public sealed class GalaxyRepositoryGrpcService( } int pageSize = ResolvePageSize(request.PageSize); - // The shared library applies no per-identity subtree scoping; the hosting - // gateway enforces authorization at its interceptor layer. - string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null); + IReadOnlyList? browseSubtrees = scope.ResolveBrowseSubtrees(context); + string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees); PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature); GalaxyHierarchyQueryResult query = GalaxyHierarchyProjector.Project( entry, request, - browseSubtreeGlobs: null, + browseSubtrees, pageToken.Offset, pageSize); int offset = pageToken.Offset; @@ -131,6 +136,7 @@ public sealed class GalaxyRepositoryGrpcService( } int pageSize = ResolveBrowsePageSize(request.PageSize); + IReadOnlyList? browseSubtrees = scope.ResolveBrowseSubtrees(context); // Resolve the parent id once so the page-token signature can include it // and the projector sees the same resolved id when memoizing. The projector @@ -139,13 +145,13 @@ public sealed class GalaxyRepositoryGrpcService( // and keeps the projector self-contained. int parentId = GalaxyBrowseProjector.ResolveParentId(entry, request); string filterSignature = GalaxyBrowseProjector.ComputeFilterSignature( - request, browseSubtreeGlobs: null, parentId); + request, browseSubtrees, parentId); PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature); GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren( entry, request, - browseSubtreeGlobs: null, + browseSubtrees, pageToken.Offset, pageSize); @@ -181,6 +187,11 @@ public sealed class GalaxyRepositoryGrpcService( { DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset(); + // The caller's identity (and therefore its browse-subtree constraints) is fixed + // for the lifetime of the stream, so resolve the subtrees once rather than per + // streamed event. + IReadOnlyList? browseSubtrees = scope.ResolveBrowseSubtrees(context); + await foreach (GalaxyDeployEventInfo info in notifier .SubscribeAsync(context.CancellationToken) .ConfigureAwait(false)) @@ -195,7 +206,7 @@ public sealed class GalaxyRepositoryGrpcService( } lastSeen = null; - await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false); + await responseStream.WriteAsync(MapDeployEvent(info, browseSubtrees), context.CancellationToken).ConfigureAwait(false); } } @@ -223,14 +234,28 @@ public sealed class GalaxyRepositoryGrpcService( } } - private static DeployEvent MapDeployEvent(GalaxyDeployEventInfo info) + private DeployEvent MapDeployEvent( + GalaxyDeployEventInfo info, + IReadOnlyList? browseSubtrees) { + int objectCount = info.ObjectCount; + int attributeCount = info.AttributeCount; + if (browseSubtrees is { Count: > 0 } && cache.Current.HasData) + { + GalaxyHierarchyQueryResult scoped = GalaxyHierarchyProjector.Project( + cache.Current, + new DiscoverHierarchyRequest(), + browseSubtrees); + objectCount = scoped.TotalObjectCount; + attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count); + } + DeployEvent ev = new() { Sequence = (ulong)info.Sequence, ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt), - ObjectCount = info.ObjectCount, - AttributeCount = info.AttributeCount, + ObjectCount = objectCount, + AttributeCount = attributeCount, TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue, }; if (info.TimeOfLastDeploy.HasValue) diff --git a/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyRepositoryGrpcServiceScopeTests.cs b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyRepositoryGrpcServiceScopeTests.cs new file mode 100644 index 0000000..5873223 --- /dev/null +++ b/ZB.MOM.WW.GalaxyRepository/tests/ZB.MOM.WW.GalaxyRepository.Tests/GalaxyRepositoryGrpcServiceScopeTests.cs @@ -0,0 +1,207 @@ +using Grpc.Core; +using ZB.MOM.WW.GalaxyRepository; +using ZB.MOM.WW.GalaxyRepository.Grpc; + +namespace ZB.MOM.WW.GalaxyRepository.Tests; + +/// +/// Verifies that scopes browse/discover +/// results through the injected . The default +/// (null-returning) provider must preserve full-hierarchy behavior, and a provider +/// returning a glob that matches nothing must filter the result to empty. +/// +public sealed class GalaxyRepositoryGrpcServiceScopeTests +{ + /// + /// A scope provider built with a result behaves like the + /// default : DiscoverHierarchy returns + /// the full hierarchy. + /// + [Fact] + public async Task DiscoverHierarchy_DefaultScope_ReturnsFullHierarchy() + { + GalaxyRepositoryGrpcService service = CreateService( + BuildSampleEntry(), + new FakeBrowseScopeProvider(subtrees: null)); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest { PageSize = 100 }, + new TestServerCallContext()); + + // The sample hierarchy has six objects; with no scoping all are returned. + Assert.Equal(6, reply.TotalObjectCount); + Assert.Equal(6, reply.Objects.Count); + } + + /// + /// A scope provider returning a glob that matches no contained path filters the + /// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior. + /// + [Fact] + public async Task BrowseChildren_ScopedProvider_FiltersChildren() + { + GalaxyHierarchyCacheEntry entry = BuildSampleEntry(); + + // Sanity: with the default (unscoped) provider, LineA(2) has two children. + GalaxyRepositoryGrpcService unscopedService = CreateService( + entry, + new FakeBrowseScopeProvider(subtrees: null)); + BrowseChildrenReply unscoped = await unscopedService.BrowseChildren( + new BrowseChildrenRequest { ParentGobjectId = 2 }, + new TestServerCallContext()); + Assert.Equal(2, unscoped.Children.Count); + + // A glob matching nothing scopes the result to empty. + GalaxyRepositoryGrpcService scopedService = CreateService( + entry, + new FakeBrowseScopeProvider(subtrees: ["NonExistent"])); + BrowseChildrenReply scoped = await scopedService.BrowseChildren( + new BrowseChildrenRequest { ParentGobjectId = 2 }, + new TestServerCallContext()); + + Assert.Empty(scoped.Children); + Assert.Equal(0, scoped.TotalChildCount); + } + + private static GalaxyRepositoryGrpcService CreateService( + GalaxyHierarchyCacheEntry entry, + IGalaxyBrowseScopeProvider scope) + { + GalaxyRepositoryOptions options = new() + { + ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;", + }; + return new GalaxyRepositoryGrpcService( + new GalaxyRepository(options), + new StubGalaxyHierarchyCache(entry), + new RecordingDeployNotifier(), + scope); + } + + // A small but representative galaxy, materialized through the real cache refresh path + // so the projectors run against a real GalaxyHierarchyIndex: + // PlantArea (area, id 1) + // ├─ LineA (area, id 2) + // │ ├─ Pump01 (id 10) + // │ └─ Valve01 (id 11) + // └─ Mixer01 (id 12) + // StandaloneTank (id 20, root) + private static GalaxyHierarchyCacheEntry BuildSampleEntry() + { + List hierarchy = + [ + Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100), + Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100), + Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump"]), + Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]), + Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]), + Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]), + ]; + + List attributes = + [ + Attribute(10, "Pump01.PV"), + Attribute(11, "Valve01.Cmd"), + Attribute(12, "Mixer01.Fault"), + Attribute(20, "StandaloneTank.Level"), + ]; + + FakeGalaxyRepository repository = new( + hierarchy, + attributes, + deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier()); + cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult(); + GalaxyHierarchyCacheEntry entry = cache.Current; + Assert.True(entry.HasData); + return entry; + } + + private static GalaxyHierarchyRow Hierarchy( + int id, + string tagName, + int parent, + bool isArea = false, + int category = 0, + IReadOnlyList? templates = null) => new() + { + GobjectId = id, + TagName = tagName, + ContainedName = tagName, + BrowseName = tagName, + ParentGobjectId = parent, + IsArea = isArea, + CategoryId = category, + TemplateChain = templates ?? Array.Empty(), + }; + + private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new() + { + GobjectId = gobjectId, + AttributeName = fullTagReference.Split('.')[^1], + FullTagReference = fullTagReference, + }; + + /// An that returns a fixed glob list. + private sealed class FakeBrowseScopeProvider(IReadOnlyList? subtrees) : IGalaxyBrowseScopeProvider + { + public IReadOnlyList? ResolveBrowseSubtrees(ServerCallContext context) => subtrees; + } + + /// Serves a fixed cache entry; never blocks on first load. + 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; + } + + /// Minimal in-memory for direct service unit tests. + private sealed class TestServerCallContext : ServerCallContext + { + private readonly Metadata _requestHeaders = []; + private readonly Metadata _responseTrailers = []; + private readonly Dictionary _userState = []; + private Status _status; + private WriteOptions? _writeOptions; + + protected override string MethodCore => "/zb.galaxy.v1.GalaxyRepository/Test"; + + 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.None; + + 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>(StringComparer.Ordinal)); + + protected override IDictionary UserStateCore => _userState; + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException(); + } +}