Implement Galaxy filters and API key constraints
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<int> CategoryIds { get; init; } = Array.Empty<int>();
|
||||
|
||||
public IReadOnlyList<string> TemplateChainContains { get; init; } = Array.Empty<string>();
|
||||
|
||||
public string? TagNameGlob { get; init; }
|
||||
|
||||
public bool? IncludeAttributes { get; init; }
|
||||
|
||||
public bool AlarmBearingOnly { get; init; }
|
||||
|
||||
public bool HistorizedOnly { get; init; }
|
||||
}
|
||||
@@ -144,18 +144,24 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
/// they may subscribe to via the MxAccessGateway service.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||
DiscoverHierarchyOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<GalaxyObject> objects = [];
|
||||
HashSet<string> 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<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
|
||||
DiscoverHierarchyRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -164,6 +164,19 @@ foreach (GalaxyObject galaxyObject in objects)
|
||||
}
|
||||
```
|
||||
|
||||
Use `DiscoverHierarchyOptions` to request a server-side slice without pulling
|
||||
the full Galaxy:
|
||||
|
||||
```csharp
|
||||
IReadOnlyList<GalaxyObject> pumps = await repository.DiscoverHierarchyAsync(
|
||||
new DiscoverHierarchyOptions
|
||||
{
|
||||
RootContainedPath = "Area1/Line3",
|
||||
TagNameGlob = "Pump_*",
|
||||
IncludeAttributes = false,
|
||||
});
|
||||
```
|
||||
|
||||
The CLI exposes the same operations:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -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{
|
||||
|
||||
+1929
-48
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
+21
-8
@@ -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_<keyId>_<secret>` 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_<keyId>_<secret>` 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
|
||||
|
||||
+45
-2
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<DiscoverHierarchyRequest> _parser = new pb::MessageParser<DiscoverHierarchyRequest>(() => new DiscoverHierarchyRequest());
|
||||
private pb::UnknownFieldSet _unknownFields;
|
||||
private int _hasBits0;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pb::MessageParser<DiscoverHierarchyRequest> 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 {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_gobject_id" field.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_gobject_id" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootGobjectId {
|
||||
get { return rootCase_ == RootOneofCase.RootGobjectId; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_gobject_id" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootGobjectId() {
|
||||
if (HasRootGobjectId) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_tag_name" field.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_tag_name" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootTagName {
|
||||
get { return rootCase_ == RootOneofCase.RootTagName; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_tag_name" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootTagName() {
|
||||
if (HasRootTagName) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_contained_path" field.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_contained_path" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootContainedPath {
|
||||
get { return rootCase_ == RootOneofCase.RootContainedPath; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_contained_path" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootContainedPath() {
|
||||
if (HasRootContainedPath) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "max_depth" field.</summary>
|
||||
public const int MaxDepthFieldNumber = 6;
|
||||
private static readonly pb::FieldCodec<int?> _single_maxDepth_codec = pb::FieldCodec.ForStructWrapper<int>(50);
|
||||
private int? maxDepth_;
|
||||
/// <summary>
|
||||
/// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||
/// Unset means unlimited depth.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int? MaxDepth {
|
||||
get { return maxDepth_; }
|
||||
set {
|
||||
maxDepth_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Field number for the "category_ids" field.</summary>
|
||||
public const int CategoryIdsFieldNumber = 7;
|
||||
private static readonly pb::FieldCodec<int> _repeated_categoryIds_codec
|
||||
= pb::FieldCodec.ForInt32(58);
|
||||
private readonly pbc::RepeatedField<int> categoryIds_ = new pbc::RepeatedField<int>();
|
||||
/// <summary>
|
||||
/// Optional object category id filters.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<int> CategoryIds {
|
||||
get { return categoryIds_; }
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "template_chain_contains" field.</summary>
|
||||
public const int TemplateChainContainsFieldNumber = 8;
|
||||
private static readonly pb::FieldCodec<string> _repeated_templateChainContains_codec
|
||||
= pb::FieldCodec.ForString(66);
|
||||
private readonly pbc::RepeatedField<string> templateChainContains_ = new pbc::RepeatedField<string>();
|
||||
/// <summary>
|
||||
/// Optional case-insensitive substring filters against template names.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<string> TemplateChainContains {
|
||||
get { return templateChainContains_; }
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "tag_name_glob" field.</summary>
|
||||
public const int TagNameGlobFieldNumber = 9;
|
||||
private string tagNameGlob_ = "";
|
||||
/// <summary>
|
||||
/// Optional anchored, case-insensitive glob over object tag_name.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string TagNameGlob {
|
||||
get { return tagNameGlob_; }
|
||||
set {
|
||||
tagNameGlob_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "include_attributes" field.</summary>
|
||||
public const int IncludeAttributesFieldNumber = 10;
|
||||
private readonly static bool IncludeAttributesDefaultValue = false;
|
||||
|
||||
private bool includeAttributes_;
|
||||
/// <summary>
|
||||
/// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "include_attributes" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasIncludeAttributes {
|
||||
get { return (_hasBits0 & 1) != 0; }
|
||||
}
|
||||
/// <summary>Clears the value of the "include_attributes" field</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearIncludeAttributes() {
|
||||
_hasBits0 &= ~1;
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "alarm_bearing_only" field.</summary>
|
||||
public const int AlarmBearingOnlyFieldNumber = 11;
|
||||
private bool alarmBearingOnly_;
|
||||
/// <summary>
|
||||
/// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool AlarmBearingOnly {
|
||||
get { return alarmBearingOnly_; }
|
||||
set {
|
||||
alarmBearingOnly_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "historized_only" field.</summary>
|
||||
public const int HistorizedOnlyFieldNumber = 12;
|
||||
private bool historizedOnly_;
|
||||
/// <summary>
|
||||
/// Optional. Return only objects with at least one historized attribute.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HistorizedOnly {
|
||||
get { return historizedOnly_; }
|
||||
set {
|
||||
historizedOnly_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
private object root_;
|
||||
/// <summary>Enum of possible cases for the "root" oneof.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="apikeys">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="settings">Settings</NavLink>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
@page "/apikeys"
|
||||
@page "/dashboard/apikeys"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard API Keys</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading API keys.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>API Keys</h1>
|
||||
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.ApiKeys.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No API keys are available for display.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Display Name</th>
|
||||
<th scope="col">Scopes</th>
|
||||
<th scope="col">Constraints</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last Used</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@key.KeyId</code></td>
|
||||
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
|
||||
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
||||
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
||||
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
||||
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
||||
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static string ConstraintText(MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints)
|
||||
{
|
||||
if (constraints.IsEmpty)
|
||||
{
|
||||
return "unconstrained";
|
||||
}
|
||||
|
||||
List<string> 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<string> parts, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count > 0)
|
||||
{
|
||||
parts.Add($"{name}=[{string.Join(", ", values)}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeySummary(
|
||||
string KeyId,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
@@ -12,5 +12,6 @@ public sealed record DashboardSnapshot(
|
||||
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
|
||||
EffectiveGatewayConfiguration Configuration,
|
||||
DashboardGalaxySummary Galaxy);
|
||||
|
||||
@@ -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<GatewayOptions> 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<DashboardApiKeySummary> 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<DashboardApiKeySummary>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFault(GatewaySession session)
|
||||
{
|
||||
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string>? browseSubtreeGlobs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
IReadOnlyList<ObjectView> 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<GalaxyObject> 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<ObjectView> BuildViews(IReadOnlyList<GalaxyObject> objects)
|
||||
{
|
||||
Dictionary<int, GalaxyObject> byId = objects.ToDictionary(obj => obj.GobjectId);
|
||||
List<ObjectView> 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<int, GalaxyObject> byId)
|
||||
{
|
||||
Stack<string> names = new();
|
||||
HashSet<int> 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<ObjectView> 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<string>? 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<string>? 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<string>()).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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Server.Galaxy;
|
||||
|
||||
public sealed record GalaxyHierarchyQueryResult(
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
int TotalObjectCount,
|
||||
string FilterSignature);
|
||||
@@ -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<GalaxyRepositoryGrpcService> 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<string> 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<string> 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<string> 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",
|
||||
|
||||
@@ -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<BulkConstraintPlan?> 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<BulkConstraintPlan?> FilterTagBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<string> 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<BulkConstraintPlan?> FilterHandleBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<int> itemHandles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<int> 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<SubscribeResult> 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<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems);
|
||||
|
||||
private RpcException MapException(Exception exception)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,4 +7,5 @@ public sealed record ApiKeyAdminCommand(
|
||||
string? Pepper,
|
||||
string? KeyId,
|
||||
string? DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints);
|
||||
|
||||
@@ -19,7 +19,7 @@ public static class ApiKeyAdminCommandLineParser
|
||||
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
|
||||
}
|
||||
|
||||
Dictionary<string, string?> options = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, List<string?>> 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<string?>? values))
|
||||
{
|
||||
values = [];
|
||||
options[name] = values;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
string? keyId = GetOption(options, "key-id");
|
||||
string? displayName = GetOption(options, "display-name");
|
||||
IReadOnlySet<string> 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<string, string?> options, string name)
|
||||
private static string? GetOption(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out string? value) ? value : null;
|
||||
return options.TryGetValue(name, out List<string?>? values) && values.Count > 0 ? values[^1] : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetOptions(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out List<string?>? values)
|
||||
? values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static bool HasFlag(Dictionary<string, List<string?>> 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<string, List<string?>> 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<string> ParseScopes(string? scopes)
|
||||
|
||||
@@ -5,6 +5,7 @@ public sealed record ApiKeyAdminListedKey(
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -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<ApiKeyConstraints>(json, JsonOptions) ?? ApiKeyConstraints.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public sealed record ApiKeyConstraints(
|
||||
IReadOnlyList<string> ReadSubtrees,
|
||||
IReadOnlyList<string> WriteSubtrees,
|
||||
IReadOnlyList<string> ReadTagGlobs,
|
||||
IReadOnlyList<string> WriteTagGlobs,
|
||||
int? MaxWriteClassification,
|
||||
IReadOnlyList<string> BrowseSubtrees,
|
||||
bool ReadAlarmOnly,
|
||||
bool ReadHistorizedOnly)
|
||||
{
|
||||
public static ApiKeyConstraints Empty { get; } = new(
|
||||
ReadSubtrees: Array.Empty<string>(),
|
||||
WriteSubtrees: Array.Empty<string>(),
|
||||
ReadTagGlobs: Array.Empty<string>(),
|
||||
WriteTagGlobs: Array.Empty<string>(),
|
||||
MaxWriteClassification: null,
|
||||
BrowseSubtrees: Array.Empty<string>(),
|
||||
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;
|
||||
}
|
||||
@@ -6,4 +6,5 @@ public sealed record ApiKeyCreateRequest(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc);
|
||||
|
||||
@@ -4,4 +4,8 @@ public sealed record ApiKeyIdentity(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints? Constraints = null)
|
||||
{
|
||||
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public sealed record ApiKeyRecord(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
""";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<bool> 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,
|
||||
|
||||
@@ -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<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
if (!constraints.HasReadConstraints)
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
|
||||
}
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
if (!constraints.HasReadConstraints)
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
|
||||
}
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
if (!constraints.HasWriteConstraints)
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
|
||||
}
|
||||
|
||||
if (attribute.SecurityClassification > maxClassification)
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
|
||||
"max_write_classification",
|
||||
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<ConstraintFailure?>(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<string> subtreeGlobs,
|
||||
IReadOnlyList<string> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Security.Authorization;
|
||||
|
||||
public sealed record ConstraintFailure(string ConstraintName, string Message);
|
||||
+1
@@ -10,6 +10,7 @@ public static class GrpcAuthorizationServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
||||
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||
services
|
||||
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Sessions;
|
||||
|
||||
namespace MxGateway.Server.Security.Authorization;
|
||||
|
||||
public interface IConstraintEnforcer
|
||||
{
|
||||
Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -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<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> 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<int> itemHandles)
|
||||
{
|
||||
foreach (int itemHandle in itemHandles)
|
||||
{
|
||||
_items.Remove((serverHandle, itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachEventSubscriber()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Sessions;
|
||||
|
||||
public sealed record SessionItemRegistration(
|
||||
int ServerHandle,
|
||||
int ItemHandle,
|
||||
string TagAddress);
|
||||
@@ -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<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeAsync(
|
||||
string keyId,
|
||||
DateTimeOffset revokedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> RotateAsync(
|
||||
string keyId,
|
||||
byte[] secretHash,
|
||||
DateTimeOffset rotatedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(
|
||||
string sessionId,
|
||||
string? clientIdentity,
|
||||
|
||||
@@ -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<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RpcException>(
|
||||
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<RpcException>(
|
||||
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<GalaxyRepositoryGrpcService>.Instance);
|
||||
}
|
||||
|
||||
@@ -113,6 +238,79 @@ public sealed class GalaxyRepositoryGrpcServiceTests
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> 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;
|
||||
|
||||
@@ -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<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(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";
|
||||
|
||||
@@ -23,7 +23,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }),
|
||||
Scopes: new HashSet<string>(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<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(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<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(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<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(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<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(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<ApiKeyAdminCliRunner>();
|
||||
StringWriter output = new();
|
||||
|
||||
await runner.RunAsync(
|
||||
new ApiKeyAdminCommand(
|
||||
Kind: ApiKeyAdminCommandKind.CreateKey,
|
||||
Json: true,
|
||||
SqlitePath: null,
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(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<IApiKeyVerifier>()
|
||||
.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<string> 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<string>(StringComparer.Ordinal) { "session:open" }),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" },
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -137,6 +137,7 @@ public sealed class ApiKeyVerifierTests
|
||||
"session:open",
|
||||
"events:read"
|
||||
},
|
||||
Constraints: ApiKeyConstraints.Empty,
|
||||
CreatedUtc: DateTimeOffset.UtcNow,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: revokedUtc);
|
||||
|
||||
@@ -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<string>(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<ApiKeyAuditEntry> Entries { get; } = [];
|
||||
|
||||
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
Entries.Add(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user