feat: scope GalaxyRepositoryGrpcService results via IGalaxyBrowseScopeProvider
This commit is contained in:
+39
-14
@@ -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.
|
/// <c>TestConnection</c> remains a direct SQL probe since callers use it as a health check.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This service applies <b>no</b> per-identity browse-subtree filtering — the full
|
/// Per-identity browse-subtree filtering is delegated to the injected
|
||||||
/// hierarchy is projected (<c>null</c> subtree globs). Authorization (including any
|
/// <see cref="IGalaxyBrowseScopeProvider"/>. The default
|
||||||
/// subtree scoping) is the responsibility of the hosting gateway's interceptor layer.
|
/// <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>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="repository">Direct SQL surface used by <c>TestConnection</c>.</param>
|
/// <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="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="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(
|
public sealed class GalaxyRepositoryGrpcService(
|
||||||
IGalaxyRepository repository,
|
IGalaxyRepository repository,
|
||||||
IGalaxyHierarchyCache cache,
|
IGalaxyHierarchyCache cache,
|
||||||
IGalaxyDeployNotifier notifier) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
IGalaxyDeployNotifier notifier,
|
||||||
|
IGalaxyBrowseScopeProvider scope) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||||
private const int DefaultDiscoverPageSize = 1000;
|
private const int DefaultDiscoverPageSize = 1000;
|
||||||
@@ -82,14 +88,13 @@ public sealed class GalaxyRepositoryGrpcService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
int pageSize = ResolvePageSize(request.PageSize);
|
int pageSize = ResolvePageSize(request.PageSize);
|
||||||
// The shared library applies no per-identity subtree scoping; the hosting
|
IReadOnlyList<string>? browseSubtrees = scope.ResolveBrowseSubtrees(context);
|
||||||
// gateway enforces authorization at its interceptor layer.
|
string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
|
||||||
string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null);
|
|
||||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||||
GalaxyHierarchyQueryResult query = GalaxyHierarchyProjector.Project(
|
GalaxyHierarchyQueryResult query = GalaxyHierarchyProjector.Project(
|
||||||
entry,
|
entry,
|
||||||
request,
|
request,
|
||||||
browseSubtreeGlobs: null,
|
browseSubtrees,
|
||||||
pageToken.Offset,
|
pageToken.Offset,
|
||||||
pageSize);
|
pageSize);
|
||||||
int offset = pageToken.Offset;
|
int offset = pageToken.Offset;
|
||||||
@@ -131,6 +136,7 @@ public sealed class GalaxyRepositoryGrpcService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
||||||
|
IReadOnlyList<string>? browseSubtrees = scope.ResolveBrowseSubtrees(context);
|
||||||
|
|
||||||
// Resolve the parent id once so the page-token signature can include it
|
// 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
|
// 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.
|
// and keeps the projector self-contained.
|
||||||
int parentId = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
int parentId = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||||
string filterSignature = GalaxyBrowseProjector.ComputeFilterSignature(
|
string filterSignature = GalaxyBrowseProjector.ComputeFilterSignature(
|
||||||
request, browseSubtreeGlobs: null, parentId);
|
request, browseSubtrees, parentId);
|
||||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||||
|
|
||||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||||
entry,
|
entry,
|
||||||
request,
|
request,
|
||||||
browseSubtreeGlobs: null,
|
browseSubtrees,
|
||||||
pageToken.Offset,
|
pageToken.Offset,
|
||||||
pageSize);
|
pageSize);
|
||||||
|
|
||||||
@@ -181,6 +187,11 @@ public sealed class GalaxyRepositoryGrpcService(
|
|||||||
{
|
{
|
||||||
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
|
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
|
await foreach (GalaxyDeployEventInfo info in notifier
|
||||||
.SubscribeAsync(context.CancellationToken)
|
.SubscribeAsync(context.CancellationToken)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
@@ -195,7 +206,7 @@ public sealed class GalaxyRepositoryGrpcService(
|
|||||||
}
|
}
|
||||||
lastSeen = null;
|
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()
|
DeployEvent ev = new()
|
||||||
{
|
{
|
||||||
Sequence = (ulong)info.Sequence,
|
Sequence = (ulong)info.Sequence,
|
||||||
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||||
ObjectCount = info.ObjectCount,
|
ObjectCount = objectCount,
|
||||||
AttributeCount = info.AttributeCount,
|
AttributeCount = attributeCount,
|
||||||
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
||||||
};
|
};
|
||||||
if (info.TimeOfLastDeploy.HasValue)
|
if (info.TimeOfLastDeploy.HasValue)
|
||||||
|
|||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository;
|
||||||
|
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="GalaxyRepositoryGrpcService"/> scopes browse/discover
|
||||||
|
/// results through the injected <see cref="IGalaxyBrowseScopeProvider"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GalaxyRepositoryGrpcServiceScopeTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A scope provider built with a <see langword="null"/> result behaves like the
|
||||||
|
/// default <see cref="NullGalaxyBrowseScopeProvider"/>: DiscoverHierarchy returns
|
||||||
|
/// the full hierarchy.
|
||||||
|
/// </summary>
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A scope provider returning a glob that matches no contained path filters the
|
||||||
|
/// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior.
|
||||||
|
/// </summary>
|
||||||
|
[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<GalaxyHierarchyRow> 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<GalaxyAttributeRow> 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<string>? templates = null) => new()
|
||||||
|
{
|
||||||
|
GobjectId = id,
|
||||||
|
TagName = tagName,
|
||||||
|
ContainedName = tagName,
|
||||||
|
BrowseName = tagName,
|
||||||
|
ParentGobjectId = parent,
|
||||||
|
IsArea = isArea,
|
||||||
|
CategoryId = category,
|
||||||
|
TemplateChain = templates ?? Array.Empty<string>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new()
|
||||||
|
{
|
||||||
|
GobjectId = gobjectId,
|
||||||
|
AttributeName = fullTagReference.Split('.')[^1],
|
||||||
|
FullTagReference = fullTagReference,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>An <see cref="IGalaxyBrowseScopeProvider"/> that returns a fixed glob list.</summary>
|
||||||
|
private sealed class FakeBrowseScopeProvider(IReadOnlyList<string>? subtrees) : IGalaxyBrowseScopeProvider
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context) => subtrees;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Serves a fixed cache entry; never blocks on first load.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal in-memory <see cref="ServerCallContext"/> for direct service unit tests.</summary>
|
||||||
|
private sealed class TestServerCallContext : ServerCallContext
|
||||||
|
{
|
||||||
|
private readonly Metadata _requestHeaders = [];
|
||||||
|
private readonly Metadata _responseTrailers = [];
|
||||||
|
private readonly Dictionary<object, object> _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<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user