From b995c174eb043e2db76c6bea7cba0393d7be35f4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 29 Apr 2026 13:37:00 -0400 Subject: [PATCH] Implement Galaxy filters and API key constraints --- .../GalaxyRepositoryClientTests.cs | 31 + .../DiscoverHierarchyOptions.cs | 24 + .../GalaxyRepositoryClient.cs | 59 +- clients/dotnet/README.md | 13 + .../generated/galaxy_repository.pb.go | 198 +- .../v1/GalaxyRepositoryOuterClass.java | 1977 ++++++++++++++++- .../descriptors/mxaccessgw-client-v1.protoset | Bin 63627 -> 71261 bytes .../generated/galaxy_repository_pb2.py | 47 +- clients/rust/src/galaxy.rs | 1 + docs/Authentication.md | 29 +- docs/Authorization.md | 47 +- docs/GalaxyRepository.md | 32 +- .../Generated/GalaxyRepository.cs | 569 ++++- .../Protos/galaxy_repository.proto | 23 + .../WorkerLiveMxAccessSmokeTests.cs | 31 + .../Components/Layout/DashboardLayout.razor | 3 + .../Components/Pages/ApiKeysPage.razor | 99 + .../Dashboard/DashboardApiKeySummary.cs | 12 + .../Dashboard/DashboardSnapshot.cs | 1 + .../Dashboard/DashboardSnapshotService.cs | 28 + .../Galaxy/GalaxyGlobMatcher.cs | 44 + .../Galaxy/GalaxyHierarchyProjector.cs | 287 +++ .../Galaxy/GalaxyHierarchyQueryResult.cs | 8 + .../Grpc/GalaxyRepositoryGrpcService.cs | 76 +- .../Grpc/MxAccessGatewayService.cs | 347 ++- .../Authentication/ApiKeyAdminCliRunner.cs | 2 + .../Authentication/ApiKeyAdminCommand.cs | 3 +- .../ApiKeyAdminCommandLineParser.cs | 88 +- .../Authentication/ApiKeyAdminListedKey.cs | 1 + .../ApiKeyConstraintSerializer.cs | 28 + .../Authentication/ApiKeyConstraints.cs | 43 + .../Authentication/ApiKeyCreateRequest.cs | 1 + .../Security/Authentication/ApiKeyIdentity.cs | 6 +- .../Security/Authentication/ApiKeyRecord.cs | 1 + .../Authentication/ApiKeyRecordReader.cs | 7 +- .../Security/Authentication/ApiKeyVerifier.cs | 3 +- .../Authentication/SqliteApiKeyAdminStore.cs | 7 +- .../Authentication/SqliteApiKeyStore.cs | 4 +- .../Authentication/SqliteAuthSchema.cs | 2 +- .../Authentication/SqliteAuthStoreMigrator.cs | 56 + .../Authorization/ConstraintEnforcer.cs | 160 ++ .../Authorization/ConstraintFailure.cs | 3 + ...uthorizationServiceCollectionExtensions.cs | 1 + .../Authorization/IConstraintEnforcer.cs | 33 + .../Sessions/GatewaySession.cs | 87 + .../Sessions/SessionItemRegistration.cs | 6 + .../DashboardSnapshotServiceTests.cs | 32 + .../GatewayEndToEndFakeWorkerSmokeTests.cs | 31 + .../Grpc/GalaxyRepositoryGrpcServiceTests.cs | 202 +- .../Grpc/MxAccessGatewayServiceTests.cs | 30 + .../ApiKeyAdminCliRunnerTests.cs | 53 +- .../ApiKeyAdminCommandLineParserTests.cs | 36 + .../Authentication/ApiKeyVerifierTests.cs | 1 + .../Authorization/ConstraintEnforcerTests.cs | 179 ++ 54 files changed, 4889 insertions(+), 203 deletions(-) create mode 100644 clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs create mode 100644 src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor create mode 100644 src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs create mode 100644 src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs create mode 100644 src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs create mode 100644 src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs create mode 100644 src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs create mode 100644 src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs create mode 100644 src/MxGateway.Server/Sessions/SessionItemRegistration.cs create mode 100644 src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs diff --git a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs index c1cb75a..25672fb 100644 --- a/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs +++ b/clients/dotnet/MxGateway.Client.Tests/GalaxyRepositoryClientTests.cs @@ -160,6 +160,37 @@ public sealed class GalaxyRepositoryClientTests Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters() + { + FakeGalaxyRepositoryTransport transport = CreateTransport(); + await using GalaxyRepositoryClient client = CreateClient(transport); + + await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions + { + RootContainedPath = "Area1/Line3", + MaxDepth = 2, + CategoryIds = [10, 13], + TemplateChainContains = ["Pump"], + TagNameGlob = "Pump_*", + IncludeAttributes = false, + AlarmBearingOnly = true, + HistorizedOnly = true, + }); + + DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request; + Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase); + Assert.Equal("Area1/Line3", request.RootContainedPath); + Assert.Equal(2, request.MaxDepth); + Assert.Equal([10, 13], request.CategoryIds); + Assert.Equal(["Pump"], request.TemplateChainContains); + Assert.Equal("Pump_*", request.TagNameGlob); + Assert.True(request.HasIncludeAttributes); + Assert.False(request.IncludeAttributes); + Assert.True(request.AlarmBearingOnly); + Assert.True(request.HistorizedOnly); + } + [Fact] public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure() { diff --git a/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs b/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs new file mode 100644 index 0000000..2ef067f --- /dev/null +++ b/clients/dotnet/MxGateway.Client/DiscoverHierarchyOptions.cs @@ -0,0 +1,24 @@ +namespace MxGateway.Client; + +public sealed record DiscoverHierarchyOptions +{ + public int? RootGobjectId { get; init; } + + public string? RootTagName { get; init; } + + public string? RootContainedPath { get; init; } + + public int? MaxDepth { get; init; } + + public IReadOnlyList CategoryIds { get; init; } = Array.Empty(); + + public IReadOnlyList TemplateChainContains { get; init; } = Array.Empty(); + + public string? TagNameGlob { get; init; } + + public bool? IncludeAttributes { get; init; } + + public bool AlarmBearingOnly { get; init; } + + public bool HistorizedOnly { get; init; } +} diff --git a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs index 09aa041..60467a3 100644 --- a/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs +++ b/clients/dotnet/MxGateway.Client/GalaxyRepositoryClient.cs @@ -144,18 +144,24 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable /// they may subscribe to via the MxAccessGateway service. /// public async Task> DiscoverHierarchyAsync(CancellationToken cancellationToken = default) + { + return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false); + } + + public async Task> DiscoverHierarchyAsync( + DiscoverHierarchyOptions options, + CancellationToken cancellationToken = default) { List objects = []; HashSet seenPageTokens = new(StringComparer.Ordinal); string pageToken = string.Empty; do { + DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options); + request.PageSize = DiscoverHierarchyPageSize; + request.PageToken = pageToken; DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync( - new DiscoverHierarchyRequest - { - PageSize = DiscoverHierarchyPageSize, - PageToken = pageToken, - }, + request, cancellationToken) .ConfigureAwait(false); @@ -173,6 +179,49 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable return objects; } + private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + DiscoverHierarchyRequest request = new() + { + AlarmBearingOnly = options.AlarmBearingOnly, + HistorizedOnly = options.HistorizedOnly, + }; + + if (options.RootGobjectId.HasValue) + { + request.RootGobjectId = options.RootGobjectId.Value; + } + else if (!string.IsNullOrWhiteSpace(options.RootTagName)) + { + request.RootTagName = options.RootTagName; + } + else if (!string.IsNullOrWhiteSpace(options.RootContainedPath)) + { + request.RootContainedPath = options.RootContainedPath; + } + + if (options.MaxDepth.HasValue) + { + request.MaxDepth = options.MaxDepth.Value; + } + + request.CategoryIds.Add(options.CategoryIds); + request.TemplateChainContains.Add(options.TemplateChainContains); + if (!string.IsNullOrWhiteSpace(options.TagNameGlob)) + { + request.TagNameGlob = options.TagNameGlob; + } + + if (options.IncludeAttributes.HasValue) + { + request.IncludeAttributes = options.IncludeAttributes.Value; + } + + return request; + } + public Task DiscoverHierarchyRawAsync( DiscoverHierarchyRequest request, CancellationToken cancellationToken = default) diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index 60f4915..16e0215 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -164,6 +164,19 @@ foreach (GalaxyObject galaxyObject in objects) } ``` +Use `DiscoverHierarchyOptions` to request a server-side slice without pulling +the full Galaxy: + +```csharp +IReadOnlyList pumps = await repository.DiscoverHierarchyAsync( + new DiscoverHierarchyOptions + { + RootContainedPath = "Area1/Line3", + TagNameGlob = "Pump_*", + IncludeAttributes = false, + }); +``` + The CLI exposes the same operations: ```powershell diff --git a/clients/go/internal/generated/galaxy_repository.pb.go b/clients/go/internal/generated/galaxy_repository.pb.go index 20e9ea8..ca3370c 100644 --- a/clients/go/internal/generated/galaxy_repository.pb.go +++ b/clients/go/internal/generated/galaxy_repository.pb.go @@ -10,6 +10,7 @@ import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -196,9 +197,33 @@ type DiscoverHierarchyRequest struct { // unset and rejects non-positive values. PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // Opaque token returned by a previous DiscoverHierarchy response. - PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // Optional. When set, return only this object and its descendants. + // Empty = full hierarchy. + // + // Types that are valid to be assigned to Root: + // + // *DiscoverHierarchyRequest_RootGobjectId + // *DiscoverHierarchyRequest_RootTagName + // *DiscoverHierarchyRequest_RootContainedPath + Root isDiscoverHierarchyRequest_Root `protobuf_oneof:"root"` + // Optional. Cap on descendant depth from root. Zero returns only the root. + // Unset means unlimited depth. + MaxDepth *wrapperspb.Int32Value `protobuf:"bytes,6,opt,name=max_depth,json=maxDepth,proto3" json:"max_depth,omitempty"` + // Optional object category id filters. + CategoryIds []int32 `protobuf:"varint,7,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"` + // Optional case-insensitive substring filters against template names. + TemplateChainContains []string `protobuf:"bytes,8,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"` + // Optional anchored, case-insensitive glob over object tag_name. + TagNameGlob string `protobuf:"bytes,9,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"` + // Optional. Unset or true includes attributes. False returns object skeletons. + IncludeAttributes *bool `protobuf:"varint,10,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"` + // Optional. Return only objects with at least one alarm-bearing attribute. + AlarmBearingOnly bool `protobuf:"varint,11,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"` + // Optional. Return only objects with at least one historized attribute. + HistorizedOnly bool `protobuf:"varint,12,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DiscoverHierarchyRequest) Reset() { @@ -245,6 +270,111 @@ func (x *DiscoverHierarchyRequest) GetPageToken() string { return "" } +func (x *DiscoverHierarchyRequest) GetRoot() isDiscoverHierarchyRequest_Root { + if x != nil { + return x.Root + } + return nil +} + +func (x *DiscoverHierarchyRequest) GetRootGobjectId() int32 { + if x != nil { + if x, ok := x.Root.(*DiscoverHierarchyRequest_RootGobjectId); ok { + return x.RootGobjectId + } + } + return 0 +} + +func (x *DiscoverHierarchyRequest) GetRootTagName() string { + if x != nil { + if x, ok := x.Root.(*DiscoverHierarchyRequest_RootTagName); ok { + return x.RootTagName + } + } + return "" +} + +func (x *DiscoverHierarchyRequest) GetRootContainedPath() string { + if x != nil { + if x, ok := x.Root.(*DiscoverHierarchyRequest_RootContainedPath); ok { + return x.RootContainedPath + } + } + return "" +} + +func (x *DiscoverHierarchyRequest) GetMaxDepth() *wrapperspb.Int32Value { + if x != nil { + return x.MaxDepth + } + return nil +} + +func (x *DiscoverHierarchyRequest) GetCategoryIds() []int32 { + if x != nil { + return x.CategoryIds + } + return nil +} + +func (x *DiscoverHierarchyRequest) GetTemplateChainContains() []string { + if x != nil { + return x.TemplateChainContains + } + return nil +} + +func (x *DiscoverHierarchyRequest) GetTagNameGlob() string { + if x != nil { + return x.TagNameGlob + } + return "" +} + +func (x *DiscoverHierarchyRequest) GetIncludeAttributes() bool { + if x != nil && x.IncludeAttributes != nil { + return *x.IncludeAttributes + } + return false +} + +func (x *DiscoverHierarchyRequest) GetAlarmBearingOnly() bool { + if x != nil { + return x.AlarmBearingOnly + } + return false +} + +func (x *DiscoverHierarchyRequest) GetHistorizedOnly() bool { + if x != nil { + return x.HistorizedOnly + } + return false +} + +type isDiscoverHierarchyRequest_Root interface { + isDiscoverHierarchyRequest_Root() +} + +type DiscoverHierarchyRequest_RootGobjectId struct { + RootGobjectId int32 `protobuf:"varint,3,opt,name=root_gobject_id,json=rootGobjectId,proto3,oneof"` +} + +type DiscoverHierarchyRequest_RootTagName struct { + RootTagName string `protobuf:"bytes,4,opt,name=root_tag_name,json=rootTagName,proto3,oneof"` +} + +type DiscoverHierarchyRequest_RootContainedPath struct { + RootContainedPath string `protobuf:"bytes,5,opt,name=root_contained_path,json=rootContainedPath,proto3,oneof"` +} + +func (*DiscoverHierarchyRequest_RootGobjectId) isDiscoverHierarchyRequest_Root() {} + +func (*DiscoverHierarchyRequest_RootTagName) isDiscoverHierarchyRequest_Root() {} + +func (*DiscoverHierarchyRequest_RootContainedPath) isDiscoverHierarchyRequest_Root() {} + type DiscoverHierarchyReply struct { state protoimpl.MessageState `protogen:"open.v1"` Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"` @@ -684,18 +814,31 @@ var File_galaxy_repository_proto protoreflect.FileDescriptor const file_galaxy_repository_proto_rawDesc = "" + "\n" + - "\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n" + + "\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n" + "\x15TestConnectionRequest\"%\n" + "\x13TestConnectionReply\x12\x0e\n" + "\x02ok\x18\x01 \x01(\bR\x02ok\"\x1a\n" + "\x18GetLastDeployTimeRequest\"}\n" + "\x16GetLastDeployTimeReply\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12I\n" + - "\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"V\n" + + "\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\xbb\x04\n" + "\x18DiscoverHierarchyRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x02 \x01(\tR\tpageToken\"\xac\x01\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\x12(\n" + + "\x0froot_gobject_id\x18\x03 \x01(\x05H\x00R\rrootGobjectId\x12$\n" + + "\rroot_tag_name\x18\x04 \x01(\tH\x00R\vrootTagName\x120\n" + + "\x13root_contained_path\x18\x05 \x01(\tH\x00R\x11rootContainedPath\x128\n" + + "\tmax_depth\x18\x06 \x01(\v2\x1b.google.protobuf.Int32ValueR\bmaxDepth\x12!\n" + + "\fcategory_ids\x18\a \x03(\x05R\vcategoryIds\x126\n" + + "\x17template_chain_contains\x18\b \x03(\tR\x15templateChainContains\x12\"\n" + + "\rtag_name_glob\x18\t \x01(\tR\vtagNameGlob\x122\n" + + "\x12include_attributes\x18\n" + + " \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" + + "\x12alarm_bearing_only\x18\v \x01(\bR\x10alarmBearingOnly\x12'\n" + + "\x0fhistorized_only\x18\f \x01(\bR\x0ehistorizedOnlyB\x06\n" + + "\x04rootB\x15\n" + + "\x13_include_attributes\"\xac\x01\n" + "\x16DiscoverHierarchyReply\x12<\n" + "\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12,\n" + @@ -772,27 +915,29 @@ var file_galaxy_repository_proto_goTypes = []any{ (*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject (*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute (*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp + (*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value } var file_galaxy_repository_proto_depIdxs = []int32{ 10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp - 8, // 1: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject - 10, // 2: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp - 10, // 3: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp - 10, // 4: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp - 9, // 5: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute - 0, // 6: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest - 2, // 7: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest - 4, // 8: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest - 6, // 9: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest - 1, // 10: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply - 3, // 11: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply - 5, // 12: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply - 7, // 13: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent - 10, // [10:14] is the sub-list for method output_type - 6, // [6:10] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value + 8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject + 10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp + 10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp + 10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp + 9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute + 0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest + 2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest + 4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest + 6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest + 1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply + 3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply + 5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply + 7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent + 11, // [11:15] is the sub-list for method output_type + 7, // [7:11] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_galaxy_repository_proto_init() } @@ -800,6 +945,11 @@ func file_galaxy_repository_proto_init() { if File_galaxy_repository_proto != nil { return } + file_galaxy_repository_proto_msgTypes[4].OneofWrappers = []any{ + (*DiscoverHierarchyRequest_RootGobjectId)(nil), + (*DiscoverHierarchyRequest_RootTagName)(nil), + (*DiscoverHierarchyRequest_RootContainedPath)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java b/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java index 9a93cdd..be8f55c 100644 --- a/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java +++ b/clients/java/src/main/generated/main/java/galaxy_repository/v1/GalaxyRepositoryOuterClass.java @@ -1848,6 +1848,212 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera */ com.google.protobuf.ByteString getPageTokenBytes(); + + /** + * int32 root_gobject_id = 3; + * @return Whether the rootGobjectId field is set. + */ + boolean hasRootGobjectId(); + /** + * int32 root_gobject_id = 3; + * @return The rootGobjectId. + */ + int getRootGobjectId(); + + /** + * string root_tag_name = 4; + * @return Whether the rootTagName field is set. + */ + boolean hasRootTagName(); + /** + * string root_tag_name = 4; + * @return The rootTagName. + */ + java.lang.String getRootTagName(); + /** + * string root_tag_name = 4; + * @return The bytes for rootTagName. + */ + com.google.protobuf.ByteString + getRootTagNameBytes(); + + /** + * string root_contained_path = 5; + * @return Whether the rootContainedPath field is set. + */ + boolean hasRootContainedPath(); + /** + * string root_contained_path = 5; + * @return The rootContainedPath. + */ + java.lang.String getRootContainedPath(); + /** + * string root_contained_path = 5; + * @return The bytes for rootContainedPath. + */ + com.google.protobuf.ByteString + getRootContainedPathBytes(); + + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return Whether the maxDepth field is set. + */ + boolean hasMaxDepth(); + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return The maxDepth. + */ + com.google.protobuf.Int32Value getMaxDepth(); + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + com.google.protobuf.Int32ValueOrBuilder getMaxDepthOrBuilder(); + + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @return A list containing the categoryIds. + */ + java.util.List getCategoryIdsList(); + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @return The count of categoryIds. + */ + int getCategoryIdsCount(); + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @param index The index of the element to return. + * @return The categoryIds at the given index. + */ + int getCategoryIds(int index); + + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @return A list containing the templateChainContains. + */ + java.util.List + getTemplateChainContainsList(); + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @return The count of templateChainContains. + */ + int getTemplateChainContainsCount(); + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the element to return. + * @return The templateChainContains at the given index. + */ + java.lang.String getTemplateChainContains(int index); + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the value to return. + * @return The bytes of the templateChainContains at the given index. + */ + com.google.protobuf.ByteString + getTemplateChainContainsBytes(int index); + + /** + *
+     * Optional anchored, case-insensitive glob over object tag_name.
+     * 
+ * + * string tag_name_glob = 9; + * @return The tagNameGlob. + */ + java.lang.String getTagNameGlob(); + /** + *
+     * Optional anchored, case-insensitive glob over object tag_name.
+     * 
+ * + * string tag_name_glob = 9; + * @return The bytes for tagNameGlob. + */ + com.google.protobuf.ByteString + getTagNameGlobBytes(); + + /** + *
+     * Optional. Unset or true includes attributes. False returns object skeletons.
+     * 
+ * + * optional bool include_attributes = 10; + * @return Whether the includeAttributes field is set. + */ + boolean hasIncludeAttributes(); + /** + *
+     * Optional. Unset or true includes attributes. False returns object skeletons.
+     * 
+ * + * optional bool include_attributes = 10; + * @return The includeAttributes. + */ + boolean getIncludeAttributes(); + + /** + *
+     * Optional. Return only objects with at least one alarm-bearing attribute.
+     * 
+ * + * bool alarm_bearing_only = 11; + * @return The alarmBearingOnly. + */ + boolean getAlarmBearingOnly(); + + /** + *
+     * Optional. Return only objects with at least one historized attribute.
+     * 
+ * + * bool historized_only = 12; + * @return The historizedOnly. + */ + boolean getHistorizedOnly(); + + galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.RootCase getRootCase(); } /** * Protobuf type {@code galaxy_repository.v1.DiscoverHierarchyRequest} @@ -1872,6 +2078,10 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } private DiscoverHierarchyRequest() { pageToken_ = ""; + categoryIds_ = emptyIntList(); + templateChainContains_ = + com.google.protobuf.LazyStringArrayList.emptyList(); + tagNameGlob_ = ""; } public static final com.google.protobuf.Descriptors.Descriptor @@ -1887,6 +2097,51 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.Builder.class); } + private int bitField0_; + private int rootCase_ = 0; + @SuppressWarnings("serial") + private java.lang.Object root_; + public enum RootCase + implements com.google.protobuf.Internal.EnumLite, + com.google.protobuf.AbstractMessage.InternalOneOfEnum { + ROOT_GOBJECT_ID(3), + ROOT_TAG_NAME(4), + ROOT_CONTAINED_PATH(5), + ROOT_NOT_SET(0); + private final int value; + private RootCase(int value) { + this.value = value; + } + /** + * @param value The number of the enum to look for. + * @return The enum associated with the given number. + * @deprecated Use {@link #forNumber(int)} instead. + */ + @java.lang.Deprecated + public static RootCase valueOf(int value) { + return forNumber(value); + } + + public static RootCase forNumber(int value) { + switch (value) { + case 3: return ROOT_GOBJECT_ID; + case 4: return ROOT_TAG_NAME; + case 5: return ROOT_CONTAINED_PATH; + case 0: return ROOT_NOT_SET; + default: return null; + } + } + public int getNumber() { + return this.value; + } + }; + + public RootCase + getRootCase() { + return RootCase.forNumber( + rootCase_); + } + public static final int PAGE_SIZE_FIELD_NUMBER = 1; private int pageSize_ = 0; /** @@ -1950,6 +2205,371 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } } + public static final int ROOT_GOBJECT_ID_FIELD_NUMBER = 3; + /** + * int32 root_gobject_id = 3; + * @return Whether the rootGobjectId field is set. + */ + @java.lang.Override + public boolean hasRootGobjectId() { + return rootCase_ == 3; + } + /** + * int32 root_gobject_id = 3; + * @return The rootGobjectId. + */ + @java.lang.Override + public int getRootGobjectId() { + if (rootCase_ == 3) { + return (java.lang.Integer) root_; + } + return 0; + } + + public static final int ROOT_TAG_NAME_FIELD_NUMBER = 4; + /** + * string root_tag_name = 4; + * @return Whether the rootTagName field is set. + */ + public boolean hasRootTagName() { + return rootCase_ == 4; + } + /** + * string root_tag_name = 4; + * @return The rootTagName. + */ + public java.lang.String getRootTagName() { + java.lang.Object ref = ""; + if (rootCase_ == 4) { + ref = root_; + } + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (rootCase_ == 4) { + root_ = s; + } + return s; + } + } + /** + * string root_tag_name = 4; + * @return The bytes for rootTagName. + */ + public com.google.protobuf.ByteString + getRootTagNameBytes() { + java.lang.Object ref = ""; + if (rootCase_ == 4) { + ref = root_; + } + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + if (rootCase_ == 4) { + root_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int ROOT_CONTAINED_PATH_FIELD_NUMBER = 5; + /** + * string root_contained_path = 5; + * @return Whether the rootContainedPath field is set. + */ + public boolean hasRootContainedPath() { + return rootCase_ == 5; + } + /** + * string root_contained_path = 5; + * @return The rootContainedPath. + */ + public java.lang.String getRootContainedPath() { + java.lang.Object ref = ""; + if (rootCase_ == 5) { + ref = root_; + } + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (rootCase_ == 5) { + root_ = s; + } + return s; + } + } + /** + * string root_contained_path = 5; + * @return The bytes for rootContainedPath. + */ + public com.google.protobuf.ByteString + getRootContainedPathBytes() { + java.lang.Object ref = ""; + if (rootCase_ == 5) { + ref = root_; + } + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + if (rootCase_ == 5) { + root_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int MAX_DEPTH_FIELD_NUMBER = 6; + private com.google.protobuf.Int32Value maxDepth_; + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return Whether the maxDepth field is set. + */ + @java.lang.Override + public boolean hasMaxDepth() { + return ((bitField0_ & 0x00000001) != 0); + } + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return The maxDepth. + */ + @java.lang.Override + public com.google.protobuf.Int32Value getMaxDepth() { + return maxDepth_ == null ? com.google.protobuf.Int32Value.getDefaultInstance() : maxDepth_; + } + /** + *
+     * Optional. Cap on descendant depth from root. Zero returns only the root.
+     * Unset means unlimited depth.
+     * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + @java.lang.Override + public com.google.protobuf.Int32ValueOrBuilder getMaxDepthOrBuilder() { + return maxDepth_ == null ? com.google.protobuf.Int32Value.getDefaultInstance() : maxDepth_; + } + + public static final int CATEGORY_IDS_FIELD_NUMBER = 7; + @SuppressWarnings("serial") + private com.google.protobuf.Internal.IntList categoryIds_ = + emptyIntList(); + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @return A list containing the categoryIds. + */ + @java.lang.Override + public java.util.List + getCategoryIdsList() { + return categoryIds_; + } + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @return The count of categoryIds. + */ + public int getCategoryIdsCount() { + return categoryIds_.size(); + } + /** + *
+     * Optional object category id filters.
+     * 
+ * + * repeated int32 category_ids = 7; + * @param index The index of the element to return. + * @return The categoryIds at the given index. + */ + public int getCategoryIds(int index) { + return categoryIds_.getInt(index); + } + private int categoryIdsMemoizedSerializedSize = -1; + + public static final int TEMPLATE_CHAIN_CONTAINS_FIELD_NUMBER = 8; + @SuppressWarnings("serial") + private com.google.protobuf.LazyStringArrayList templateChainContains_ = + com.google.protobuf.LazyStringArrayList.emptyList(); + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @return A list containing the templateChainContains. + */ + public com.google.protobuf.ProtocolStringList + getTemplateChainContainsList() { + return templateChainContains_; + } + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @return The count of templateChainContains. + */ + public int getTemplateChainContainsCount() { + return templateChainContains_.size(); + } + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the element to return. + * @return The templateChainContains at the given index. + */ + public java.lang.String getTemplateChainContains(int index) { + return templateChainContains_.get(index); + } + /** + *
+     * Optional case-insensitive substring filters against template names.
+     * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the value to return. + * @return The bytes of the templateChainContains at the given index. + */ + public com.google.protobuf.ByteString + getTemplateChainContainsBytes(int index) { + return templateChainContains_.getByteString(index); + } + + public static final int TAG_NAME_GLOB_FIELD_NUMBER = 9; + @SuppressWarnings("serial") + private volatile java.lang.Object tagNameGlob_ = ""; + /** + *
+     * Optional anchored, case-insensitive glob over object tag_name.
+     * 
+ * + * string tag_name_glob = 9; + * @return The tagNameGlob. + */ + @java.lang.Override + public java.lang.String getTagNameGlob() { + java.lang.Object ref = tagNameGlob_; + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + tagNameGlob_ = s; + return s; + } + } + /** + *
+     * Optional anchored, case-insensitive glob over object tag_name.
+     * 
+ * + * string tag_name_glob = 9; + * @return The bytes for tagNameGlob. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getTagNameGlobBytes() { + java.lang.Object ref = tagNameGlob_; + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + tagNameGlob_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + public static final int INCLUDE_ATTRIBUTES_FIELD_NUMBER = 10; + private boolean includeAttributes_ = false; + /** + *
+     * Optional. Unset or true includes attributes. False returns object skeletons.
+     * 
+ * + * optional bool include_attributes = 10; + * @return Whether the includeAttributes field is set. + */ + @java.lang.Override + public boolean hasIncludeAttributes() { + return ((bitField0_ & 0x00000002) != 0); + } + /** + *
+     * Optional. Unset or true includes attributes. False returns object skeletons.
+     * 
+ * + * optional bool include_attributes = 10; + * @return The includeAttributes. + */ + @java.lang.Override + public boolean getIncludeAttributes() { + return includeAttributes_; + } + + public static final int ALARM_BEARING_ONLY_FIELD_NUMBER = 11; + private boolean alarmBearingOnly_ = false; + /** + *
+     * Optional. Return only objects with at least one alarm-bearing attribute.
+     * 
+ * + * bool alarm_bearing_only = 11; + * @return The alarmBearingOnly. + */ + @java.lang.Override + public boolean getAlarmBearingOnly() { + return alarmBearingOnly_; + } + + public static final int HISTORIZED_ONLY_FIELD_NUMBER = 12; + private boolean historizedOnly_ = false; + /** + *
+     * Optional. Return only objects with at least one historized attribute.
+     * 
+ * + * bool historized_only = 12; + * @return The historizedOnly. + */ + @java.lang.Override + public boolean getHistorizedOnly() { + return historizedOnly_; + } + private byte memoizedIsInitialized = -1; @java.lang.Override public final boolean isInitialized() { @@ -1964,12 +2584,48 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera @java.lang.Override public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io.IOException { + getSerializedSize(); if (pageSize_ != 0) { output.writeInt32(1, pageSize_); } if (!com.google.protobuf.GeneratedMessage.isStringEmpty(pageToken_)) { com.google.protobuf.GeneratedMessage.writeString(output, 2, pageToken_); } + if (rootCase_ == 3) { + output.writeInt32( + 3, (int)((java.lang.Integer) root_)); + } + if (rootCase_ == 4) { + com.google.protobuf.GeneratedMessage.writeString(output, 4, root_); + } + if (rootCase_ == 5) { + com.google.protobuf.GeneratedMessage.writeString(output, 5, root_); + } + if (((bitField0_ & 0x00000001) != 0)) { + output.writeMessage(6, getMaxDepth()); + } + if (getCategoryIdsList().size() > 0) { + output.writeUInt32NoTag(58); + output.writeUInt32NoTag(categoryIdsMemoizedSerializedSize); + } + for (int i = 0; i < categoryIds_.size(); i++) { + output.writeInt32NoTag(categoryIds_.getInt(i)); + } + for (int i = 0; i < templateChainContains_.size(); i++) { + com.google.protobuf.GeneratedMessage.writeString(output, 8, templateChainContains_.getRaw(i)); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(tagNameGlob_)) { + com.google.protobuf.GeneratedMessage.writeString(output, 9, tagNameGlob_); + } + if (((bitField0_ & 0x00000002) != 0)) { + output.writeBool(10, includeAttributes_); + } + if (alarmBearingOnly_ != false) { + output.writeBool(11, alarmBearingOnly_); + } + if (historizedOnly_ != false) { + output.writeBool(12, historizedOnly_); + } getUnknownFields().writeTo(output); } @@ -1986,6 +2642,58 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (!com.google.protobuf.GeneratedMessage.isStringEmpty(pageToken_)) { size += com.google.protobuf.GeneratedMessage.computeStringSize(2, pageToken_); } + if (rootCase_ == 3) { + size += com.google.protobuf.CodedOutputStream + .computeInt32Size( + 3, (int)((java.lang.Integer) root_)); + } + if (rootCase_ == 4) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(4, root_); + } + if (rootCase_ == 5) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(5, root_); + } + if (((bitField0_ & 0x00000001) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeMessageSize(6, getMaxDepth()); + } + { + int dataSize = 0; + for (int i = 0; i < categoryIds_.size(); i++) { + dataSize += com.google.protobuf.CodedOutputStream + .computeInt32SizeNoTag(categoryIds_.getInt(i)); + } + size += dataSize; + if (!getCategoryIdsList().isEmpty()) { + size += 1; + size += com.google.protobuf.CodedOutputStream + .computeInt32SizeNoTag(dataSize); + } + categoryIdsMemoizedSerializedSize = dataSize; + } + { + int dataSize = 0; + for (int i = 0; i < templateChainContains_.size(); i++) { + dataSize += computeStringSizeNoTag(templateChainContains_.getRaw(i)); + } + size += dataSize; + size += 1 * getTemplateChainContainsList().size(); + } + if (!com.google.protobuf.GeneratedMessage.isStringEmpty(tagNameGlob_)) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(9, tagNameGlob_); + } + if (((bitField0_ & 0x00000002) != 0)) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(10, includeAttributes_); + } + if (alarmBearingOnly_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(11, alarmBearingOnly_); + } + if (historizedOnly_ != false) { + size += com.google.protobuf.CodedOutputStream + .computeBoolSize(12, historizedOnly_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -2005,6 +2713,43 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera != other.getPageSize()) return false; if (!getPageToken() .equals(other.getPageToken())) return false; + if (hasMaxDepth() != other.hasMaxDepth()) return false; + if (hasMaxDepth()) { + if (!getMaxDepth() + .equals(other.getMaxDepth())) return false; + } + if (!getCategoryIdsList() + .equals(other.getCategoryIdsList())) return false; + if (!getTemplateChainContainsList() + .equals(other.getTemplateChainContainsList())) return false; + if (!getTagNameGlob() + .equals(other.getTagNameGlob())) return false; + if (hasIncludeAttributes() != other.hasIncludeAttributes()) return false; + if (hasIncludeAttributes()) { + if (getIncludeAttributes() + != other.getIncludeAttributes()) return false; + } + if (getAlarmBearingOnly() + != other.getAlarmBearingOnly()) return false; + if (getHistorizedOnly() + != other.getHistorizedOnly()) return false; + if (!getRootCase().equals(other.getRootCase())) return false; + switch (rootCase_) { + case 3: + if (getRootGobjectId() + != other.getRootGobjectId()) return false; + break; + case 4: + if (!getRootTagName() + .equals(other.getRootTagName())) return false; + break; + case 5: + if (!getRootContainedPath() + .equals(other.getRootContainedPath())) return false; + break; + case 0: + default: + } if (!getUnknownFields().equals(other.getUnknownFields())) return false; return true; } @@ -2020,6 +2765,47 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera hash = (53 * hash) + getPageSize(); hash = (37 * hash) + PAGE_TOKEN_FIELD_NUMBER; hash = (53 * hash) + getPageToken().hashCode(); + if (hasMaxDepth()) { + hash = (37 * hash) + MAX_DEPTH_FIELD_NUMBER; + hash = (53 * hash) + getMaxDepth().hashCode(); + } + if (getCategoryIdsCount() > 0) { + hash = (37 * hash) + CATEGORY_IDS_FIELD_NUMBER; + hash = (53 * hash) + getCategoryIdsList().hashCode(); + } + if (getTemplateChainContainsCount() > 0) { + hash = (37 * hash) + TEMPLATE_CHAIN_CONTAINS_FIELD_NUMBER; + hash = (53 * hash) + getTemplateChainContainsList().hashCode(); + } + hash = (37 * hash) + TAG_NAME_GLOB_FIELD_NUMBER; + hash = (53 * hash) + getTagNameGlob().hashCode(); + if (hasIncludeAttributes()) { + hash = (37 * hash) + INCLUDE_ATTRIBUTES_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getIncludeAttributes()); + } + hash = (37 * hash) + ALARM_BEARING_ONLY_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getAlarmBearingOnly()); + hash = (37 * hash) + HISTORIZED_ONLY_FIELD_NUMBER; + hash = (53 * hash) + com.google.protobuf.Internal.hashBoolean( + getHistorizedOnly()); + switch (rootCase_) { + case 3: + hash = (37 * hash) + ROOT_GOBJECT_ID_FIELD_NUMBER; + hash = (53 * hash) + getRootGobjectId(); + break; + case 4: + hash = (37 * hash) + ROOT_TAG_NAME_FIELD_NUMBER; + hash = (53 * hash) + getRootTagName().hashCode(); + break; + case 5: + hash = (37 * hash) + ROOT_CONTAINED_PATH_FIELD_NUMBER; + hash = (53 * hash) + getRootContainedPath().hashCode(); + break; + case 0: + default: + } hash = (29 * hash) + getUnknownFields().hashCode(); memoizedHashCode = hash; return hash; @@ -2139,13 +2925,19 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera // Construct using galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.newBuilder() private Builder() { - + maybeForceBuilderInitialization(); } private Builder( com.google.protobuf.GeneratedMessage.BuilderParent parent) { super(parent); - + maybeForceBuilderInitialization(); + } + private void maybeForceBuilderInitialization() { + if (com.google.protobuf.GeneratedMessage + .alwaysUseFieldBuilders) { + internalGetMaxDepthFieldBuilder(); + } } @java.lang.Override public Builder clear() { @@ -2153,6 +2945,20 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera bitField0_ = 0; pageSize_ = 0; pageToken_ = ""; + maxDepth_ = null; + if (maxDepthBuilder_ != null) { + maxDepthBuilder_.dispose(); + maxDepthBuilder_ = null; + } + categoryIds_ = emptyIntList(); + templateChainContains_ = + com.google.protobuf.LazyStringArrayList.emptyList(); + tagNameGlob_ = ""; + includeAttributes_ = false; + alarmBearingOnly_ = false; + historizedOnly_ = false; + rootCase_ = 0; + root_ = null; return this; } @@ -2180,6 +2986,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest buildPartial() { galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest result = new galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest(this); if (bitField0_ != 0) { buildPartial0(result); } + buildPartialOneofs(result); onBuilt(); return result; } @@ -2192,6 +2999,40 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera if (((from_bitField0_ & 0x00000002) != 0)) { result.pageToken_ = pageToken_; } + int to_bitField0_ = 0; + if (((from_bitField0_ & 0x00000020) != 0)) { + result.maxDepth_ = maxDepthBuilder_ == null + ? maxDepth_ + : maxDepthBuilder_.build(); + to_bitField0_ |= 0x00000001; + } + if (((from_bitField0_ & 0x00000040) != 0)) { + categoryIds_.makeImmutable(); + result.categoryIds_ = categoryIds_; + } + if (((from_bitField0_ & 0x00000080) != 0)) { + templateChainContains_.makeImmutable(); + result.templateChainContains_ = templateChainContains_; + } + if (((from_bitField0_ & 0x00000100) != 0)) { + result.tagNameGlob_ = tagNameGlob_; + } + if (((from_bitField0_ & 0x00000200) != 0)) { + result.includeAttributes_ = includeAttributes_; + to_bitField0_ |= 0x00000002; + } + if (((from_bitField0_ & 0x00000400) != 0)) { + result.alarmBearingOnly_ = alarmBearingOnly_; + } + if (((from_bitField0_ & 0x00000800) != 0)) { + result.historizedOnly_ = historizedOnly_; + } + result.bitField0_ |= to_bitField0_; + } + + private void buildPartialOneofs(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest result) { + result.rootCase_ = rootCase_; + result.root_ = this.root_; } @java.lang.Override @@ -2214,6 +3055,65 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera bitField0_ |= 0x00000002; onChanged(); } + if (other.hasMaxDepth()) { + mergeMaxDepth(other.getMaxDepth()); + } + if (!other.categoryIds_.isEmpty()) { + if (categoryIds_.isEmpty()) { + categoryIds_ = other.categoryIds_; + categoryIds_.makeImmutable(); + bitField0_ |= 0x00000040; + } else { + ensureCategoryIdsIsMutable(); + categoryIds_.addAll(other.categoryIds_); + } + onChanged(); + } + if (!other.templateChainContains_.isEmpty()) { + if (templateChainContains_.isEmpty()) { + templateChainContains_ = other.templateChainContains_; + bitField0_ |= 0x00000080; + } else { + ensureTemplateChainContainsIsMutable(); + templateChainContains_.addAll(other.templateChainContains_); + } + onChanged(); + } + if (!other.getTagNameGlob().isEmpty()) { + tagNameGlob_ = other.tagNameGlob_; + bitField0_ |= 0x00000100; + onChanged(); + } + if (other.hasIncludeAttributes()) { + setIncludeAttributes(other.getIncludeAttributes()); + } + if (other.getAlarmBearingOnly() != false) { + setAlarmBearingOnly(other.getAlarmBearingOnly()); + } + if (other.getHistorizedOnly() != false) { + setHistorizedOnly(other.getHistorizedOnly()); + } + switch (other.getRootCase()) { + case ROOT_GOBJECT_ID: { + setRootGobjectId(other.getRootGobjectId()); + break; + } + case ROOT_TAG_NAME: { + rootCase_ = 4; + root_ = other.root_; + onChanged(); + break; + } + case ROOT_CONTAINED_PATH: { + rootCase_ = 5; + root_ = other.root_; + onChanged(); + break; + } + case ROOT_NOT_SET: { + break; + } + } this.mergeUnknownFields(other.getUnknownFields()); onChanged(); return this; @@ -2250,6 +3150,72 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera bitField0_ |= 0x00000002; break; } // case 18 + case 24: { + root_ = input.readInt32(); + rootCase_ = 3; + break; + } // case 24 + case 34: { + java.lang.String s = input.readStringRequireUtf8(); + rootCase_ = 4; + root_ = s; + break; + } // case 34 + case 42: { + java.lang.String s = input.readStringRequireUtf8(); + rootCase_ = 5; + root_ = s; + break; + } // case 42 + case 50: { + input.readMessage( + internalGetMaxDepthFieldBuilder().getBuilder(), + extensionRegistry); + bitField0_ |= 0x00000020; + break; + } // case 50 + case 56: { + int v = input.readInt32(); + ensureCategoryIdsIsMutable(); + categoryIds_.addInt(v); + break; + } // case 56 + case 58: { + int length = input.readRawVarint32(); + int limit = input.pushLimit(length); + ensureCategoryIdsIsMutable(); + while (input.getBytesUntilLimit() > 0) { + categoryIds_.addInt(input.readInt32()); + } + input.popLimit(limit); + break; + } // case 58 + case 66: { + java.lang.String s = input.readStringRequireUtf8(); + ensureTemplateChainContainsIsMutable(); + templateChainContains_.add(s); + break; + } // case 66 + case 74: { + tagNameGlob_ = input.readStringRequireUtf8(); + bitField0_ |= 0x00000100; + break; + } // case 74 + case 80: { + includeAttributes_ = input.readBool(); + bitField0_ |= 0x00000200; + break; + } // case 80 + case 88: { + alarmBearingOnly_ = input.readBool(); + bitField0_ |= 0x00000400; + break; + } // case 88 + case 96: { + historizedOnly_ = input.readBool(); + bitField0_ |= 0x00000800; + break; + } // case 96 default: { if (!super.parseUnknownField(input, extensionRegistry, tag)) { done = true; // was an endgroup tag @@ -2265,6 +3231,21 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera } // finally return this; } + private int rootCase_ = 0; + private java.lang.Object root_; + public RootCase + getRootCase() { + return RootCase.forNumber( + rootCase_); + } + + public Builder clearRoot() { + rootCase_ = 0; + root_ = null; + onChanged(); + return this; + } + private int bitField0_; private int pageSize_ ; @@ -2406,6 +3387,895 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera return this; } + /** + * int32 root_gobject_id = 3; + * @return Whether the rootGobjectId field is set. + */ + public boolean hasRootGobjectId() { + return rootCase_ == 3; + } + /** + * int32 root_gobject_id = 3; + * @return The rootGobjectId. + */ + public int getRootGobjectId() { + if (rootCase_ == 3) { + return (java.lang.Integer) root_; + } + return 0; + } + /** + * int32 root_gobject_id = 3; + * @param value The rootGobjectId to set. + * @return This builder for chaining. + */ + public Builder setRootGobjectId(int value) { + + rootCase_ = 3; + root_ = value; + onChanged(); + return this; + } + /** + * int32 root_gobject_id = 3; + * @return This builder for chaining. + */ + public Builder clearRootGobjectId() { + if (rootCase_ == 3) { + rootCase_ = 0; + root_ = null; + onChanged(); + } + return this; + } + + /** + * string root_tag_name = 4; + * @return Whether the rootTagName field is set. + */ + @java.lang.Override + public boolean hasRootTagName() { + return rootCase_ == 4; + } + /** + * string root_tag_name = 4; + * @return The rootTagName. + */ + @java.lang.Override + public java.lang.String getRootTagName() { + java.lang.Object ref = ""; + if (rootCase_ == 4) { + ref = root_; + } + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (rootCase_ == 4) { + root_ = s; + } + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * string root_tag_name = 4; + * @return The bytes for rootTagName. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getRootTagNameBytes() { + java.lang.Object ref = ""; + if (rootCase_ == 4) { + ref = root_; + } + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + if (rootCase_ == 4) { + root_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string root_tag_name = 4; + * @param value The rootTagName to set. + * @return This builder for chaining. + */ + public Builder setRootTagName( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + rootCase_ = 4; + root_ = value; + onChanged(); + return this; + } + /** + * string root_tag_name = 4; + * @return This builder for chaining. + */ + public Builder clearRootTagName() { + if (rootCase_ == 4) { + rootCase_ = 0; + root_ = null; + onChanged(); + } + return this; + } + /** + * string root_tag_name = 4; + * @param value The bytes for rootTagName to set. + * @return This builder for chaining. + */ + public Builder setRootTagNameBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + rootCase_ = 4; + root_ = value; + onChanged(); + return this; + } + + /** + * string root_contained_path = 5; + * @return Whether the rootContainedPath field is set. + */ + @java.lang.Override + public boolean hasRootContainedPath() { + return rootCase_ == 5; + } + /** + * string root_contained_path = 5; + * @return The rootContainedPath. + */ + @java.lang.Override + public java.lang.String getRootContainedPath() { + java.lang.Object ref = ""; + if (rootCase_ == 5) { + ref = root_; + } + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (rootCase_ == 5) { + root_ = s; + } + return s; + } else { + return (java.lang.String) ref; + } + } + /** + * string root_contained_path = 5; + * @return The bytes for rootContainedPath. + */ + @java.lang.Override + public com.google.protobuf.ByteString + getRootContainedPathBytes() { + java.lang.Object ref = ""; + if (rootCase_ == 5) { + ref = root_; + } + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + if (rootCase_ == 5) { + root_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + * string root_contained_path = 5; + * @param value The rootContainedPath to set. + * @return This builder for chaining. + */ + public Builder setRootContainedPath( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + rootCase_ = 5; + root_ = value; + onChanged(); + return this; + } + /** + * string root_contained_path = 5; + * @return This builder for chaining. + */ + public Builder clearRootContainedPath() { + if (rootCase_ == 5) { + rootCase_ = 0; + root_ = null; + onChanged(); + } + return this; + } + /** + * string root_contained_path = 5; + * @param value The bytes for rootContainedPath to set. + * @return This builder for chaining. + */ + public Builder setRootContainedPathBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + rootCase_ = 5; + root_ = value; + onChanged(); + return this; + } + + private com.google.protobuf.Int32Value maxDepth_; + private com.google.protobuf.SingleFieldBuilder< + com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder> maxDepthBuilder_; + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return Whether the maxDepth field is set. + */ + public boolean hasMaxDepth() { + return ((bitField0_ & 0x00000020) != 0); + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + * @return The maxDepth. + */ + public com.google.protobuf.Int32Value getMaxDepth() { + if (maxDepthBuilder_ == null) { + return maxDepth_ == null ? com.google.protobuf.Int32Value.getDefaultInstance() : maxDepth_; + } else { + return maxDepthBuilder_.getMessage(); + } + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public Builder setMaxDepth(com.google.protobuf.Int32Value value) { + if (maxDepthBuilder_ == null) { + if (value == null) { + throw new NullPointerException(); + } + maxDepth_ = value; + } else { + maxDepthBuilder_.setMessage(value); + } + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public Builder setMaxDepth( + com.google.protobuf.Int32Value.Builder builderForValue) { + if (maxDepthBuilder_ == null) { + maxDepth_ = builderForValue.build(); + } else { + maxDepthBuilder_.setMessage(builderForValue.build()); + } + bitField0_ |= 0x00000020; + onChanged(); + return this; + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public Builder mergeMaxDepth(com.google.protobuf.Int32Value value) { + if (maxDepthBuilder_ == null) { + if (((bitField0_ & 0x00000020) != 0) && + maxDepth_ != null && + maxDepth_ != com.google.protobuf.Int32Value.getDefaultInstance()) { + getMaxDepthBuilder().mergeFrom(value); + } else { + maxDepth_ = value; + } + } else { + maxDepthBuilder_.mergeFrom(value); + } + if (maxDepth_ != null) { + bitField0_ |= 0x00000020; + onChanged(); + } + return this; + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public Builder clearMaxDepth() { + bitField0_ = (bitField0_ & ~0x00000020); + maxDepth_ = null; + if (maxDepthBuilder_ != null) { + maxDepthBuilder_.dispose(); + maxDepthBuilder_ = null; + } + onChanged(); + return this; + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public com.google.protobuf.Int32Value.Builder getMaxDepthBuilder() { + bitField0_ |= 0x00000020; + onChanged(); + return internalGetMaxDepthFieldBuilder().getBuilder(); + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + public com.google.protobuf.Int32ValueOrBuilder getMaxDepthOrBuilder() { + if (maxDepthBuilder_ != null) { + return maxDepthBuilder_.getMessageOrBuilder(); + } else { + return maxDepth_ == null ? + com.google.protobuf.Int32Value.getDefaultInstance() : maxDepth_; + } + } + /** + *
+       * Optional. Cap on descendant depth from root. Zero returns only the root.
+       * Unset means unlimited depth.
+       * 
+ * + * .google.protobuf.Int32Value max_depth = 6; + */ + private com.google.protobuf.SingleFieldBuilder< + com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder> + internalGetMaxDepthFieldBuilder() { + if (maxDepthBuilder_ == null) { + maxDepthBuilder_ = new com.google.protobuf.SingleFieldBuilder< + com.google.protobuf.Int32Value, com.google.protobuf.Int32Value.Builder, com.google.protobuf.Int32ValueOrBuilder>( + getMaxDepth(), + getParentForChildren(), + isClean()); + maxDepth_ = null; + } + return maxDepthBuilder_; + } + + private com.google.protobuf.Internal.IntList categoryIds_ = emptyIntList(); + private void ensureCategoryIdsIsMutable() { + if (!categoryIds_.isModifiable()) { + categoryIds_ = makeMutableCopy(categoryIds_); + } + bitField0_ |= 0x00000040; + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @return A list containing the categoryIds. + */ + public java.util.List + getCategoryIdsList() { + categoryIds_.makeImmutable(); + return categoryIds_; + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @return The count of categoryIds. + */ + public int getCategoryIdsCount() { + return categoryIds_.size(); + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @param index The index of the element to return. + * @return The categoryIds at the given index. + */ + public int getCategoryIds(int index) { + return categoryIds_.getInt(index); + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @param index The index to set the value at. + * @param value The categoryIds to set. + * @return This builder for chaining. + */ + public Builder setCategoryIds( + int index, int value) { + + ensureCategoryIdsIsMutable(); + categoryIds_.setInt(index, value); + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @param value The categoryIds to add. + * @return This builder for chaining. + */ + public Builder addCategoryIds(int value) { + + ensureCategoryIdsIsMutable(); + categoryIds_.addInt(value); + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @param values The categoryIds to add. + * @return This builder for chaining. + */ + public Builder addAllCategoryIds( + java.lang.Iterable values) { + ensureCategoryIdsIsMutable(); + com.google.protobuf.AbstractMessageLite.Builder.addAll( + values, categoryIds_); + bitField0_ |= 0x00000040; + onChanged(); + return this; + } + /** + *
+       * Optional object category id filters.
+       * 
+ * + * repeated int32 category_ids = 7; + * @return This builder for chaining. + */ + public Builder clearCategoryIds() { + categoryIds_ = emptyIntList(); + bitField0_ = (bitField0_ & ~0x00000040); + onChanged(); + return this; + } + + private com.google.protobuf.LazyStringArrayList templateChainContains_ = + com.google.protobuf.LazyStringArrayList.emptyList(); + private void ensureTemplateChainContainsIsMutable() { + if (!templateChainContains_.isModifiable()) { + templateChainContains_ = new com.google.protobuf.LazyStringArrayList(templateChainContains_); + } + bitField0_ |= 0x00000080; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @return A list containing the templateChainContains. + */ + public com.google.protobuf.ProtocolStringList + getTemplateChainContainsList() { + templateChainContains_.makeImmutable(); + return templateChainContains_; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @return The count of templateChainContains. + */ + public int getTemplateChainContainsCount() { + return templateChainContains_.size(); + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the element to return. + * @return The templateChainContains at the given index. + */ + public java.lang.String getTemplateChainContains(int index) { + return templateChainContains_.get(index); + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index of the value to return. + * @return The bytes of the templateChainContains at the given index. + */ + public com.google.protobuf.ByteString + getTemplateChainContainsBytes(int index) { + return templateChainContains_.getByteString(index); + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param index The index to set the value at. + * @param value The templateChainContains to set. + * @return This builder for chaining. + */ + public Builder setTemplateChainContains( + int index, java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + ensureTemplateChainContainsIsMutable(); + templateChainContains_.set(index, value); + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param value The templateChainContains to add. + * @return This builder for chaining. + */ + public Builder addTemplateChainContains( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + ensureTemplateChainContainsIsMutable(); + templateChainContains_.add(value); + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param values The templateChainContains to add. + * @return This builder for chaining. + */ + public Builder addAllTemplateChainContains( + java.lang.Iterable values) { + ensureTemplateChainContainsIsMutable(); + com.google.protobuf.AbstractMessageLite.Builder.addAll( + values, templateChainContains_); + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @return This builder for chaining. + */ + public Builder clearTemplateChainContains() { + templateChainContains_ = + com.google.protobuf.LazyStringArrayList.emptyList(); + bitField0_ = (bitField0_ & ~0x00000080);; + onChanged(); + return this; + } + /** + *
+       * Optional case-insensitive substring filters against template names.
+       * 
+ * + * repeated string template_chain_contains = 8; + * @param value The bytes of the templateChainContains to add. + * @return This builder for chaining. + */ + public Builder addTemplateChainContainsBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + ensureTemplateChainContainsIsMutable(); + templateChainContains_.add(value); + bitField0_ |= 0x00000080; + onChanged(); + return this; + } + + private java.lang.Object tagNameGlob_ = ""; + /** + *
+       * Optional anchored, case-insensitive glob over object tag_name.
+       * 
+ * + * string tag_name_glob = 9; + * @return The tagNameGlob. + */ + public java.lang.String getTagNameGlob() { + java.lang.Object ref = tagNameGlob_; + if (!(ref instanceof java.lang.String)) { + com.google.protobuf.ByteString bs = + (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + tagNameGlob_ = s; + return s; + } else { + return (java.lang.String) ref; + } + } + /** + *
+       * Optional anchored, case-insensitive glob over object tag_name.
+       * 
+ * + * string tag_name_glob = 9; + * @return The bytes for tagNameGlob. + */ + public com.google.protobuf.ByteString + getTagNameGlobBytes() { + java.lang.Object ref = tagNameGlob_; + if (ref instanceof String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8( + (java.lang.String) ref); + tagNameGlob_ = b; + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + /** + *
+       * Optional anchored, case-insensitive glob over object tag_name.
+       * 
+ * + * string tag_name_glob = 9; + * @param value The tagNameGlob to set. + * @return This builder for chaining. + */ + public Builder setTagNameGlob( + java.lang.String value) { + if (value == null) { throw new NullPointerException(); } + tagNameGlob_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + /** + *
+       * Optional anchored, case-insensitive glob over object tag_name.
+       * 
+ * + * string tag_name_glob = 9; + * @return This builder for chaining. + */ + public Builder clearTagNameGlob() { + tagNameGlob_ = getDefaultInstance().getTagNameGlob(); + bitField0_ = (bitField0_ & ~0x00000100); + onChanged(); + return this; + } + /** + *
+       * Optional anchored, case-insensitive glob over object tag_name.
+       * 
+ * + * string tag_name_glob = 9; + * @param value The bytes for tagNameGlob to set. + * @return This builder for chaining. + */ + public Builder setTagNameGlobBytes( + com.google.protobuf.ByteString value) { + if (value == null) { throw new NullPointerException(); } + checkByteStringIsUtf8(value); + tagNameGlob_ = value; + bitField0_ |= 0x00000100; + onChanged(); + return this; + } + + private boolean includeAttributes_ ; + /** + *
+       * Optional. Unset or true includes attributes. False returns object skeletons.
+       * 
+ * + * optional bool include_attributes = 10; + * @return Whether the includeAttributes field is set. + */ + @java.lang.Override + public boolean hasIncludeAttributes() { + return ((bitField0_ & 0x00000200) != 0); + } + /** + *
+       * Optional. Unset or true includes attributes. False returns object skeletons.
+       * 
+ * + * optional bool include_attributes = 10; + * @return The includeAttributes. + */ + @java.lang.Override + public boolean getIncludeAttributes() { + return includeAttributes_; + } + /** + *
+       * Optional. Unset or true includes attributes. False returns object skeletons.
+       * 
+ * + * optional bool include_attributes = 10; + * @param value The includeAttributes to set. + * @return This builder for chaining. + */ + public Builder setIncludeAttributes(boolean value) { + + includeAttributes_ = value; + bitField0_ |= 0x00000200; + onChanged(); + return this; + } + /** + *
+       * Optional. Unset or true includes attributes. False returns object skeletons.
+       * 
+ * + * optional bool include_attributes = 10; + * @return This builder for chaining. + */ + public Builder clearIncludeAttributes() { + bitField0_ = (bitField0_ & ~0x00000200); + includeAttributes_ = false; + onChanged(); + return this; + } + + private boolean alarmBearingOnly_ ; + /** + *
+       * Optional. Return only objects with at least one alarm-bearing attribute.
+       * 
+ * + * bool alarm_bearing_only = 11; + * @return The alarmBearingOnly. + */ + @java.lang.Override + public boolean getAlarmBearingOnly() { + return alarmBearingOnly_; + } + /** + *
+       * Optional. Return only objects with at least one alarm-bearing attribute.
+       * 
+ * + * bool alarm_bearing_only = 11; + * @param value The alarmBearingOnly to set. + * @return This builder for chaining. + */ + public Builder setAlarmBearingOnly(boolean value) { + + alarmBearingOnly_ = value; + bitField0_ |= 0x00000400; + onChanged(); + return this; + } + /** + *
+       * Optional. Return only objects with at least one alarm-bearing attribute.
+       * 
+ * + * bool alarm_bearing_only = 11; + * @return This builder for chaining. + */ + public Builder clearAlarmBearingOnly() { + bitField0_ = (bitField0_ & ~0x00000400); + alarmBearingOnly_ = false; + onChanged(); + return this; + } + + private boolean historizedOnly_ ; + /** + *
+       * Optional. Return only objects with at least one historized attribute.
+       * 
+ * + * bool historized_only = 12; + * @return The historizedOnly. + */ + @java.lang.Override + public boolean getHistorizedOnly() { + return historizedOnly_; + } + /** + *
+       * Optional. Return only objects with at least one historized attribute.
+       * 
+ * + * bool historized_only = 12; + * @param value The historizedOnly to set. + * @return This builder for chaining. + */ + public Builder setHistorizedOnly(boolean value) { + + historizedOnly_ = value; + bitField0_ |= 0x00000800; + onChanged(); + return this; + } + /** + *
+       * Optional. Return only objects with at least one historized attribute.
+       * 
+ * + * bool historized_only = 12; + * @return This builder for chaining. + */ + public Builder clearHistorizedOnly() { + bitField0_ = (bitField0_ & ~0x00000800); + historizedOnly_ = false; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:galaxy_repository.v1.DiscoverHierarchyRequest) } @@ -8524,56 +10394,66 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera java.lang.String[] descriptorData = { "\n\027galaxy_repository.proto\022\024galaxy_reposi" + "tory.v1\032\037google/protobuf/timestamp.proto" + - "\"\027\n\025TestConnectionRequest\"!\n\023TestConnect" + - "ionReply\022\n\n\002ok\030\001 \001(\010\"\032\n\030GetLastDeployTim" + - "eRequest\"b\n\026GetLastDeployTimeReply\022\017\n\007pr" + - "esent\030\001 \001(\010\0227\n\023time_of_last_deploy\030\002 \001(\013" + - "2\032.google.protobuf.Timestamp\"A\n\030Discover" + - "HierarchyRequest\022\021\n\tpage_size\030\001 \001(\005\022\022\n\np" + - "age_token\030\002 \001(\t\"\202\001\n\026DiscoverHierarchyRep" + - "ly\0223\n\007objects\030\001 \003(\0132\".galaxy_repository." + - "v1.GalaxyObject\022\027\n\017next_page_token\030\002 \001(\t" + - "\022\032\n\022total_object_count\030\003 \001(\005\"U\n\030WatchDep" + - "loyEventsRequest\0229\n\025last_seen_deploy_tim" + - "e\030\001 \001(\0132\032.google.protobuf.Timestamp\"\335\001\n\013" + - "DeployEvent\022\020\n\010sequence\030\001 \001(\004\022/\n\013observe" + - "d_at\030\002 \001(\0132\032.google.protobuf.Timestamp\0227" + - "\n\023time_of_last_deploy\030\003 \001(\0132\032.google.pro" + - "tobuf.Timestamp\022#\n\033time_of_last_deploy_p" + - "resent\030\004 \001(\010\022\024\n\014object_count\030\005 \001(\005\022\027\n\017at" + - "tribute_count\030\006 \001(\005\"\223\002\n\014GalaxyObject\022\022\n\n" + - "gobject_id\030\001 \001(\005\022\020\n\010tag_name\030\002 \001(\t\022\026\n\016co" + - "ntained_name\030\003 \001(\t\022\023\n\013browse_name\030\004 \001(\t\022" + - "\031\n\021parent_gobject_id\030\005 \001(\005\022\017\n\007is_area\030\006 " + - "\001(\010\022\023\n\013category_id\030\007 \001(\005\022\034\n\024hosted_by_go" + - "bject_id\030\010 \001(\005\022\026\n\016template_chain\030\t \003(\t\0229" + - "\n\nattributes\030\n \003(\0132%.galaxy_repository.v" + - "1.GalaxyAttribute\"\250\002\n\017GalaxyAttribute\022\026\n" + - "\016attribute_name\030\001 \001(\t\022\032\n\022full_tag_refere" + - "nce\030\002 \001(\t\022\024\n\014mx_data_type\030\003 \001(\005\022\026\n\016data_" + - "type_name\030\004 \001(\t\022\020\n\010is_array\030\005 \001(\010\022\027\n\017arr" + - "ay_dimension\030\006 \001(\005\022\037\n\027array_dimension_pr" + - "esent\030\007 \001(\010\022\035\n\025mx_attribute_category\030\010 \001" + - "(\005\022\037\n\027security_classification\030\t \001(\005\022\025\n\ri" + - "s_historized\030\n \001(\010\022\020\n\010is_alarm\030\013 \001(\0102\314\003\n" + - "\020GalaxyRepository\022h\n\016TestConnection\022+.ga" + - "laxy_repository.v1.TestConnectionRequest" + - "\032).galaxy_repository.v1.TestConnectionRe" + - "ply\022q\n\021GetLastDeployTime\022..galaxy_reposi" + - "tory.v1.GetLastDeployTimeRequest\032,.galax" + - "y_repository.v1.GetLastDeployTimeReply\022q" + - "\n\021DiscoverHierarchy\022..galaxy_repository." + - "v1.DiscoverHierarchyRequest\032,.galaxy_rep" + - "ository.v1.DiscoverHierarchyReply\022h\n\021Wat" + - "chDeployEvents\022..galaxy_repository.v1.Wa" + - "tchDeployEventsRequest\032!.galaxy_reposito" + - "ry.v1.DeployEvent0\001B#\252\002 MxGateway.Contra" + - "cts.Proto.Galaxyb\006proto3" + "\032\036google/protobuf/wrappers.proto\"\027\n\025Test" + + "ConnectionRequest\"!\n\023TestConnectionReply" + + "\022\n\n\002ok\030\001 \001(\010\"\032\n\030GetLastDeployTimeRequest" + + "\"b\n\026GetLastDeployTimeReply\022\017\n\007present\030\001 " + + "\001(\010\0227\n\023time_of_last_deploy\030\002 \001(\0132\032.googl" + + "e.protobuf.Timestamp\"\207\003\n\030DiscoverHierarc" + + "hyRequest\022\021\n\tpage_size\030\001 \001(\005\022\022\n\npage_tok" + + "en\030\002 \001(\t\022\031\n\017root_gobject_id\030\003 \001(\005H\000\022\027\n\rr" + + "oot_tag_name\030\004 \001(\tH\000\022\035\n\023root_contained_p" + + "ath\030\005 \001(\tH\000\022.\n\tmax_depth\030\006 \001(\0132\033.google." + + "protobuf.Int32Value\022\024\n\014category_ids\030\007 \003(" + + "\005\022\037\n\027template_chain_contains\030\010 \003(\t\022\025\n\rta" + + "g_name_glob\030\t \001(\t\022\037\n\022include_attributes\030" + + "\n \001(\010H\001\210\001\001\022\032\n\022alarm_bearing_only\030\013 \001(\010\022\027" + + "\n\017historized_only\030\014 \001(\010B\006\n\004rootB\025\n\023_incl" + + "ude_attributes\"\202\001\n\026DiscoverHierarchyRepl" + + "y\0223\n\007objects\030\001 \003(\0132\".galaxy_repository.v" + + "1.GalaxyObject\022\027\n\017next_page_token\030\002 \001(\t\022" + + "\032\n\022total_object_count\030\003 \001(\005\"U\n\030WatchDepl" + + "oyEventsRequest\0229\n\025last_seen_deploy_time" + + "\030\001 \001(\0132\032.google.protobuf.Timestamp\"\335\001\n\013D" + + "eployEvent\022\020\n\010sequence\030\001 \001(\004\022/\n\013observed" + + "_at\030\002 \001(\0132\032.google.protobuf.Timestamp\0227\n" + + "\023time_of_last_deploy\030\003 \001(\0132\032.google.prot" + + "obuf.Timestamp\022#\n\033time_of_last_deploy_pr" + + "esent\030\004 \001(\010\022\024\n\014object_count\030\005 \001(\005\022\027\n\017att" + + "ribute_count\030\006 \001(\005\"\223\002\n\014GalaxyObject\022\022\n\ng" + + "object_id\030\001 \001(\005\022\020\n\010tag_name\030\002 \001(\t\022\026\n\016con" + + "tained_name\030\003 \001(\t\022\023\n\013browse_name\030\004 \001(\t\022\031" + + "\n\021parent_gobject_id\030\005 \001(\005\022\017\n\007is_area\030\006 \001" + + "(\010\022\023\n\013category_id\030\007 \001(\005\022\034\n\024hosted_by_gob" + + "ject_id\030\010 \001(\005\022\026\n\016template_chain\030\t \003(\t\0229\n" + + "\nattributes\030\n \003(\0132%.galaxy_repository.v1" + + ".GalaxyAttribute\"\250\002\n\017GalaxyAttribute\022\026\n\016" + + "attribute_name\030\001 \001(\t\022\032\n\022full_tag_referen" + + "ce\030\002 \001(\t\022\024\n\014mx_data_type\030\003 \001(\005\022\026\n\016data_t" + + "ype_name\030\004 \001(\t\022\020\n\010is_array\030\005 \001(\010\022\027\n\017arra" + + "y_dimension\030\006 \001(\005\022\037\n\027array_dimension_pre" + + "sent\030\007 \001(\010\022\035\n\025mx_attribute_category\030\010 \001(" + + "\005\022\037\n\027security_classification\030\t \001(\005\022\025\n\ris" + + "_historized\030\n \001(\010\022\020\n\010is_alarm\030\013 \001(\0102\314\003\n\020" + + "GalaxyRepository\022h\n\016TestConnection\022+.gal" + + "axy_repository.v1.TestConnectionRequest\032" + + ").galaxy_repository.v1.TestConnectionRep" + + "ly\022q\n\021GetLastDeployTime\022..galaxy_reposit" + + "ory.v1.GetLastDeployTimeRequest\032,.galaxy" + + "_repository.v1.GetLastDeployTimeReply\022q\n" + + "\021DiscoverHierarchy\022..galaxy_repository.v" + + "1.DiscoverHierarchyRequest\032,.galaxy_repo" + + "sitory.v1.DiscoverHierarchyReply\022h\n\021Watc" + + "hDeployEvents\022..galaxy_repository.v1.Wat" + + "chDeployEventsRequest\032!.galaxy_repositor" + + "y.v1.DeployEvent0\001B#\252\002 MxGateway.Contrac" + + "ts.Proto.Galaxyb\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor .internalBuildGeneratedFileFrom(descriptorData, new com.google.protobuf.Descriptors.FileDescriptor[] { com.google.protobuf.TimestampProto.getDescriptor(), + com.google.protobuf.WrappersProto.getDescriptor(), }); internal_static_galaxy_repository_v1_TestConnectionRequest_descriptor = getDescriptor().getMessageType(0); @@ -8604,7 +10484,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( internal_static_galaxy_repository_v1_DiscoverHierarchyRequest_descriptor, - new java.lang.String[] { "PageSize", "PageToken", }); + new java.lang.String[] { "PageSize", "PageToken", "RootGobjectId", "RootTagName", "RootContainedPath", "MaxDepth", "CategoryIds", "TemplateChainContains", "TagNameGlob", "IncludeAttributes", "AlarmBearingOnly", "HistorizedOnly", "Root", }); internal_static_galaxy_repository_v1_DiscoverHierarchyReply_descriptor = getDescriptor().getMessageType(5); internal_static_galaxy_repository_v1_DiscoverHierarchyReply_fieldAccessorTable = new @@ -8637,6 +10517,7 @@ public final class GalaxyRepositoryOuterClass extends com.google.protobuf.Genera new java.lang.String[] { "AttributeName", "FullTagReference", "MxDataType", "DataTypeName", "IsArray", "ArrayDimension", "ArrayDimensionPresent", "MxAttributeCategory", "SecurityClassification", "IsHistorized", "IsAlarm", }); descriptor.resolveAllFeaturesImmutable(); com.google.protobuf.TimestampProto.getDescriptor(); + com.google.protobuf.WrappersProto.getDescriptor(); } // @@protoc_insertion_point(outer_class_scope) diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index fba3c48c70a781c496aee79718dcd527d964bfe5..09a9e6e226d7fc88466c2725b02a5bc4b38e1506 100644 GIT binary patch delta 8472 zcmcIoU2GiJb)G-&&fJwma;Trx@<&$`CDW2fNu+F9Aq178qaWL$En1eWAD5in9dbur z&a7ukNP@qqFXyL~`q(P9E7ATtZtw52cPYGJ2Nr1lhJNMq1 zC1XkzQuk%CXYM)Yp8MVJo^$T~)8Cl?`hDxKfAX`D|DIxFYf-e;_Rn?VD2?jf)pMJ% z*Xj6i^66+bJHaZiN8Nhczw5QTzI%}MZP1^p&h<%sNebiRj9qU>Ub=^lu|wxpn7(>( z4;?et!DR34R?2#_S-BQwgVOBG9-jJg9p!0HrTw|mapqi$qV^ts>|Et09KD;yLAbVu zmXm8CqH9~JpX{NtPw9NxsoD2(Tt7Q+7a0Hj)Y1QZnU&qXbK0NlI{TX;rtfpjbDBHz zS3_U)>(@#d&+YhX6!T6TtOseZ;q%Eb3a8fnB=Oe#mwCF?@snwOr{yQU6!@ke`#kjh zrr%`3w(YNZjV&JgUJ``~_rfOkNF_?~tr4xS`yq6NY8PID5Z0R+pMU38GR+vj86;^G z2Mw>?-r}j4C|`!03r4HmHcuno@(^GrMl4~&=TV6NAlGa!dIS{s?PhY8*Sl#xnrwxs z_XNfXuls(;yPc+&W{3zLlWwOI#VO6pSE5cDM4{JS;UD^2n^D}HX8gSbyM$pc%|G%W z!P+*3Ns`7tu2w&-g-Nb?|fZh%|q%BOB#eT>4V5wl>wA_}z#oJ!vL#zY);njn7kP0-Le(E=9Lwj2vgw0?hXm-6e z_uD>20XDsO3>gLC@pZ4mL4OZq@}pIb&2IP%aP)kKIH!OegN3%_nEWK|2pq4C6VTk^ zzY{Bvv6*hT<^`=v|7_Qs)sC^uVTOH7{+C}bYBw2uthgrql^GeGn)FwGd`&|(g3k%pq`&gxWo?c*8NQx$P5S>&Ix{TIwdS?g_1cg0 zJTVmePx)b`Dbn?d+Gqa8yKgVzbjuA%JvukyfUoRG>nlV;p%`|-;+Sj~^Q4gNnH5A* zkcT%(gSKD{5r;xhX@=4Ucx@DeqUA@SnNH8ltD<{ zFOU*z*@YAofYg136eJkm7f5A&7DPaGA5e)CXu4Ki^PhbJyDtdUj$Y?ESY^+s%U|gU zG_l;eI)Nr()hte6CYQ4dEvNvkraFO;VBSWzxxCtshI1ER~BIv?c~+$%r2y$0Hh+tD@ZVo)Cq(Hq@tWw zeXjLH`=c+>DnolGA)t+TK2}nXc=gp`m8q`o_sZ;jSnd zw1Ncdr?ODKO>+ksje79?pR)azPi6MM z_YeeG8B6}Q4yqkTVJs73pXdeGWJtR(g9~no!uwIgvJ@(nv?SKB+JGI&~9~ibr|Lxz6zqLO5 z`Q6N&Wc_gzrORtk{V{G3%R#eh;*Fv2rna=725!jjx0>z*6CajSZ*4jB*8QplJqLOf z(!1lWE#jklo(&0qxUHpL5cK&sVy<){g2`hnBEZ!ADx?4SaHEn2Y;RyW8}ay%fzB z?$mx#Wl*$l>Pv%aY`z%!7P`o@%=Ow{yuMub@r(X!ISSib)e0Fc4Tzy@7{S2X`0l>M z`djk;{}fmX)qO(sK<_gtUMn$+u)TJO4K05;sPXUX?C^HiJxPS^wN^Wrm>vn7tyWqn>i0urp|| zurpvc0J|TVB1}ltFHZi)kM*ot7??1q76xVs(%ih7)+B>#-=JF{?s(>~##w$h8n#FA zU)*L!wPU*YPAeF-hbc;V4;VA#4M_IgIf@}dNkUS~6_e^qr(zvMX zbS(R%F`P>@HgYbP5Z%d{SrG<0$4OjtMR?S6O(MJu{zT7pX8X#o{`mBnU47LV6iiyP z?5oZkJj51-I&7!5fAOn#vMJSBSO%dt<;ayl!mLw=kFbE$ZLsdVanYikFNtwbl-J z7U*#%+_852MPB5qakS1U8%*;Dew;-LJ%A?RL*GM#c)SY%u6Te-csFd*6DJ;=gp(XA zqYu2fWQ?eIsk6K^dMcL?;iZ>Pvsn=#6fYa&wNpiea+9(YZ(}k`^MIbS+9_VS3;!kH zaoNa`D`AOc}w3Q%m!e5E)xXc^2a&S5pPjhU|;t+9t< zX2&(J8k4ofV&-YJt9TWlhMEVNEKztADhkmwzwV*G?(sqtf1Z4Zm%i5tgSn5s#gS3vX8* zHU)2mcqK$xoTB1L+g5~JM8puoyh?wT!4SiIIX|BaG0cyQu{$CLyb~@MBefgZ4rpF1 zhF)piBY+A5SSSLhAb^E(zBLsDuyAx((Gw$QIdOe`^2yBFTAOeH$FlAO}nPNQyqPCbWFsK=3r4Aa`#=>uC_w!I3LX{8G6@fj+!JN;hiZQ@1ha=_NpeV}dGeGp!^S-_B=^b& zCGtlKT1YT^WFO2_bd<>-9TA-rri11ODxpFUl6)zI1iKGxx&4q}_CZb3$#$(3?L!GA z<%1Rbpa_F*5T<8H6LD97B;DCYMqA_Iu0iz~gYf4aI;`w4R5v7ZZ9_*Gb+r|cXiRMd zB$(B)6$F+|*fv!35Mb6Q%n0L#lZOIWGqZv4r5&6!6Q4+g8 zqS-Q`N%Z=t2$SgbQ4uE4ijCYQ(JNL*-w|gxF3V~M31+c7q@sjkQcOqeN{T3nU5Prv znIq_x6w_fhQbYEBj-XdsOh@!e%aZ9B!*nTnKe-2>SJ#k9$T5gs-C{aoR~Hu*2@0{R zixx?m*_mr@s6yiiLTwawM5qm0mKKb`ZUd!7+JR7;hUAQ6P|@Ekq9kr?mUF;}L7PX8 hDl-)QC&nQa3DxN*rwThN)K3a9sZc+059Uu6{{w_(|L6b! delta 2543 zcmX|CJ98XG5Z>wCyPa7*4zC_}mhPm}on%pGfn@1~DG8TK)&s2PNe6nr1B!&Q3&^*K ziHw5FD5^*uP(@&Jiin6Dia$W&z=k5?>zUro?wjtfzwVxI_WpbkeDlix@%O*z=LfgH zy=mPp{_}@_5S2QQlq%iM{rf?#_CiI2ozImj{`Bm>A42o@NR*EQ&%5cANxj$gEk%^~ z;-K3rz5I++tdeeBin^j8?~xb#J+u*ZM>z%xDBqtV0vc$f$OoGLNN7mxrM83=qkJ(% zp!Jq!QUqG>;-1hDDu6ccy2t<_u^ze> zfS^*p6I~`TvL0e<-zgNz zbNdUO=e_Ft#|yH4oFQeQ{J<6hLZOA`ye$;Q!6WZOS13f*Bi&~k3c1cw=gWdRZY;?F zI77ygaq9;{oh7V)6}ln!y4-nFQ0t9l(Zv}bmM5()5b~EB_oEY=kAYV@pT4Eel9eo< zi>!!ELgIl}U=ucf$Y7PyQa)U{Dy~EbwAHXCGW^6=C+-Oi_qRrOB@}?RChHCPrTJ^R zKS_h&trhJD2y)0=r?P}{7C93l1m3!KE&u{;eQ4NjQ^&y*?_;-3jjSiS-?pK7n;YaP z04TR1ZD>k(s~bADS_q=v#=xNP5Za_c2`TV439qn4?im4q*2?7gF|^=e{zf7O zS_==}4Hw4#9*s#T1Z~gK63%4r?`e16K;Z3-R)wcyf1f6Vr!nikd=NBgRN3!x4+029 z_Ty@ri6Z;;aVcVAzfJcgWKg7?7D>oppmvvg=b8+vw1-nYgDUN}8`fSX4i3B KFP?t&bnbr!fU`OP diff --git a/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py b/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py index fda53a6..a15dfdf 100644 --- a/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py +++ b/clients/python/src/mxgateway/generated/galaxy_repository_pb2.py @@ -23,9 +23,10 @@ _sym_db = _symbol_database.Default() from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 +from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"A\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -33,26 +34,26 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _gl if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy' - _globals['_TESTCONNECTIONREQUEST']._serialized_start=82 - _globals['_TESTCONNECTIONREQUEST']._serialized_end=105 - _globals['_TESTCONNECTIONREPLY']._serialized_start=107 - _globals['_TESTCONNECTIONREPLY']._serialized_end=140 - _globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=142 - _globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=168 - _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=170 - _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=268 - _globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=270 - _globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=335 - _globals['_DISCOVERHIERARCHYREPLY']._serialized_start=338 - _globals['_DISCOVERHIERARCHYREPLY']._serialized_end=468 - _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=470 - _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=555 - _globals['_DEPLOYEVENT']._serialized_start=558 - _globals['_DEPLOYEVENT']._serialized_end=779 - _globals['_GALAXYOBJECT']._serialized_start=782 - _globals['_GALAXYOBJECT']._serialized_end=1057 - _globals['_GALAXYATTRIBUTE']._serialized_start=1060 - _globals['_GALAXYATTRIBUTE']._serialized_end=1356 - _globals['_GALAXYREPOSITORY']._serialized_start=1359 - _globals['_GALAXYREPOSITORY']._serialized_end=1819 + _globals['_TESTCONNECTIONREQUEST']._serialized_start=114 + _globals['_TESTCONNECTIONREQUEST']._serialized_end=137 + _globals['_TESTCONNECTIONREPLY']._serialized_start=139 + _globals['_TESTCONNECTIONREPLY']._serialized_end=172 + _globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=174 + _globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=200 + _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=202 + _globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=300 + _globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=303 + _globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=694 + _globals['_DISCOVERHIERARCHYREPLY']._serialized_start=697 + _globals['_DISCOVERHIERARCHYREPLY']._serialized_end=827 + _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=829 + _globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=914 + _globals['_DEPLOYEVENT']._serialized_start=917 + _globals['_DEPLOYEVENT']._serialized_end=1138 + _globals['_GALAXYOBJECT']._serialized_start=1141 + _globals['_GALAXYOBJECT']._serialized_end=1416 + _globals['_GALAXYATTRIBUTE']._serialized_start=1419 + _globals['_GALAXYATTRIBUTE']._serialized_end=1715 + _globals['_GALAXYREPOSITORY']._serialized_start=1718 + _globals['_GALAXYREPOSITORY']._serialized_end=2178 # @@protoc_insertion_point(module_scope) diff --git a/clients/rust/src/galaxy.rs b/clients/rust/src/galaxy.rs index 4a82af5..4349f03 100644 --- a/clients/rust/src/galaxy.rs +++ b/clients/rust/src/galaxy.rs @@ -152,6 +152,7 @@ impl GalaxyClient { .discover_hierarchy(self.unary_request(DiscoverHierarchyRequest { page_size: DISCOVER_HIERARCHY_PAGE_SIZE, page_token, + ..Default::default() })) .await?; let reply = response.into_inner(); diff --git a/docs/Authentication.md b/docs/Authentication.md index 2732fe8..79d524f 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -91,12 +91,15 @@ return ApiKeyVerificationResult.Success(new ApiKeyIdentity( KeyId: storedKey.KeyId, KeyPrefix: storedKey.KeyPrefix, DisplayName: storedKey.DisplayName, - Scopes: storedKey.Scopes)); + Scopes: storedKey.Scopes, + Constraints: storedKey.Constraints)); ``` `ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client. -`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`) and is the type downstream authorization code consumes. +`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`, +`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream +authorization code consumes. ## Storage @@ -131,7 +134,9 @@ public SqliteConnection CreateConnection() `SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved: -- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob, `display_name`, serialized `scopes`, and the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps. +- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob, + `display_name`, serialized `scopes`, optional serialized `constraints`, and + the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps. - `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns. - `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`. @@ -150,9 +155,10 @@ public static ApiKeyRecord Read(SqliteDataReader reader) SecretHash: (byte[])reader["secret_hash"], DisplayName: reader.GetString(3), Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)), - CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture), - LastUsedUtc: ReadNullableDateTimeOffset(reader, 6), - RevokedUtc: ReadNullableDateTimeOffset(reader, 7)); + Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)), + CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture), + LastUsedUtc: ReadNullableDateTimeOffset(reader, 7), + RevokedUtc: ReadNullableDateTimeOffset(reader, 8)); } ``` @@ -193,8 +199,8 @@ The supported subcommands match `ApiKeyAdminCommandKind` exactly: | Subcommand | Required options | Behaviour | |------------|------------------|-----------| | `init-db` | none | Runs the migrator and records an audit entry. | -| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash, and prints the assembled `mxgw__` token. | -| `list-keys` | none | Lists every stored key with its scopes and revocation state. | +| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw__` token. | +| `list-keys` | none | Lists every stored key with its scopes, constraints, and revocation state. | | `revoke-key` | `--key-id` | Sets `revoked_utc` if the key is currently active. | | `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. | @@ -203,11 +209,18 @@ Examples: ```bash mxgateway apikey init-db mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write +mxgateway apikey create-key --key-id area1.reader --display-name "Area 1 reader" --scopes invoke:read,metadata:read --read-subtree "Area1/*" --browse-subtree "Area1/*" mxgateway apikey list-keys --json mxgateway apikey revoke-key --key-id ops.alice mxgateway apikey rotate-key --key-id ops.alice ``` +Constraint flags are optional. `--read-subtree`, `--write-subtree`, +`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable. +`--max-write-classification` accepts one integer. `--read-alarm-only` and +`--read-historized-only` are boolean flags. Existing rows with null +constraints remain fully unconstrained after migration. + Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling. ## Scope Serialization diff --git a/docs/Authorization.md b/docs/Authorization.md index 9265313..c212bfc 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -1,6 +1,8 @@ # Gateway gRPC Authorization -The authorization subsystem enforces per-RPC scope checks against the authenticated `ApiKeyIdentity` produced by the authentication layer, so service implementations never need to repeat permission logic. +The authorization subsystem has two layers. The gRPC interceptor enforces the +verb scope required by the RPC. Service-layer constraint checks then narrow +what an authenticated API key can browse, read, or write inside the Galaxy. ## Overview @@ -12,6 +14,8 @@ The participating types live under `src/MxGateway.Server/Security/Authorization/ - `GatewayGrpcScopeResolver` maps a request message (and, for `MxCommandRequest`, the inner `MxCommandKind`) to the scope string that must be present on the caller. - `GatewayScopes` exposes the canonical scope constants used by the resolver and any downstream consumer. - `GatewayRequestIdentityAccessor` and `IGatewayRequestIdentityAccessor` expose the verified identity to handlers and any service code that runs inside the call. +- `IConstraintEnforcer` applies optional API-key constraints against the + cached Galaxy hierarchy from service bodies. - `GrpcAuthorizationServiceCollectionExtensions` wires the components into the DI container and the gRPC pipeline. The `ApiKeyIdentity` consumed here is produced by the authentication layer; see [Authentication](./Authentication.md) for how it is built and how scopes are persisted. @@ -21,7 +25,9 @@ The `ApiKeyIdentity` consumed here is produced by the authentication layer; see Centralizing the policy in `GatewayGrpcAuthorizationInterceptor` produces three concrete benefits: 1. Every RPC defined in `MxAccessGatewayService` is covered by construction. A new RPC inherits the check the moment its request type is added to `GatewayGrpcScopeResolver`, instead of relying on each service method to remember to call an authorization helper. -2. The service class stays a thin translator between proto contracts and domain calls. RPC methods do not branch on identity or scope, which keeps the AGENTS.md guideline that gRPC handlers contain no policy. +2. Verb-scope policy stays centralized. Request-specific constraints still run + in service bodies because they need command payloads, item handles, and + Galaxy metadata that the interceptor should not inspect. 3. Authentication and authorization happen in one place, so the gRPC `Status` mapping is consistent. A failed key check always returns `Unauthenticated`, and a missing scope always returns `PermissionDenied` with the offending scope name. ## Interceptor Flow @@ -131,6 +137,43 @@ private static string ResolveCommandScope(MxCommandKind kind) Reads (`Register`, `AddItem`, `Advise`, and any other unspecified kind) fall through to `InvokeRead`, which keeps the matrix small while still separating reads from writes, secured writes, metadata lookups, event drains, and worker shutdown. +## Constraint Enforcement + +`ApiKeyIdentity.Constraints` is optional. Empty constraints preserve the +previous behavior: the key is authorized only by its verb scopes. Non-empty +constraints are stored as JSON in `api_keys.constraints` and are applied by +`IConstraintEnforcer` after the interceptor succeeds. + +Supported constraints are: + +| Constraint | Meaning | +|------------|---------| +| `read_subtrees` | Contained-path globs allowed for read/subscription commands. | +| `write_subtrees` | Contained-path globs allowed for write commands. | +| `read_tag_globs` | Tag-address globs allowed for read/subscription commands. | +| `write_tag_globs` | Tag-address globs allowed for write commands. | +| `max_write_classification` | Maximum Galaxy attribute `security_classification` a key may write. | +| `browse_subtrees` | Contained-path globs used to filter Galaxy browse results and deploy-event counts. | +| `read_alarm_only` | Read/subscription commands must target objects with alarm-bearing attributes. | +| `read_historized_only` | Read/subscription commands must target objects with historized attributes. | + +Glob matching is anchored, case-insensitive, and supports `*` and `?`. +Subtree and tag glob lists are alternatives: matching either list allows that +scope dimension. Empty lists mean unconstrained for that dimension. + +The service checks read constraints for `AddItem`, `AddItem2`, `AddItemBulk`, +`SubscribeBulk`, and `AdviseItemBulk`. It checks write constraints for +`Write`, `Write2`, `WriteSecured`, and `WriteSecured2`. Successful item +registrations are tracked per session so later item-handle commands resolve +back to the original tag address. If a constrained key presents an unknown item +handle, the gateway fails closed. + +Non-bulk constraint failures return gRPC `PermissionDenied`. Bulk read +commands preserve input order and return a failed `SubscribeResult` for each +denied item while still forwarding allowed items to the worker. Every denial +adds an `api_key_audit` entry with the key id, command kind, target, and +blocking constraint; secured values and raw credentials are never logged. + ## Scope Catalog `GatewayScopes` is the single source of truth for scope strings. Every entry is currently mapped by either the resolver or another security component: diff --git a/docs/GalaxyRepository.md b/docs/GalaxyRepository.md index a438f6c..71dc5d6 100644 --- a/docs/GalaxyRepository.md +++ b/docs/GalaxyRepository.md @@ -37,10 +37,19 @@ The service is defined in `DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size` and `page_token`; the server defaults omitted page size to 1000 objects and -caps every page at 5000 objects. Invalid page tokens and negative page sizes -return `InvalidArgument`. Official high-level clients preserve the older +caps every page at 5000 objects. Page tokens bind to the cache sequence and the +active filter set, so changing filters between pages returns `InvalidArgument` +instead of mixing snapshots. Official high-level clients preserve the older "return the full hierarchy" behavior by looping pages internally. +The request can also slice the cached hierarchy without running new SQL. A +caller may choose one root (`root_gobject_id`, `root_tag_name`, or +`root_contained_path`) and may combine that with `max_depth`, category ids, +template-chain substring filters, an anchored case-insensitive tag-name glob, +alarm-only, historized-only, and `include_attributes = false` for a skeleton +tree. All filters are applied with AND semantics, and `total_object_count` +reports the post-filter count. + ## Hierarchy Cache The gateway holds a single shared `IGalaxyHierarchyCache` @@ -145,7 +154,19 @@ message GalaxyAttribute { message DiscoverHierarchyRequest { int32 page_size = 1; // omitted/0 uses the server default of 1000 - string page_token = 2; // opaque offset token returned by the previous page + string page_token = 2; // opaque token returned by the previous page + oneof root { + int32 root_gobject_id = 3; + string root_tag_name = 4; + string root_contained_path = 5; + } + google.protobuf.Int32Value max_depth = 6; + repeated int32 category_ids = 7; + repeated string template_chain_contains = 8; + string tag_name_glob = 9; + optional bool include_attributes = 10; + bool alarm_bearing_only = 11; + bool historized_only = 12; } message DiscoverHierarchyReply { @@ -249,6 +270,11 @@ privilege to `MxCommandKind.GetSessionState` or `MxCommandKind.GetWorkerInfo`. The mapping lives in `GatewayGrpcScopeResolver`; see [Authorization](./Authorization.md) for the full scope catalog. +API keys can also carry `browse_subtrees` constraints. `DiscoverHierarchy` +intersects those contained-path globs with the caller's request filters. +`WatchDeployEvents` still emits deploy notifications, but its object and +attribute counts are scoped to the caller's browsable subtrees. + A request without an API key returns `Unauthenticated`. A request with a key that lacks `metadata:read` returns `PermissionDenied` with the missing scope embedded in the status detail. diff --git a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs b/src/MxGateway.Contracts/Generated/GalaxyRepository.cs index a2ada0d..3224a65 100644 --- a/src/MxGateway.Contracts/Generated/GalaxyRepository.cs +++ b/src/MxGateway.Contracts/Generated/GalaxyRepository.cs @@ -25,55 +25,63 @@ namespace MxGateway.Contracts.Proto.Galaxy { byte[] descriptorData = global::System.Convert.FromBase64String( string.Concat( "ChdnYWxheHlfcmVwb3NpdG9yeS5wcm90bxIUZ2FsYXh5X3JlcG9zaXRvcnku", - "djEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJvdG8iFwoVVGVzdENv", - "bm5lY3Rpb25SZXF1ZXN0IiEKE1Rlc3RDb25uZWN0aW9uUmVwbHkSCgoCb2sY", - "ASABKAgiGgoYR2V0TGFzdERlcGxveVRpbWVSZXF1ZXN0ImIKFkdldExhc3RE", - "ZXBsb3lUaW1lUmVwbHkSDwoHcHJlc2VudBgBIAEoCBI3ChN0aW1lX29mX2xh", - "c3RfZGVwbG95GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCJB", - "ChhEaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3QSEQoJcGFnZV9zaXplGAEgASgF", - "EhIKCnBhZ2VfdG9rZW4YAiABKAkiggEKFkRpc2NvdmVySGllcmFyY2h5UmVw", - "bHkSMwoHb2JqZWN0cxgBIAMoCzIiLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdh", - "bGF4eU9iamVjdBIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSGgoSdG90YWxf", - "b2JqZWN0X2NvdW50GAMgASgFIlUKGFdhdGNoRGVwbG95RXZlbnRzUmVxdWVz", - "dBI5ChVsYXN0X3NlZW5fZGVwbG95X3RpbWUYASABKAsyGi5nb29nbGUucHJv", - "dG9idWYuVGltZXN0YW1wIt0BCgtEZXBsb3lFdmVudBIQCghzZXF1ZW5jZRgB", - "IAEoBBIvCgtvYnNlcnZlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", - "aW1lc3RhbXASNwoTdGltZV9vZl9sYXN0X2RlcGxveRgDIAEoCzIaLmdvb2ds", - "ZS5wcm90b2J1Zi5UaW1lc3RhbXASIwobdGltZV9vZl9sYXN0X2RlcGxveV9w", - "cmVzZW50GAQgASgIEhQKDG9iamVjdF9jb3VudBgFIAEoBRIXCg9hdHRyaWJ1", - "dGVfY291bnQYBiABKAUikwIKDEdhbGF4eU9iamVjdBISCgpnb2JqZWN0X2lk", - "GAEgASgFEhAKCHRhZ19uYW1lGAIgASgJEhYKDmNvbnRhaW5lZF9uYW1lGAMg", - "ASgJEhMKC2Jyb3dzZV9uYW1lGAQgASgJEhkKEXBhcmVudF9nb2JqZWN0X2lk", - "GAUgASgFEg8KB2lzX2FyZWEYBiABKAgSEwoLY2F0ZWdvcnlfaWQYByABKAUS", - "HAoUaG9zdGVkX2J5X2dvYmplY3RfaWQYCCABKAUSFgoOdGVtcGxhdGVfY2hh", - "aW4YCSADKAkSOQoKYXR0cmlidXRlcxgKIAMoCzIlLmdhbGF4eV9yZXBvc2l0", - "b3J5LnYxLkdhbGF4eUF0dHJpYnV0ZSKoAgoPR2FsYXh5QXR0cmlidXRlEhYK", - "DmF0dHJpYnV0ZV9uYW1lGAEgASgJEhoKEmZ1bGxfdGFnX3JlZmVyZW5jZRgC", - "IAEoCRIUCgxteF9kYXRhX3R5cGUYAyABKAUSFgoOZGF0YV90eXBlX25hbWUY", - "BCABKAkSEAoIaXNfYXJyYXkYBSABKAgSFwoPYXJyYXlfZGltZW5zaW9uGAYg", - "ASgFEh8KF2FycmF5X2RpbWVuc2lvbl9wcmVzZW50GAcgASgIEh0KFW14X2F0", - "dHJpYnV0ZV9jYXRlZ29yeRgIIAEoBRIfChdzZWN1cml0eV9jbGFzc2lmaWNh", - "dGlvbhgJIAEoBRIVCg1pc19oaXN0b3JpemVkGAogASgIEhAKCGlzX2FsYXJt", - "GAsgASgIMswDChBHYWxheHlSZXBvc2l0b3J5EmgKDlRlc3RDb25uZWN0aW9u", - "EisuZ2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXF1ZXN0", - "GikuZ2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXBseRJx", - "ChFHZXRMYXN0RGVwbG95VGltZRIuLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdl", - "dExhc3REZXBsb3lUaW1lUmVxdWVzdBosLmdhbGF4eV9yZXBvc2l0b3J5LnYx", - "LkdldExhc3REZXBsb3lUaW1lUmVwbHkScQoRRGlzY292ZXJIaWVyYXJjaHkS", - "Li5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcXVl", - "c3QaLC5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJl", - "cGx5EmgKEVdhdGNoRGVwbG95RXZlbnRzEi4uZ2FsYXh5X3JlcG9zaXRvcnku", - "djEuV2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0GiEuZ2FsYXh5X3JlcG9zaXRv", - "cnkudjEuRGVwbG95RXZlbnQwAUIjqgIgTXhHYXRld2F5LkNvbnRyYWN0cy5Q", - "cm90by5HYWxheHliBnByb3RvMw==")); + "djEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJvdG8aHmdvb2dsZS9w", + "cm90b2J1Zi93cmFwcGVycy5wcm90byIXChVUZXN0Q29ubmVjdGlvblJlcXVl", + "c3QiIQoTVGVzdENvbm5lY3Rpb25SZXBseRIKCgJvaxgBIAEoCCIaChhHZXRM", + "YXN0RGVwbG95VGltZVJlcXVlc3QiYgoWR2V0TGFzdERlcGxveVRpbWVSZXBs", + "eRIPCgdwcmVzZW50GAEgASgIEjcKE3RpbWVfb2ZfbGFzdF9kZXBsb3kYAiAB", + "KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIocDChhEaXNjb3Zlckhp", + "ZXJhcmNoeVJlcXVlc3QSEQoJcGFnZV9zaXplGAEgASgFEhIKCnBhZ2VfdG9r", + "ZW4YAiABKAkSGQoPcm9vdF9nb2JqZWN0X2lkGAMgASgFSAASFwoNcm9vdF90", + "YWdfbmFtZRgEIAEoCUgAEh0KE3Jvb3RfY29udGFpbmVkX3BhdGgYBSABKAlI", + "ABIuCgltYXhfZGVwdGgYBiABKAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJW", + "YWx1ZRIUCgxjYXRlZ29yeV9pZHMYByADKAUSHwoXdGVtcGxhdGVfY2hhaW5f", + "Y29udGFpbnMYCCADKAkSFQoNdGFnX25hbWVfZ2xvYhgJIAEoCRIfChJpbmNs", + "dWRlX2F0dHJpYnV0ZXMYCiABKAhIAYgBARIaChJhbGFybV9iZWFyaW5nX29u", + "bHkYCyABKAgSFwoPaGlzdG9yaXplZF9vbmx5GAwgASgIQgYKBHJvb3RCFQoT", + "X2luY2x1ZGVfYXR0cmlidXRlcyKCAQoWRGlzY292ZXJIaWVyYXJjaHlSZXBs", + "eRIzCgdvYmplY3RzGAEgAygLMiIuZ2FsYXh5X3JlcG9zaXRvcnkudjEuR2Fs", + "YXh5T2JqZWN0EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRIaChJ0b3RhbF9v", + "YmplY3RfY291bnQYAyABKAUiVQoYV2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0", + "EjkKFWxhc3Rfc2Vlbl9kZXBsb3lfdGltZRgBIAEoCzIaLmdvb2dsZS5wcm90", + "b2J1Zi5UaW1lc3RhbXAi3QEKC0RlcGxveUV2ZW50EhAKCHNlcXVlbmNlGAEg", + "ASgEEi8KC29ic2VydmVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp", + "bWVzdGFtcBI3ChN0aW1lX29mX2xhc3RfZGVwbG95GAMgASgLMhouZ29vZ2xl", + "LnByb3RvYnVmLlRpbWVzdGFtcBIjCht0aW1lX29mX2xhc3RfZGVwbG95X3By", + "ZXNlbnQYBCABKAgSFAoMb2JqZWN0X2NvdW50GAUgASgFEhcKD2F0dHJpYnV0", + "ZV9jb3VudBgGIAEoBSKTAgoMR2FsYXh5T2JqZWN0EhIKCmdvYmplY3RfaWQY", + "ASABKAUSEAoIdGFnX25hbWUYAiABKAkSFgoOY29udGFpbmVkX25hbWUYAyAB", + "KAkSEwoLYnJvd3NlX25hbWUYBCABKAkSGQoRcGFyZW50X2dvYmplY3RfaWQY", + "BSABKAUSDwoHaXNfYXJlYRgGIAEoCBITCgtjYXRlZ29yeV9pZBgHIAEoBRIc", + "ChRob3N0ZWRfYnlfZ29iamVjdF9pZBgIIAEoBRIWCg50ZW1wbGF0ZV9jaGFp", + "bhgJIAMoCRI5CgphdHRyaWJ1dGVzGAogAygLMiUuZ2FsYXh5X3JlcG9zaXRv", + "cnkudjEuR2FsYXh5QXR0cmlidXRlIqgCCg9HYWxheHlBdHRyaWJ1dGUSFgoO", + "YXR0cmlidXRlX25hbWUYASABKAkSGgoSZnVsbF90YWdfcmVmZXJlbmNlGAIg", + "ASgJEhQKDG14X2RhdGFfdHlwZRgDIAEoBRIWCg5kYXRhX3R5cGVfbmFtZRgE", + "IAEoCRIQCghpc19hcnJheRgFIAEoCBIXCg9hcnJheV9kaW1lbnNpb24YBiAB", + "KAUSHwoXYXJyYXlfZGltZW5zaW9uX3ByZXNlbnQYByABKAgSHQoVbXhfYXR0", + "cmlidXRlX2NhdGVnb3J5GAggASgFEh8KF3NlY3VyaXR5X2NsYXNzaWZpY2F0", + "aW9uGAkgASgFEhUKDWlzX2hpc3Rvcml6ZWQYCiABKAgSEAoIaXNfYWxhcm0Y", + "CyABKAgyzAMKEEdhbGF4eVJlcG9zaXRvcnkSaAoOVGVzdENvbm5lY3Rpb24S", + "Ky5nYWxheHlfcmVwb3NpdG9yeS52MS5UZXN0Q29ubmVjdGlvblJlcXVlc3Qa", + "KS5nYWxheHlfcmVwb3NpdG9yeS52MS5UZXN0Q29ubmVjdGlvblJlcGx5EnEK", + "EUdldExhc3REZXBsb3lUaW1lEi4uZ2FsYXh5X3JlcG9zaXRvcnkudjEuR2V0", + "TGFzdERlcGxveVRpbWVSZXF1ZXN0GiwuZ2FsYXh5X3JlcG9zaXRvcnkudjEu", + "R2V0TGFzdERlcGxveVRpbWVSZXBseRJxChFEaXNjb3ZlckhpZXJhcmNoeRIu", + "LmdhbGF4eV9yZXBvc2l0b3J5LnYxLkRpc2NvdmVySGllcmFyY2h5UmVxdWVz", + "dBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkRpc2NvdmVySGllcmFyY2h5UmVw", + "bHkSaAoRV2F0Y2hEZXBsb3lFdmVudHMSLi5nYWxheHlfcmVwb3NpdG9yeS52", + "MS5XYXRjaERlcGxveUV2ZW50c1JlcXVlc3QaIS5nYWxheHlfcmVwb3NpdG9y", + "eS52MS5EZXBsb3lFdmVudDABQiOqAiBNeEdhdGV3YXkuQ29udHJhY3RzLlBy", + "b3RvLkdhbGF4eWIGcHJvdG8z")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, - new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, + new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser, new[]{ "Ok" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser, new[]{ "Present", "TimeOfLastDeploy" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken", "RootGobjectId", "RootTagName", "RootContainedPath", "MaxDepth", "CategoryIds", "TemplateChainContains", "TagNameGlob", "IncludeAttributes", "AlarmBearingOnly", "HistorizedOnly" }, new[]{ "Root", "IncludeAttributes" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects", "NextPageToken", "TotalObjectCount" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest), global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser, new[]{ "LastSeenDeployTime" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DeployEvent), global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser, new[]{ "Sequence", "ObservedAt", "TimeOfLastDeploy", "TimeOfLastDeployPresent", "ObjectCount", "AttributeCount" }, null, null, null, null), @@ -857,6 +865,7 @@ namespace MxGateway.Contracts.Proto.Galaxy { { private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new DiscoverHierarchyRequest()); private pb::UnknownFieldSet _unknownFields; + private int _hasBits0; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pb::MessageParser Parser { get { return _parser; } } @@ -884,8 +893,28 @@ namespace MxGateway.Contracts.Proto.Galaxy { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public DiscoverHierarchyRequest(DiscoverHierarchyRequest other) : this() { + _hasBits0 = other._hasBits0; pageSize_ = other.pageSize_; pageToken_ = other.pageToken_; + MaxDepth = other.MaxDepth; + categoryIds_ = other.categoryIds_.Clone(); + templateChainContains_ = other.templateChainContains_.Clone(); + tagNameGlob_ = other.tagNameGlob_; + includeAttributes_ = other.includeAttributes_; + alarmBearingOnly_ = other.alarmBearingOnly_; + historizedOnly_ = other.historizedOnly_; + switch (other.RootCase) { + case RootOneofCase.RootGobjectId: + RootGobjectId = other.RootGobjectId; + break; + case RootOneofCase.RootTagName: + RootTagName = other.RootTagName; + break; + case RootOneofCase.RootContainedPath: + RootContainedPath = other.RootContainedPath; + break; + } + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -926,6 +955,227 @@ namespace MxGateway.Contracts.Proto.Galaxy { } } + /// Field number for the "root_gobject_id" field. + public const int RootGobjectIdFieldNumber = 3; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int RootGobjectId { + get { return HasRootGobjectId ? (int) root_ : 0; } + set { + root_ = value; + rootCase_ = RootOneofCase.RootGobjectId; + } + } + /// Gets whether the "root_gobject_id" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasRootGobjectId { + get { return rootCase_ == RootOneofCase.RootGobjectId; } + } + /// Clears the value of the oneof if it's currently set to "root_gobject_id" + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearRootGobjectId() { + if (HasRootGobjectId) { + ClearRoot(); + } + } + + /// Field number for the "root_tag_name" field. + public const int RootTagNameFieldNumber = 4; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string RootTagName { + get { return HasRootTagName ? (string) root_ : ""; } + set { + root_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + rootCase_ = RootOneofCase.RootTagName; + } + } + /// Gets whether the "root_tag_name" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasRootTagName { + get { return rootCase_ == RootOneofCase.RootTagName; } + } + /// Clears the value of the oneof if it's currently set to "root_tag_name" + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearRootTagName() { + if (HasRootTagName) { + ClearRoot(); + } + } + + /// Field number for the "root_contained_path" field. + public const int RootContainedPathFieldNumber = 5; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string RootContainedPath { + get { return HasRootContainedPath ? (string) root_ : ""; } + set { + root_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + rootCase_ = RootOneofCase.RootContainedPath; + } + } + /// Gets whether the "root_contained_path" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasRootContainedPath { + get { return rootCase_ == RootOneofCase.RootContainedPath; } + } + /// Clears the value of the oneof if it's currently set to "root_contained_path" + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearRootContainedPath() { + if (HasRootContainedPath) { + ClearRoot(); + } + } + + /// Field number for the "max_depth" field. + public const int MaxDepthFieldNumber = 6; + private static readonly pb::FieldCodec _single_maxDepth_codec = pb::FieldCodec.ForStructWrapper(50); + private int? maxDepth_; + /// + /// Optional. Cap on descendant depth from root. Zero returns only the root. + /// Unset means unlimited depth. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int? MaxDepth { + get { return maxDepth_; } + set { + maxDepth_ = value; + } + } + + + /// Field number for the "category_ids" field. + public const int CategoryIdsFieldNumber = 7; + private static readonly pb::FieldCodec _repeated_categoryIds_codec + = pb::FieldCodec.ForInt32(58); + private readonly pbc::RepeatedField categoryIds_ = new pbc::RepeatedField(); + /// + /// Optional object category id filters. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField CategoryIds { + get { return categoryIds_; } + } + + /// Field number for the "template_chain_contains" field. + public const int TemplateChainContainsFieldNumber = 8; + private static readonly pb::FieldCodec _repeated_templateChainContains_codec + = pb::FieldCodec.ForString(66); + private readonly pbc::RepeatedField templateChainContains_ = new pbc::RepeatedField(); + /// + /// Optional case-insensitive substring filters against template names. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField TemplateChainContains { + get { return templateChainContains_; } + } + + /// Field number for the "tag_name_glob" field. + public const int TagNameGlobFieldNumber = 9; + private string tagNameGlob_ = ""; + /// + /// Optional anchored, case-insensitive glob over object tag_name. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string TagNameGlob { + get { return tagNameGlob_; } + set { + tagNameGlob_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "include_attributes" field. + public const int IncludeAttributesFieldNumber = 10; + private readonly static bool IncludeAttributesDefaultValue = false; + + private bool includeAttributes_; + /// + /// Optional. Unset or true includes attributes. False returns object skeletons. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool IncludeAttributes { + get { if ((_hasBits0 & 1) != 0) { return includeAttributes_; } else { return IncludeAttributesDefaultValue; } } + set { + _hasBits0 |= 1; + includeAttributes_ = value; + } + } + /// Gets whether the "include_attributes" field is set + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HasIncludeAttributes { + get { return (_hasBits0 & 1) != 0; } + } + /// Clears the value of the "include_attributes" field + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearIncludeAttributes() { + _hasBits0 &= ~1; + } + + /// Field number for the "alarm_bearing_only" field. + public const int AlarmBearingOnlyFieldNumber = 11; + private bool alarmBearingOnly_; + /// + /// Optional. Return only objects with at least one alarm-bearing attribute. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool AlarmBearingOnly { + get { return alarmBearingOnly_; } + set { + alarmBearingOnly_ = value; + } + } + + /// Field number for the "historized_only" field. + public const int HistorizedOnlyFieldNumber = 12; + private bool historizedOnly_; + /// + /// Optional. Return only objects with at least one historized attribute. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool HistorizedOnly { + get { return historizedOnly_; } + set { + historizedOnly_ = value; + } + } + + private object root_; + /// Enum of possible cases for the "root" oneof. + public enum RootOneofCase { + None = 0, + RootGobjectId = 3, + RootTagName = 4, + RootContainedPath = 5, + } + private RootOneofCase rootCase_ = RootOneofCase.None; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public RootOneofCase RootCase { + get { return rootCase_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void ClearRoot() { + rootCase_ = RootOneofCase.None; + root_ = null; + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -943,6 +1193,17 @@ namespace MxGateway.Contracts.Proto.Galaxy { } if (PageSize != other.PageSize) return false; if (PageToken != other.PageToken) return false; + if (RootGobjectId != other.RootGobjectId) return false; + if (RootTagName != other.RootTagName) return false; + if (RootContainedPath != other.RootContainedPath) return false; + if (MaxDepth != other.MaxDepth) return false; + if(!categoryIds_.Equals(other.categoryIds_)) return false; + if(!templateChainContains_.Equals(other.templateChainContains_)) return false; + if (TagNameGlob != other.TagNameGlob) return false; + if (IncludeAttributes != other.IncludeAttributes) return false; + if (AlarmBearingOnly != other.AlarmBearingOnly) return false; + if (HistorizedOnly != other.HistorizedOnly) return false; + if (RootCase != other.RootCase) return false; return Equals(_unknownFields, other._unknownFields); } @@ -952,6 +1213,17 @@ namespace MxGateway.Contracts.Proto.Galaxy { int hash = 1; if (PageSize != 0) hash ^= PageSize.GetHashCode(); if (PageToken.Length != 0) hash ^= PageToken.GetHashCode(); + if (HasRootGobjectId) hash ^= RootGobjectId.GetHashCode(); + if (HasRootTagName) hash ^= RootTagName.GetHashCode(); + if (HasRootContainedPath) hash ^= RootContainedPath.GetHashCode(); + if (maxDepth_ != null) hash ^= MaxDepth.GetHashCode(); + hash ^= categoryIds_.GetHashCode(); + hash ^= templateChainContains_.GetHashCode(); + if (TagNameGlob.Length != 0) hash ^= TagNameGlob.GetHashCode(); + if (HasIncludeAttributes) hash ^= IncludeAttributes.GetHashCode(); + if (AlarmBearingOnly != false) hash ^= AlarmBearingOnly.GetHashCode(); + if (HistorizedOnly != false) hash ^= HistorizedOnly.GetHashCode(); + hash ^= (int) rootCase_; if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -978,6 +1250,39 @@ namespace MxGateway.Contracts.Proto.Galaxy { output.WriteRawTag(18); output.WriteString(PageToken); } + if (HasRootGobjectId) { + output.WriteRawTag(24); + output.WriteInt32(RootGobjectId); + } + if (HasRootTagName) { + output.WriteRawTag(34); + output.WriteString(RootTagName); + } + if (HasRootContainedPath) { + output.WriteRawTag(42); + output.WriteString(RootContainedPath); + } + if (maxDepth_ != null) { + _single_maxDepth_codec.WriteTagAndValue(output, MaxDepth); + } + categoryIds_.WriteTo(output, _repeated_categoryIds_codec); + templateChainContains_.WriteTo(output, _repeated_templateChainContains_codec); + if (TagNameGlob.Length != 0) { + output.WriteRawTag(74); + output.WriteString(TagNameGlob); + } + if (HasIncludeAttributes) { + output.WriteRawTag(80); + output.WriteBool(IncludeAttributes); + } + if (AlarmBearingOnly != false) { + output.WriteRawTag(88); + output.WriteBool(AlarmBearingOnly); + } + if (HistorizedOnly != false) { + output.WriteRawTag(96); + output.WriteBool(HistorizedOnly); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -996,6 +1301,39 @@ namespace MxGateway.Contracts.Proto.Galaxy { output.WriteRawTag(18); output.WriteString(PageToken); } + if (HasRootGobjectId) { + output.WriteRawTag(24); + output.WriteInt32(RootGobjectId); + } + if (HasRootTagName) { + output.WriteRawTag(34); + output.WriteString(RootTagName); + } + if (HasRootContainedPath) { + output.WriteRawTag(42); + output.WriteString(RootContainedPath); + } + if (maxDepth_ != null) { + _single_maxDepth_codec.WriteTagAndValue(ref output, MaxDepth); + } + categoryIds_.WriteTo(ref output, _repeated_categoryIds_codec); + templateChainContains_.WriteTo(ref output, _repeated_templateChainContains_codec); + if (TagNameGlob.Length != 0) { + output.WriteRawTag(74); + output.WriteString(TagNameGlob); + } + if (HasIncludeAttributes) { + output.WriteRawTag(80); + output.WriteBool(IncludeAttributes); + } + if (AlarmBearingOnly != false) { + output.WriteRawTag(88); + output.WriteBool(AlarmBearingOnly); + } + if (HistorizedOnly != false) { + output.WriteRawTag(96); + output.WriteBool(HistorizedOnly); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -1012,6 +1350,32 @@ namespace MxGateway.Contracts.Proto.Galaxy { if (PageToken.Length != 0) { size += 1 + pb::CodedOutputStream.ComputeStringSize(PageToken); } + if (HasRootGobjectId) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(RootGobjectId); + } + if (HasRootTagName) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(RootTagName); + } + if (HasRootContainedPath) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(RootContainedPath); + } + if (maxDepth_ != null) { + size += _single_maxDepth_codec.CalculateSizeWithTag(MaxDepth); + } + size += categoryIds_.CalculateSize(_repeated_categoryIds_codec); + size += templateChainContains_.CalculateSize(_repeated_templateChainContains_codec); + if (TagNameGlob.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(TagNameGlob); + } + if (HasIncludeAttributes) { + size += 1 + 1; + } + if (AlarmBearingOnly != false) { + size += 1 + 1; + } + if (HistorizedOnly != false) { + size += 1 + 1; + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -1030,6 +1394,37 @@ namespace MxGateway.Contracts.Proto.Galaxy { if (other.PageToken.Length != 0) { PageToken = other.PageToken; } + if (other.maxDepth_ != null) { + if (maxDepth_ == null || other.MaxDepth != 0) { + MaxDepth = other.MaxDepth; + } + } + categoryIds_.Add(other.categoryIds_); + templateChainContains_.Add(other.templateChainContains_); + if (other.TagNameGlob.Length != 0) { + TagNameGlob = other.TagNameGlob; + } + if (other.HasIncludeAttributes) { + IncludeAttributes = other.IncludeAttributes; + } + if (other.AlarmBearingOnly != false) { + AlarmBearingOnly = other.AlarmBearingOnly; + } + if (other.HistorizedOnly != false) { + HistorizedOnly = other.HistorizedOnly; + } + switch (other.RootCase) { + case RootOneofCase.RootGobjectId: + RootGobjectId = other.RootGobjectId; + break; + case RootOneofCase.RootTagName: + RootTagName = other.RootTagName; + break; + case RootOneofCase.RootContainedPath: + RootContainedPath = other.RootContainedPath; + break; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -1057,6 +1452,50 @@ namespace MxGateway.Contracts.Proto.Galaxy { PageToken = input.ReadString(); break; } + case 24: { + RootGobjectId = input.ReadInt32(); + break; + } + case 34: { + RootTagName = input.ReadString(); + break; + } + case 42: { + RootContainedPath = input.ReadString(); + break; + } + case 50: { + int? value = _single_maxDepth_codec.Read(input); + if (maxDepth_ == null || value != 0) { + MaxDepth = value; + } + break; + } + case 58: + case 56: { + categoryIds_.AddEntriesFrom(input, _repeated_categoryIds_codec); + break; + } + case 66: { + templateChainContains_.AddEntriesFrom(input, _repeated_templateChainContains_codec); + break; + } + case 74: { + TagNameGlob = input.ReadString(); + break; + } + case 80: { + IncludeAttributes = input.ReadBool(); + break; + } + case 88: { + AlarmBearingOnly = input.ReadBool(); + break; + } + case 96: { + HistorizedOnly = input.ReadBool(); + break; + } } } #endif @@ -1084,6 +1523,50 @@ namespace MxGateway.Contracts.Proto.Galaxy { PageToken = input.ReadString(); break; } + case 24: { + RootGobjectId = input.ReadInt32(); + break; + } + case 34: { + RootTagName = input.ReadString(); + break; + } + case 42: { + RootContainedPath = input.ReadString(); + break; + } + case 50: { + int? value = _single_maxDepth_codec.Read(ref input); + if (maxDepth_ == null || value != 0) { + MaxDepth = value; + } + break; + } + case 58: + case 56: { + categoryIds_.AddEntriesFrom(ref input, _repeated_categoryIds_codec); + break; + } + case 66: { + templateChainContains_.AddEntriesFrom(ref input, _repeated_templateChainContains_codec); + break; + } + case 74: { + TagNameGlob = input.ReadString(); + break; + } + case 80: { + IncludeAttributes = input.ReadBool(); + break; + } + case 88: { + AlarmBearingOnly = input.ReadBool(); + break; + } + case 96: { + HistorizedOnly = input.ReadBool(); + break; + } } } } diff --git a/src/MxGateway.Contracts/Protos/galaxy_repository.proto b/src/MxGateway.Contracts/Protos/galaxy_repository.proto index 1ae9b9d..3701f04 100644 --- a/src/MxGateway.Contracts/Protos/galaxy_repository.proto +++ b/src/MxGateway.Contracts/Protos/galaxy_repository.proto @@ -5,6 +5,7 @@ package galaxy_repository.v1; option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; // Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL // database). Lets clients enumerate the deployed object hierarchy and each @@ -43,6 +44,28 @@ message DiscoverHierarchyRequest { int32 page_size = 1; // Opaque token returned by a previous DiscoverHierarchy response. string page_token = 2; + // Optional. When set, return only this object and its descendants. + // Empty = full hierarchy. + oneof root { + int32 root_gobject_id = 3; + string root_tag_name = 4; + string root_contained_path = 5; + } + // Optional. Cap on descendant depth from root. Zero returns only the root. + // Unset means unlimited depth. + google.protobuf.Int32Value max_depth = 6; + // Optional object category id filters. + repeated int32 category_ids = 7; + // Optional case-insensitive substring filters against template names. + repeated string template_chain_contains = 8; + // Optional anchored, case-insensitive glob over object tag_name. + string tag_name_glob = 9; + // Optional. Unset or true includes attributes. False returns object skeletons. + optional bool include_attributes = 10; + // Optional. Return only objects with at least one alarm-bearing attribute. + bool alarm_bearing_only = 11; + // Optional. Return only objects with at least one historized attribute. + bool historized_only = 12; } message DiscoverHierarchyReply { diff --git a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs index b773604..c181111 100644 --- a/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs +++ b/src/MxGateway.IntegrationTests/WorkerLiveMxAccessSmokeTests.cs @@ -9,6 +9,7 @@ using MxGateway.Contracts.Proto; using MxGateway.Server.Configuration; using MxGateway.Server.Grpc; using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; @@ -248,6 +249,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) Service = new MxAccessGatewayService( sessionManager, new GatewayRequestIdentityAccessor(), + new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), mapper, eventStreamService, @@ -515,4 +517,33 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output) } } } + + private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer + { + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor index 1a9106e..6a45d0f 100644 --- a/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor +++ b/src/MxGateway.Server/Dashboard/Components/Layout/DashboardLayout.razor @@ -26,6 +26,9 @@ + diff --git a/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor b/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor new file mode 100644 index 0000000..06eebcd --- /dev/null +++ b/src/MxGateway.Server/Dashboard/Components/Pages/ApiKeysPage.razor @@ -0,0 +1,99 @@ +@page "/apikeys" +@page "/dashboard/apikeys" +@inherits DashboardPageBase + +Dashboard API Keys + +@if (Snapshot is null) +{ +
Loading API keys.
+} +else +{ +
+
+

API Keys

+
@Snapshot.ApiKeys.Count key rows
+
+
+ +
+ @if (Snapshot.ApiKeys.Count == 0) + { +
No API keys are available for display.
+ } + else + { +
+ + + + + + + + + + + + + + @foreach (DashboardApiKeySummary key in Snapshot.ApiKeys) + { + + + + + + + + + + } + +
KeyStatusDisplay NameScopesConstraintsCreatedLast Used
@key.KeyId@DashboardDisplay.Text(key.DisplayName)@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))@DashboardDisplay.Text(ConstraintText(key.Constraints))@DashboardDisplay.DateTime(key.CreatedUtc)@DashboardDisplay.DateTime(key.LastUsedUtc)
+
+ } +
+} + +@code { + private static string ConstraintText(MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints) + { + if (constraints.IsEmpty) + { + return "unconstrained"; + } + + List parts = []; + AddList(parts, "read_subtrees", constraints.ReadSubtrees); + AddList(parts, "write_subtrees", constraints.WriteSubtrees); + AddList(parts, "read_tag_globs", constraints.ReadTagGlobs); + AddList(parts, "write_tag_globs", constraints.WriteTagGlobs); + AddList(parts, "browse_subtrees", constraints.BrowseSubtrees); + if (constraints.MaxWriteClassification is { } max) + { + parts.Add($"max_write_classification={max}"); + } + + if (constraints.ReadAlarmOnly) + { + parts.Add("read_alarm_only"); + } + + if (constraints.ReadHistorizedOnly) + { + parts.Add("read_historized_only"); + } + + return string.Join("; ", parts); + } + + private static void AddList(List parts, string name, IReadOnlyList values) + { + if (values.Count > 0) + { + parts.Add($"{name}=[{string.Join(", ", values)}]"); + } + } +} diff --git a/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs b/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs new file mode 100644 index 0000000..4125b50 --- /dev/null +++ b/src/MxGateway.Server/Dashboard/DashboardApiKeySummary.cs @@ -0,0 +1,12 @@ +using MxGateway.Server.Security.Authentication; + +namespace MxGateway.Server.Dashboard; + +public sealed record DashboardApiKeySummary( + string KeyId, + string DisplayName, + IReadOnlySet Scopes, + ApiKeyConstraints Constraints, + DateTimeOffset CreatedUtc, + DateTimeOffset? LastUsedUtc, + DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs b/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs index 8f30de7..3a54ab7 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs +++ b/src/MxGateway.Server/Dashboard/DashboardSnapshot.cs @@ -12,5 +12,6 @@ public sealed record DashboardSnapshot( IReadOnlyList Workers, IReadOnlyList Metrics, IReadOnlyList Faults, + IReadOnlyList ApiKeys, EffectiveGatewayConfiguration Configuration, DashboardGalaxySummary Galaxy); diff --git a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs index f996503..8cbfefe 100644 --- a/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using MxGateway.Server.Configuration; using MxGateway.Server.Galaxy; using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authentication; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; @@ -16,6 +17,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService private readonly GatewayMetrics _metrics; private readonly IGatewayConfigurationProvider _configurationProvider; private readonly IGalaxyHierarchyCache _galaxyHierarchyCache; + private readonly IApiKeyAdminStore _apiKeyAdminStore; private readonly TimeProvider _timeProvider; private readonly DateTimeOffset _gatewayStartedAt; private readonly TimeSpan _snapshotInterval; @@ -27,6 +29,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService GatewayMetrics metrics, IGatewayConfigurationProvider configurationProvider, IGalaxyHierarchyCache galaxyHierarchyCache, + IApiKeyAdminStore apiKeyAdminStore, IOptions options, TimeProvider? timeProvider = null) { @@ -34,6 +37,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics)); _configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider)); _galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache)); + _apiKeyAdminStore = apiKeyAdminStore ?? throw new ArgumentNullException(nameof(apiKeyAdminStore)); ArgumentNullException.ThrowIfNull(options); _timeProvider = timeProvider ?? TimeProvider.System; @@ -69,6 +73,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService Workers: workerSummaries, Metrics: CreateMetricSummaries(metricsSnapshot), Faults: CreateFaultSummaries(sessions, generatedAt), + ApiKeys: CreateApiKeySummaries(), Configuration: _configurationProvider.GetEffectiveConfiguration(), Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current)); } @@ -192,6 +197,29 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService .ToArray(); } + private IReadOnlyList CreateApiKeySummaries() + { + try + { + return _apiKeyAdminStore.ListAsync(CancellationToken.None) + .GetAwaiter() + .GetResult() + .Select(key => new DashboardApiKeySummary( + KeyId: key.KeyId, + DisplayName: key.DisplayName, + Scopes: key.Scopes, + Constraints: key.Constraints, + CreatedUtc: key.CreatedUtc, + LastUsedUtc: key.LastUsedUtc, + RevokedUtc: key.RevokedUtc)) + .ToArray(); + } + catch (Exception) + { + return Array.Empty(); + } + } + private static bool HasFault(GatewaySession session) { return session.State == MxGateway.Contracts.Proto.SessionState.Faulted diff --git a/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs b/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs new file mode 100644 index 0000000..51a0955 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyGlobMatcher.cs @@ -0,0 +1,44 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace MxGateway.Server.Galaxy; + +public static class GalaxyGlobMatcher +{ + public static bool IsMatch(string value, string glob) + { + if (string.IsNullOrWhiteSpace(glob)) + { + return true; + } + + return Regex.IsMatch( + value ?? string.Empty, + BuildRegex(glob), + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromMilliseconds(100)); + } + + private static string BuildRegex(string glob) + { + StringBuilder builder = new("^", glob.Length + 2); + foreach (char character in glob) + { + switch (character) + { + case '*': + builder.Append(".*"); + break; + case '?': + builder.Append('.'); + break; + default: + builder.Append(Regex.Escape(character.ToString())); + break; + } + } + + builder.Append('$'); + return builder.ToString(); + } +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs new file mode 100644 index 0000000..c2f3d30 --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyProjector.cs @@ -0,0 +1,287 @@ +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public static class GalaxyHierarchyProjector +{ + public static GalaxyHierarchyQueryResult Project( + GalaxyHierarchyCacheEntry entry, + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs = null) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(request); + + IReadOnlyList views = BuildViews(entry.Objects); + ObjectView? root = ResolveRoot(request, views); + int? maxDepth = request.MaxDepth; + if (maxDepth < 0) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy max_depth must be greater than or equal to zero when provided.")); + } + + List filtered = []; + foreach (ObjectView view in views) + { + if (!MatchesRoot(view, root, maxDepth) + || !MatchesBrowseSubtrees(view, browseSubtreeGlobs) + || !MatchesFilters(view.Object, request)) + { + continue; + } + + filtered.Add(CloneObject(view.Object, IncludeAttributes(request))); + } + + return new GalaxyHierarchyQueryResult( + filtered, + filtered.Count, + ComputeFilterSignature(request, browseSubtreeGlobs)); + } + + public static GalaxyObject? FindObjectForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + foreach (GalaxyObject obj in entry.Objects) + { + if (string.Equals(obj.TagName, tagAddress, StringComparison.OrdinalIgnoreCase)) + { + return obj; + } + + foreach (GalaxyAttribute attribute in obj.Attributes) + { + if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase)) + { + return obj; + } + } + } + + return null; + } + + public static GalaxyAttribute? FindAttributeForTag( + GalaxyHierarchyCacheEntry entry, + string tagAddress) + { + if (string.IsNullOrWhiteSpace(tagAddress)) + { + return null; + } + + foreach (GalaxyObject obj in entry.Objects) + { + foreach (GalaxyAttribute attribute in obj.Attributes) + { + if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase)) + { + return attribute; + } + } + } + + return null; + } + + public static string GetContainedPath( + GalaxyHierarchyCacheEntry entry, + int gobjectId) + { + return BuildViews(entry.Objects) + .FirstOrDefault(view => view.Object.GobjectId == gobjectId) + ?.ContainedPath ?? string.Empty; + } + + private static IReadOnlyList BuildViews(IReadOnlyList objects) + { + Dictionary byId = objects.ToDictionary(obj => obj.GobjectId); + List views = new(objects.Count); + foreach (GalaxyObject obj in objects) + { + string path = BuildContainedPath(obj, byId); + int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/'); + views.Add(new ObjectView(obj, path, depth)); + } + + return views; + } + + private static string BuildContainedPath( + GalaxyObject obj, + IReadOnlyDictionary byId) + { + Stack names = new(); + HashSet seen = []; + GalaxyObject? current = obj; + while (current is not null && seen.Add(current.GobjectId)) + { + names.Push(ResolvePathSegment(current)); + current = current.ParentGobjectId != 0 && byId.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent) + ? parent + : null; + } + + return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name))); + } + + private static string ResolvePathSegment(GalaxyObject obj) + { + if (!string.IsNullOrWhiteSpace(obj.ContainedName)) + { + return obj.ContainedName; + } + + if (!string.IsNullOrWhiteSpace(obj.BrowseName)) + { + return obj.BrowseName; + } + + return obj.TagName; + } + + private static ObjectView? ResolveRoot( + DiscoverHierarchyRequest request, + IReadOnlyList views) + { + ObjectView? root = request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.None => null, + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault( + view => view.Object.GobjectId == request.RootGobjectId), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault( + view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)), + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault( + view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)), + _ => null, + }; + + if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null) + { + throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found.")); + } + + return root; + } + + private static bool MatchesRoot( + ObjectView view, + ObjectView? root, + int? maxDepth) + { + if (root is null) + { + return true; + } + + bool isRoot = view.Object.GobjectId == root.Object.GobjectId; + bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase); + if (!isRoot && !isDescendant) + { + return false; + } + + return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value; + } + + private static bool MatchesBrowseSubtrees( + ObjectView view, + IReadOnlyList? browseSubtreeGlobs) + { + return browseSubtreeGlobs is null + || browseSubtreeGlobs.Count == 0 + || browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob)); + } + + private static bool MatchesFilters( + GalaxyObject obj, + DiscoverHierarchyRequest request) + { + if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId)) + { + return false; + } + + foreach (string templateFilter in request.TemplateChainContains) + { + if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(request.TagNameGlob) + && !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob)) + { + return false; + } + + if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) + { + return false; + } + + if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) + { + return false; + } + + return true; + } + + private static bool IncludeAttributes(DiscoverHierarchyRequest request) + { + return !request.HasIncludeAttributes || request.IncludeAttributes; + } + + private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes) + { + GalaxyObject clone = source.Clone(); + if (!includeAttributes) + { + clone.Attributes.Clear(); + } + + return clone; + } + + private static string ComputeFilterSignature( + DiscoverHierarchyRequest request, + IReadOnlyList? browseSubtreeGlobs) + { + StringBuilder builder = new(); + builder.Append("root=").Append(request.RootCase).Append('|'); + builder.Append(request.RootCase switch + { + DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString( + System.Globalization.CultureInfo.InvariantCulture), + DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName, + DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath, + _ => string.Empty, + }); + builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? ""); + builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order()); + builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase)); + builder.Append("|glob=").Append(request.TagNameGlob); + builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset"); + builder.Append("|alarm=").Append(request.AlarmBearingOnly); + builder.Append("|hist=").Append(request.HistorizedOnly); + builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty()).Order(StringComparer.OrdinalIgnoreCase)); + + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())); + return Convert.ToHexString(hash, 0, 12); + } + + private sealed record ObjectView(GalaxyObject Object, string ContainedPath, int Depth); +} diff --git a/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs b/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs new file mode 100644 index 0000000..90f0c0a --- /dev/null +++ b/src/MxGateway.Server/Galaxy/GalaxyHierarchyQueryResult.cs @@ -0,0 +1,8 @@ +using MxGateway.Contracts.Proto.Galaxy; + +namespace MxGateway.Server.Galaxy; + +public sealed record GalaxyHierarchyQueryResult( + IReadOnlyList Objects, + int TotalObjectCount, + string FilterSignature); diff --git a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs index 60ca68f..2fb17f0 100644 --- a/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs +++ b/src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs @@ -3,6 +3,8 @@ using Grpc.Core; using Microsoft.Data.SqlClient; using MxGateway.Contracts.Proto.Galaxy; using GalaxyDb = MxGateway.Server.Galaxy; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository; namespace MxGateway.Server.Grpc; @@ -18,6 +20,7 @@ public sealed class GalaxyRepositoryGrpcService( GalaxyDb.GalaxyRepository repository, GalaxyDb.IGalaxyHierarchyCache cache, GalaxyDb.IGalaxyDeployNotifier notifier, + IGatewayRequestIdentityAccessor identityAccessor, ILogger logger) : ProtoGalaxyRepository.GalaxyRepositoryBase { private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5); @@ -68,9 +71,15 @@ public sealed class GalaxyRepositoryGrpcService( ResolveUnavailableMessage(entry))); } - PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence); + IReadOnlyList browseSubtrees = ResolveBrowseSubtrees(); + GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project( + entry, + request, + browseSubtrees); + + PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, query.FilterSignature); int offset = pageToken.Offset; - if (offset > entry.Objects.Count) + if (offset > query.Objects.Count) { throw new RpcException(new Status( StatusCode.InvalidArgument, @@ -78,20 +87,20 @@ public sealed class GalaxyRepositoryGrpcService( } int pageSize = ResolvePageSize(request.PageSize); - int take = Math.Min(pageSize, entry.Objects.Count - offset); + int take = Math.Min(pageSize, query.Objects.Count - offset); DiscoverHierarchyReply reply = new() { - TotalObjectCount = entry.Objects.Count, + TotalObjectCount = query.TotalObjectCount, }; for (int index = offset; index < offset + take; index++) { - reply.Objects.Add(entry.Objects[index].Clone()); + reply.Objects.Add(query.Objects[index]); } int nextOffset = offset + take; - if (nextOffset < entry.Objects.Count) + if (nextOffset < query.Objects.Count) { - reply.NextPageToken = FormatPageToken(entry.Sequence, nextOffset); + reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset); } return reply; @@ -118,7 +127,7 @@ public sealed class GalaxyRepositoryGrpcService( } lastSeen = null; - await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false); + await responseStream.WriteAsync(MapDeployEvent(info, ResolveBrowseSubtrees()), context.CancellationToken).ConfigureAwait(false); } } @@ -146,14 +155,28 @@ public sealed class GalaxyRepositoryGrpcService( } } - private static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info) + private DeployEvent MapDeployEvent( + GalaxyDb.GalaxyDeployEventInfo info, + IReadOnlyList browseSubtrees) { + int objectCount = info.ObjectCount; + int attributeCount = info.AttributeCount; + if (browseSubtrees.Count > 0 && cache.Current.HasData) + { + GalaxyDb.GalaxyHierarchyQueryResult scoped = GalaxyDb.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) @@ -183,30 +206,38 @@ public sealed class GalaxyRepositoryGrpcService( return Math.Min(pageSize, MaxDiscoverPageSize); } - private static string FormatPageToken(long sequence, int offset) + private IReadOnlyList ResolveBrowseSubtrees() + { + ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + return constraints.BrowseSubtrees; + } + + private static string FormatPageToken(long sequence, string filterSignature, int offset) { return string.Concat( sequence.ToString(System.Globalization.CultureInfo.InvariantCulture), ":", + filterSignature, + ":", offset.ToString(System.Globalization.CultureInfo.InvariantCulture)); } - private static PageToken ParsePageToken(string pageToken, long currentSequence) + private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature) { if (string.IsNullOrWhiteSpace(pageToken)) { - return new PageToken(currentSequence, Offset: 0); + return new PageToken(currentSequence, currentFilterSignature, Offset: 0); } - string[] parts = pageToken.Split(':', count: 2); - if (parts.Length != 2 + string[] parts = pageToken.Split(':', count: 3); + if (parts.Length != 3 || !long.TryParse( parts[0], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out long sequence) || !int.TryParse( - parts[1], + parts[2], System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int offset) @@ -224,10 +255,17 @@ public sealed class GalaxyRepositoryGrpcService( "DiscoverHierarchy page_token is stale.")); } - return new PageToken(sequence, offset); + if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal)) + { + throw new RpcException(new Status( + StatusCode.InvalidArgument, + "DiscoverHierarchy page_token does not match the current filters.")); + } + + return new PageToken(sequence, parts[1], offset); } - private sealed record PageToken(long Sequence, int Offset); + private sealed record PageToken(long Sequence, string FilterSignature, int Offset); [System.Diagnostics.CodeAnalysis.SuppressMessage( "Style", diff --git a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs index 53f7f0f..a21fbbb 100644 --- a/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs +++ b/src/MxGateway.Server/Grpc/MxAccessGatewayService.cs @@ -1,8 +1,10 @@ using System.Diagnostics; using Grpc.Core; +using Google.Protobuf.WellKnownTypes; using MxGateway.Contracts; using MxGateway.Contracts.Proto; using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; @@ -12,6 +14,7 @@ namespace MxGateway.Server.Grpc; public sealed class MxAccessGatewayService( ISessionManager sessionManager, IGatewayRequestIdentityAccessor identityAccessor, + IConstraintEnforcer constraintEnforcer, MxAccessGrpcRequestValidator requestValidator, MxAccessGrpcMapper mapper, IEventStreamService eventStreamService, @@ -87,12 +90,35 @@ public sealed class MxAccessGatewayService( try { requestValidator.ValidateInvoke(request); - WorkerCommand workerCommand = mapper.MapCommand(request); + GatewaySession session = ResolveSession(request.SessionId); + MxCommand command = request.Command; + BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync( + session, + command, + context.CancellationToken) + .ConfigureAwait(false); + + MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command; + if (bulkConstraintPlan is { HasAllowedItems: false }) + { + return CreateDeniedBulkReply(request, bulkConstraintPlan); + } + + MxCommandRequest invokeRequest = request.Clone(); + invokeRequest.Command = commandToInvoke; + WorkerCommand workerCommand = mapper.MapCommand(invokeRequest); WorkerCommandReply workerReply = await sessionManager .InvokeAsync(request.SessionId, workerCommand, context.CancellationToken) .ConfigureAwait(false); - return mapper.MapCommandReply(workerReply); + MxCommandReply publicReply = mapper.MapCommandReply(workerReply); + if (bulkConstraintPlan is not null) + { + publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan); + } + + session.TrackCommandReply(commandToInvoke, publicReply); + return publicReply; } catch (Exception exception) when (exception is not RpcException) { @@ -129,6 +155,323 @@ public sealed class MxAccessGatewayService( return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId; } + private GatewaySession ResolveSession(string sessionId) + { + if (!sessionManager.TryGetSession(sessionId, out GatewaySession session)) + { + throw new SessionManagerException( + SessionManagerErrorCode.SessionNotFound, + $"Session {sessionId} was not found."); + } + + return session; + } + + private async Task ApplyConstraintsAsync( + GatewaySession session, + MxCommand command, + CancellationToken cancellationToken) + { + ApiKeyIdentity? identity = identityAccessor.Current; + switch (command.Kind) + { + case MxCommandKind.AddItem: + await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, cancellationToken) + .ConfigureAwait(false); + return null; + case MxCommandKind.AddItem2: + await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, cancellationToken) + .ConfigureAwait(false); + return null; + case MxCommandKind.AddItemBulk: + return await FilterTagBulkAsync( + identity, + command, + command.AddItemBulk.ServerHandle, + command.AddItemBulk.TagAddresses, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.SubscribeBulk: + return await FilterTagBulkAsync( + identity, + command, + command.SubscribeBulk.ServerHandle, + command.SubscribeBulk.TagAddresses, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.AdviseItemBulk: + return await FilterHandleBulkAsync( + identity, + session, + command, + command.AdviseItemBulk.ServerHandle, + command.AdviseItemBulk.ItemHandles, + cancellationToken) + .ConfigureAwait(false); + case MxCommandKind.Write: + await EnforceWriteHandleAsync( + identity, + session, + command.Kind, + command.Write.ServerHandle, + command.Write.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + return null; + case MxCommandKind.Write2: + await EnforceWriteHandleAsync( + identity, + session, + command.Kind, + command.Write2.ServerHandle, + command.Write2.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + return null; + case MxCommandKind.WriteSecured: + await EnforceWriteHandleAsync( + identity, + session, + command.Kind, + command.WriteSecured.ServerHandle, + command.WriteSecured.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + return null; + case MxCommandKind.WriteSecured2: + await EnforceWriteHandleAsync( + identity, + session, + command.Kind, + command.WriteSecured2.ServerHandle, + command.WriteSecured2.ItemHandle, + cancellationToken) + .ConfigureAwait(false); + return null; + default: + return null; + } + } + + private async Task EnforceReadTagAsync( + ApiKeyIdentity? identity, + MxCommandKind commandKind, + string tagAddress, + CancellationToken cancellationToken) + { + ConstraintFailure? failure = await constraintEnforcer + .CheckReadTagAsync(identity, tagAddress, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + return; + } + + await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, cancellationToken) + .ConfigureAwait(false); + throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message)); + } + + private async Task EnforceWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + MxCommandKind commandKind, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ConstraintFailure? failure = await constraintEnforcer + .CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + return; + } + + await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken) + .ConfigureAwait(false); + throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message)); + } + + private async Task FilterTagBulkAsync( + ApiKeyIdentity? identity, + MxCommand command, + int serverHandle, + IReadOnlyList tagAddresses, + CancellationToken cancellationToken) + { + Dictionary denied = []; + List allowed = []; + for (int index = 0; index < tagAddresses.Count; index++) + { + string tagAddress = tagAddresses[index]; + ConstraintFailure? failure = await constraintEnforcer + .CheckReadTagAsync(identity, tagAddress, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + allowed.Add(tagAddress); + continue; + } + + await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken) + .ConfigureAwait(false); + denied[index] = new SubscribeResult + { + ServerHandle = serverHandle, + TagAddress = tagAddress, + WasSuccessful = false, + ErrorMessage = failure.Message, + }; + } + + if (denied.Count == 0) + { + return null; + } + + MxCommand filtered = command.Clone(); + if (filtered.Kind == MxCommandKind.AddItemBulk) + { + filtered.AddItemBulk.TagAddresses.Clear(); + filtered.AddItemBulk.TagAddresses.Add(allowed); + } + else + { + filtered.SubscribeBulk.TagAddresses.Clear(); + filtered.SubscribeBulk.TagAddresses.Add(allowed); + } + + return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0); + } + + private async Task FilterHandleBulkAsync( + ApiKeyIdentity? identity, + GatewaySession session, + MxCommand command, + int serverHandle, + IReadOnlyList itemHandles, + CancellationToken cancellationToken) + { + Dictionary denied = []; + List allowed = []; + for (int index = 0; index < itemHandles.Count; index++) + { + int itemHandle = itemHandles[index]; + ConstraintFailure? failure = await constraintEnforcer + .CheckReadHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken) + .ConfigureAwait(false); + if (failure is null) + { + allowed.Add(itemHandle); + continue; + } + + await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken) + .ConfigureAwait(false); + denied[index] = new SubscribeResult + { + ServerHandle = serverHandle, + ItemHandle = itemHandle, + WasSuccessful = false, + ErrorMessage = failure.Message, + }; + } + + if (denied.Count == 0) + { + return null; + } + + MxCommand filtered = command.Clone(); + filtered.AdviseItemBulk.ItemHandles.Clear(); + filtered.AdviseItemBulk.ItemHandles.Add(allowed); + + return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0); + } + + private static MxCommandReply CreateDeniedBulkReply( + MxCommandRequest request, + BulkConstraintPlan plan) + { + MxCommandReply reply = new() + { + SessionId = request.SessionId, + CorrelationId = request.ClientCorrelationId, + Kind = request.Command.Kind, + ProtocolStatus = MxAccessGrpcMapper.Ok(), + }; + SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan)); + return reply; + } + + private static MxCommandReply MergeDeniedBulkResults( + MxCommandReply reply, + MxCommandKind commandKind, + BulkConstraintPlan plan) + { + BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply(); + SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan)); + return reply; + } + + private static BulkSubscribeReply BuildMergedBulkReply( + BulkSubscribeReply allowed, + BulkConstraintPlan plan) + { + Queue allowedResults = new(allowed.Results); + BulkSubscribeReply merged = new(); + for (int index = 0; index < plan.OriginalCount; index++) + { + if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied)) + { + merged.Results.Add(denied); + } + else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult)) + { + merged.Results.Add(allowedResult); + } + } + + return merged; + } + + private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind) + { + return commandKind switch + { + MxCommandKind.AddItemBulk => reply.AddItemBulk, + MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk, + MxCommandKind.SubscribeBulk => reply.SubscribeBulk, + _ => null, + }; + } + + private static void SetBulkPayload( + MxCommandReply reply, + MxCommandKind commandKind, + BulkSubscribeReply payload) + { + switch (commandKind) + { + case MxCommandKind.AddItemBulk: + reply.AddItemBulk = payload; + break; + case MxCommandKind.AdviseItemBulk: + reply.AdviseItemBulk = payload; + break; + case MxCommandKind.SubscribeBulk: + reply.SubscribeBulk = payload; + break; + } + } + + private sealed record BulkConstraintPlan( + MxCommand Command, + int OriginalCount, + IReadOnlyDictionary DeniedResults, + bool HasAllowedItems); + private RpcException MapException(Exception exception) { if (exception is OperationCanceledException) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs index 828ec7e..650a951 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -58,6 +58,7 @@ public sealed class ApiKeyAdminCliRunner( SecretHash: hasher.HashSecret(secret), DisplayName: Required(command.DisplayName), Scopes: command.Scopes, + Constraints: command.Constraints, CreatedUtc: DateTimeOffset.UtcNow), cancellationToken) .ConfigureAwait(false); @@ -163,6 +164,7 @@ public sealed class ApiKeyAdminCliRunner( KeyPrefix: key.KeyPrefix, DisplayName: key.DisplayName, Scopes: key.Scopes, + Constraints: key.Constraints, CreatedUtc: key.CreatedUtc, LastUsedUtc: key.LastUsedUtc, RevokedUtc: key.RevokedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs index 1337743..4369591 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommand.cs @@ -7,4 +7,5 @@ public sealed record ApiKeyAdminCommand( string? Pepper, string? KeyId, string? DisplayName, - IReadOnlySet Scopes); + IReadOnlySet Scopes, + ApiKeyConstraints Constraints); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs index 0384a8f..ef07d5e 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminCommandLineParser.cs @@ -19,7 +19,7 @@ public static class ApiKeyAdminCommandLineParser return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'."); } - Dictionary options = new(StringComparer.OrdinalIgnoreCase); + Dictionary> options = new(StringComparer.OrdinalIgnoreCase); bool json = false; for (int index = 2; index < args.Count; index++) @@ -49,18 +49,42 @@ public static class ApiKeyAdminCommandLineParser { if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal)) { - return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value."); + if (IsBooleanConstraintFlag(name)) + { + value = "true"; + } + else + { + return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value."); + } + } + else + { + value = args[++index]; } - - value = args[++index]; } - options[name] = value; + if (!options.TryGetValue(name, out List? values)) + { + values = []; + options[name] = values; + } + + values.Add(value); } string? keyId = GetOption(options, "key-id"); string? displayName = GetOption(options, "display-name"); IReadOnlySet scopes = ParseScopes(GetOption(options, "scopes")); + ApiKeyConstraints constraints; + try + { + constraints = ParseConstraints(options); + } + catch (FormatException exception) + { + return ApiKeyAdminParseResult.Fail(exception.Message); + } string? validationError = Validate(kind, keyId, displayName); if (validationError is not null) @@ -75,7 +99,8 @@ public static class ApiKeyAdminCommandLineParser Pepper: GetOption(options, "pepper"), KeyId: keyId, DisplayName: displayName, - Scopes: scopes)); + Scopes: scopes, + Constraints: constraints)); } private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind) @@ -144,9 +169,56 @@ public static class ApiKeyAdminCommandLineParser || character is '.' or '-'); } - private static string? GetOption(Dictionary options, string name) + private static string? GetOption(Dictionary> options, string name) { - return options.TryGetValue(name, out string? value) ? value : null; + return options.TryGetValue(name, out List? values) && values.Count > 0 ? values[^1] : null; + } + + private static IReadOnlyList GetOptions(Dictionary> options, string name) + { + return options.TryGetValue(name, out List? values) + ? values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).ToArray() + : Array.Empty(); + } + + private static bool HasFlag(Dictionary> options, string name) + { + return options.ContainsKey(name); + } + + private static bool IsBooleanConstraintFlag(string name) + { + return string.Equals(name, "read-alarm-only", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "read-historized-only", StringComparison.OrdinalIgnoreCase); + } + + private static ApiKeyConstraints ParseConstraints(Dictionary> options) + { + return new ApiKeyConstraints( + ReadSubtrees: GetOptions(options, "read-subtree"), + WriteSubtrees: GetOptions(options, "write-subtree"), + ReadTagGlobs: GetOptions(options, "read-tag-glob"), + WriteTagGlobs: GetOptions(options, "write-tag-glob"), + MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")), + BrowseSubtrees: GetOptions(options, "browse-subtree"), + ReadAlarmOnly: HasFlag(options, "read-alarm-only"), + ReadHistorizedOnly: HasFlag(options, "read-historized-only")); + } + + private static int? ParseNullableInt(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return int.TryParse( + value, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int parsed) + ? parsed + : throw new FormatException("--max-write-classification must be an integer."); } private static IReadOnlySet ParseScopes(string? scopes) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs index 8b94d3e..286d51d 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyAdminListedKey.cs @@ -5,6 +5,7 @@ public sealed record ApiKeyAdminListedKey( string KeyPrefix, string DisplayName, IReadOnlySet Scopes, + ApiKeyConstraints Constraints, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs new file mode 100644 index 0000000..935e79a --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraintSerializer.cs @@ -0,0 +1,28 @@ +using System.Text.Json; + +namespace MxGateway.Server.Security.Authentication; + +public static class ApiKeyConstraintSerializer +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + WriteIndented = false, + }; + + public static string? Serialize(ApiKeyConstraints constraints) + { + ArgumentNullException.ThrowIfNull(constraints); + return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions); + } + + public static ApiKeyConstraints Deserialize(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return ApiKeyConstraints.Empty; + } + + return JsonSerializer.Deserialize(json, JsonOptions) ?? ApiKeyConstraints.Empty; + } +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs new file mode 100644 index 0000000..80acdae --- /dev/null +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyConstraints.cs @@ -0,0 +1,43 @@ +namespace MxGateway.Server.Security.Authentication; + +public sealed record ApiKeyConstraints( + IReadOnlyList ReadSubtrees, + IReadOnlyList WriteSubtrees, + IReadOnlyList ReadTagGlobs, + IReadOnlyList WriteTagGlobs, + int? MaxWriteClassification, + IReadOnlyList BrowseSubtrees, + bool ReadAlarmOnly, + bool ReadHistorizedOnly) +{ + public static ApiKeyConstraints Empty { get; } = new( + ReadSubtrees: Array.Empty(), + WriteSubtrees: Array.Empty(), + ReadTagGlobs: Array.Empty(), + WriteTagGlobs: Array.Empty(), + MaxWriteClassification: null, + BrowseSubtrees: Array.Empty(), + ReadAlarmOnly: false, + ReadHistorizedOnly: false); + + public bool IsEmpty => + ReadSubtrees.Count == 0 + && WriteSubtrees.Count == 0 + && ReadTagGlobs.Count == 0 + && WriteTagGlobs.Count == 0 + && MaxWriteClassification is null + && BrowseSubtrees.Count == 0 + && !ReadAlarmOnly + && !ReadHistorizedOnly; + + public bool HasReadConstraints => + ReadSubtrees.Count > 0 + || ReadTagGlobs.Count > 0 + || ReadAlarmOnly + || ReadHistorizedOnly; + + public bool HasWriteConstraints => + WriteSubtrees.Count > 0 + || WriteTagGlobs.Count > 0 + || MaxWriteClassification is not null; +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs index 785f7c1..fa3d32d 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs @@ -6,4 +6,5 @@ public sealed record ApiKeyCreateRequest( byte[] SecretHash, string DisplayName, IReadOnlySet Scopes, + ApiKeyConstraints Constraints, DateTimeOffset CreatedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs index caab0d4..1d1ee53 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyIdentity.cs @@ -4,4 +4,8 @@ public sealed record ApiKeyIdentity( string KeyId, string KeyPrefix, string DisplayName, - IReadOnlySet Scopes); + IReadOnlySet Scopes, + ApiKeyConstraints? Constraints = null) +{ + public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty; +} diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs index e737994..a27ae57 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyRecord.cs @@ -6,6 +6,7 @@ public sealed record ApiKeyRecord( byte[] SecretHash, string DisplayName, IReadOnlySet Scopes, + ApiKeyConstraints Constraints, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc); diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs index 9ed9cc7..9248e77 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs @@ -12,9 +12,10 @@ public static class ApiKeyRecordReader SecretHash: (byte[])reader["secret_hash"], DisplayName: reader.GetString(3), Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)), - CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture), - LastUsedUtc: ReadNullableDateTimeOffset(reader, 6), - RevokedUtc: ReadNullableDateTimeOffset(reader, 7)); + Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)), + CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture), + LastUsedUtc: ReadNullableDateTimeOffset(reader, 7), + RevokedUtc: ReadNullableDateTimeOffset(reader, 8)); } private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal) diff --git a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs index a6354ec..51fab92 100644 --- a/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs +++ b/src/MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs @@ -52,6 +52,7 @@ public sealed class ApiKeyVerifier( KeyId: storedKey.KeyId, KeyPrefix: storedKey.KeyPrefix, DisplayName: storedKey.DisplayName, - Scopes: storedKey.Scopes)); + Scopes: storedKey.Scopes, + Constraints: storedKey.Constraints)); } } diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs index a5893f4..0d4fd6e 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs @@ -17,6 +17,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio secret_hash, display_name, scopes, + constraints, created_utc, last_used_utc, revoked_utc) @@ -26,6 +27,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio $secret_hash, $display_name, $scopes, + $constraints, $created_utc, NULL, NULL); @@ -42,7 +44,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio await using SqliteCommand command = connection.CreateCommand(); command.CommandText = """ - SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc + SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc FROM api_keys ORDER BY key_id; """; @@ -111,6 +113,9 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash; command.Parameters.AddWithValue("$display_name", request.DisplayName); command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes)); + command.Parameters.AddWithValue( + "$constraints", + (object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value); command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O")); } } diff --git a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs index 414c2b4..ad0aa95 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs @@ -42,12 +42,12 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact await using SqliteCommand command = connection.CreateCommand(); command.CommandText = requireActive ? """ - SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc + SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc FROM api_keys WHERE key_id = $key_id AND revoked_utc IS NULL; """ : """ - SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc + SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc FROM api_keys WHERE key_id = $key_id; """; diff --git a/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs b/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs index 6cca9fa..9b309e5 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs @@ -2,7 +2,7 @@ namespace MxGateway.Server.Security.Authentication; public static class SqliteAuthSchema { - public const int CurrentVersion = 1; + public const int CurrentVersion = 2; public const string SchemaVersionTable = "schema_version"; diff --git a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs b/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs index cb6bb13..3fcfe0d 100644 --- a/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs +++ b/src/MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs @@ -22,6 +22,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti } await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false); + await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false); + await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); } @@ -83,6 +85,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti secret_hash BLOB NOT NULL, display_name TEXT NOT NULL, scopes TEXT NOT NULL, + constraints TEXT NULL, created_utc TEXT NOT NULL, last_used_utc TEXT NULL, revoked_utc TEXT NULL @@ -105,6 +108,34 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti """, cancellationToken).ConfigureAwait(false); + } + + private static async Task ApplyVersionTwoAsync( + SqliteConnection connection, + SqliteTransaction transaction, + CancellationToken cancellationToken) + { + if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken) + .ConfigureAwait(false)) + { + return; + } + + await ExecuteNonQueryAsync( + connection, + transaction, + """ + ALTER TABLE api_keys + ADD COLUMN constraints TEXT NULL; + """, + cancellationToken).ConfigureAwait(false); + } + + private static async Task WriteSchemaVersionAsync( + SqliteConnection connection, + SqliteTransaction transaction, + CancellationToken cancellationToken) + { await using SqliteCommand versionCommand = connection.CreateCommand(); versionCommand.Transaction = transaction; versionCommand.CommandText = """ @@ -120,6 +151,31 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } + private static async Task ColumnExistsAsync( + SqliteConnection connection, + SqliteTransaction transaction, + string tableName, + string columnName, + CancellationToken cancellationToken) + { + await using SqliteCommand command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $"PRAGMA table_info({tableName});"; + + await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken) + .ConfigureAwait(false); + + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + private static async Task ExecuteNonQueryAsync( SqliteConnection connection, SqliteTransaction transaction, diff --git a/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs new file mode 100644 index 0000000..6d5bfcc --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -0,0 +1,160 @@ +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Security.Authorization; + +public sealed class ConstraintEnforcer( + IGalaxyHierarchyCache cache, + IApiKeyAuditStore auditStore) : IConstraintEnforcer +{ + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + return Task.FromResult(CheckReadTarget(constraints, tagAddress)); + } + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasReadConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); + } + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) + { + ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; + if (!constraints.HasWriteConstraints) + { + return Task.FromResult(null); + } + + if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) + { + return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); + } + + GalaxyHierarchyCacheEntry entry = cache.Current; + GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, registration.TagAddress); + if (obj is null) + { + return Task.FromResult(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); + } + + string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId); + if (!MatchesPathOrTag(containedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs)) + { + return Task.FromResult(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); + } + + if (constraints.MaxWriteClassification is { } maxClassification) + { + GalaxyAttribute? attribute = GalaxyHierarchyProjector.FindAttributeForTag(entry, registration.TagAddress); + if (attribute is null) + { + return Task.FromResult(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); + } + + if (attribute.SecurityClassification > maxClassification) + { + return Task.FromResult(new ConstraintFailure( + "max_write_classification", + $"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}.")); + } + } + + return Task.FromResult(null); + } + + public async Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) + { + await auditStore.AppendAsync( + new ApiKeyAuditEntry( + KeyId: identity?.KeyId, + EventType: "constraint-denied", + RemoteAddress: null, + Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"), + cancellationToken) + .ConfigureAwait(false); + } + + private ConstraintFailure? CheckReadTarget( + ApiKeyConstraints constraints, + string tagAddress) + { + GalaxyHierarchyCacheEntry entry = cache.Current; + GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, tagAddress); + if (obj is null) + { + return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."); + } + + string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId); + if (!MatchesPathOrTag(containedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs)) + { + return new ConstraintFailure("read_scope", "Tag is outside the API key read scope."); + } + + if (constraints.ReadAlarmOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm)) + { + return new ConstraintFailure("read_alarm_only", "Object has no alarm-bearing attributes."); + } + + if (constraints.ReadHistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized)) + { + return new ConstraintFailure("read_historized_only", "Object has no historized attributes."); + } + + return null; + } + + private static bool MatchesPathOrTag( + string containedPath, + string tagAddress, + IReadOnlyList subtreeGlobs, + IReadOnlyList tagGlobs) + { + bool hasSubtreeConstraint = subtreeGlobs.Count > 0; + bool hasTagConstraint = tagGlobs.Count > 0; + if (!hasSubtreeConstraint && !hasTagConstraint) + { + return true; + } + + return subtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(containedPath, glob)) + || tagGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(tagAddress, glob)); + } +} diff --git a/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs b/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs new file mode 100644 index 0000000..be5e614 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/ConstraintFailure.cs @@ -0,0 +1,3 @@ +namespace MxGateway.Server.Security.Authorization; + +public sealed record ConstraintFailure(string ConstraintName, string Message); diff --git a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs index 2f28b08..35e4784 100644 --- a/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs +++ b/src/MxGateway.Server/Security/Authorization/GrpcAuthorizationServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ public static class GrpcAuthorizationServiceCollectionExtensions { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services .AddOptions() diff --git a/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs b/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs new file mode 100644 index 0000000..c656634 --- /dev/null +++ b/src/MxGateway.Server/Security/Authorization/IConstraintEnforcer.cs @@ -0,0 +1,33 @@ +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Sessions; + +namespace MxGateway.Server.Security.Authorization; + +public interface IConstraintEnforcer +{ + Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken); + + Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken); + + Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken); +} diff --git a/src/MxGateway.Server/Sessions/GatewaySession.cs b/src/MxGateway.Server/Sessions/GatewaySession.cs index cd4682e..7dab82f 100644 --- a/src/MxGateway.Server/Sessions/GatewaySession.cs +++ b/src/MxGateway.Server/Sessions/GatewaySession.cs @@ -14,6 +14,7 @@ public sealed class GatewaySession private DateTimeOffset? _leaseExpiresAt; private bool _closeStarted; private int _activeEventSubscriberCount; + private readonly Dictionary<(int ServerHandle, int ItemHandle), SessionItemRegistration> _items = []; public GatewaySession( string sessionId, @@ -283,6 +284,58 @@ public sealed class GatewaySession return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false); } + public bool TryGetItemRegistration( + int serverHandle, + int itemHandle, + out SessionItemRegistration registration) + { + lock (_syncRoot) + { + return _items.TryGetValue((serverHandle, itemHandle), out registration!); + } + } + + public void TrackCommandReply( + MxCommand command, + MxCommandReply reply) + { + if (reply.ProtocolStatus?.Code is not ProtocolStatusCode.Ok) + { + return; + } + + lock (_syncRoot) + { + switch (command.Kind) + { + case MxCommandKind.AddItem when reply.AddItem is not null: + TrackItem(command.AddItem.ServerHandle, reply.AddItem.ItemHandle, command.AddItem.ItemDefinition); + break; + case MxCommandKind.AddItem2 when reply.AddItem2 is not null: + TrackItem(command.AddItem2.ServerHandle, reply.AddItem2.ItemHandle, command.AddItem2.ItemDefinition); + break; + case MxCommandKind.AddBufferedItem when reply.AddBufferedItem is not null: + TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, command.AddBufferedItem.ItemDefinition); + break; + case MxCommandKind.AddItemBulk when reply.AddItemBulk is not null: + TrackBulkItems(reply.AddItemBulk); + break; + case MxCommandKind.SubscribeBulk when reply.SubscribeBulk is not null: + TrackBulkItems(reply.SubscribeBulk); + break; + case MxCommandKind.RemoveItem: + _items.Remove((command.RemoveItem.ServerHandle, command.RemoveItem.ItemHandle)); + break; + case MxCommandKind.RemoveItemBulk: + RemoveItems(command.RemoveItemBulk.ServerHandle, command.RemoveItemBulk.ItemHandles); + break; + case MxCommandKind.UnsubscribeBulk: + RemoveItems(command.UnsubscribeBulk.ServerHandle, command.UnsubscribeBulk.ItemHandles); + break; + } + } + } + public Task> AddItemBulkAsync( int serverHandle, IReadOnlyList tagAddresses, @@ -521,6 +574,40 @@ public sealed class GatewaySession } } + private void TrackItem( + int serverHandle, + int itemHandle, + string tagAddress) + { + if (itemHandle == 0 || string.IsNullOrWhiteSpace(tagAddress)) + { + return; + } + + _items[(serverHandle, itemHandle)] = new SessionItemRegistration(serverHandle, itemHandle, tagAddress); + } + + private void TrackBulkItems(BulkSubscribeReply reply) + { + foreach (SubscribeResult result in reply.Results) + { + if (result.WasSuccessful) + { + TrackItem(result.ServerHandle, result.ItemHandle, result.TagAddress); + } + } + } + + private void RemoveItems( + int serverHandle, + IEnumerable itemHandles) + { + foreach (int itemHandle in itemHandles) + { + _items.Remove((serverHandle, itemHandle)); + } + } + private void DetachEventSubscriber() { lock (_syncRoot) diff --git a/src/MxGateway.Server/Sessions/SessionItemRegistration.cs b/src/MxGateway.Server/Sessions/SessionItemRegistration.cs new file mode 100644 index 0000000..83aad6b --- /dev/null +++ b/src/MxGateway.Server/Sessions/SessionItemRegistration.cs @@ -0,0 +1,6 @@ +namespace MxGateway.Server.Sessions; + +public sealed record SessionItemRegistration( + int ServerHandle, + int ItemHandle, + string TagAddress); diff --git a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index 387bcfd..a8efd7d 100644 --- a/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -4,6 +4,7 @@ using MxGateway.Server.Configuration; using MxGateway.Server.Dashboard; using MxGateway.Server.Galaxy; using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authentication; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; @@ -273,6 +274,7 @@ public sealed class DashboardSnapshotServiceTests metrics, configurationProvider, galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty), + new FakeApiKeyAdminStore(), Options.Create(resolvedOptions)); } @@ -285,6 +287,36 @@ public sealed class DashboardSnapshotServiceTests public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore + { + public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task> ListAsync(CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + + public Task RevokeAsync( + string keyId, + DateTimeOffset revokedUtc, + CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + + public Task RotateAsync( + string keyId, + byte[] secretHash, + DateTimeOffset rotatedUtc, + CancellationToken cancellationToken) + { + return Task.FromResult(false); + } + } + private static GatewaySession CreateSession( string sessionId, string? clientIdentity, diff --git a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs index 274ac67..f43c1ce 100644 --- a/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs +++ b/src/MxGateway.Tests/Gateway/GatewayEndToEndFakeWorkerSmokeTests.cs @@ -8,6 +8,7 @@ using MxGateway.Contracts.Proto; using MxGateway.Server.Configuration; using MxGateway.Server.Grpc; using MxGateway.Server.Metrics; +using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; using MxGateway.Server.Sessions; using MxGateway.Server.Workers; @@ -171,6 +172,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests Service = new MxAccessGatewayService( sessionManager, new GatewayRequestIdentityAccessor(), + new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), mapper, eventStreamService, @@ -437,4 +439,33 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests throw new NotSupportedException(); } } + + private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer + { + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs index 7aecc65..a8a0fa8 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/GalaxyRepositoryGrpcServiceTests.cs @@ -4,6 +4,7 @@ using MxGateway.Contracts.Proto.Galaxy; using MxGateway.Server.Dashboard; using MxGateway.Server.Galaxy; using MxGateway.Server.Grpc; +using MxGateway.Server.Security.Authorization; namespace MxGateway.Tests.Gateway.Grpc; @@ -24,7 +25,8 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(2, reply.Objects.Count); Assert.Equal("Object_001", reply.Objects[0].TagName); Assert.Equal("Object_002", reply.Objects[1].TagName); - Assert.Equal("7:2", reply.NextPageToken); + Assert.StartsWith("7:", reply.NextPageToken, StringComparison.Ordinal); + Assert.EndsWith(":2", reply.NextPageToken, StringComparison.Ordinal); Assert.Equal(3, reply.TotalObjectCount); } @@ -32,12 +34,18 @@ public sealed class GalaxyRepositoryGrpcServiceTests public async Task DiscoverHierarchy_WithNextPageToken_ReturnsRemainingObjects() { GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateObjects(3))); + DiscoverHierarchyReply firstPage = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 2, + }, + new TestServerCallContext()); DiscoverHierarchyReply reply = await service.DiscoverHierarchy( new DiscoverHierarchyRequest { PageSize = 2, - PageToken = "7:2", + PageToken = firstPage.NextPageToken, }, new TestServerCallContext()); @@ -71,6 +79,122 @@ public sealed class GalaxyRepositoryGrpcServiceTests Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); } + [Fact] + public async Task DiscoverHierarchy_WithSubtreeRootAndDepth_FiltersDescendants() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootContainedPath = "Area1/Line3", + MaxDepth = 1, + PageSize = 10, + }, + new TestServerCallContext()); + + Assert.Equal(["Line3", "Pump_001", "Valve_001"], reply.Objects.Select(obj => obj.TagName)); + Assert.Equal(3, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithServerSideFilters_AppliesAllFiltersAndOmitsAttributes() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply reply = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Area1", + TagNameGlob = "Pump_*", + AlarmBearingOnly = true, + HistorizedOnly = true, + IncludeAttributes = false, + PageSize = 10, + CategoryIds = { 10 }, + TemplateChainContains = { "Pump" }, + }, + new TestServerCallContext()); + + GalaxyObject obj = Assert.Single(reply.Objects); + Assert.Equal("Pump_001", obj.TagName); + Assert.Empty(obj.Attributes); + Assert.Equal(1, reply.TotalObjectCount); + } + + [Fact] + public async Task DiscoverHierarchy_WithFilteredPaging_ReturnsPostFilterTotal() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + DiscoverHierarchyReply second = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootGobjectId = 1, + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + GalaxyObject firstObject = Assert.Single(first.Objects); + GalaxyObject secondObject = Assert.Single(second.Objects); + Assert.Equal(2, first.TotalObjectCount); + Assert.Equal(2, second.TotalObjectCount); + Assert.NotEqual(firstObject.TagName, secondObject.TagName); + } + + [Fact] + public async Task DiscoverHierarchy_WithMismatchedFilterToken_ReturnsInvalidArgument() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + DiscoverHierarchyReply first = await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + CategoryIds = { 10 }, + }, + new TestServerCallContext()); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + PageSize = 1, + PageToken = first.NextPageToken, + CategoryIds = { 11 }, + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode); + Assert.Contains("filters", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DiscoverHierarchy_WithMissingRoot_ReturnsNotFound() + { + GalaxyRepositoryGrpcService service = CreateService(CreateEntry(CreateFilterObjects())); + + RpcException exception = await Assert.ThrowsAsync( + async () => await service.DiscoverHierarchy( + new DiscoverHierarchyRequest + { + RootTagName = "Missing", + }, + new TestServerCallContext())); + + Assert.Equal(StatusCode.NotFound, exception.StatusCode); + } + private static GalaxyRepositoryGrpcService CreateService(GalaxyHierarchyCacheEntry entry) { GalaxyRepositoryOptions options = new() @@ -81,6 +205,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests new global::MxGateway.Server.Galaxy.GalaxyRepository(options), new StubGalaxyHierarchyCache(entry), new GalaxyDeployNotifier(), + new GatewayRequestIdentityAccessor(), NullLogger.Instance); } @@ -113,6 +238,79 @@ public sealed class GalaxyRepositoryGrpcServiceTests .ToArray(); } + private static IReadOnlyList CreateFilterObjects() + { + return + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + BrowseName = "Area1", + IsArea = true, + CategoryId = 13, + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Line3", + ContainedName = "Line3", + BrowseName = "Line3", + ParentGobjectId = 1, + CategoryId = 10, + TemplateChain = { "$Line", "$Base" }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Pump_001", + ContainedName = "Pump", + BrowseName = "Pump_001", + ParentGobjectId = 2, + CategoryId = 10, + TemplateChain = { "$Pump", "$Base" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + IsAlarm = true, + IsHistorized = true, + SecurityClassification = 2, + }, + }, + }, + new GalaxyObject + { + GobjectId = 4, + TagName = "Valve_001", + ContainedName = "Valve", + BrowseName = "Valve_001", + ParentGobjectId = 2, + CategoryId = 11, + TemplateChain = { "$Valve" }, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Valve_001.PV", + }, + }, + }, + new GalaxyObject + { + GobjectId = 5, + TagName = "Other_001", + ContainedName = "Other", + BrowseName = "Other_001", + CategoryId = 10, + }, + ]; + } + private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { public GalaxyHierarchyCacheEntry Current { get; } = current; diff --git a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs index 5739084..89c4b96 100644 --- a/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs +++ b/src/MxGateway.Tests/Gateway/Grpc/MxAccessGatewayServiceTests.cs @@ -230,6 +230,7 @@ public sealed class MxAccessGatewayServiceTests return new MxAccessGatewayService( sessionManager, identityAccessor ?? new GatewayRequestIdentityAccessor(), + new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new FakeEventStreamService(sessionManager), @@ -419,6 +420,35 @@ public sealed class MxAccessGatewayServiceTests } } + private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer + { + public Task CheckReadTagAsync( + ApiKeyIdentity? identity, + string tagAddress, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckReadHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task CheckWriteHandleAsync( + ApiKeyIdentity? identity, + GatewaySession session, + int serverHandle, + int itemHandle, + CancellationToken cancellationToken) => Task.FromResult(null); + + public Task RecordDenialAsync( + ApiKeyIdentity? identity, + string commandKind, + string target, + ConstraintFailure failure, + CancellationToken cancellationToken) => Task.CompletedTask; + } + private sealed class FakeWorkerClient(int processId) : IWorkerClient { public string SessionId { get; } = "session-1"; diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index c924a29..2425048 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -23,7 +23,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: "operator01", DisplayName: "Operator", - Scopes: new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }), + Scopes: new HashSet(StringComparer.Ordinal) { "session:open", "events:read" }, + Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); @@ -60,7 +61,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: null, DisplayName: null, - Scopes: new HashSet(StringComparer.Ordinal)), + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty), listOutput, CancellationToken.None); @@ -87,7 +89,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: "operator01", DisplayName: null, - Scopes: new HashSet(StringComparer.Ordinal)), + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty), TextWriter.Null, CancellationToken.None); @@ -121,7 +124,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: "operator01", DisplayName: null, - Scopes: new HashSet(StringComparer.Ordinal)), + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty), rotateOutput, CancellationToken.None); @@ -155,7 +159,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: "operator01", DisplayName: "Operator", - Scopes: new HashSet(StringComparer.Ordinal)), + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); @@ -166,6 +171,41 @@ public sealed class ApiKeyAdminCliRunnerTests Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey))); } + [Fact] + public async Task CreateKeyAsync_WithConstraints_PersistsConstraints() + { + await using ServiceProvider services = BuildServices(CreateTempDatabasePath()); + ApiKeyAdminCliRunner runner = services.GetRequiredService(); + StringWriter output = new(); + + await runner.RunAsync( + new ApiKeyAdminCommand( + Kind: ApiKeyAdminCommandKind.CreateKey, + Json: true, + SqlitePath: null, + Pepper: null, + KeyId: "operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal) { "metadata:read" }, + Constraints: ApiKeyConstraints.Empty with + { + BrowseSubtrees = ["Area1/*"], + ReadAlarmOnly = true, + }), + output, + CancellationToken.None); + + string apiKey = ReadApiKey(output.ToString()); + ApiKeyVerificationResult verification = await services + .GetRequiredService() + .VerifyAsync($"Bearer {apiKey}", CancellationToken.None); + + Assert.True(verification.Succeeded); + Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees); + Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly); + } + + private static async Task CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId) { StringWriter output = new(); @@ -177,7 +217,8 @@ public sealed class ApiKeyAdminCliRunnerTests Pepper: null, KeyId: keyId, DisplayName: "Operator", - Scopes: new HashSet(StringComparer.Ordinal) { "session:open" }), + Scopes: new HashSet(StringComparer.Ordinal) { "session:open" }, + Constraints: ApiKeyConstraints.Empty), output, CancellationToken.None); diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs index a7d67e6..fc7a110 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyAdminCommandLineParserTests.cs @@ -46,6 +46,42 @@ public sealed class ApiKeyAdminCommandLineParserTests Assert.Contains("events:read", result.Command.Scopes); } + [Fact] + public void Parse_CreateKeyCommand_ReturnsConstraints() + { + ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse( + [ + "apikey", + "create-key", + "--key-id", + "operator01", + "--display-name", + "Operator", + "--read-subtree", + "Area1/*", + "--read-subtree", + "Area2/*", + "--write-tag-glob", + "Pump_*", + "--max-write-classification", + "2", + "--browse-subtree", + "Area1/*", + "--read-alarm-only", + "--read-historized-only" + ]); + + Assert.True(result.IsApiKeyCommand); + Assert.NotNull(result.Command); + ApiKeyConstraints constraints = result.Command.Constraints; + Assert.Equal(["Area1/*", "Area2/*"], constraints.ReadSubtrees); + Assert.Equal(["Pump_*"], constraints.WriteTagGlobs); + Assert.Equal(2, constraints.MaxWriteClassification); + Assert.Equal(["Area1/*"], constraints.BrowseSubtrees); + Assert.True(constraints.ReadAlarmOnly); + Assert.True(constraints.ReadHistorizedOnly); + } + [Fact] public void Parse_CreateKeyWithoutDisplayName_ReturnsError() { diff --git a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs index afe0dac..d9517e4 100644 --- a/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs +++ b/src/MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs @@ -137,6 +137,7 @@ public sealed class ApiKeyVerifierTests "session:open", "events:read" }, + Constraints: ApiKeyConstraints.Empty, CreatedUtc: DateTimeOffset.UtcNow, LastUsedUtc: null, RevokedUtc: revokedUtc); diff --git a/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs new file mode 100644 index 0000000..8878d2b --- /dev/null +++ b/src/MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -0,0 +1,179 @@ +using MxGateway.Contracts.Proto.Galaxy; +using MxGateway.Contracts.Proto; +using MxGateway.Server.Dashboard; +using MxGateway.Server.Galaxy; +using MxGateway.Server.Security.Authentication; +using MxGateway.Server.Security.Authorization; +using MxGateway.Server.Sessions; + +namespace MxGateway.Tests.Security.Authorization; + +public sealed class ConstraintEnforcerTests +{ + [Fact] + public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() + { + ConstraintEnforcer enforcer = CreateEnforcer(out _); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + ReadSubtrees = ["Area1/*"], + }); + + ConstraintFailure? failure = await enforcer.CheckReadTagAsync( + identity, + "Other_001.PV", + CancellationToken.None); + + Assert.NotNull(failure); + Assert.Equal("read_scope", failure.ConstraintName); + } + + [Fact] + public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() + { + ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore); + ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with + { + WriteSubtrees = ["Area1/*"], + MaxWriteClassification = 1, + }); + GatewaySession session = CreateSession(); + session.TrackCommandReply( + new MxCommand + { + Kind = MxCommandKind.AddItem, + AddItem = new AddItemCommand + { + ServerHandle = 12, + ItemDefinition = "Pump_001.PV", + }, + }, + new MxCommandReply + { + ProtocolStatus = MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(), + AddItem = new AddItemReply { ItemHandle = 42 }, + }); + + ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync( + identity, + session, + serverHandle: 12, + itemHandle: 42, + CancellationToken.None); + Assert.NotNull(failure); + + await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); + + ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries); + Assert.Equal("operator01", entry.KeyId); + Assert.Equal("constraint-denied", entry.EventType); + Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); + } + + private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) + { + auditStore = new FakeAuditStore(); + return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore); + } + + private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints) + { + return new ApiKeyIdentity( + KeyId: "operator01", + KeyPrefix: "mxgw_operator01", + DisplayName: "Operator", + Scopes: new HashSet(StringComparer.Ordinal), + Constraints: constraints); + } + + private static GatewaySession CreateSession() + { + GatewaySession session = new( + "session-1", + "mxaccess", + "pipe", + "nonce", + "operator", + "client", + "correlation", + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(5), + DateTimeOffset.UtcNow); + return session; + } + + private static GalaxyHierarchyCacheEntry CreateEntry() + { + return GalaxyHierarchyCacheEntry.Empty with + { + Status = GalaxyCacheStatus.Healthy, + Objects = + [ + new GalaxyObject + { + GobjectId = 1, + TagName = "Area1", + ContainedName = "Area1", + }, + new GalaxyObject + { + GobjectId = 2, + TagName = "Pump_001", + ContainedName = "Pump", + ParentGobjectId = 1, + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Pump_001.PV", + SecurityClassification = 2, + IsHistorized = true, + }, + }, + }, + new GalaxyObject + { + GobjectId = 3, + TagName = "Other_001", + ContainedName = "Other", + Attributes = + { + new GalaxyAttribute + { + AttributeName = "PV", + FullTagReference = "Other_001.PV", + }, + }, + }, + ], + DashboardSummary = DashboardGalaxySummary.Unknown, + }; + } + + 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; + } + + private sealed class FakeAuditStore : IApiKeyAuditStore + { + public List Entries { get; } = []; + + public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) + { + Entries.Add(entry); + return Task.CompletedTask; + } + + public Task> ListRecentAsync(int count, CancellationToken cancellationToken) + { + return Task.FromResult>([]); + } + } +}