grpc: implement BrowseChildren handler + metadata:read scope

This commit is contained in:
Joseph Doherty
2026-05-28 13:08:45 -04:00
parent 87e22dd529
commit ba157b4b4f
4 changed files with 251 additions and 1 deletions
@@ -26,6 +26,8 @@ public sealed class GalaxyRepositoryGrpcService(
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
private const int DefaultDiscoverPageSize = 1000;
private const int MaxDiscoverPageSize = 5000;
private const int DefaultBrowsePageSize = 500;
// MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap.
/// <inheritdoc />
public override async Task<TestConnectionReply> TestConnection(
@@ -107,6 +109,62 @@ public sealed class GalaxyRepositoryGrpcService(
return reply;
}
/// <inheritdoc />
public override async Task<BrowseChildrenReply> BrowseChildren(
BrowseChildrenRequest request,
ServerCallContext context)
{
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData)
{
throw new RpcException(new Status(
StatusCode.Unavailable,
ResolveUnavailableMessage(entry)));
}
int pageSize = ResolveBrowsePageSize(request.PageSize);
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
// Resolve the parent id once so the page-token signature can include it
// and the projector sees the same resolved id when memoizing.
int parentId = ResolveParentIdForToken(entry, request);
string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(
request, browseSubtrees, parentId);
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
GalaxyDb.GalaxyBrowseChildrenResult result = GalaxyDb.GalaxyBrowseProjector.ProjectChildren(
entry,
request,
browseSubtrees,
pageToken.Offset,
pageSize);
if (pageToken.Offset > result.TotalChildCount)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"BrowseChildren page_token is outside the current children set."));
}
BrowseChildrenReply reply = new()
{
TotalChildCount = result.TotalChildCount,
CacheSequence = (ulong)entry.Sequence,
};
reply.Children.Add(result.Children);
reply.ChildHasChildren.Add(result.ChildHasChildren);
int nextOffset = pageToken.Offset + result.Children.Count;
if (nextOffset < result.TotalChildCount)
{
reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
}
return reply;
}
/// <inheritdoc />
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
@@ -213,6 +271,44 @@ public sealed class GalaxyRepositoryGrpcService(
return Math.Min(pageSize, MaxDiscoverPageSize);
}
private static int ResolveBrowsePageSize(int requested)
{
if (requested < 0)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"BrowseChildren page_size must be greater than zero when provided."));
}
int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
return Math.Min(pageSize, MaxDiscoverPageSize);
}
// Lightweight parent resolver used only for signature computation. Re-throws
// NotFound consistently with the projector so the error surface matches.
private static int ResolveParentIdForToken(
GalaxyDb.GalaxyHierarchyCacheEntry entry,
BrowseChildrenRequest request)
{
return request.ParentCase switch
{
BrowseChildrenRequest.ParentOneofCase.None => 0,
BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
request.ParentGobjectId == 0 ? 0
: entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
? request.ParentGobjectId
: throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
entry.Index.ObjectViews.FirstOrDefault(
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
entry.Index.ObjectViews.FirstOrDefault(
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
_ => 0,
};
}
private IReadOnlyList<string> ResolveBrowseSubtrees()
{
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
@@ -23,7 +23,8 @@ public sealed class GatewayGrpcScopeResolver
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
WatchDeployEventsRequest or
BrowseChildrenRequest => GatewayScopes.MetadataRead,
_ => GatewayScopes.Admin
};
}
@@ -4,6 +4,7 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Grpc;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
@@ -335,4 +336,155 @@ public sealed class GalaxyRepositoryGrpcServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Verifies that BrowseChildren returns root objects and the current cache sequence when called with no parent.</summary>
[Fact]
public async Task BrowseChildren_RootCall_ReturnsRootsWithCacheSequence()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply reply = await service.BrowseChildren(
new BrowseChildrenRequest(),
new TestServerCallContext());
Assert.Equal(2, reply.Children.Count);
Assert.Equal("Area1", reply.Children[0].TagName);
Assert.Equal("Other_001", reply.Children[1].TagName);
Assert.Equal(7UL, reply.CacheSequence);
Assert.Equal(2, reply.TotalChildCount);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
/// <summary>Verifies that BrowseChildren returns Unavailable when the cache's first load never completes.</summary>
[Fact]
public async Task BrowseChildren_FirstLoadNotComplete_ReturnsUnavailable()
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
GalaxyRepositoryGrpcService service = new(
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
new NeverLoadsHierarchyCache(),
new GalaxyDeployNotifier(),
new GatewayRequestIdentityAccessor(),
NullLogger<GalaxyRepositoryGrpcService>.Instance);
// No caller-supplied CT so WaitForCacheBootstrap exits via its 5s internal budget
// (instead of re-throwing OperationCanceledException from the caller's CT). The
// handler then sees Status=Unknown and returns Unavailable.
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.BrowseChildren(
new BrowseChildrenRequest(),
new TestServerCallContext()));
Assert.Equal(StatusCode.Unavailable, exception.StatusCode);
}
/// <summary>Verifies that a page token bound to a stale cache sequence is rejected with InvalidArgument.</summary>
[Fact]
public async Task BrowseChildren_StaleToken_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService firstService = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply firstReply = await firstService.BrowseChildren(
new BrowseChildrenRequest { PageSize = 1 },
new TestServerCallContext());
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
GalaxyHierarchyCacheEntry newerEntry = CreateEntry(CreateFilterObjects()) with { Sequence = 8 };
GalaxyRepositoryGrpcService secondService = CreateService(newerEntry);
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await secondService.BrowseChildren(
new BrowseChildrenRequest
{
PageSize = 1,
PageToken = firstReply.NextPageToken,
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("stale", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that switching filters between paged BrowseChildren calls is rejected.</summary>
[Fact]
public async Task BrowseChildren_FilterChangeBetweenPages_ReturnsInvalidArgument()
{
GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects()));
BrowseChildrenReply firstReply = await service.BrowseChildren(
new BrowseChildrenRequest
{
ParentGobjectId = 2,
PageSize = 1,
},
new TestServerCallContext());
Assert.False(string.IsNullOrEmpty(firstReply.NextPageToken));
RpcException exception = await Assert.ThrowsAsync<RpcException>(
async () => await service.BrowseChildren(
new BrowseChildrenRequest
{
ParentGobjectId = 2,
PageSize = 1,
PageToken = firstReply.NextPageToken,
TagNameGlob = "Pump*",
},
new TestServerCallContext()));
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that an ApiKeyIdentity browse-subtrees constraint that matches nothing produces an empty child list.</summary>
[Fact]
public async Task BrowseChildren_BrowseSubtreesConstraint_FiltersChildren()
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=localhost;Database=ZB;Integrated Security=True;Encrypt=False;",
};
GatewayRequestIdentityAccessor identityAccessor = new();
GalaxyRepositoryGrpcService service = new(
new global::ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepository(options),
new StubGalaxyHierarchyCache(CreateEntry(CreateFilterObjects())),
new GalaxyDeployNotifier(),
identityAccessor,
NullLogger<GalaxyRepositoryGrpcService>.Instance);
// Sanity: with no identity pushed, both Pump and Valve come back under Line3 (id=2).
BrowseChildrenReply unconstrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Equal(2, unconstrained.Children.Count);
ApiKeyIdentity identity = new(
KeyId: "test-key",
KeyPrefix: "mxgw_test",
DisplayName: "constraint-only",
Scopes: new HashSet<string>(StringComparer.Ordinal) { GatewayScopes.MetadataRead },
Constraints: ApiKeyConstraints.Empty with { BrowseSubtrees = new[] { "NonExistent" } });
using (identityAccessor.Push(identity))
{
BrowseChildrenReply constrained = await service.BrowseChildren(
new BrowseChildrenRequest { ParentGobjectId = 2 },
new TestServerCallContext());
Assert.Empty(constrained.Children);
Assert.Equal(0, constrained.TotalChildCount);
}
}
private sealed class NeverLoadsHierarchyCache : IGalaxyHierarchyCache
{
/// <inheritdoc />
public GalaxyHierarchyCacheEntry Current { get; } =
GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Unknown };
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) =>
Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
}
@@ -19,6 +19,7 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(WatchDeployEventsRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(BrowseChildrenRequest), GatewayScopes.MetadataRead)]
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
Type requestType,
string expectedScope)