feat: scope GalaxyRepositoryGrpcService results via IGalaxyBrowseScopeProvider

This commit is contained in:
Joseph Doherty
2026-06-25 10:48:34 -04:00
parent 2e4df81ba9
commit 480f7c7a49
2 changed files with 246 additions and 14 deletions
@@ -15,18 +15,24 @@ namespace ZB.MOM.WW.GalaxyRepository.Grpc;
/// <c>TestConnection</c> remains a direct SQL probe since callers use it as a health check.
/// </para>
/// <para>
/// This service applies <b>no</b> per-identity browse-subtree filtering — the full
/// hierarchy is projected (<c>null</c> 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
/// <see cref="IGalaxyBrowseScopeProvider"/>. The default
/// <see cref="NullGalaxyBrowseScopeProvider"/> returns <c>null</c> globs, so the full
/// hierarchy is projected and behavior is unchanged. A hosting gateway can register its
/// own provider to scope <c>DiscoverHierarchy</c>/<c>BrowseChildren</c> results — and the
/// <c>object_count</c>/<c>attribute_count</c> totals streamed by <c>WatchDeployEvents</c> —
/// to the caller's allowed subtrees.
/// </para>
/// </summary>
/// <param name="repository">Direct SQL surface used by <c>TestConnection</c>.</param>
/// <param name="cache">Shared hierarchy cache that <c>DiscoverHierarchy</c>/<c>BrowseChildren</c>/<c>GetLastDeployTime</c> serve from.</param>
/// <param name="notifier">Deploy-event source streamed by <c>WatchDeployEvents</c>.</param>
/// <param name="scope">Resolves the per-caller browse-subtree globs applied to browse/discover results.</param>
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<string>? 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<string>? 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<string>? 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<string>? 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)