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