Merge origin/main with local pending work and update AGENTS.md references

- Resolve 14 conflicts from popping local stash on top of origin's
  eed1e88 + 8d3352f doc-comment additions (11 mechanical, plus
  version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs)
- Fix 4 test files that used AGENTS.md as the repo-root sentinel
  (now use CLAUDE.md, since AGENTS.md was removed in 4731ab5)
- Redirect 10 doc citations from AGENTS.md to the matching gateway.md
  sections (Value Model, Status Model, Security, STA Worker Thread
  Model, gRPC Layer rule, cancellation rule)

Verified: solution build clean, x86 worker build clean, 266/266
gateway tests passing, 121/121 worker tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 14:13:33 -04:00
parent 8d3352f2c6
commit ddad573b75
101 changed files with 6053 additions and 621 deletions
@@ -6,7 +6,7 @@ namespace MxGateway.Contracts;
/// </summary>
public static class GatewayContractInfo
{
public const uint GatewayProtocolVersion = 1;
public const uint GatewayProtocolVersion = 2;
public const uint WorkerProtocolVersion = 1;
@@ -25,54 +25,64 @@ namespace MxGateway.Contracts.Proto.Galaxy {
byte[] descriptorData = global::System.Convert.FromBase64String(
string.Concat(
"ChdnYWxheHlfcmVwb3NpdG9yeS5wcm90bxIUZ2FsYXh5X3JlcG9zaXRvcnku",
"djEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJvdG8iFwoVVGVzdENv",
"bm5lY3Rpb25SZXF1ZXN0IiEKE1Rlc3RDb25uZWN0aW9uUmVwbHkSCgoCb2sY",
"ASABKAgiGgoYR2V0TGFzdERlcGxveVRpbWVSZXF1ZXN0ImIKFkdldExhc3RE",
"ZXBsb3lUaW1lUmVwbHkSDwoHcHJlc2VudBgBIAEoCBI3ChN0aW1lX29mX2xh",
"c3RfZGVwbG95GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa",
"ChhEaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3QiTQoWRGlzY292ZXJIaWVyYXJj",
"aHlSZXBseRIzCgdvYmplY3RzGAEgAygLMiIuZ2FsYXh5X3JlcG9zaXRvcnku",
"djEuR2FsYXh5T2JqZWN0IlUKGFdhdGNoRGVwbG95RXZlbnRzUmVxdWVzdBI5",
"ChVsYXN0X3NlZW5fZGVwbG95X3RpbWUYASABKAsyGi5nb29nbGUucHJvdG9i",
"dWYuVGltZXN0YW1wIt0BCgtEZXBsb3lFdmVudBIQCghzZXF1ZW5jZRgBIAEo",
"BBIvCgtvYnNlcnZlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
"c3RhbXASNwoTdGltZV9vZl9sYXN0X2RlcGxveRgDIAEoCzIaLmdvb2dsZS5w",
"cm90b2J1Zi5UaW1lc3RhbXASIwobdGltZV9vZl9sYXN0X2RlcGxveV9wcmVz",
"ZW50GAQgASgIEhQKDG9iamVjdF9jb3VudBgFIAEoBRIXCg9hdHRyaWJ1dGVf",
"Y291bnQYBiABKAUikwIKDEdhbGF4eU9iamVjdBISCgpnb2JqZWN0X2lkGAEg",
"ASgFEhAKCHRhZ19uYW1lGAIgASgJEhYKDmNvbnRhaW5lZF9uYW1lGAMgASgJ",
"EhMKC2Jyb3dzZV9uYW1lGAQgASgJEhkKEXBhcmVudF9nb2JqZWN0X2lkGAUg",
"ASgFEg8KB2lzX2FyZWEYBiABKAgSEwoLY2F0ZWdvcnlfaWQYByABKAUSHAoU",
"aG9zdGVkX2J5X2dvYmplY3RfaWQYCCABKAUSFgoOdGVtcGxhdGVfY2hhaW4Y",
"CSADKAkSOQoKYXR0cmlidXRlcxgKIAMoCzIlLmdhbGF4eV9yZXBvc2l0b3J5",
"LnYxLkdhbGF4eUF0dHJpYnV0ZSKoAgoPR2FsYXh5QXR0cmlidXRlEhYKDmF0",
"dHJpYnV0ZV9uYW1lGAEgASgJEhoKEmZ1bGxfdGFnX3JlZmVyZW5jZRgCIAEo",
"CRIUCgxteF9kYXRhX3R5cGUYAyABKAUSFgoOZGF0YV90eXBlX25hbWUYBCAB",
"KAkSEAoIaXNfYXJyYXkYBSABKAgSFwoPYXJyYXlfZGltZW5zaW9uGAYgASgF",
"Eh8KF2FycmF5X2RpbWVuc2lvbl9wcmVzZW50GAcgASgIEh0KFW14X2F0dHJp",
"YnV0ZV9jYXRlZ29yeRgIIAEoBRIfChdzZWN1cml0eV9jbGFzc2lmaWNhdGlv",
"bhgJIAEoBRIVCg1pc19oaXN0b3JpemVkGAogASgIEhAKCGlzX2FsYXJtGAsg",
"ASgIMswDChBHYWxheHlSZXBvc2l0b3J5EmgKDlRlc3RDb25uZWN0aW9uEisu",
"Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXF1ZXN0Giku",
"Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXBseRJxChFH",
"ZXRMYXN0RGVwbG95VGltZRIuLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdldExh",
"c3REZXBsb3lUaW1lUmVxdWVzdBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdl",
"dExhc3REZXBsb3lUaW1lUmVwbHkScQoRRGlzY292ZXJIaWVyYXJjaHkSLi5n",
"YWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3Qa",
"LC5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcGx5",
"EmgKEVdhdGNoRGVwbG95RXZlbnRzEi4uZ2FsYXh5X3JlcG9zaXRvcnkudjEu",
"V2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0GiEuZ2FsYXh5X3JlcG9zaXRvcnku",
"djEuRGVwbG95RXZlbnQwAUIjqgIgTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90",
"by5HYWxheHliBnByb3RvMw=="));
"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, null, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects" }, 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),
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject), global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser, new[]{ "GobjectId", "TagName", "ContainedName", "BrowseName", "ParentGobjectId", "IsArea", "CategoryId", "HostedByGobjectId", "TemplateChain", "Attributes" }, null, null, null, null),
@@ -855,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; } }
@@ -882,6 +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);
}
@@ -891,6 +924,258 @@ namespace MxGateway.Contracts.Proto.Galaxy {
return new DiscoverHierarchyRequest(this);
}
/// <summary>Field number for the "page_size" field.</summary>
public const int PageSizeFieldNumber = 1;
private int pageSize_;
/// <summary>
/// Maximum number of objects to return. The server applies its default when
/// unset and rejects non-positive values.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int PageSize {
get { return pageSize_; }
set {
pageSize_ = value;
}
}
/// <summary>Field number for the "page_token" field.</summary>
public const int PageTokenFieldNumber = 2;
private string pageToken_ = "";
/// <summary>
/// Opaque token returned by a previous DiscoverHierarchy response.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string PageToken {
get { return pageToken_; }
set {
pageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <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) {
@@ -906,6 +1191,19 @@ namespace MxGateway.Contracts.Proto.Galaxy {
if (ReferenceEquals(other, this)) {
return true;
}
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);
}
@@ -913,6 +1211,19 @@ namespace MxGateway.Contracts.Proto.Galaxy {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override int GetHashCode() {
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();
}
@@ -931,6 +1242,47 @@ namespace MxGateway.Contracts.Proto.Galaxy {
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
output.WriteRawMessage(this);
#else
if (PageSize != 0) {
output.WriteRawTag(8);
output.WriteInt32(PageSize);
}
if (PageToken.Length != 0) {
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);
}
@@ -941,6 +1293,47 @@ namespace MxGateway.Contracts.Proto.Galaxy {
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
if (PageSize != 0) {
output.WriteRawTag(8);
output.WriteInt32(PageSize);
}
if (PageToken.Length != 0) {
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);
}
@@ -951,6 +1344,38 @@ namespace MxGateway.Contracts.Proto.Galaxy {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int CalculateSize() {
int size = 0;
if (PageSize != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(PageSize);
}
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();
}
@@ -963,6 +1388,43 @@ namespace MxGateway.Contracts.Proto.Galaxy {
if (other == null) {
return;
}
if (other.PageSize != 0) {
PageSize = other.PageSize;
}
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);
}
@@ -982,6 +1444,58 @@ namespace MxGateway.Contracts.Proto.Galaxy {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
break;
case 8: {
PageSize = input.ReadInt32();
break;
}
case 18: {
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
@@ -1001,6 +1515,58 @@ namespace MxGateway.Contracts.Proto.Galaxy {
default:
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
break;
case 8: {
PageSize = input.ReadInt32();
break;
}
case 18: {
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;
}
}
}
}
@@ -1044,6 +1610,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public DiscoverHierarchyReply(DiscoverHierarchyReply other) : this() {
objects_ = other.objects_.Clone();
nextPageToken_ = other.nextPageToken_;
totalObjectCount_ = other.totalObjectCount_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -1064,6 +1632,36 @@ namespace MxGateway.Contracts.Proto.Galaxy {
get { return objects_; }
}
/// <summary>Field number for the "next_page_token" field.</summary>
public const int NextPageTokenFieldNumber = 2;
private string nextPageToken_ = "";
/// <summary>
/// Non-empty when another page is available.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string NextPageToken {
get { return nextPageToken_; }
set {
nextPageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "total_object_count" field.</summary>
public const int TotalObjectCountFieldNumber = 3;
private int totalObjectCount_;
/// <summary>
/// Total number of objects in the cached hierarchy at the time of the call.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int TotalObjectCount {
get { return totalObjectCount_; }
set {
totalObjectCount_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -1080,6 +1678,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
return true;
}
if(!objects_.Equals(other.objects_)) return false;
if (NextPageToken != other.NextPageToken) return false;
if (TotalObjectCount != other.TotalObjectCount) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -1088,6 +1688,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
public override int GetHashCode() {
int hash = 1;
hash ^= objects_.GetHashCode();
if (NextPageToken.Length != 0) hash ^= NextPageToken.GetHashCode();
if (TotalObjectCount != 0) hash ^= TotalObjectCount.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -1107,6 +1709,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
output.WriteRawMessage(this);
#else
objects_.WriteTo(output, _repeated_objects_codec);
if (NextPageToken.Length != 0) {
output.WriteRawTag(18);
output.WriteString(NextPageToken);
}
if (TotalObjectCount != 0) {
output.WriteRawTag(24);
output.WriteInt32(TotalObjectCount);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -1118,6 +1728,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
objects_.WriteTo(ref output, _repeated_objects_codec);
if (NextPageToken.Length != 0) {
output.WriteRawTag(18);
output.WriteString(NextPageToken);
}
if (TotalObjectCount != 0) {
output.WriteRawTag(24);
output.WriteInt32(TotalObjectCount);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -1129,6 +1747,12 @@ namespace MxGateway.Contracts.Proto.Galaxy {
public int CalculateSize() {
int size = 0;
size += objects_.CalculateSize(_repeated_objects_codec);
if (NextPageToken.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(NextPageToken);
}
if (TotalObjectCount != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(TotalObjectCount);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -1142,6 +1766,12 @@ namespace MxGateway.Contracts.Proto.Galaxy {
return;
}
objects_.Add(other.objects_);
if (other.NextPageToken.Length != 0) {
NextPageToken = other.NextPageToken;
}
if (other.TotalObjectCount != 0) {
TotalObjectCount = other.TotalObjectCount;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -1165,6 +1795,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
objects_.AddEntriesFrom(input, _repeated_objects_codec);
break;
}
case 18: {
NextPageToken = input.ReadString();
break;
}
case 24: {
TotalObjectCount = input.ReadInt32();
break;
}
}
}
#endif
@@ -1188,6 +1826,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
objects_.AddEntriesFrom(ref input, _repeated_objects_codec);
break;
}
case 18: {
NextPageToken = input.ReadString();
break;
}
case 24: {
TotalObjectCount = input.ReadInt32();
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
@@ -37,10 +38,42 @@ message GetLastDeployTimeReply {
google.protobuf.Timestamp time_of_last_deploy = 2;
}
message DiscoverHierarchyRequest {}
message DiscoverHierarchyRequest {
// Maximum number of objects to return. The server applies its default when
// unset and rejects non-positive values.
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 {
repeated GalaxyObject objects = 1;
// Non-empty when another page is available.
string next_page_token = 2;
// Total number of objects in the cached hierarchy at the time of the call.
int32 total_object_count = 3;
}
message WatchDeployEventsRequest {
@@ -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;
@@ -260,6 +261,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
Service = new MxAccessGatewayService(
sessionManager,
new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
@@ -592,4 +594,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;
}
}
@@ -2,6 +2,7 @@ namespace MxGateway.Server.Configuration;
public sealed record EffectiveGatewayConfiguration(
EffectiveAuthenticationConfiguration Authentication,
EffectiveLdapConfiguration Ldap,
EffectiveWorkerConfiguration Worker,
EffectiveSessionConfiguration Sessions,
EffectiveEventConfiguration Events,
@@ -1,3 +1,5 @@
namespace MxGateway.Server.Configuration;
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
public sealed record EffectiveProtocolConfiguration(
uint WorkerProtocolVersion,
int MaxGrpcMessageBytes);
@@ -3,4 +3,7 @@ namespace MxGateway.Server.Configuration;
public sealed record EffectiveSessionConfiguration(
int DefaultCommandTimeoutSeconds,
int MaxSessions,
int MaxPendingCommandsPerSession,
int DefaultLeaseSeconds,
int LeaseSweepIntervalSeconds,
bool AllowMultipleEventSubscribers);
@@ -19,6 +19,19 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
SqlitePath: value.Authentication.SqlitePath,
PepperSecretName: RedactedValue,
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
Ldap: new EffectiveLdapConfiguration(
Enabled: value.Ldap.Enabled,
Server: value.Ldap.Server,
Port: value.Ldap.Port,
UseTls: value.Ldap.UseTls,
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
SearchBase: value.Ldap.SearchBase,
ServiceAccountDn: value.Ldap.ServiceAccountDn,
ServiceAccountPassword: RedactedValue,
UserNameAttribute: value.Ldap.UserNameAttribute,
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
GroupAttribute: value.Ldap.GroupAttribute,
RequiredGroup: value.Ldap.RequiredGroup),
Worker: new EffectiveWorkerConfiguration(
ExecutablePath: value.Worker.ExecutablePath,
WorkingDirectory: value.Worker.WorkingDirectory,
@@ -31,6 +44,9 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
Sessions: new EffectiveSessionConfiguration(
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
MaxSessions: value.Sessions.MaxSessions,
MaxPendingCommandsPerSession: value.Sessions.MaxPendingCommandsPerSession,
DefaultLeaseSeconds: value.Sessions.DefaultLeaseSeconds,
LeaseSweepIntervalSeconds: value.Sessions.LeaseSweepIntervalSeconds,
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
Events: new EffectiveEventConfiguration(
QueueCapacity: value.Events.QueueCapacity,
@@ -44,6 +60,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
ShowTagValues: value.Dashboard.ShowTagValues),
Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion));
Protocol: new EffectiveProtocolConfiguration(
value.Protocol.WorkerProtocolVersion,
value.Protocol.MaxGrpcMessageBytes));
}
}
@@ -9,6 +9,8 @@ public sealed class GatewayOptions
/// </summary>
public AuthenticationOptions Authentication { get; init; } = new();
public LdapOptions Ldap { get; init; } = new();
/// <summary>
/// Gets worker process configuration options.
/// </summary>
@@ -19,6 +19,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
List<string> failures = [];
ValidateAuthentication(options.Authentication, failures);
ValidateLdap(options.Ldap, failures);
ValidateWorker(options.Worker, failures);
ValidateSessions(options.Sessions, failures);
ValidateEvents(options.Events, failures);
@@ -55,6 +56,47 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
}
}
private static void ValidateLdap(LdapOptions options, List<string> failures)
{
if (!options.Enabled)
{
return;
}
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
AddIfBlank(
options.ServiceAccountDn,
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.ServiceAccountPassword,
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.UserNameAttribute,
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.DisplayNameAttribute,
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.GroupAttribute,
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
failures);
AddIfBlank(
options.RequiredGroup,
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
failures);
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
if (!options.UseTls && !options.AllowInsecureLdap)
{
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
}
}
private static void ValidateWorker(WorkerOptions options, List<string> failures)
{
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
@@ -135,6 +177,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
options.MaxPendingCommandsPerSession,
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
failures);
AddIfNotPositive(
options.DefaultLeaseSeconds,
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
failures);
AddIfNotPositive(
options.LeaseSweepIntervalSeconds,
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
failures);
if (options.AllowMultipleEventSubscribers)
{
@@ -185,6 +235,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
failures.Add(
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
}
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
{
failures.Add(
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
}
}
private static void AddIfBlank(string? value, string message, List<string> failures)
@@ -11,4 +11,6 @@ public sealed class ProtocolOptions
/// Gets or sets the worker protocol version.
/// </summary>
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
}
@@ -17,6 +17,10 @@ public sealed class SessionOptions
/// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128;
public int DefaultLeaseSeconds { get; init; } = 1800;
public int LeaseSweepIntervalSeconds { get; init; } = 30;
/// <summary>
/// Gets a value indicating whether multiple event subscribers are allowed per session.
/// </summary>
@@ -26,14 +26,27 @@
<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>
</ul>
<form method="post" action="@DashboardPath("/logout")" class="d-flex">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
<AuthorizeView>
<Authorized Context="authState">
<div class="d-flex align-items-center gap-2">
<span class="navbar-text">@authState.User.Identity?.Name</span>
<form method="post" action="@DashboardPath("/logout")">
<AntiforgeryToken />
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</nav>
@@ -190,6 +190,8 @@ else
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
private string? GalaxyConnectionStringDisplay() =>
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
private string GalaxyConnectionStringDisplay()
{
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
}
}
@@ -25,6 +25,15 @@ else
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
@@ -8,5 +8,6 @@
@using MxGateway.Server.Dashboard
@using MxGateway.Server.Dashboard.Components.Layout
@using MxGateway.Server.Dashboard.Components.Shared
@using MxGateway.Server.Security.Authorization
@using MxGateway.Server.Workers
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@@ -5,6 +5,7 @@ public static class DashboardAuthenticationDefaults
public const string AuthenticationScheme = "MxGateway.Dashboard";
public const string AuthorizationPolicy = "MxGateway.Dashboard";
public const string ScopeClaimType = "scope";
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
}
@@ -1,81 +1,258 @@
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthenticator(
IApiKeyVerifier apiKeyVerifier,
IOptions<GatewayOptions> options) : IDashboardAuthenticator
IOptions<GatewayOptions> options,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
/// <inheritdoc />
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
string? username,
string? password,
CancellationToken cancellationToken)
{
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
KeyId: "authentication-disabled",
KeyPrefix: "authentication-disabled",
DisplayName: "Authentication Disabled",
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
}
if (string.IsNullOrWhiteSpace(apiKey))
LdapOptions ldapOptions = options.Value.Ldap;
if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string normalizedUsername = username.Trim();
try
{
using LdapConnection connection = new();
connection.SecureSocketLayer = ldapOptions.UseTls;
await Task.Run(
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? candidate = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (candidate is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
await Task.Run(
() => connection.Bind(candidate.Dn, password),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? authenticatedEntry = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (authenticatedEntry is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
{
logger.LogInformation(
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
normalizedUsername,
ldapOptions.RequiredGroup);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername,
displayName,
groups));
}
catch (OperationCanceledException)
{
throw;
}
catch (LdapException ex)
{
logger.LogInformation(
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
normalizedUsername,
ex.ResultCode);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
}
internal static string EscapeLdapFilter(string value)
{
StringBuilder builder = new(value.Length);
foreach (char character in value)
{
builder.Append(character switch
{
'\\' => @"\5c",
'*' => @"\2a",
'(' => @"\28",
')' => @"\29",
'\0' => @"\00",
_ => character.ToString()
});
}
return builder.ToString();
}
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
{
string normalizedRequiredGroup = requiredGroup.Trim();
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
{
return false;
}
foreach (string group in groups)
{
string normalizedGroup = group.Trim();
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|| string.Equals(
ExtractFirstRdnValue(normalizedGroup),
normalizedRequiredGroup,
StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
internal static string ExtractFirstRdnValue(string distinguishedName)
{
int equalsIndex = distinguishedName.IndexOf('=');
if (equalsIndex < 0)
{
return distinguishedName;
}
int valueStart = equalsIndex + 1;
int commaIndex = distinguishedName.IndexOf(',', valueStart);
return commaIndex > valueStart
? distinguishedName[valueStart..commaIndex]
: distinguishedName[valueStart..];
}
private static Task BindServiceAccountAsync(
LdapConnection connection,
LdapOptions ldapOptions,
CancellationToken cancellationToken)
{
return Task.Run(
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
cancellationToken);
}
private static async Task<LdapEntry?> SearchUserAsync(
LdapConnection connection,
LdapOptions ldapOptions,
string username,
CancellationToken cancellationToken)
{
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
ILdapSearchResults results = await Task.Run(
() => connection.Search(
ldapOptions.SearchBase,
LdapConnection.ScopeSub,
filter,
attrs: null,
typesOnly: false),
cancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
LdapEntry? entry = null;
while (results.HasMore())
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
LdapEntry next = results.Next();
if (entry is not null)
{
return null;
}
entry = next;
}
if (options.Value.Dashboard.RequireAdminScope
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
return entry;
}
private static string FormatAuthorizationHeader(string apiKey)
private static string? ReadAttribute(LdapEntry entry, string attributeName)
{
string trimmedApiKey = apiKey.Trim();
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? trimmedApiKey
: $"Bearer {trimmedApiKey}";
return ReadLdapAttribute(entry, attributeName)?.StringValue;
}
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
{
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
return attribute?.StringValueArray ?? [];
}
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
{
return entry.GetAttribute(attributeName)
?? entry.GetAttribute(attributeName.ToLowerInvariant())
?? entry.GetAttribute(attributeName.ToUpperInvariant());
}
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable<string> groups)
{
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
new Claim(ClaimTypes.Name, identity.DisplayName),
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, displayName)
];
claims.AddRange(identity.Scopes.Select(scope => new Claim(
DashboardAuthenticationDefaults.ScopeClaimType,
scope)));
claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
DashboardAuthenticationDefaults.ScopeClaimType);
DashboardAuthenticationDefaults.LdapGroupClaimType);
return new ClaimsPrincipal(claimsIdentity);
}
@@ -43,18 +43,17 @@ public static class DashboardEndpointRouteBuilderExtensions
dashboard.MapPost(
"/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
.AllowAnonymous()
.WithName("DashboardLogout");
dashboard.MapGet("/denied", () => Results.Content(
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
"text/html"))
.AllowAnonymous()
.WithName("DashboardAccessDenied");
dashboard.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
.AddInteractiveServerRenderMode();
return endpoints;
}
@@ -89,7 +88,10 @@ public static class DashboardEndpointRouteBuilderExtensions
pathBase);
DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
.AuthenticateAsync(
form["username"].ToString(),
form["password"].ToString(),
httpContext.RequestAborted)
.ConfigureAwait(false);
if (!result.Succeeded || result.Principal is null)
@@ -131,7 +133,7 @@ public static class DashboardEndpointRouteBuilderExtensions
string requestToken = tokens.RequestToken ?? string.Empty;
string alert = string.IsNullOrWhiteSpace(failureMessage)
? string.Empty
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
<section class="dashboard-login">
@@ -141,8 +143,12 @@ public static class DashboardEndpointRouteBuilderExtensions
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<div class="mb-3">
<label for="apiKey" class="form-label">API key</label>
<input id="apiKey" name="apiKey" type="password" autocomplete="off" class="form-control" />
<label for="username" class="form-label">Username</label>
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
</div>
<button type="submit" class="btn btn-primary">Sign in</button>
</div>
@@ -2,101 +2,11 @@ using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
/// <see cref="DashboardGalaxySummary"/> for the Blazor pages. Top-templates and
/// per-category breakdowns are computed here rather than stored on the cache so the
/// Galaxy namespace stays free of dashboard-presentation concepts.
/// </summary>
/// <summary>Projects Galaxy Repository cache entries to dashboard presentation format.</summary>
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
internal static class DashboardGalaxyProjector
{
private const int TopTemplatesLimit = 10;
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
{
[1] = "WinPlatform",
[3] = "AppEngine",
[4] = "InTouchViewApp",
[10] = "UserDefined",
[11] = "FieldReference",
[13] = "Area",
[17] = "DIObject",
[24] = "DDESuiteLinkClient",
[26] = "OPCClient",
};
/// <summary>Projects a Galaxy Repository cache entry to a dashboard summary.</summary>
/// <param name="entry">Galaxy cache entry to project.</param>
/// <returns>Dashboard-formatted Galaxy summary.</returns>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
DashboardGalaxyStatus status = entry.Status switch
{
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
_ => DashboardGalaxyStatus.Unknown,
};
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (entry.Hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
foreach (GalaxyHierarchyRow row in entry.Hierarchy)
{
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
objectsByCategory[row.CategoryId] = categoryCount + 1;
if (row.TemplateChain.Count > 0)
{
string immediate = row.TemplateChain[0];
if (!string.IsNullOrWhiteSpace(immediate))
{
templateUsage.TryGetValue(immediate, out int templateCount);
templateUsage[immediate] = templateCount + 1;
}
}
}
topTemplates = templateUsage
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Take(TopTemplatesLimit)
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
.ToArray();
objectCategories = objectsByCategory
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key)
.Select(entry => new DashboardGalaxyCategoryCount(
entry.Key,
CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}",
entry.Value))
.ToArray();
}
return new DashboardGalaxySummary(
Status: status,
LastQueriedAt: entry.LastQueriedAt,
LastSuccessAt: entry.LastSuccessAt,
LastDeployTime: entry.LastDeployTime,
LastError: entry.LastError,
ObjectCount: entry.ObjectCount,
AreaCount: entry.AreaCount,
AttributeCount: entry.AttributeCount,
HistorizedAttributeCount: entry.HistorizedAttributeCount,
AlarmAttributeCount: entry.AlarmAttributeCount,
TopTemplates: topTemplates,
ObjectCategories: objectCategories);
return entry.DashboardSummary;
}
}
@@ -18,6 +18,8 @@ public static class DashboardServiceCollectionExtensions
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services.AddCascadingAuthenticationState();
@@ -12,5 +12,6 @@ public sealed record DashboardSnapshot(
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
@@ -1,8 +1,11 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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,11 +19,16 @@ 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;
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
private readonly ILogger<DashboardSnapshotService> _logger;
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
@@ -34,13 +42,16 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IGalaxyHierarchyCache galaxyHierarchyCache,
IApiKeyAdminStore apiKeyAdminStore,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
ILogger<DashboardSnapshotService>? logger = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_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;
@@ -48,6 +59,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
}
/// <summary>
@@ -80,6 +92,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
ApiKeys: Volatile.Read(ref _apiKeySummaries),
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
@@ -97,6 +110,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
@@ -117,6 +131,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
yield break;
}
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
yield return GetSnapshot();
}
}
@@ -208,6 +223,51 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
.ToArray();
}
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
{
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
.ConfigureAwait(false))
.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();
Volatile.Write(ref _apiKeySummaries, summaries);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
_logger.LogWarning(
"Timed out refreshing dashboard API key summaries after {Timeout}.",
_apiKeySummaryRefreshTimeout);
}
catch (Exception)
{
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
}
finally
{
_apiKeySummaryRefreshGate.Release();
}
}
private static bool HasFault(GatewaySession session)
{
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
/// <param name="apiKey">The API key to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
string? username,
string? password,
CancellationToken cancellationToken);
}
@@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Grpc;
namespace MxGateway.Server.Galaxy;
@@ -49,7 +50,16 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
{
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
GalaxyCacheStatus projected = ProjectStatus(snapshot);
return projected == snapshot.Status ? snapshot : snapshot with { Status = projected };
return projected == snapshot.Status
? snapshot
: snapshot with
{
Status = projected,
DashboardSummary = snapshot.DashboardSummary with
{
Status = MapDashboardStatus(projected),
},
};
}
}
@@ -101,6 +111,14 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
LastQueriedAt = queriedAt,
LastSuccessAt = queriedAt,
LastError = null,
DashboardSummary = previous.DashboardSummary with
{
Status = DashboardGalaxyStatus.Healthy,
LastQueriedAt = queriedAt,
LastSuccessAt = queriedAt,
LastDeployTime = deployTime,
LastError = null,
},
};
Volatile.Write(ref _current, refreshed);
_firstLoad.TrySetResult();
@@ -113,11 +131,24 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result;
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
int areaCount = hierarchy.Count(row => row.IsArea);
int historized = attributes.Count(row => row.IsHistorized);
int alarms = attributes.Count(row => row.IsAlarm);
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
status: GalaxyCacheStatus.Healthy,
lastQueriedAt: queriedAt,
lastSuccessAt: queriedAt,
lastDeployTime: deployTime,
lastError: null,
hierarchy: hierarchy,
objectCount: hierarchy.Count,
areaCount: areaCount,
attributeCount: attributes.Count,
historizedAttributeCount: historized,
alarmAttributeCount: alarms);
long nextSequence = previous.Sequence + 1;
GalaxyHierarchyCacheEntry next = new(
@@ -127,9 +158,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
LastSuccessAt: queriedAt,
LastDeployTime: deployTime,
LastError: null,
Hierarchy: hierarchy,
Attributes: attributes,
Reply: reply,
Objects: objects,
Index: index,
DashboardSummary: dashboardSummary,
ObjectCount: hierarchy.Count,
AreaCount: areaCount,
AttributeCount: attributes.Count,
@@ -158,13 +189,19 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
LastQueriedAt = queriedAt,
LastError = exception.Message,
DashboardSummary = previous.DashboardSummary with
{
Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable),
LastQueriedAt = queriedAt,
LastError = exception.Message,
},
};
Volatile.Write(ref _current, failed);
_firstLoad.TrySetResult();
}
}
private static DiscoverHierarchyReply BuildReply(
private static IReadOnlyList<GalaxyObject> BuildObjects(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
{
@@ -172,14 +209,110 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
DiscoverHierarchyReply reply = new();
List<GalaxyObject> objects = new(hierarchy.Count);
foreach (GalaxyHierarchyRow row in hierarchy)
{
reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
}
return reply;
return objects;
}
private static DashboardGalaxySummary BuildDashboardSummary(
GalaxyCacheStatus status,
DateTimeOffset? lastQueriedAt,
DateTimeOffset? lastSuccessAt,
DateTimeOffset? lastDeployTime,
string? lastError,
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
int objectCount,
int areaCount,
int attributeCount,
int historizedAttributeCount,
int alarmAttributeCount)
{
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
foreach (GalaxyHierarchyRow row in hierarchy)
{
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
objectsByCategory[row.CategoryId] = categoryCount + 1;
if (row.TemplateChain.Count > 0)
{
string immediate = row.TemplateChain[0];
if (!string.IsNullOrWhiteSpace(immediate))
{
templateUsage.TryGetValue(immediate, out int templateCount);
templateUsage[immediate] = templateCount + 1;
}
}
}
topTemplates = templateUsage
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Take(10)
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
.ToArray();
objectCategories = objectsByCategory
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key)
.Select(entry => new DashboardGalaxyCategoryCount(
entry.Key,
ResolveCategoryName(entry.Key),
entry.Value))
.ToArray();
}
return new DashboardGalaxySummary(
Status: MapDashboardStatus(status),
LastQueriedAt: lastQueriedAt,
LastSuccessAt: lastSuccessAt,
LastDeployTime: lastDeployTime,
LastError: lastError,
ObjectCount: objectCount,
AreaCount: areaCount,
AttributeCount: attributeCount,
HistorizedAttributeCount: historizedAttributeCount,
AlarmAttributeCount: alarmAttributeCount,
TopTemplates: topTemplates,
ObjectCategories: objectCategories);
}
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
{
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
_ => DashboardGalaxyStatus.Unknown,
};
private static string ResolveCategoryName(int categoryId) => categoryId switch
{
1 => "WinPlatform",
3 => "AppEngine",
4 => "InTouchViewApp",
10 => "UserDefined",
11 => "FieldReference",
13 => "Area",
17 => "DIObject",
24 => "DDESuiteLinkClient",
26 => "OPCClient",
_ => $"Category {categoryId}",
};
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
{
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
@@ -1,11 +1,12 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Dashboard;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Immutable snapshot of the Galaxy Repository browse data held by
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same instance —
/// the materialized <see cref="Reply"/> is produced once per refresh and reused.
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
/// materialized object list and precomputed dashboard projection.
/// </summary>
public sealed record GalaxyHierarchyCacheEntry(
GalaxyCacheStatus Status,
@@ -14,9 +15,9 @@ public sealed record GalaxyHierarchyCacheEntry(
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
IReadOnlyList<GalaxyAttributeRow> Attributes,
DiscoverHierarchyReply? Reply,
IReadOnlyList<GalaxyObject> Objects,
GalaxyHierarchyIndex Index,
DashboardGalaxySummary DashboardSummary,
int ObjectCount,
int AreaCount,
int AttributeCount,
@@ -31,9 +32,9 @@ public sealed record GalaxyHierarchyCacheEntry(
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
Hierarchy: Array.Empty<GalaxyHierarchyRow>(),
Attributes: Array.Empty<GalaxyAttributeRow>(),
Reply: null,
Objects: Array.Empty<GalaxyObject>(),
Index: GalaxyHierarchyIndex.Empty,
DashboardSummary: DashboardGalaxySummary.Unknown,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
@@ -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,9 +20,12 @@ 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);
private const int DefaultDiscoverPageSize = 1000;
private const int MaxDiscoverPageSize = 5000;
/// <inheritdoc />
public override async Task<TestConnectionReply> TestConnection(
@@ -62,16 +67,44 @@ public sealed class GalaxyRepositoryGrpcService(
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData || entry.Reply is null)
if (!entry.HasData)
{
throw new RpcException(new Status(
StatusCode.Unavailable,
ResolveUnavailableMessage(entry)));
}
// Same materialized reply is shared across all clients — gRPC serialization is
// read-only and the entry is replaced atomically on the next refresh.
return entry.Reply;
int pageSize = ResolvePageSize(request.PageSize);
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
entry,
request,
browseSubtrees,
pageToken.Offset,
pageSize);
int offset = pageToken.Offset;
if (offset > query.TotalObjectCount)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is outside the current hierarchy."));
}
DiscoverHierarchyReply reply = new()
{
TotalObjectCount = query.TotalObjectCount,
};
reply.Objects.Add(query.Objects);
int nextOffset = offset + query.Objects.Count;
if (nextOffset < query.TotalObjectCount)
{
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
}
return reply;
}
/// <inheritdoc />
@@ -96,7 +129,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);
}
}
@@ -124,14 +157,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)
@@ -148,6 +195,80 @@ public sealed class GalaxyRepositoryGrpcService(
_ => "Galaxy cache has no data available.",
};
private static int ResolvePageSize(int requestedPageSize)
{
if (requestedPageSize < 0)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_size must be greater than zero when provided."));
}
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
return Math.Min(pageSize, MaxDiscoverPageSize);
}
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, string currentFilterSignature)
{
if (string.IsNullOrWhiteSpace(pageToken))
{
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
}
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[2],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out int offset)
|| offset < 0)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is invalid."));
}
if (sequence != currentSequence)
{
throw new RpcException(new Status(
StatusCode.InvalidArgument,
"DiscoverHierarchy page_token is stale."));
}
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, string FilterSignature, int Offset);
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Style",
"IDE0051:Remove unused private members",
@@ -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;
@@ -13,6 +15,7 @@ namespace MxGateway.Server.Grpc;
public sealed class MxAccessGatewayService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor,
IConstraintEnforcer constraintEnforcer,
MxAccessGrpcRequestValidator requestValidator,
MxAccessGrpcMapper mapper,
IEventStreamService eventStreamService,
@@ -91,12 +94,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)
{
@@ -134,6 +160,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)
@@ -8,6 +8,7 @@
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
@@ -67,6 +67,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);
@@ -172,6 +173,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);
@@ -22,7 +22,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++)
@@ -52,18 +52,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)
@@ -78,7 +102,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)
@@ -147,9 +172,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);
@@ -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);
@@ -16,9 +16,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)
@@ -58,6 +58,7 @@ public sealed class ApiKeyVerifier(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes));
Scopes: storedKey.Scopes,
Constraints: storedKey.Constraints));
}
}
@@ -21,6 +21,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
secret_hash,
display_name,
scopes,
constraints,
created_utc,
last_used_utc,
revoked_utc)
@@ -30,6 +31,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
$secret_hash,
$display_name,
$scopes,
$constraints,
$created_utc,
NULL,
NULL);
@@ -47,7 +49,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;
""";
@@ -118,6 +120,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"));
}
}
@@ -46,12 +46,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";
@@ -24,6 +24,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);
}
@@ -85,6 +87,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
@@ -107,6 +110,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 = """
@@ -122,6 +153,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,
@@ -1,4 +1,6 @@
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Configuration;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authorization;
@@ -15,7 +17,17 @@ 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>()
.Configure<IConfiguration>((grpcOptions, configuration) =>
{
ProtocolOptions protocolOptions = new();
configuration.GetSection("MxGateway:Protocol").Bind(protocolOptions);
grpcOptions.MaxReceiveMessageSize = protocolOptions.MaxGrpcMessageBytes;
grpcOptions.MaxSendMessageSize = protocolOptions.MaxGrpcMessageBytes;
});
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
return services;
+124 -1
View File
@@ -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 = [];
/// <summary>
/// Initializes a gateway session with session metadata and timeout configuration.
@@ -41,6 +42,35 @@ public sealed class GatewaySession
TimeSpan startupTimeout,
TimeSpan shutdownTimeout,
DateTimeOffset openedAt)
: this(
sessionId,
backendName,
pipeName,
nonce,
clientIdentity,
clientSessionName,
clientCorrelationId,
commandTimeout,
startupTimeout,
shutdownTimeout,
TimeSpan.FromMinutes(30),
openedAt)
{
}
public GatewaySession(
string sessionId,
string backendName,
string pipeName,
string nonce,
string? clientIdentity,
string? clientSessionName,
string? clientCorrelationId,
TimeSpan commandTimeout,
TimeSpan startupTimeout,
TimeSpan shutdownTimeout,
TimeSpan leaseDuration,
DateTimeOffset openedAt)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
@@ -72,8 +102,10 @@ public sealed class GatewaySession
CommandTimeout = commandTimeout;
StartupTimeout = startupTimeout;
ShutdownTimeout = shutdownTimeout;
LeaseDuration = leaseDuration;
OpenedAt = openedAt;
_lastClientActivityAt = openedAt;
_leaseExpiresAt = openedAt + leaseDuration;
}
/// <summary>
@@ -126,6 +158,8 @@ public sealed class GatewaySession
/// </summary>
public TimeSpan ShutdownTimeout { get; }
public TimeSpan LeaseDuration { get; }
/// <summary>
/// Gets the timestamp when the session opened.
/// </summary>
@@ -282,6 +316,7 @@ public sealed class GatewaySession
lock (_syncRoot)
{
_lastClientActivityAt = activityAt;
_leaseExpiresAt = activityAt + LeaseDuration;
}
}
@@ -305,7 +340,9 @@ public sealed class GatewaySession
{
lock (_syncRoot)
{
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
return _activeEventSubscriberCount == 0
&& _leaseExpiresAt is not null
&& _leaseExpiresAt <= now;
}
}
@@ -351,6 +388,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;
}
}
}
/// <summary>
/// Executes a bulk add-item command for the specified server and tag addresses.
/// </summary>
@@ -641,6 +730,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)
@@ -339,6 +339,7 @@ public sealed class SessionManager : ISessionManager
TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout);
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds);
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
string nonce = CreateNonce();
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
@@ -355,6 +356,7 @@ public sealed class SessionManager : ISessionManager
commandTimeout,
startupTimeout,
shutdownTimeout,
leaseDuration,
openedAt);
}
@@ -11,6 +11,7 @@ public static class SessionServiceCollectionExtensions
services.AddSingleton<ISessionRegistry, SessionRegistry>();
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
services.AddSingleton<ISessionManager, SessionManager>();
services.AddHostedService<SessionLeaseMonitorHostedService>();
services.AddHostedService<SessionShutdownHostedService>();
return services;
+36 -3
View File
@@ -254,11 +254,17 @@ public sealed class WorkerClient : IWorkerClient
}
WorkerClientState state = State;
if (state is WorkerClientState.Closed or WorkerClientState.Faulted)
if (state == WorkerClientState.Closed)
{
return;
}
if (state == WorkerClientState.Faulted)
{
KillOwnedProcess("ShutdownFaulted");
return;
}
MarkClosing();
await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false);
_outboundEnvelopes.Writer.TryComplete();
@@ -288,8 +294,7 @@ public sealed class WorkerClient : IWorkerClient
public void Kill(string reason)
{
ThrowIfDisposed();
_connection.ProcessHandle?.Process.Kill(entireProcessTree: true);
_metrics?.WorkerKilled(reason);
KillOwnedProcess(reason);
SetFaulted(
WorkerClientErrorCode.WorkerFaulted,
$"Worker was killed by the gateway: {reason}.",
@@ -305,6 +310,7 @@ public sealed class WorkerClient : IWorkerClient
}
_disposed = true;
KillOwnedProcess("Dispose");
_stopCts.Cancel();
_outboundEnvelopes.Writer.TryComplete();
_events.Writer.TryComplete();
@@ -666,12 +672,39 @@ public sealed class WorkerClient : IWorkerClient
_stopCts.Cancel();
_outboundEnvelopes.Writer.TryComplete(fault);
_events.Writer.TryComplete(fault);
KillOwnedProcess(errorCode.ToString());
CompletePendingCommands(fault);
RecordWorkerStoppedOnce(errorCode.ToString());
_metrics?.Fault(errorCode.ToString());
_logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message);
}
private void KillOwnedProcess(string reason)
{
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
if (processHandle is null)
{
return;
}
try
{
if (!processHandle.Process.HasExited)
{
processHandle.Process.Kill(entireProcessTree: true);
_metrics?.WorkerKilled(reason);
}
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to kill worker process {ProcessId} for session {SessionId}.",
processHandle.ProcessId,
SessionId);
}
}
/// <summary>Records worker stopped metric only once.</summary>
/// <param name="reason">Reason for stopping.</param>
private void RecordWorkerStoppedOnce(string reason)
+19 -1
View File
@@ -13,6 +13,20 @@
"PepperSecretName": "MxGateway:ApiKeyPepper",
"RunMigrationsOnStartup": true
},
"Ldap": {
"Enabled": true,
"Server": "localhost",
"Port": 3893,
"UseTls": false,
"AllowInsecureLdap": true,
"SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123",
"UserNameAttribute": "cn",
"DisplayNameAttribute": "cn",
"GroupAttribute": "memberOf",
"RequiredGroup": "GwAdmin"
},
"Worker": {
"ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe",
"RequiredArchitecture": "X86",
@@ -25,6 +39,9 @@
"Sessions": {
"DefaultCommandTimeoutSeconds": 30,
"MaxSessions": 64,
"MaxPendingCommandsPerSession": 128,
"DefaultLeaseSeconds": 1800,
"LeaseSweepIntervalSeconds": 30,
"AllowMultipleEventSubscribers": false
},
"Events": {
@@ -42,7 +59,8 @@
"ShowTagValues": false
},
"Protocol": {
"WorkerProtocolVersion": 1
"WorkerProtocolVersion": 1,
"MaxGrpcMessageBytes": 16777216
},
"Galaxy": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
@@ -122,6 +122,33 @@
border-radius: .375rem;
}
.api-key-management-grid {
display: grid;
gap: .75rem;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
}
.scope-grid {
display: grid;
gap: .35rem .75rem;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.one-time-secret {
display: block;
overflow-wrap: anywhere;
white-space: normal;
}
.api-key-create-modal {
display: block;
}
.api-key-create-modal .modal-body {
max-height: min(70vh, 44rem);
overflow-y: auto;
}
@media (max-width: 700px) {
.dashboard-content {
padding: .75rem;
@@ -31,6 +31,8 @@ public sealed class GatewayOptionsTests
Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds);
Assert.Equal(64, options.Sessions.MaxSessions);
Assert.Equal(1800, options.Sessions.DefaultLeaseSeconds);
Assert.Equal(30, options.Sessions.LeaseSweepIntervalSeconds);
Assert.False(options.Sessions.AllowMultipleEventSubscribers);
Assert.Equal(10_000, options.Events.QueueCapacity);
@@ -46,6 +48,7 @@ public sealed class GatewayOptionsTests
Assert.False(options.Dashboard.ShowTagValues);
Assert.Equal(1u, options.Protocol.WorkerProtocolVersion);
Assert.Equal(16 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
}
/// <summary>Verifies that options binding applies configuration overrides.</summary>
@@ -58,15 +61,19 @@ public sealed class GatewayOptionsTests
["MxGateway:Authentication:Mode"] = "Disabled",
["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe",
["MxGateway:Sessions:MaxSessions"] = "12",
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
["MxGateway:Events:QueueCapacity"] = "256",
["MxGateway:Dashboard:Enabled"] = "false"
["MxGateway:Dashboard:Enabled"] = "false",
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608"
});
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath);
Assert.Equal(12, options.Sessions.MaxSessions);
Assert.Equal(900, options.Sessions.DefaultLeaseSeconds);
Assert.Equal(256, options.Events.QueueCapacity);
Assert.False(options.Dashboard.Enabled);
Assert.Equal(8 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
}
/// <summary>Verifies that invalid configuration values fail with expected error messages.</summary>
@@ -77,7 +84,10 @@ public sealed class GatewayOptionsTests
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
@@ -347,7 +347,7 @@ public sealed class ClientBehaviorFixtureTests
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
&& Directory.Exists(Path.Combine(current.FullName, "src"))
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
{
@@ -91,7 +91,7 @@ public sealed class ClientProtoInputTests
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
&& Directory.Exists(Path.Combine(current.FullName, "src"))
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
{
@@ -264,7 +264,7 @@ public sealed class CrossLanguageSmokeMatrixTests
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
&& Directory.Exists(Path.Combine(current.FullName, "src"))
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
{
@@ -13,9 +13,9 @@ public sealed class GatewayContractInfoTests
/// <summary>Verifies that the gateway protocol version starts at version one.</summary>
[Fact]
public void GatewayProtocolVersion_StartsAtVersionOne()
public void GatewayProtocolVersion_IsVersionTwo()
{
Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion);
Assert.Equal(2u, GatewayContractInfo.GatewayProtocolVersion);
}
/// <summary>Verifies that the worker protocol version starts at version one.</summary>
@@ -282,7 +282,7 @@ public sealed class ParityFixtureMatrixTests
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
&& Directory.Exists(Path.Combine(current.FullName, "src"))
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
{
@@ -1,4 +1,5 @@
using MxGateway.Server.Galaxy;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Tests.Galaxy;
@@ -18,7 +19,7 @@ public sealed class GalaxyHierarchyCacheTests
Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
Assert.False(entry.HasData);
Assert.Equal(0, entry.ObjectCount);
Assert.Null(entry.Reply);
Assert.Empty(entry.Objects);
}
/// <summary>
@@ -64,6 +65,53 @@ public sealed class GalaxyHierarchyCacheTests
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
[Fact]
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
{
GalaxyObject root = new()
{
GobjectId = 1,
TagName = "Area1",
ContainedName = "Area1",
};
GalaxyObject duplicate = new()
{
GobjectId = 1,
TagName = "DuplicateArea",
ContainedName = "DuplicateArea",
};
GalaxyObject child = new()
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Pump_001",
ContainedName = "Pump",
Attributes =
{
new GalaxyAttribute
{
FullTagReference = "Pump_001.PV",
IsHistorized = true,
},
},
};
GalaxyObject orphan = new()
{
GobjectId = 3,
ParentGobjectId = 99,
TagName = "Orphan_001",
ContainedName = "Orphan",
};
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);
Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
Assert.Same(root, index.ObjectViewsById[1].Object);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{
GalaxyRepositoryOptions options = new()
@@ -71,7 +119,7 @@ public sealed class GalaxyHierarchyCacheTests
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
GalaxyRepository repository = new(options);
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
}
@@ -1,135 +1,74 @@
using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Tests for dashboard authentication using API keys.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
/// <summary>
/// Verifies an admin-scoped key produces a valid cookie principal.
/// </summary>
[Fact]
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
public void EscapeLdapFilter_EscapesSpecialCharacters()
{
FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin));
DashboardAuthenticator authenticator = CreateAuthenticator(verifier);
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value);
Assert.Contains(result.Principal.Claims, claim =>
claim.Type == DashboardAuthenticationDefaults.ScopeClaimType
&& claim.Value == GatewayScopes.Admin);
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
}
/// <summary>
/// Verifies a non-admin key fails authentication without exposing the API key.
/// </summary>
[Fact]
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
[Theory]
[InlineData("GwAdmin", true)]
[InlineData("gwadmin", true)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
[InlineData("OtherGroup", false)]
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
string requiredGroup,
bool expected)
{
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
SuccessWithScopes(GatewayScopes.EventsRead)));
string[] groups =
[
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
];
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
Assert.Equal(expected, result);
}
[Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
{
string result = DashboardAuthenticator.ExtractFirstRdnValue(
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
Assert.Equal("Gateway Admins", result);
}
[Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
{
Ldap = new LdapOptions
{
Enabled = false,
},
});
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
"admin",
"admin123",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>
/// Verifies that when admin scope is not required, any authenticated key is accepted.
/// </summary>
[Fact]
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
{
DashboardAuthenticator authenticator = CreateAuthenticator(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
requireAdminScope: false);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_secret",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
}
/// <summary>
/// Verifies an invalid key returns a generic failure message.
/// </summary>
[Fact]
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
{
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)));
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
}
private static DashboardAuthenticator CreateAuthenticator(
IApiKeyVerifier verifier,
bool requireAdminScope = true)
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
{
return new DashboardAuthenticator(
verifier,
Options.Create(new GatewayOptions
{
Dashboard = new DashboardOptions
{
RequireAdminScope = requireAdminScope
}
}));
}
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
{
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
}
/// <summary>
/// Test implementation that records the authorization header for verification.
/// </summary>
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
/// <summary>
/// The authorization header that was last verified.
/// </summary>
public string? LastAuthorizationHeader { get; private set; }
/// <inheritdoc />
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
{
LastAuthorizationHeader = authorizationHeader;
return Task.FromResult(result);
}
Options.Create(options),
NullLogger<DashboardAuthenticator>.Instance);
}
}
@@ -4,6 +4,8 @@ using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -200,17 +202,27 @@ public sealed class DashboardSnapshotServiceTests
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
Hierarchy =
[
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Pump_001", BrowseName = "Pump_001", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump_002", BrowseName = "Pump_002", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 3, TagName = "Area_A", BrowseName = "Area_A", CategoryId = 13, IsArea = true, TemplateChain = ["$Area"] },
],
Attributes =
[
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Speed", IsHistorized = true },
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Status", IsAlarm = true },
],
DashboardSummary = new DashboardGalaxySummary(
DashboardGalaxyStatus.Healthy,
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
LastError: null,
ObjectCount: 3,
AreaCount: 1,
AttributeCount: 2,
HistorizedAttributeCount: 1,
AlarmAttributeCount: 1,
TopTemplates:
[
new DashboardGalaxyTemplateUsage("$Pump", 2),
new DashboardGalaxyTemplateUsage("$Area", 1),
],
ObjectCategories:
[
new DashboardGalaxyCategoryCount(10, "UserDefined", 2),
new DashboardGalaxyCategoryCount(13, "Area", 1),
]),
ObjectCount = 3,
AreaCount = 1,
AttributeCount = 2,
@@ -238,6 +250,101 @@ public sealed class DashboardSnapshotServiceTests
/// <summary>
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
/// </summary>
[Fact]
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Empty(snapshot.ApiKeys);
Assert.Equal(0, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
},
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot snapshot = enumerator.Current;
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
Assert.Equal("operator01", key.KeyId);
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
Assert.Equal(1, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
LastUsedUtc: null,
RevokedUtc: null));
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
new GatewayOptions
{
Dashboard = new DashboardOptions
{
SnapshotIntervalMilliseconds = 1,
},
},
apiKeyAdminStore: apiKeyAdminStore);
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
.WatchSnapshotsAsync(cancellation.Token)
.GetAsyncEnumerator(cancellation.Token);
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot first = enumerator.Current;
apiKeyAdminStore.FailNext = true;
Assert.True(await enumerator.MoveNextAsync());
DashboardSnapshot second = enumerator.Current;
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
Assert.Equal(2, apiKeyAdminStore.ListCount);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -268,7 +375,8 @@ public sealed class DashboardSnapshotServiceTests
SessionRegistry registry,
GatewayMetrics metrics,
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
IApiKeyAdminStore? apiKeyAdminStore = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -284,6 +392,7 @@ public sealed class DashboardSnapshotServiceTests
metrics,
configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
Options.Create(resolvedOptions));
}
@@ -309,6 +418,64 @@ public sealed class DashboardSnapshotServiceTests
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public virtual 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 class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
{
public int ListCount { get; protected set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
ListCount++;
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
}
}
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
{
public bool FailNext { get; set; }
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
if (FailNext)
{
FailNext = false;
ListCount++;
throw new InvalidOperationException("Simulated SQLite failure.");
}
return base.ListAsync(cancellationToken);
}
}
private static GatewaySession CreateSession(
string sessionId,
string? clientIdentity,
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -54,6 +55,20 @@ public sealed class GatewayApplicationTests
}
/// <summary>Verifies that Build does not map dashboard routes when the dashboard is disabled.</summary>
[Fact]
public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess()
{
WebApplication app = GatewayApplication.Build([]);
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app)
.Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith(
"/dashboard",
StringComparison.Ordinal) == true)
.ToArray();
Assert.NotEmpty(endpoints);
Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null);
}
[Fact]
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
{
@@ -89,6 +104,14 @@ public sealed class GatewayApplicationTests
"MxGateway:Dashboard:PathBase",
"dashboard",
"MxGateway:Dashboard:PathBase must start with '/'.")]
[InlineData(
"MxGateway:Ldap:RequiredGroup",
"",
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
[InlineData(
"MxGateway:Ldap:AllowInsecureLdap",
"false",
"MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")]
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
string key,
string value,
@@ -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;
@@ -178,6 +179,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
Service = new MxAccessGatewayService(
sessionManager,
new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
@@ -529,4 +531,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;
}
}
@@ -237,6 +237,7 @@ public sealed class MxAccessGatewayServiceTests
return new MxAccessGatewayService(
sessionManager,
identityAccessor ?? new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
@@ -445,6 +446,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
{
/// <inheritdoc />
@@ -34,6 +34,21 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that opening a session generates a correlation ID from the client name and session ID.</summary>
[Fact]
public async Task OpenSessionAsync_SetsInitialDefaultLease()
{
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z"));
GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800);
SessionManager manager = CreateManager(
new FakeSessionWorkerClientFactory(new FakeWorkerClient()),
options: options,
timeProvider: clock);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt);
}
[Fact]
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
{
@@ -82,6 +97,32 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that bulk subscribe forwards the command and returns subscription results.</summary>
[Fact]
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
{
GatewaySession session = new(
"session-lease-refresh",
"mxaccess",
"mxaccess-gateway-1-session-lease-refresh",
"nonce",
"client-1",
"test-session",
"client-correlation-1",
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(5),
TimeSpan.FromMinutes(30),
DateTimeOffset.UtcNow - TimeSpan.FromHours(1));
session.AttachWorkerClient(new FakeWorkerClient());
session.MarkReady();
DateTimeOffset? initialLease = session.LeaseExpiresAt;
await session.InvokeAsync(CreateCommand(MxCommandKind.Ping), CancellationToken.None);
Assert.True(session.LeaseExpiresAt > initialLease);
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
}
[Fact]
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
{
@@ -322,6 +363,23 @@ public sealed class SessionManagerTests
}
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
[Fact]
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
{
FakeWorkerClient workerClient = new();
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
DateTimeOffset now = DateTimeOffset.UtcNow;
session.ExtendLease(now.AddSeconds(-1));
using IDisposable eventSubscriber = session.AttachEventSubscriber(allowMultipleSubscribers: false);
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
Assert.Equal(0, closedCount);
Assert.Equal(SessionState.Ready, session.State);
Assert.Equal(0, workerClient.ShutdownCount);
}
[Fact]
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
{
@@ -353,16 +411,20 @@ public sealed class SessionManagerTests
ISessionWorkerClientFactory factory,
ISessionRegistry? registry = null,
GatewayMetrics? metrics = null,
GatewayOptions? options = null)
GatewayOptions? options = null,
TimeProvider? timeProvider = null)
{
return new SessionManager(
registry ?? new SessionRegistry(),
factory,
Options.Create(options ?? CreateOptions()),
metrics ?? new GatewayMetrics());
metrics ?? new GatewayMetrics(),
timeProvider);
}
private static GatewayOptions CreateOptions(int maxSessions = 64)
private static GatewayOptions CreateOptions(
int maxSessions = 64,
int defaultLeaseSeconds = 1800)
{
return new GatewayOptions
{
@@ -370,6 +432,7 @@ public sealed class SessionManagerTests
{
DefaultCommandTimeoutSeconds = 30,
MaxSessions = maxSessions,
DefaultLeaseSeconds = defaultLeaseSeconds,
},
Worker = new WorkerOptions
{
@@ -586,4 +649,11 @@ public sealed class SessionManagerTests
ShutdownReleased.TrySetResult();
}
}
private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
}
}
@@ -143,6 +143,36 @@ public sealed class WorkerClientTests
}
/// <summary>Verifies that the read loop faults the client when the pipe disconnects.</summary>
[Fact]
public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess()
{
await using PipePair pipePair = await PipePair.CreateAsync();
FakeWorkerProcess process = new();
await using WorkerClient client = CreateClient(
pipePair,
new WorkerClientOptions
{
EventChannelCapacity = 1,
HeartbeatGrace = TimeSpan.FromSeconds(30),
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
},
processHandle: CreateProcessHandle(process));
await CompleteHandshakeAsync(client, pipePair);
await pipePair.WorkerWriter.WriteAsync(
CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange));
await pipePair.WorkerWriter.WriteAsync(
CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange));
await WaitUntilAsync(
() => client.State == WorkerClientState.Faulted,
TestTimeout);
Assert.Equal(1, process.KillCount);
Assert.True(process.KillEntireProcessTree);
Assert.True(process.HasExited);
}
[Fact]
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
{
@@ -200,6 +230,20 @@ public sealed class WorkerClientTests
}
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
[Fact]
public async Task DisposeAsync_WhenOwnedWorkerStillRuns_KillsProcessBeforeDisposing()
{
await using PipePair pipePair = await PipePair.CreateAsync();
FakeWorkerProcess process = new();
WorkerClient client = CreateClient(pipePair, processHandle: CreateProcessHandle(process));
await client.DisposeAsync().AsTask().WaitAsync(TestTimeout);
Assert.Equal(1, process.KillCount);
Assert.True(process.KillEntireProcessTree);
Assert.True(process.Disposed);
}
[Fact]
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
{
@@ -243,18 +287,28 @@ public sealed class WorkerClientTests
private static WorkerClient CreateClient(
PipePair pipePair,
WorkerClientOptions? options = null,
GatewayMetrics? metrics = null)
GatewayMetrics? metrics = null,
WorkerProcessHandle? processHandle = null)
{
WorkerFrameProtocolOptions frameOptions = new(SessionId);
WorkerClientConnection connection = new(
SessionId,
Nonce,
pipePair.GatewayStream,
frameOptions);
frameOptions,
processHandle);
return new WorkerClient(connection, options, metrics);
}
private static WorkerProcessHandle CreateProcessHandle(FakeWorkerProcess process)
{
return new WorkerProcessHandle(
process,
new WorkerProcessCommandLine("MxGateway.Worker.exe", []),
DateTimeOffset.UtcNow);
}
private static async Task CompleteHandshakeAsync(
WorkerClient client,
PipePair pipePair)
@@ -454,4 +508,40 @@ public sealed class WorkerClientTests
await GatewayStream.DisposeAsync();
}
}
private sealed class FakeWorkerProcess : IWorkerProcess
{
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
public int Id { get; } = WorkerProcessId;
public bool HasExited { get; private set; }
public int? ExitCode { get; private set; }
public int KillCount { get; private set; }
public bool KillEntireProcessTree { get; private set; }
public bool Disposed { get; private set; }
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
}
public void Kill(bool entireProcessTree)
{
KillCount++;
KillEntireProcessTree = entireProcessTree;
HasExited = true;
ExitCode = -1;
_exited.TrySetResult();
}
public void Dispose()
{
Disposed = true;
}
}
}
@@ -24,7 +24,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);
@@ -62,7 +63,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);
@@ -90,7 +92,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);
@@ -125,7 +128,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);
@@ -160,7 +164,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);
@@ -171,6 +176,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();
@@ -182,7 +222,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);
@@ -55,6 +55,42 @@ public sealed class ApiKeyAdminCommandLineParserTests
/// <summary>
/// Verifies create key without display name returns error.
/// </summary>
[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()
{
@@ -145,6 +145,7 @@ public sealed class ApiKeyVerifierTests
"session:open",
"events:read"
},
Constraints: ApiKeyConstraints.Empty,
CreatedUtc: DateTimeOffset.UtcNow,
LastUsedUtc: null,
RevokedUtc: revokedUtc);